diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md
index eba5b78..c805558 100644
--- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md
+++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md
@@ -269,3 +269,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 安装预览由 Host 注入应用版本、OS 与架构,renderer 只提交根包、当前 Vault 和配置;后台阻塞任务中保持 Vault 绑定稳定并调用完整预览。尚未开放实际安装执行命令。
- 桌面二进制 5 项测试和全目标 Clippy -D warnings 通过,日志 `.build/extension-commands-tests.log`。新增确认凭据生命周期与并发冲突测试;ACL 文件及生成权限项已更新。
- 前端确认对话框、桌面包下载/暂存入口、安装执行编排、沙箱及真实 UI 验收尚待完成;extensions capability 仍关闭。本轮不宣称生产化或 D-04 完成。
+
+
+## 增量:社区来源确认界面接入 Host
+
+- 桌面社区检查来源后请求 Host 复核,对话框展示 source_id、候选公钥、原有设置和确认摘要;只有点击确认才调用 Host 确认命令,成功后更新本地目录设置。启用/停用也经同一确认流程更新 Host,旧本地来源不自动迁入信任库。
+- 新增 extensionTrustService,规范 HTTPS 来源、公钥解码和数量限制;确认只传 Host 签发的 review_id 与摘要。两分钟过期或并发冲突提示重新检查;多键确认是逐键调用,部分失败可能已有部分键完成确认,界面不声称整组成功,需重新检查后处理。
+- 来源发现保留服务端 source_id,Web 开发保留本地来源流程。对话框中的错误可见,忙碌时禁止重复确认。
+- 前端全量 99 文件 / 521 项通过,两套 TypeScript 项目检查通过;日志 `.build/extension-trust-ui-tests.log`。新增服务参数/非法输入测试与 Vue 组件确认前无写入、Host 拒绝后不保存测试。测试使用 Host mock,不等同于真实桌面 UI 端到端验收。
+- 桌面包下载暂存、安装确认 UI 与执行编排、沙箱、真实双端及部署验收仍未完成;整体生产化目标继续进行。
diff --git a/frontend/src/contracts/community.ts b/frontend/src/contracts/community.ts
index 74aec5f..852018a 100644
--- a/frontend/src/contracts/community.ts
+++ b/frontend/src/contracts/community.ts
@@ -1,7 +1,7 @@
/** 社区协议 v1;签名元数据与安装运行状态分离。 */
export type PackageKind = 'theme' | 'skill' | 'plugin' | 'mcp' | 'persona' | 'template' | 'model'
export interface CommunityKey { key_id: string; namespace: string; public_key: string; revoked: boolean }
-export interface CommunitySource { id: string; url: string; enabled: boolean; keys: CommunityKey[]; fetchedAt?: string }
+export interface CommunitySource { source_id?: string; id: string; url: string; enabled: boolean; keys: CommunityKey[]; fetchedAt?: string }
export interface CommunityRelease {
schema_version: 1; namespace: string; package_id: string; type: PackageKind; version: string
name: string; author_id: string; license: string; description: string; sha256: string; size: number
diff --git a/frontend/src/features/community/CommunityView.spec.ts b/frontend/src/features/community/CommunityView.spec.ts
new file mode 100644
index 0000000..b6bbbf4
--- /dev/null
+++ b/frontend/src/features/community/CommunityView.spec.ts
@@ -0,0 +1,26 @@
+// @vitest-environment jsdom
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+const service = vi.hoisted(() => ({ discover: vi.fn(), review: vi.fn(), confirm: vi.fn(), save: vi.fn() }))
+vi.mock('@/services/platform/desktop', () => ({ isDesktop: () => true }))
+vi.mock('@/services/extensionTrustService', () => ({ reviewTrust: service.review, confirmTrust: service.confirm }))
+vi.mock('@/services/communityService', () => ({ loadSources: () => [], saveSources: service.save, discoverSource: service.discover, cachedCatalog: vi.fn(), fetchCatalog: vi.fn(), installRelease: vi.fn() }))
+import CommunityView from './CommunityView.vue'
+beforeEach(() => { vi.clearAllMocks(); localStorage.clear() })
+it('requires explicit confirmation and does not save when Host rejects it', async () => {
+ service.discover.mockResolvedValue({ source_id: 'catalog', keys: [{ key_id: 'key', namespace: 'examples', public_key: 'public', revoked: false }] })
+ service.review.mockResolvedValue([{ review_id: 'review', fingerprint: 'visible-digest', previous: null, proposed: { namespace: 'examples', key_id: 'key' } }])
+ const wrapper = mount(CommunityView, { global: { stubs: { AppDialog: { template: '' } } } })
+ await wrapper.get('input').setValue('https://catalog.example')
+ await wrapper.findAll('button').find(b => b.text() === '检查来源与公钥')!.trigger('click')
+ await flushPromises()
+ expect(wrapper.text()).toContain('visible-digest')
+ expect(service.confirm).not.toHaveBeenCalled()
+ expect(service.save).not.toHaveBeenCalled()
+ service.confirm.mockRejectedValueOnce(new Error('确认已过期'))
+ await wrapper.findAll('button').find(b => b.text() === '确认来源设置')!.trigger('click')
+ await flushPromises()
+ expect(wrapper.text()).toContain('确认已过期')
+ expect(service.save).not.toHaveBeenCalled()
+ wrapper.unmount()
+})
diff --git a/frontend/src/features/community/CommunityView.vue b/frontend/src/features/community/CommunityView.vue
index 5f40fd0..25aca7c 100644
--- a/frontend/src/features/community/CommunityView.vue
+++ b/frontend/src/features/community/CommunityView.vue
@@ -1,7 +1,9 @@
@@ -109,10 +125,17 @@ function toggleSource() {
已保存的声明式候选
这些候选尚未应用到人设、MCP 或模型运行配置。
{{ item.key.replace('community-candidate:', '') }}
{{ item.value }}
-
- {{ candidateUrl }}
请与来源维护者公布的公钥核对。确认后固定这些公钥;密钥改变时不会自动信任。
+
+ {{ error }}
{{ candidateUrl }}
请与来源维护者公布的公钥核对。确认后固定这些公钥;密钥改变时不会自动信任。
+ 来源标识:{{ candidateSourceId }} · {{ candidateEnabled ? '启用' : '停用' }}
{{ JSON.stringify(candidateKeys, null, 2) }}
-
+
+
{{ review.proposed.namespace }} / {{ review.proposed.key_id }}:{{ review.previous ? '更新已有信任设置' : '首次确认' }}
+
原有公钥与状态
{{ JSON.stringify(review.previous, null, 2) }}
+
确认摘要:{{ review.fingerprint }}
+
+ 确认在两分钟内有效。过期或设置已改变时,请关闭对话框并重新检查来源。
+
diff --git a/frontend/src/services/communityService.spec.ts b/frontend/src/services/communityService.spec.ts
index 4ddb052..ca983d9 100644
--- a/frontend/src/services/communityService.spec.ts
+++ b/frontend/src/services/communityService.spec.ts
@@ -21,7 +21,7 @@ describe('社区 Python / TypeScript 签名契约', () => {
await expect(verifyRelease(release, { ...vector.key, revoked: true }, bytes)).rejects.toThrow('撤回')
})
it('不携带凭据、拒绝重定向并支持取消', async () => {
- const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ schema_version: 1, keys: [] })))
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ schema_version: 1, source_id: "fixture", keys: [] })))
vi.stubGlobal('fetch', fetch)
await discoverKeys('https://example.org')
expect(fetch.mock.calls[0]?.[1]).toMatchObject({ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer' })
diff --git a/frontend/src/services/communityService.ts b/frontend/src/services/communityService.ts
index ef33a07..934c9ff 100644
--- a/frontend/src/services/communityService.ts
+++ b/frontend/src/services/communityService.ts
@@ -47,11 +47,14 @@ async function download(source: CommunitySource, path: string, maxSize: number,
} finally { clearTimeout(timeout); signal?.removeEventListener('abort', abort) }
}
-export async function discoverKeys(url: string, signal?: AbortSignal): Promise {
+export async function discoverSource(url: string, signal?: AbortSignal): Promise<{ source_id: string; keys: CommunityKey[] }> {
const source: CommunitySource = { id: 'candidate', url, enabled: true, keys: [] }
const value = JSON.parse(new TextDecoder().decode(await download(source, '/catalog/v1/sources', 1024 * 1024, signal)))
- if (value.schema_version !== 1 || !Array.isArray(value.keys)) throw new Error('不支持的社区来源协议')
- return value.keys
+ if (value.schema_version !== 1 || typeof value.source_id !== 'string' || !value.source_id || !Array.isArray(value.keys) || value.keys.length > 64) throw new Error('不支持的社区来源协议')
+ return { source_id: value.source_id, keys: value.keys }
+}
+export async function discoverKeys(url: string, signal?: AbortSignal): Promise {
+ return (await discoverSource(url, signal)).keys
}
export async function fetchCatalog(source: CommunitySource, q = '', kind = '', signal?: AbortSignal): Promise {
diff --git a/frontend/src/services/extensionTrustService.spec.ts b/frontend/src/services/extensionTrustService.spec.ts
new file mode 100644
index 0000000..7531e6a
--- /dev/null
+++ b/frontend/src/services/extensionTrustService.spec.ts
@@ -0,0 +1,21 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+const native = vi.hoisted(() => ({ invoke: vi.fn() }))
+vi.mock('@tauri-apps/api/core', () => ({ invoke: native.invoke }))
+import { confirmTrust, reviewTrust } from './extensionTrustService'
+beforeEach(() => native.invoke.mockReset())
+const key = { key_id: 'key', namespace: 'examples', public_key: btoa('a'.repeat(32)), revoked: false }
+it('reviews normalized source and confirms only Host-issued id and fingerprint', async () => {
+ native.invoke.mockResolvedValueOnce({ review_id: 'review', fingerprint: 'digest', previous: null })
+ const reviews = await reviewTrust('https://catalog.example', 'catalog', [key], true)
+ expect(native.invoke).toHaveBeenCalledWith('extension_trust_review', { setting: { source: 'https://catalog.example/', source_id: 'catalog', key_id: 'key', namespace: 'examples', public_key: Array(32).fill(97), enabled: true } })
+ expect(native.invoke).toHaveBeenCalledTimes(1)
+ await confirmTrust(reviews)
+ expect(native.invoke).toHaveBeenLastCalledWith('extension_trust_confirm', { request: { review_id: 'review', fingerprint: 'digest' } })
+})
+it('rejects invalid and revoked input before invoking Host', async () => {
+ await expect(reviewTrust('http://example.com', 'catalog', [key], true)).rejects.toThrow()
+ await expect(reviewTrust('https://example.com', 'catalog', [{ ...key, revoked: true }], true)).rejects.toThrow()
+ await expect(reviewTrust('https://example.com', 'catalog', [{ ...key, public_key: 'bad' }], true)).rejects.toThrow()
+ await expect(confirmTrust([])).rejects.toThrow()
+ expect(native.invoke).not.toHaveBeenCalled()
+})
diff --git a/frontend/src/services/extensionTrustService.ts b/frontend/src/services/extensionTrustService.ts
new file mode 100644
index 0000000..86eddd2
--- /dev/null
+++ b/frontend/src/services/extensionTrustService.ts
@@ -0,0 +1,30 @@
+import { invoke } from '@tauri-apps/api/core'
+import type { CommunityKey } from '@/contracts/community'
+
+export interface TrustSetting {
+ source: string; source_id: string; namespace: string; key_id: string
+ public_key: number[]; enabled: boolean
+}
+export interface TrustReview {
+ review_id: string; fingerprint: string; previous: TrustSetting | null; proposed: TrustSetting
+}
+export async function reviewTrust(url: string, sourceId: string, keys: CommunityKey[], enabled: boolean): Promise {
+ const source = new URL(url)
+ if (source.protocol !== 'https:' || source.username || source.password || source.search || source.hash) throw new Error('桌面信任来源必须使用 HTTPS')
+ source.pathname = `${source.pathname.replace(/\/+$/, '')}/`
+ if (!keys.length || keys.length > 64) throw new Error('来源公钥数量必须为 1–64')
+ const settings = keys.map(key => {
+ const publicKey = Array.from(atob(key.public_key), c => c.charCodeAt(0))
+ if (publicKey.length !== 32 || (enabled && key.revoked)) throw new Error('公钥无效或已撤销')
+ return { source: source.toString(), source_id: sourceId, namespace: key.namespace, key_id: key.key_id, public_key: publicKey, enabled }
+ })
+ const reviews: TrustReview[] = []
+ for (const setting of settings) reviews.push(await invoke('extension_trust_review', { setting }))
+ return reviews
+}
+export async function confirmTrust(reviews: TrustReview[]): Promise {
+ if (!reviews.length || reviews.length > 64) throw new Error('请重新检查来源后确认')
+ for (const review of reviews) {
+ await invoke('extension_trust_confirm', { request: { review_id: review.review_id, fingerprint: review.fingerprint } })
+ }
+}