From 2e4d85b7676591986fbd1adf934e14d0a0827679 Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 27 Oct 2026 20:13:57 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(client):=20=E9=A1=B5=E9=9D=A2=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E6=94=B9=E7=94=A8=E5=8F=AF=E9=99=8D=E7=BA=A7=E7=9A=84?= =?UTF-8?q?=20randomId=EF=BC=8C=E4=BF=AE=E5=A4=8D=20http=20=E5=B1=80?= =?UTF-8?q?=E5=9F=9F=E7=BD=91=E8=AE=BF=E9=97=AE=E4=B8=8B=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E7=A9=BA=E7=99=BD=EF=BC=88#102=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crypto.randomUUID 只在安全上下文可用;以 http://<局域网 IP>:3080 打开设置页时它是 undefined,而 AccountEnrollmentController 在 AccountsCard 的 useState 惰性初始化里 构造并立刻取页面身份,异常落在渲染期、被设置页插槽错误边界接住,表现为标签正常、 右侧内容区整块空白且无提示。 新增 src/client/random-id.ts:randomUUID 可用时照旧,否则用非安全上下文同样可用的 crypto.getRandomValues 按 RFC 4122 拼同形 v4;控制器默认 newId 改用它。页面身份只是 宿主把开通过程绑到单个浏览器页面的分组键,不参与鉴权,因此不要求 HTTPS。 用例:tests/random-id.test.ts(v4 形态、去掉 randomUUID 后的兜底、非安全上下文下 构造控制器并把页面身份带给宿主);改回 () => crypto.randomUUID() 时第三条失败。 验证:typecheck 退出码 0;npm ci 后 npm test 1129/1129;build 重建 lib/client.js (lib/index.js 与 lib/index.d.ts 字节不变)。 --- lib/client.js | 19 ++++++++++++++++- src/client/enrollment.ts | 3 ++- src/client/random-id.ts | 16 +++++++++++++++ tests/random-id.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/client/random-id.ts create mode 100644 tests/random-id.test.ts diff --git a/lib/client.js b/lib/client.js index 6229012..3c4784a 100644 --- a/lib/client.js +++ b/lib/client.js @@ -2899,6 +2899,23 @@ window.__ModuleLoader__.load({ ] }; //#endregion + //#region src/client/random-id.ts + /** + * 页面身份(issue #102)。`crypto.randomUUID` 是安全上下文专属:局域网里用 + * `http://:3080` 打开设置页时它是 `undefined`,而这里在控制器构造期就调用, + * 异常会落到设置页插槽的错误边界上,整块内容变成空白。`crypto.getRandomValues` + * 在非安全上下文同样可用,因此只在 `randomUUID` 不可用时按 RFC 4122 自行拼一个 v4。 + */ + function randomId() { + const source = globalThis.crypto; + if (typeof source?.randomUUID === "function") return source.randomUUID(); + const bytes = source.getRandomValues(/* @__PURE__ */ new Uint8Array(16)); + bytes[6] = bytes[6] & 15 | 64; + bytes[8] = bytes[8] & 63 | 128; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + //#endregion //#region src/client/enrollment.ts const live = (state) => [ "creating", @@ -2924,7 +2941,7 @@ window.__ModuleLoader__.load({ watch; ready; openedPage = false; - constructor(remote, changed, pollMs = 750, newId = () => crypto.randomUUID(), openPage = openLoginPage) { + constructor(remote, changed, pollMs = 750, newId = randomId, openPage = openLoginPage) { this.remote = remote; this.changed = changed; this.pollMs = pollMs; diff --git a/src/client/enrollment.ts b/src/client/enrollment.ts index d46643a..3a7b81d 100644 --- a/src/client/enrollment.ts +++ b/src/client/enrollment.ts @@ -3,6 +3,7 @@ import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-p import type { EnrollmentAction, EnrollmentInput, EnrollmentMessageKey, EnrollmentPage, EnrollmentState } from '../enrollment-wire.ts' import { createSnapshotStore } from './snapshot-store.ts' import { openLoginPage } from './login-page.ts' +import { randomId } from './random-id.ts' export interface EnrollmentRemote { enrollmentBegin(input: EnrollmentInput): Promise> @@ -40,7 +41,7 @@ export class AccountEnrollmentController { private watch: RemoteStreamHandle | undefined private ready: Promise | undefined private openedPage = false - constructor(private readonly remote: () => EnrollmentRemote, private readonly changed: () => void, private readonly pollMs = 750, private readonly newId = () => crypto.randomUUID(), private readonly openPage = openLoginPage) { this.pageId = newId() } + constructor(private readonly remote: () => EnrollmentRemote, private readonly changed: () => void, private readonly pollMs = 750, private readonly newId: () => string = randomId, private readonly openPage = openLoginPage) { this.pageId = newId() } /** 支持 React 开发模式的挂载检查;每次挂载仍重新读取宿主事实。 */ activate(): void { if (this.disposed) this.pageId = this.newId(); this.disposed = false; void this.refresh() } reset(): void { if (!this.id) this.store.set({ ...this.store.getSnapshot(), active: undefined, error: undefined }) } diff --git a/src/client/random-id.ts b/src/client/random-id.ts new file mode 100644 index 0000000..ae3bfb9 --- /dev/null +++ b/src/client/random-id.ts @@ -0,0 +1,16 @@ +/** + * 页面身份(issue #102)。`crypto.randomUUID` 是安全上下文专属:局域网里用 + * `http://:3080` 打开设置页时它是 `undefined`,而这里在控制器构造期就调用, + * 异常会落到设置页插槽的错误边界上,整块内容变成空白。`crypto.getRandomValues` + * 在非安全上下文同样可用,因此只在 `randomUUID` 不可用时按 RFC 4122 自行拼一个 v4。 + */ +export function randomId(): string { + const source = globalThis.crypto + if (typeof source?.randomUUID === 'function') return source.randomUUID() + const bytes = source.getRandomValues(new Uint8Array(16)) + // 版本位固定 4(v4 UUID),变体位固定 `10`,与 `randomUUID` 的输出同形。 + bytes[6] = (bytes[6]! & 0x0f) | 0x40 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/tests/random-id.test.ts b/tests/random-id.test.ts new file mode 100644 index 0000000..547974a --- /dev/null +++ b/tests/random-id.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { randomId } from '../src/client/random-id.ts' +import { AccountEnrollmentController, type EnrollmentRemote } from '../src/client/enrollment.ts' + +const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +/** 复现 `http://:3080` 的局域网访问:`crypto.randomUUID` 只属于安全上下文,那里不存在。 */ +function withoutRandomUuid(run: () => T): T { + const source = globalThis.crypto + Object.defineProperty(source, 'randomUUID', { value: undefined, configurable: true, writable: true }) + try { return run() } finally { delete (source as { randomUUID?: unknown }).randomUUID } +} + +test('页面身份是 v4 UUID,且每次不同', () => { + const ids = Array.from({ length: 100 }, () => randomId()) + assert.equal(new Set(ids).size, 100) + for (const id of ids) assert.match(id, V4) +}) + +test('没有 crypto.randomUUID(非安全上下文)时仍生成形态相同的 v4 UUID', () => { + const ids = withoutRandomUuid(() => Array.from({ length: 100 }, () => randomId())) + assert.equal(new Set(ids).size, 100, '退回 getRandomValues 后仍是随机值') + for (const id of ids) assert.match(id, V4) +}) + +test('非安全上下文下仍能构造页面控制器,并把同一页面身份带给宿主', async () => { + const pages: string[] = [] + const remote: EnrollmentRemote = { + enrollmentBegin: async () => { throw new Error('未使用') }, + enrollmentStatus: async () => { throw new Error('未使用') }, + enrollmentCancel: async () => { throw new Error('未使用') }, + enrollmentName: async () => { throw new Error('未使用') }, + enrollmentRetry: async () => { throw new Error('未使用') }, + enrollmentPending: async ({ pageId }) => { pages.push(pageId); return { ok: true, value: [] } }, + enrollmentWatch: () => { throw new Error('未使用') }, + } + // 设置页在 `useState(createEnrollment)` 的惰性初始化里构造控制器:构造抛错即整块空白(issue #102)。 + const controller = withoutRandomUuid(() => new AccountEnrollmentController(() => remote, () => {})) + await controller.refresh() + controller.dispose() + assert.equal(pages.length, 1) + assert.match(pages[0]!, V4) +}) From 96c7d9a2401fdb5208cb3c88a02bd76a7027bf6a Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 27 Oct 2026 20:14:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20issue=20#102?= =?UTF-8?q?=20=E7=9A=84=E5=AE=9A=E5=9B=A0=E3=80=81=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E4=B8=8E=E9=AA=8C=E8=AF=81=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG 的 [Unreleased] → Fixed 补 1 条,并附用例与验证边界(1129/1129); 客户端契约新增「页面身份不依赖安全上下文」;仓库地图补 src/client/random-id.ts 与 tests/random-id.test.ts;决策记录第四章第 9 条记录现象、定因、未采用的两种 方案(要求 HTTPS、彻底降级到 Math.random)与边界;提交历史补总览与阶段条目。 --- CHANGELOG.md | 3 +++ docs/agent-reference/client-and-settings.md | 1 + docs/agent-reference/repository-map.md | 9 +++++++++ ...5\206\263\347\255\226\350\256\260\345\275\225.md" | 1 + ...6\217\220\344\272\244\345\216\206\345\217\262.md" | 12 ++++++++++++ 5 files changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a00c4d3..b795b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`loaded()` 只认联网来源。** `snapshot()` 灌入的磁盘行与刷新失败后的空目录都不再被当成选择器首屏:冷启动"先聊天再开选择器"不会画出无年龄校验的旧目录,刷新失败后也不会先闪一次空列表。(`src/gateway-facts.ts`) - **可见性监听按任意卸载顺序解绑。** `unsubscribe` 句柄从函数局部提升到模块层,启动循环的 surface 先卸载时不再泄漏 `visibilitychange` 监听。(`src/client/panel.ts`) - **账户卡片首屏接线补真实渲染测试。** 新增 `tests/accounts-card-render.test.ts`(`react-test-renderer`):首屏必须调 `onRefreshAuto()`、刷新按钮必须调 `onRefresh()`;把组件改回旧接线时该用例失败。此前只有"两个入口存在"的断言,回退后测试仍全绿。 +- **非安全上下文(`http://` 局域网访问)下设置页不再整块空白(issue #102)。** 页面身份原来直接取 `crypto.randomUUID()`,而该 API 只在安全上下文存在:以 `http://<局域网 IP>:3080` 打开时它是 `undefined`,控制器又在 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化里构造,异常被设置页插槽的错误边界接住——左侧「Command Code」标签正常、右侧内容区空白且无任何提示。新增 `src/client/random-id.ts` 的 `randomId()`:`randomUUID` 可用时照旧,否则退回非安全上下文同样可用的 `crypto.getRandomValues`,按 RFC 4122 拼出同形 v4;`AccountEnrollmentController` 的默认 `newId` 改用它。页面身份只是宿主把开通过程绑到单个浏览器页面的分组键,不参与鉴权,因此不要求 HTTPS。(`src/client/random-id.ts`、`src/client/enrollment.ts`) 用例:`tests/adapter.test.ts` 新增「凭据解析与图片读取并行」「目录已在内存时不再等一次联网刷新」「超时定级复用已序列化的正文」「同一份工具 schema 只归一化一次」「用量卡片与选择器共享同一份账户事实」「凭据缺失时预算门不抢跑」「双超限一次报数」;`tests/gateway-facts.test.ts` 新增 4 条 `loaded()` 来源判据;`tests/panel.test.ts` 与 `tests/usage-client.test.ts` 新增自动/显式刷新、可见性与任意卸载顺序解绑用例;`tests/accounts-card-render.test.ts` 新增 2 条真实渲染接线用例。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1125/1125** 通过;`npm run build` 重建 `lib/`。未在真实宿主中人工验证。 + issue #102 的用例:`tests/random-id.test.ts` 3 条——`randomId()` 的 v4 形态与互不相同、去掉 `crypto.randomUUID` 后仍产出同形 v4、以及非安全上下文下构造 `AccountEnrollmentController` 并把页面身份带给宿主;把默认 `newId` 改回 `() => crypto.randomUUID()` 时第三条失败(变异对照)。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1129/1129** 通过;`npm run build` 重建 `lib/`(`lib/client.js` 含 `randomId`,`lib/index.js` 与 `lib/index.d.ts` 字节不变)。未在真实 NAS/局域网环境人工验证。 + ## [0.15.4] - 2026-10-24 ### Changed diff --git a/docs/agent-reference/client-and-settings.md b/docs/agent-reference/client-and-settings.md index 9afce90..f301e24 100644 --- a/docs/agent-reference/client-and-settings.md +++ b/docs/agent-reference/client-and-settings.md @@ -5,6 +5,7 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t - **账号开通**:两种新增方式统一使用 `src/enrollment.ts`;`src/client/enrollment.ts` 由每次设置页挂载持有,`AddAccountPanel` 只负责输入和意图。面板按设计稿:「网页登录/粘贴密钥」分段切换 → 备注名 →(粘贴模式)密钥框 → 右侧「取消」+「开始登录/添加」;进行中只剩「取消开通」,命名失败为「取消/接受已有名称/重试保存名称」,判定在导出纯函数 `addAccountPanelView`(`tests/section.test.ts`)。切回网页登录会清空已填密钥。离开设置页取消未完成过程,已登录待命名则接受已有名称;命名失败保留账号,清理失败保留恢复记录并提供手动重试。默认或已有账号的 `login*` 契约继续使用原控制器。 - **自动打开授权页**:新增、默认和已有账号登录均在首次收到宿主授权地址后通过 `src/client/login-page.ts` 打开新页面,不预先打开空白页;同一尝试的轮询不重复打开,已取消尝试或已卸载的新增账号面板不会因迟到地址打开页面。浏览器可能拦截异步打开,界面始终保留授权链接及手动打开提示;打开失败不取消宿主登录。使用 `noopener,noreferrer` 隔离来源页,不能根据返回 `null` 判断是否拦截。 - **页面生命期**:`enrollmentWatch({ pageId })` 是持续远端流,先确认就绪再允许 `enrollmentBegin`。所有过程调用带页面身份;宿主将它与实际调用对端共同绑定。单次调用完成不结束过程;页面卸载主动取消并释放流,刷新或关闭由网关流取消信号触发收尾。DSH 的 `OperatorPeer` 是宿主共享作用域,不能用它冒充单个浏览器连接。载体断开不会自动重建原过程。 +- **页面身份不依赖安全上下文(issue #102)**:页面身份由 `src/client/random-id.ts` 的 `randomId()` 生成,不直接调 `crypto.randomUUID()`——后者只在安全上下文存在,以 `http://<局域网 IP>:3080` 这类非 HTTPS、非 localhost 地址打开时是 `undefined`,而控制器在 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化里构造,那里的异常会被设置页插槽的错误边界接住,表现为「左侧标签正常、右侧内容区空白且无提示」。`randomUUID` 不可用时退回非安全上下文同样可用的 `crypto.getRandomValues`,按 RFC 4122 置入版本位与变体位,产出与 `randomUUID` 同形的 v4。页面身份只是宿主把开通过程绑到单个浏览器页面的分组键(宿主按不透明字符串比较,不校验 UUID 格式),不参与鉴权,因此不需要安全上下文。默认值经构造参数 `newId` 注入,测试可替换;覆盖见 `tests/random-id.test.ts`。 - **开通远端契约**:双方共享 `src/enrollment-wire.ts` 的七个严格描述符:`enrollmentBegin`、`enrollmentStatus`、`enrollmentCancel`、`enrollmentName`、`enrollmentRetry`、`enrollmentPending` 和流式 `enrollmentWatch`。前六个返回状态或恢复列表,观察流返回就绪确认;所有输入、输出使用严格编解码器。密钥仅允许在手动开始输入中出现,不进入状态、日志或设置文档。旧宿主缺少该契约时明确提示更新,不回退到旧的页面副作用编排。**状态消息只传语义键**:`EnrollmentState.messageKey` 取 `EnrollmentMessageKey`(22 个:宿主 15 个、页面控制器 7 个),`parseEnrollmentState` 校验枚举成员并拒绝自由文本;文案集中在设置页词典、渲染时才按界面语言取,因此宿主与页面不会各写一份,`phase` 也不必再兼作文案判据(同一 `phase` 对应多种语义,如 `naming` 的「正在保存名称」与「名称保存失败」)。宿主抛出的异常消息不在此列:它们不上屏,页面在所有失败路径都用自己这一侧的键覆盖。 - **登录总期限**:浏览器回调成功只关闭监听,不撤销登录计时器。身份验证带取消信号;超时、取消、替换尝试或销毁会中止验证,迟到结果不能开始存储或覆盖状态。期限同样约束等待存储的状态;宿主已发出的凭据写入不能凭空撤销,不能声称提供跨服务事务。 diff --git a/docs/agent-reference/repository-map.md b/docs/agent-reference/repository-map.md index 6f576a5..cb5a49a 100644 --- a/docs/agent-reference/repository-map.md +++ b/docs/agent-reference/repository-map.md @@ -79,6 +79,11 @@ src/enrollment-wire.ts Shared strict enrollment Remotes and page watch stream; also owns the `EnrollmentMessageKey` vocabulary the states carry (semantic keys, localized by the settings page). src/client/enrollment.ts Page-owned enrollment controller and stream lifetime. +src/client/random-id.ts Page identity for the settings page: `crypto.randomUUID` + when the context is secure, otherwise an RFC 4122 v4 over + `crypto.getRandomValues` (issue #102 — `http://` LAN + access has no `randomUUID`, and the controller is built + during React render). src/commands.ts The /commandcode usage dashboard command. src/command-locales.ts zh/en copy for the /commandcode command (Host-side plain constants, no ctx.locale). @@ -223,6 +228,10 @@ tests/volatile-config.test.ts the volatile helpers: `.volatile()` marking, tests/card.test.ts Models-page provider-card tests (posture logic, key write path, login affordance parity). tests/snapshot-store.test.ts Snapshot-store notification and disposal tests. +tests/random-id.test.ts Page identity for the settings page (issue #102): v4 + shape, the `getRandomValues` fallback when + `crypto.randomUUID` is absent (non-secure context), and + constructing the enrollment controller there. tests/update.test.ts update-hint tests (semver compare, payload parse, throttle cache, failure semantics). tests/usage-wire.test.ts usage-Remote schema + descriptor tests. diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index 9044ad9..c2541b4 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -55,6 +55,7 @@ 6. **2026-10-02 — 已登录且只差命名时离开,接受已有名称。**取消和页面断开均保留账号与凭据;确认写入失败时继续保留待处理记录。 7. **2026-10-02 — 页面生命期绑定持续远端流。**初步计划的对端作用域绑定已被真实宿主源码否定:DSH 0.2.0-rc.2 的 `OperatorPeer` 由宿主连接服务持有,所有浏览器请求共享。用户重新选择持续远端流。每页使用独立 `pageId`,先收到 `enrollmentWatch` 的就绪确认,再开始开通;卸载主动取消,刷新或关闭后由网关流取消信号触发宿主收尾。不依赖卸载事件完成异步请求,不另建心跳租约。 8. **2026-10-02 — 恢复只依据可核查事实。**宿主重启后展示日志,由用户处理;不自动重新登录或重新写密钥。若“已登录”阶段自身未能落盘,仍只有未完成日志,重启后需人工核查和处理;不能把存在凭据等同于成功登录。不承诺浏览器关闭瞬间检测、宿主崩溃后的自动补偿或真实账号端到端已验证。 +9. **2026-10-27 — 页面身份不依赖安全上下文(issue #102)。** **现象**:NAS 上的 DSH 以 Docker 运行、只经 `http://<局域网 IP>:3080` 访问(无 HTTPS、无反向代理),设置页「Command Code」标签正常但右侧整块空白,控制台只有 `TypeError: crypto.randomUUID is not a function` 与 `slot entry crashed in 'settings.section'`。**定因**:`crypto.randomUUID()` 是安全上下文专属 API,`http://` 下为 `undefined`(`crypto.getRandomValues` 仍可用);插件只有一处用它,且是 `AccountEnrollmentController` 构造函数的默认参数、构造时立刻调用,而控制器在 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化里构造——异常落在 React 渲染期,被设置页插槽的错误边界接住,于是标签已注册、内容区不渲染。**选择**:新增 `src/client/random-id.ts` 的 `randomId()`,`randomUUID` 可用时照旧,否则用 `getRandomValues` 按 RFC 4122 置入版本位与变体位拼同形 v4,`newId` 默认值改用它。**未采用**:(a) 要求用户上 HTTPS 或反向代理——把插件缺陷转成部署要求,且「标签在、内容空、无提示」这类故障极难自查;(b) 彻底降级到 `Math.random` 或时间戳——页面身份虽不参与鉴权,但没有理由放弃 `getRandomValues` 这条在非安全上下文同样可用的密码学随机源。**边界**:`pageId` 只是宿主把开通过程绑到单个浏览器页面的分组键,宿主按不透明字符串比较、不校验 UUID 格式,因此退回后的 v4 与 `randomUUID` 的输出可互换;控制器接口、宿主契约(`src/enrollment-wire.ts`)与开通过程本身零变化。**核验**:`tests/random-id.test.ts` 3 条用例(v4 形态与互不相同、去掉 `crypto.randomUUID` 后仍为同形 v4、非安全上下文下构造控制器并把页面身份带给宿主);把默认 `newId` 改回 `() => crypto.randomUUID()` 时第三条失败。**未做**:真实 NAS/局域网环境端到端验证(本机为安全上下文,只能用去掉 `randomUUID` 的方式模拟)。 ## 五、流响应收束 diff --git "a/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" "b/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" index 9019a23..427371d 100644 --- "a/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" +++ "b/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" @@ -4,6 +4,8 @@ ## 一、总览 +- **2026-10-27 20:0x**:**修复 issue #102:`http://` 局域网访问下设置页整块空白(非安全上下文没有 `crypto.randomUUID`)。** 用户给的工作方式:本仓库是 Fork,`upstream` 是上游,用本机 `gh` 看上游 issue #102,在基于 `main` 的新分支上修、提交自行规划。**定因**:`crypto.randomUUID()` 只在安全上下文存在,NAS/Docker 里以 `http://<局域网 IP>:3080` 打开时为 `undefined`;插件唯一一处调用是 `AccountEnrollmentController` 构造函数的默认参数、构造时立刻求值,而控制器由 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化构造,异常落在渲染期、被设置页插槽错误边界接住,于是「标签在、右侧空白、无提示」。**改动**:新增 `src/client/random-id.ts`(`randomUUID` 可用时照旧,否则 `getRandomValues` + RFC 4122 拼同形 v4),`AccountEnrollmentController` 的 `newId` 默认值改用它。**验证**:`typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1129/1129**;`build` 两段 `Build complete`,`lib/client.js` 增加 `randomId`、`lib/index.js` 与 `lib/index.d.ts` 字节不变;变异对照——把默认 `newId` 改回 `() => crypto.randomUUID()` 时新增的第三条用例失败。**未做**:真实 NAS/局域网端到端验证、推送、发版;未回复上游 issue。 + - **2026-10-24 11:14**:**发布 v0.15.4(patch)**——用户核对上游报告后选择「直接同步」,随即授权发布。**版本号判据**:纯上游数据同步(CLI 请求头 `1.87.3` → `1.88.0`、一个目录新模型),无破坏性变化、公开导出面零变化,按 semver 取 patch(沿 `0.14.4`/`0.14.5`/`0.15.1`/`0.15.2` 先例)。**发布前检查全通过**:锁文件隔离校验(`npm ci --dry-run`)退出码 0、`typecheck` 退出码 0、测试 **1107/1107**、构建两段 `Build complete`、`test:engine` **2/2**(`0.2.1-rc.2` 与 `0.2.1-rc.3`,末行 `engine-load smoke passed on all 2 declared-compatible engines`)、`test:install` **2/2**(pnpm 10.34.5)、`test:pack`(**16 文件**,版本 0.15.4)、`npm pack --dry-run --ignore-scripts`(16 文件,2.5 MB/解包 3.6 MB,tarball SHA-1 `e0744870…`)。**发布执行**:发布提交 `e5cf934`(13 文件,含构建产物);附注标签 `v0.15.4` 指向该提交(与 `HEAD`、`package.json` 三者一致)并推送;`gh workflow run publish.yml -f tag=v0.15.4 -f dist_tag=latest -f publish=true` 触发可信发布([运行记录](https://github.com/Mars-Sea/dsh-commandcode-provider/actions/runs/40057178521)),**全部步骤 success**(含「校验已提交的构建产物」「test:engine」「test:install」「确认版本尚未发布」「使用可信发布上传同一发布包」;日志确认 `npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access` 与 provenance `logIndex=3434637589`)。**发布结果**:npm `latest` 约 1 分钟传播到 **0.15.4**;线上包 **16/16 文件**、解包 **3,565,822 字节**、`fileCount: 16`,注册表带 SLSA provenance(`predicateType: https://slsa.dev/provenance/v1`);线上 tarball SHA-1(`95c8d0ea…`)与本地 `npm pack --dry-run`(`e0744870…`)不同属预期——工作流在 CI 重新打包,文件 mtime 不同,文件清单与体积一致。[中英双语 GitHub Release](https://github.com/Mars-Sea/dsh-commandcode-provider/releases/tag/v0.15.4)(非草稿、非预发布、`--latest`)已创建。**未做**:真实账号登录、付费生成、服务端协议验证与浏览器渲染;`test:engine` 与 `test:install` 的网络与凭据仍为合成依赖。 - **2026-10-24 11:05**:**同步 Command Code CLI `1.87.3` → `1.88.0` 并收录 Kimi K3.1 Pro(插件 0.15.3 之后)。** 用户先问「看看官方更新了什么」,在核对报告后选择「直接同步」。**上游事实**:npm `latest` 到达 `1.88.0`(10-24T02:55Z),官方变更日志仍无条目(最新 CLI 条目 `1.87.3`),故结论来自产物级静态比对——端点集合、自定义请求头、`/alpha/generate` 调用点、`promptCache`/`reasoning_effort`/`max_tokens` 字面量、注册表字段、efforts、订阅表与 ZDR 例外集逐项相同,注册表唯一变化是小米 MiMo V2.5 两款加 `hidden: true`。**目录与数据**:`catalogVersion` `02bfc7ac…` → `f41a48ed…`,默认视图 92 → 93、`?view=client` 104 → 105,唯一新增 `moonshotai/Kimi-K3.1-Pro`(1M 上下文、文本 + 图像、efforts `low`/`high`/`max`、Go 起、`$2/$10/$0.2`、额度 `$6/$20/$30`、无促销/峰值/上下文分档、ZDR 覆盖);该行官方 models 页、pricing 页与 go/goat/pro 计划页均已上架,但 `1.88.0` 的 CLI 注册表尚未收录(审计 error,能力字段以目录为准)。其余 92 个模型的价格、上下文、计划档位、模态与 efforts 零变化。**改动**:`src/adapter.ts` 版本常量与注释(1.88.0)、`src/capabilities.ts` 三张表 +1(`KNOWN_PLANS` 92 → 93、`KNOWN_EFFORTS` 65 → 66、`KNOWN_IMAGE_MODELS` 71 → 72)并更新证据注释、`src/model-prices.ts` +1 行(92 → 93)、`tests/adapter.test.ts` 常量断言与三处快照断言。**验证**:`typecheck` 退出码 0;`npm test` **1107/1107**;`build` 两段 `Build complete`,`lib/` 已重建(含 `1.88.0` 与 `kimi-k3.1-pro`)。**未做**:真实账号登录、付费生成、服务端协议验证与浏览器渲染;审计 `incomplete` 的 3 条 error 为 Kimi 不在 CLI 产物注册表与两条已知定价别名缺失。**文档**:`CHANGELOG.md` 的 `[Unreleased]`、[模型与上游同步](agent-reference/model-catalog.md)与本节。**已随 `0.15.4` 发布**(见上一条)。 @@ -484,3 +486,13 @@ 6. **2026-10-18 19:08**:推送与发布。`git push origin main`(`adc2107..7310ed8`,含本轮 4 个提交)、附注标签 `v0.14.2` 指向 `7310ed8`(`git rev-list -n1` 与 HEAD、`package.json` 三者一致)并推送;随后 `gh workflow run publish.yml -f tag=v0.14.2 -f dist_tag=latest -f publish=true` 触发可信发布([运行记录](https://github.com/Mars-Sea/dsh-commandcode-provider/actions/runs/39122043649)),**20 步全部通过**,包含「校验已提交的构建产物」「test:engine」「test:install」「确认版本尚未发布」与「使用可信发布上传同一发布包」;`gh` 具备 `workflow` 权限。 7. **2026-10-18 19:10**:发布后核验。npm `latest` 指向 **0.14.2**——首次查询仍是 0.14.1 且注册表 `versions` 里没有该版本,约两分钟后生效(期间一度怀疑上传未落地,核对工作流步骤结论后才确认是传播延迟)。从 registry 下载的包 **18/18 文件**,10 个关键文件与本地工作区及发布提交 `7310ed8` **逐字节一致**;注册表带 SLSA v1 来源证明(`https://registry.npmjs.org/-/npm/v1/attestations/...`)。 8. **2026-10-18 19:12**:创建[中英双语 GitHub Release](https://github.com/Mars-Sea/dsh-commandcode-provider/releases/tag/v0.14.2)(标题 `v0.14.2`,非草稿、非预发布、Latest;说明按 `releasing.md` 只写面向用户的变化与升级命令,不写文件名与内部机制),并回复 [issue #94](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/94#issuecomment-6249195728)(用户选定:npm 发布成功后立即回);本地草稿 `issue-replies.local.md` 同步标记为已发送并保留原文备查。 + +## 三十、阶段:非安全上下文下的设置页空白(issue #102) + +1. **2026-10-27 19:08**:读上游 issue #102(`Mars-Sea/dsh-commandcode-provider`,标题「DSH左下角设置 → Command Code 右边整块显示空白(在局域网访问,crypto.randomUUID is not a function)」,2026-10-26 创建,无评论,无标签)。**报告事实**:DSH 跑在家用 NAS 的 Docker 里(容器 PID 1 为 `dsh web`,监听 3080),仅以 `http://192.168.50.174:3080/` 局域网访问,全程无 HTTPS、无反向代理——`window.isSecureContext` 为 false。现象是设置页左侧「Command Code」标签正常、右侧内容区整块空白、无插件侧报错;控制台为 `TypeError: crypto.randomUUID is not a function`(`newId` → `new AccountEnrollmentController` → `createEnrollment` → `AccountsCard` 的 `useState`)与 `slot entry crashed in 'settings.section'`。报告者已在 `node_modules` 里用 `getRandomValues` 拼 v4 绕过并生效。环境:插件 0.15.4(npm 最新)、dsh 0.2.1-rc.2、Node 22.23.4、Chromium 客户端。 +2. **2026-10-27 19:10**:先核对现状而非照抄报告。`git fetch upstream` 后 `origin/main` 与 `upstream/main` 同为 `2e4d35d`(0.15.4 之后无提交),缺陷在 `main` 上仍在:`src/client/enrollment.ts:43` 的 `newId = () => crypto.randomUUID()`。在 `src/` 全量检索 `randomUUID`/`crypto`/`navigator.`/`isSecureContext`/`subtle`/`clipboard`:浏览器侧只有这一处,其余五处均为 Host 端的 `node:crypto`(`adapter.ts`、`gateway-facts.ts`、`stream-trace.ts`、`stream-response.ts`),不存在第二个非安全上下文 API。另确认宿主侧 `src/enrollment-wire.ts` 对 `pageId`/`id` 只做 `stringField` 校验、不校验 UUID 格式,因此兜底产物不必逐位等同 `randomUUID`,但保持 v4 同形更稳妥。 +3. **2026-10-27 19:14**:落地源码。新增 `src/client/random-id.ts` 的 `randomId()`:`typeof source?.randomUUID === 'function'` 时直接用,否则 `getRandomValues(new Uint8Array(16))` 后置 `bytes[6] = (bytes[6] & 0x0f) | 0x40`、`bytes[8] = (bytes[8] & 0x3f) | 0x80` 并拼成 8-4-4-4-12。`AccountEnrollmentController` 的 `newId` 默认值由 `() => crypto.randomUUID()` 改为 `randomId`,显式标注 `() => string`(保持注入接缝与既有四处测试注入的签名不变)。**未改**:控制器其余接口、宿主契约、`openPage` 默认值。 +4. **2026-10-27 19:16**:新增 `tests/random-id.test.ts` 3 条用例:v4 形态与 100 个互不相同;用 `Object.defineProperty` 把 `globalThis.crypto.randomUUID` 置为 `undefined`(模拟非安全上下文,`finally` 里 `delete` 还原)后仍产出同形 v4;以及在同一模拟下构造控制器、`refresh()` 通过 `enrollmentPending` 把页面身份带给宿主。**判别力对照**:把默认 `newId` 临时改回 `() => crypto.randomUUID()`,第三条由通过转失败(2/3),恢复后 3/3。 +5. **2026-10-27 19:20**:首次 `npm test` 基线为 **1124/1125**,唯一失败是 `tests/accounts-card-render.test.ts` 报 `ERR_MODULE_NOT_FOUND: react-test-renderer`——该包在 `package.json`/`package-lock.json` 里都有(`^18.3.1`),但本机 `node_modules` 缺它,属环境缺口而非代码问题。**踩坑**:先用 `npm install --no-save --no-package-lock` 补装,npm 顺带把 `rolldown` 从锁定的 `1.2.12` 抬到 `1.2.15`,`npm run build` 因此把已提交的 `lib/index.js`/`lib/index.d.ts` 整体改写(`//#region` 分组标记变成 `// `,147 行无关 diff)。改用 **`npm ci`**(102 个包,19 秒)回到锁文件树后重跑 `build`,`lib/` 的 diff 收敛为 `lib/client.js` 的 18 行新增、`lib/index.js` 与 `lib/index.d.ts` 字节不变。 +6. **2026-10-27 19:22**:最终验证(`npm ci` 干净树)。`npm run typecheck` 退出码 0;`npm test` **1129/1129**、0 失败(基线 1125 − 1 个装载失败的用例文件 + 该文件 2 条用例 + 新增 3 条);`npm run build` 两段 `Build complete`,`lib/client.js` 含 `randomId`、`getRandomValues` 与 `newId = randomId`,不再有 `() => crypto.randomUUID()` 默认值。**未做**:`test:engine`/`test:install`/`test:pack`(本轮不改引擎面、peer 范围或包清单)、真实 NAS/局域网端到端验证(本机是安全上下文,只能以去掉 `randomUUID` 的方式模拟)、推送与发版、回复上游 issue。 +7. **2026-10-27 19:24**:文档同步。`CHANGELOG.md` 的 `[Unreleased]` → `Fixed` 新增 1 条并补用例与验证边界;[客户端与设置](agent-reference/client-and-settings.md) 新增「页面身份不依赖安全上下文」条目;[仓库地图](agent-reference/repository-map.md) 补 `src/client/random-id.ts` 与 `tests/random-id.test.ts`;[《决策记录》第四章第 9 条](决策记录.md#四账号开通过程)记录现象、定因、两种未采用的方案与边界;本节与总览。**未提交前的计划**:按「源码 + 用例」与「构建产物 + 文档」分两个提交(`fix(client):` / `chore(release-artifacts):` 口径),只提交不推送。 From 7548dc0ca43ef3957e0332488624334296be41aa Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 27 Oct 2026 20:46:53 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(client):=20=E6=94=B6=E7=B4=A7=20ra?= =?UTF-8?q?ndomId=20=E7=9A=84=E5=88=86=E6=94=AF=E4=B8=8E=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 质量复查(范围限本次改动): - 去掉 randomId() 里永不可达的 `source?.`——globalThis.crypto 在所有浏览器里都存在, 可选链只会把抛错点挪到下一行 source.getRandomValues;改为 source.randomUUID,并用 一行注释说明为什么不给 crypto 对象本身设兜底。 - 去掉 newId 的 `: () => string` 标注:与 randomId 的返回类型完全一致,可由默认值推论, 该行回到「只换表达式」的最小 diff。 - 第一条用例原本与第二条断言同一条 v4 正则,关掉快速路径仍能通过,不具判别力;改为 「安全上下文直接用平台 randomUUID」的哨兵用例(兜底产出的 v4 与哨兵必然不同)。 - 测试辅助改为先取属性描述符、finally 按描述符还原,避免误删本就在自有的属性;第三条 用例的五个「未使用」抛错桩合并成一个 unused。 三处定向变异各只命中一条:关掉快速路径 → 只第一条;版本位置 0 → 只后两条;控制器默认 值改回 crypto.randomUUID() → 只第三条。typecheck 退出码 0,npm test 1129/1129, build 后 lib/client.js 相对上一提交只差 1 行(内联注释不进产物)。 --- lib/client.js | 2 +- src/client/enrollment.ts | 2 +- src/client/random-id.ts | 4 +++- tests/random-id.test.ts | 36 ++++++++++++++++++++---------------- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/lib/client.js b/lib/client.js index 3c4784a..cdc2135 100644 --- a/lib/client.js +++ b/lib/client.js @@ -2908,7 +2908,7 @@ window.__ModuleLoader__.load({ */ function randomId() { const source = globalThis.crypto; - if (typeof source?.randomUUID === "function") return source.randomUUID(); + if (typeof source.randomUUID === "function") return source.randomUUID(); const bytes = source.getRandomValues(/* @__PURE__ */ new Uint8Array(16)); bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128; diff --git a/src/client/enrollment.ts b/src/client/enrollment.ts index 3a7b81d..3137c67 100644 --- a/src/client/enrollment.ts +++ b/src/client/enrollment.ts @@ -41,7 +41,7 @@ export class AccountEnrollmentController { private watch: RemoteStreamHandle | undefined private ready: Promise | undefined private openedPage = false - constructor(private readonly remote: () => EnrollmentRemote, private readonly changed: () => void, private readonly pollMs = 750, private readonly newId: () => string = randomId, private readonly openPage = openLoginPage) { this.pageId = newId() } + constructor(private readonly remote: () => EnrollmentRemote, private readonly changed: () => void, private readonly pollMs = 750, private readonly newId = randomId, private readonly openPage = openLoginPage) { this.pageId = newId() } /** 支持 React 开发模式的挂载检查;每次挂载仍重新读取宿主事实。 */ activate(): void { if (this.disposed) this.pageId = this.newId(); this.disposed = false; void this.refresh() } reset(): void { if (!this.id) this.store.set({ ...this.store.getSnapshot(), active: undefined, error: undefined }) } diff --git a/src/client/random-id.ts b/src/client/random-id.ts index ae3bfb9..0d34c6d 100644 --- a/src/client/random-id.ts +++ b/src/client/random-id.ts @@ -5,8 +5,10 @@ * 在非安全上下文同样可用,因此只在 `randomUUID` 不可用时按 RFC 4122 自行拼一个 v4。 */ export function randomId(): string { + // `crypto` 本身在所有浏览器里都存在(`getRandomValues` 不受安全上下文限制), + // 只有 `randomUUID` 会缺席;因此不为对象本身设兜底,避免留下永不可达的分支。 const source = globalThis.crypto - if (typeof source?.randomUUID === 'function') return source.randomUUID() + if (typeof source.randomUUID === 'function') return source.randomUUID() const bytes = source.getRandomValues(new Uint8Array(16)) // 版本位固定 4(v4 UUID),变体位固定 `10`,与 `randomUUID` 的输出同形。 bytes[6] = (bytes[6]! & 0x0f) | 0x40 diff --git a/tests/random-id.test.ts b/tests/random-id.test.ts index 547974a..6eb424b 100644 --- a/tests/random-id.test.ts +++ b/tests/random-id.test.ts @@ -5,38 +5,42 @@ import { AccountEnrollmentController, type EnrollmentRemote } from '../src/clien const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ -/** 复现 `http://:3080` 的局域网访问:`crypto.randomUUID` 只属于安全上下文,那里不存在。 */ -function withoutRandomUuid(run: () => T): T { +/** + * 换掉 `crypto.randomUUID` 后运行,结束按原属性描述符还原。传 `undefined` 即复现 + * `http://:3080` 的局域网访问:该 API 只属于安全上下文,那里不存在。 + */ +function withRandomUuid(implementation: (() => string) | undefined, run: () => T): T { const source = globalThis.crypto - Object.defineProperty(source, 'randomUUID', { value: undefined, configurable: true, writable: true }) - try { return run() } finally { delete (source as { randomUUID?: unknown }).randomUUID } + const original = Object.getOwnPropertyDescriptor(source, 'randomUUID') + Object.defineProperty(source, 'randomUUID', { value: implementation, configurable: true, writable: true }) + try { return run() } finally { + if (original) Object.defineProperty(source, 'randomUUID', original) + else delete (source as { randomUUID?: unknown }).randomUUID + } } -test('页面身份是 v4 UUID,且每次不同', () => { - const ids = Array.from({ length: 100 }, () => randomId()) - assert.equal(new Set(ids).size, 100) - for (const id of ids) assert.match(id, V4) +test('安全上下文直接用平台的 randomUUID,不再自行拼装', () => { + // 未命中快速路径时会返回一个形态不同的 v4,这个哨兵值因此能分辨走了哪条分支。 + assert.equal(withRandomUuid(() => 'stub-page-id', randomId), 'stub-page-id') }) -test('没有 crypto.randomUUID(非安全上下文)时仍生成形态相同的 v4 UUID', () => { - const ids = withoutRandomUuid(() => Array.from({ length: 100 }, () => randomId())) +test('没有 crypto.randomUUID(非安全上下文)时仍生成互不相同的 v4 UUID', () => { + const ids = withRandomUuid(undefined, () => Array.from({ length: 100 }, () => randomId())) assert.equal(new Set(ids).size, 100, '退回 getRandomValues 后仍是随机值') for (const id of ids) assert.match(id, V4) }) test('非安全上下文下仍能构造页面控制器,并把同一页面身份带给宿主', async () => { const pages: string[] = [] + const unused = async (): Promise => { throw new Error('未使用') } const remote: EnrollmentRemote = { - enrollmentBegin: async () => { throw new Error('未使用') }, - enrollmentStatus: async () => { throw new Error('未使用') }, - enrollmentCancel: async () => { throw new Error('未使用') }, - enrollmentName: async () => { throw new Error('未使用') }, - enrollmentRetry: async () => { throw new Error('未使用') }, + enrollmentBegin: unused, enrollmentStatus: unused, enrollmentCancel: unused, + enrollmentName: unused, enrollmentRetry: unused, enrollmentPending: async ({ pageId }) => { pages.push(pageId); return { ok: true, value: [] } }, enrollmentWatch: () => { throw new Error('未使用') }, } // 设置页在 `useState(createEnrollment)` 的惰性初始化里构造控制器:构造抛错即整块空白(issue #102)。 - const controller = withoutRandomUuid(() => new AccountEnrollmentController(() => remote, () => {})) + const controller = withRandomUuid(undefined, () => new AccountEnrollmentController(() => remote, () => {})) await controller.refresh() controller.dispose() assert.equal(pages.length, 1) From a72f5264f628991e6ebc601b23fbf83a9ba145ff Mon Sep 17 00:00:00 2001 From: GT610 Date: Tue, 27 Oct 2026 20:47:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20#102=20?= =?UTF-8?q?=E7=9A=84=E8=B4=A8=E9=87=8F=E5=A4=8D=E6=9F=A5=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=88=86=E5=B7=A5=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG 的 [Unreleased] 用例段改为三条各管一件事(平台 API 哨兵、兜底形态与唯一性、 控制器接线)并写明三处定向变异各命中哪一条;决策记录第四章第 9 条核验段同步;提交历史 的阶段条目补第 8 条(dead code、冗余标注、无判别力用例、测试卫生、有意不做的优化), 并注明第 3、4 条被它调整的部分。 --- CHANGELOG.md | 2 +- .../\345\206\263\347\255\226\350\256\260\345\275\225.md" | 2 +- .../\346\217\220\344\272\244\345\216\206\345\217\262.md" | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b795b3d..4919e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 用例:`tests/adapter.test.ts` 新增「凭据解析与图片读取并行」「目录已在内存时不再等一次联网刷新」「超时定级复用已序列化的正文」「同一份工具 schema 只归一化一次」「用量卡片与选择器共享同一份账户事实」「凭据缺失时预算门不抢跑」「双超限一次报数」;`tests/gateway-facts.test.ts` 新增 4 条 `loaded()` 来源判据;`tests/panel.test.ts` 与 `tests/usage-client.test.ts` 新增自动/显式刷新、可见性与任意卸载顺序解绑用例;`tests/accounts-card-render.test.ts` 新增 2 条真实渲染接线用例。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1125/1125** 通过;`npm run build` 重建 `lib/`。未在真实宿主中人工验证。 - issue #102 的用例:`tests/random-id.test.ts` 3 条——`randomId()` 的 v4 形态与互不相同、去掉 `crypto.randomUUID` 后仍产出同形 v4、以及非安全上下文下构造 `AccountEnrollmentController` 并把页面身份带给宿主;把默认 `newId` 改回 `() => crypto.randomUUID()` 时第三条失败(变异对照)。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1129/1129** 通过;`npm run build` 重建 `lib/`(`lib/client.js` 含 `randomId`,`lib/index.js` 与 `lib/index.d.ts` 字节不变)。未在真实 NAS/局域网环境人工验证。 + issue #102 的用例:`tests/random-id.test.ts` 3 条——安全上下文走平台 `randomUUID`(哨兵值分辨分支,不走自行拼装)、非安全上下文退回 `getRandomValues` 时 100 个身份互不相同且为 v4、非安全上下文下构造 `AccountEnrollmentController` 并把页面身份带给宿主。三处定向变异各命中一条:关闭快速路径只失败第一条,把版本位置 0 只失败后两条,把默认 `newId` 改回 `() => crypto.randomUUID()` 只失败第三条。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1129/1129** 通过;`npm run build` 重建 `lib/`(`lib/client.js` 含 `randomId`,`lib/index.js` 与 `lib/index.d.ts` 字节不变)。未在真实 NAS/局域网环境人工验证。 ## [0.15.4] - 2026-10-24 diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index c2541b4..48c0218 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -55,7 +55,7 @@ 6. **2026-10-02 — 已登录且只差命名时离开,接受已有名称。**取消和页面断开均保留账号与凭据;确认写入失败时继续保留待处理记录。 7. **2026-10-02 — 页面生命期绑定持续远端流。**初步计划的对端作用域绑定已被真实宿主源码否定:DSH 0.2.0-rc.2 的 `OperatorPeer` 由宿主连接服务持有,所有浏览器请求共享。用户重新选择持续远端流。每页使用独立 `pageId`,先收到 `enrollmentWatch` 的就绪确认,再开始开通;卸载主动取消,刷新或关闭后由网关流取消信号触发宿主收尾。不依赖卸载事件完成异步请求,不另建心跳租约。 8. **2026-10-02 — 恢复只依据可核查事实。**宿主重启后展示日志,由用户处理;不自动重新登录或重新写密钥。若“已登录”阶段自身未能落盘,仍只有未完成日志,重启后需人工核查和处理;不能把存在凭据等同于成功登录。不承诺浏览器关闭瞬间检测、宿主崩溃后的自动补偿或真实账号端到端已验证。 -9. **2026-10-27 — 页面身份不依赖安全上下文(issue #102)。** **现象**:NAS 上的 DSH 以 Docker 运行、只经 `http://<局域网 IP>:3080` 访问(无 HTTPS、无反向代理),设置页「Command Code」标签正常但右侧整块空白,控制台只有 `TypeError: crypto.randomUUID is not a function` 与 `slot entry crashed in 'settings.section'`。**定因**:`crypto.randomUUID()` 是安全上下文专属 API,`http://` 下为 `undefined`(`crypto.getRandomValues` 仍可用);插件只有一处用它,且是 `AccountEnrollmentController` 构造函数的默认参数、构造时立刻调用,而控制器在 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化里构造——异常落在 React 渲染期,被设置页插槽的错误边界接住,于是标签已注册、内容区不渲染。**选择**:新增 `src/client/random-id.ts` 的 `randomId()`,`randomUUID` 可用时照旧,否则用 `getRandomValues` 按 RFC 4122 置入版本位与变体位拼同形 v4,`newId` 默认值改用它。**未采用**:(a) 要求用户上 HTTPS 或反向代理——把插件缺陷转成部署要求,且「标签在、内容空、无提示」这类故障极难自查;(b) 彻底降级到 `Math.random` 或时间戳——页面身份虽不参与鉴权,但没有理由放弃 `getRandomValues` 这条在非安全上下文同样可用的密码学随机源。**边界**:`pageId` 只是宿主把开通过程绑到单个浏览器页面的分组键,宿主按不透明字符串比较、不校验 UUID 格式,因此退回后的 v4 与 `randomUUID` 的输出可互换;控制器接口、宿主契约(`src/enrollment-wire.ts`)与开通过程本身零变化。**核验**:`tests/random-id.test.ts` 3 条用例(v4 形态与互不相同、去掉 `crypto.randomUUID` 后仍为同形 v4、非安全上下文下构造控制器并把页面身份带给宿主);把默认 `newId` 改回 `() => crypto.randomUUID()` 时第三条失败。**未做**:真实 NAS/局域网环境端到端验证(本机为安全上下文,只能用去掉 `randomUUID` 的方式模拟)。 +9. **2026-10-27 — 页面身份不依赖安全上下文(issue #102)。** **现象**:NAS 上的 DSH 以 Docker 运行、只经 `http://<局域网 IP>:3080` 访问(无 HTTPS、无反向代理),设置页「Command Code」标签正常但右侧整块空白,控制台只有 `TypeError: crypto.randomUUID is not a function` 与 `slot entry crashed in 'settings.section'`。**定因**:`crypto.randomUUID()` 是安全上下文专属 API,`http://` 下为 `undefined`(`crypto.getRandomValues` 仍可用);插件只有一处用它,且是 `AccountEnrollmentController` 构造函数的默认参数、构造时立刻调用,而控制器在 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化里构造——异常落在 React 渲染期,被设置页插槽的错误边界接住,于是标签已注册、内容区不渲染。**选择**:新增 `src/client/random-id.ts` 的 `randomId()`,`randomUUID` 可用时照旧,否则用 `getRandomValues` 按 RFC 4122 置入版本位与变体位拼同形 v4,`newId` 默认值改用它。**未采用**:(a) 要求用户上 HTTPS 或反向代理——把插件缺陷转成部署要求,且「标签在、内容空、无提示」这类故障极难自查;(b) 彻底降级到 `Math.random` 或时间戳——页面身份虽不参与鉴权,但没有理由放弃 `getRandomValues` 这条在非安全上下文同样可用的密码学随机源。**边界**:`pageId` 只是宿主把开通过程绑到单个浏览器页面的分组键,宿主按不透明字符串比较、不校验 UUID 格式,因此退回后的 v4 与 `randomUUID` 的输出可互换;控制器接口、宿主契约(`src/enrollment-wire.ts`)与开通过程本身零变化。**核验**:`tests/random-id.test.ts` 3 条用例——安全上下文走平台 `randomUUID`(用哨兵值分辨分支)、非安全上下文的兜底产出 100 个互不相同的 v4、非安全上下文下构造控制器并把页面身份带给宿主;三处定向变异各命中一条(关闭快速路径/版本位置 0/控制器默认值改回 `crypto.randomUUID()`)。**未做**:真实 NAS/局域网环境端到端验证(本机为安全上下文,只能用去掉 `randomUUID` 的方式模拟)。 ## 五、流响应收束 diff --git "a/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" "b/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" index 427371d..3836330 100644 --- "a/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" +++ "b/docs/\346\217\220\344\272\244\345\216\206\345\217\262.md" @@ -4,7 +4,7 @@ ## 一、总览 -- **2026-10-27 20:0x**:**修复 issue #102:`http://` 局域网访问下设置页整块空白(非安全上下文没有 `crypto.randomUUID`)。** 用户给的工作方式:本仓库是 Fork,`upstream` 是上游,用本机 `gh` 看上游 issue #102,在基于 `main` 的新分支上修、提交自行规划。**定因**:`crypto.randomUUID()` 只在安全上下文存在,NAS/Docker 里以 `http://<局域网 IP>:3080` 打开时为 `undefined`;插件唯一一处调用是 `AccountEnrollmentController` 构造函数的默认参数、构造时立刻求值,而控制器由 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化构造,异常落在渲染期、被设置页插槽错误边界接住,于是「标签在、右侧空白、无提示」。**改动**:新增 `src/client/random-id.ts`(`randomUUID` 可用时照旧,否则 `getRandomValues` + RFC 4122 拼同形 v4),`AccountEnrollmentController` 的 `newId` 默认值改用它。**验证**:`typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1129/1129**;`build` 两段 `Build complete`,`lib/client.js` 增加 `randomId`、`lib/index.js` 与 `lib/index.d.ts` 字节不变;变异对照——把默认 `newId` 改回 `() => crypto.randomUUID()` 时新增的第三条用例失败。**未做**:真实 NAS/局域网端到端验证、推送、发版;未回复上游 issue。 +- **2026-10-27 20:0x**:**修复 issue #102:`http://` 局域网访问下设置页整块空白(非安全上下文没有 `crypto.randomUUID`)。** 用户给的工作方式:本仓库是 Fork,`upstream` 是上游,用本机 `gh` 看上游 issue #102,在基于 `main` 的新分支上修、提交自行规划。**定因**:`crypto.randomUUID()` 只在安全上下文存在,NAS/Docker 里以 `http://<局域网 IP>:3080` 打开时为 `undefined`;插件唯一一处调用是 `AccountEnrollmentController` 构造函数的默认参数、构造时立刻求值,而控制器由 `AccountsCard` 的 `useState(createEnrollment)` 惰性初始化构造,异常落在渲染期、被设置页插槽错误边界接住,于是「标签在、右侧空白、无提示」。**改动**:新增 `src/client/random-id.ts`(`randomUUID` 可用时照旧,否则 `getRandomValues` + RFC 4122 拼同形 v4),`AccountEnrollmentController` 的 `newId` 默认值改用它。**验证**:`typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1129/1129**;`build` 两段 `Build complete`,`lib/client.js` 增加 `randomId`、`lib/index.js` 与 `lib/index.d.ts` 字节不变;变异对照——三处定向改动各只命中一条用例(关掉快速路径/版本位置 0/默认 `newId` 改回 `() => crypto.randomUUID()`)。**质量复查(同日 20:30)**:删掉 `randomId()` 里永不可达的 `?.` 与 `newId` 的冗余类型标注,把不具判别力的第一条用例换成「走平台 API」的哨兵用例,测试辅助改为按属性描述符还原;用例条数与测试总数不变。**未做**:真实 NAS/局域网端到端验证、推送、发版;未回复上游 issue。 - **2026-10-24 11:14**:**发布 v0.15.4(patch)**——用户核对上游报告后选择「直接同步」,随即授权发布。**版本号判据**:纯上游数据同步(CLI 请求头 `1.87.3` → `1.88.0`、一个目录新模型),无破坏性变化、公开导出面零变化,按 semver 取 patch(沿 `0.14.4`/`0.14.5`/`0.15.1`/`0.15.2` 先例)。**发布前检查全通过**:锁文件隔离校验(`npm ci --dry-run`)退出码 0、`typecheck` 退出码 0、测试 **1107/1107**、构建两段 `Build complete`、`test:engine` **2/2**(`0.2.1-rc.2` 与 `0.2.1-rc.3`,末行 `engine-load smoke passed on all 2 declared-compatible engines`)、`test:install` **2/2**(pnpm 10.34.5)、`test:pack`(**16 文件**,版本 0.15.4)、`npm pack --dry-run --ignore-scripts`(16 文件,2.5 MB/解包 3.6 MB,tarball SHA-1 `e0744870…`)。**发布执行**:发布提交 `e5cf934`(13 文件,含构建产物);附注标签 `v0.15.4` 指向该提交(与 `HEAD`、`package.json` 三者一致)并推送;`gh workflow run publish.yml -f tag=v0.15.4 -f dist_tag=latest -f publish=true` 触发可信发布([运行记录](https://github.com/Mars-Sea/dsh-commandcode-provider/actions/runs/40057178521)),**全部步骤 success**(含「校验已提交的构建产物」「test:engine」「test:install」「确认版本尚未发布」「使用可信发布上传同一发布包」;日志确认 `npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access` 与 provenance `logIndex=3434637589`)。**发布结果**:npm `latest` 约 1 分钟传播到 **0.15.4**;线上包 **16/16 文件**、解包 **3,565,822 字节**、`fileCount: 16`,注册表带 SLSA provenance(`predicateType: https://slsa.dev/provenance/v1`);线上 tarball SHA-1(`95c8d0ea…`)与本地 `npm pack --dry-run`(`e0744870…`)不同属预期——工作流在 CI 重新打包,文件 mtime 不同,文件清单与体积一致。[中英双语 GitHub Release](https://github.com/Mars-Sea/dsh-commandcode-provider/releases/tag/v0.15.4)(非草稿、非预发布、`--latest`)已创建。**未做**:真实账号登录、付费生成、服务端协议验证与浏览器渲染;`test:engine` 与 `test:install` 的网络与凭据仍为合成依赖。 @@ -491,8 +491,9 @@ 1. **2026-10-27 19:08**:读上游 issue #102(`Mars-Sea/dsh-commandcode-provider`,标题「DSH左下角设置 → Command Code 右边整块显示空白(在局域网访问,crypto.randomUUID is not a function)」,2026-10-26 创建,无评论,无标签)。**报告事实**:DSH 跑在家用 NAS 的 Docker 里(容器 PID 1 为 `dsh web`,监听 3080),仅以 `http://192.168.50.174:3080/` 局域网访问,全程无 HTTPS、无反向代理——`window.isSecureContext` 为 false。现象是设置页左侧「Command Code」标签正常、右侧内容区整块空白、无插件侧报错;控制台为 `TypeError: crypto.randomUUID is not a function`(`newId` → `new AccountEnrollmentController` → `createEnrollment` → `AccountsCard` 的 `useState`)与 `slot entry crashed in 'settings.section'`。报告者已在 `node_modules` 里用 `getRandomValues` 拼 v4 绕过并生效。环境:插件 0.15.4(npm 最新)、dsh 0.2.1-rc.2、Node 22.23.4、Chromium 客户端。 2. **2026-10-27 19:10**:先核对现状而非照抄报告。`git fetch upstream` 后 `origin/main` 与 `upstream/main` 同为 `2e4d35d`(0.15.4 之后无提交),缺陷在 `main` 上仍在:`src/client/enrollment.ts:43` 的 `newId = () => crypto.randomUUID()`。在 `src/` 全量检索 `randomUUID`/`crypto`/`navigator.`/`isSecureContext`/`subtle`/`clipboard`:浏览器侧只有这一处,其余五处均为 Host 端的 `node:crypto`(`adapter.ts`、`gateway-facts.ts`、`stream-trace.ts`、`stream-response.ts`),不存在第二个非安全上下文 API。另确认宿主侧 `src/enrollment-wire.ts` 对 `pageId`/`id` 只做 `stringField` 校验、不校验 UUID 格式,因此兜底产物不必逐位等同 `randomUUID`,但保持 v4 同形更稳妥。 -3. **2026-10-27 19:14**:落地源码。新增 `src/client/random-id.ts` 的 `randomId()`:`typeof source?.randomUUID === 'function'` 时直接用,否则 `getRandomValues(new Uint8Array(16))` 后置 `bytes[6] = (bytes[6] & 0x0f) | 0x40`、`bytes[8] = (bytes[8] & 0x3f) | 0x80` 并拼成 8-4-4-4-12。`AccountEnrollmentController` 的 `newId` 默认值由 `() => crypto.randomUUID()` 改为 `randomId`,显式标注 `() => string`(保持注入接缝与既有四处测试注入的签名不变)。**未改**:控制器其余接口、宿主契约、`openPage` 默认值。 -4. **2026-10-27 19:16**:新增 `tests/random-id.test.ts` 3 条用例:v4 形态与 100 个互不相同;用 `Object.defineProperty` 把 `globalThis.crypto.randomUUID` 置为 `undefined`(模拟非安全上下文,`finally` 里 `delete` 还原)后仍产出同形 v4;以及在同一模拟下构造控制器、`refresh()` 通过 `enrollmentPending` 把页面身份带给宿主。**判别力对照**:把默认 `newId` 临时改回 `() => crypto.randomUUID()`,第三条由通过转失败(2/3),恢复后 3/3。 +3. **2026-10-27 19:14**:落地源码。新增 `src/client/random-id.ts` 的 `randomId()`:`typeof source?.randomUUID === 'function'` 时直接用,否则 `getRandomValues(new Uint8Array(16))` 后置 `bytes[6] = (bytes[6] & 0x0f) | 0x40`、`bytes[8] = (bytes[8] & 0x3f) | 0x80` 并拼成 8-4-4-4-12。`AccountEnrollmentController` 的 `newId` 默认值由 `() => crypto.randomUUID()` 改为 `randomId`,显式标注 `() => string`(保持注入接缝与既有四处测试注入的签名不变)。**未改**:控制器其余接口、宿主契约、`openPage` 默认值。**本条的两处细节已被下文第 8 条调整**:`typeof source?.randomUUID` 的多余可选链与 `: () => string` 的冗余标注都在质量复查中删除。 +4. **2026-10-27 19:16**:新增 `tests/random-id.test.ts` 3 条用例:v4 形态与 100 个互不相同;用 `Object.defineProperty` 把 `globalThis.crypto.randomUUID` 置为 `undefined`(模拟非安全上下文,`finally` 里 `delete` 还原)后仍产出同形 v4;以及在同一模拟下构造控制器、`refresh()` 通过 `enrollmentPending` 把页面身份带给宿主。**判别力对照**:把默认 `newId` 临时改回 `() => crypto.randomUUID()`,第三条由通过转失败(2/3),恢复后 3/3。**用例分工已被下文第 8 条重排**:原第一条不具判别力,已换成「走平台 API」的哨兵用例。 5. **2026-10-27 19:20**:首次 `npm test` 基线为 **1124/1125**,唯一失败是 `tests/accounts-card-render.test.ts` 报 `ERR_MODULE_NOT_FOUND: react-test-renderer`——该包在 `package.json`/`package-lock.json` 里都有(`^18.3.1`),但本机 `node_modules` 缺它,属环境缺口而非代码问题。**踩坑**:先用 `npm install --no-save --no-package-lock` 补装,npm 顺带把 `rolldown` 从锁定的 `1.2.12` 抬到 `1.2.15`,`npm run build` 因此把已提交的 `lib/index.js`/`lib/index.d.ts` 整体改写(`//#region` 分组标记变成 `// `,147 行无关 diff)。改用 **`npm ci`**(102 个包,19 秒)回到锁文件树后重跑 `build`,`lib/` 的 diff 收敛为 `lib/client.js` 的 18 行新增、`lib/index.js` 与 `lib/index.d.ts` 字节不变。 6. **2026-10-27 19:22**:最终验证(`npm ci` 干净树)。`npm run typecheck` 退出码 0;`npm test` **1129/1129**、0 失败(基线 1125 − 1 个装载失败的用例文件 + 该文件 2 条用例 + 新增 3 条);`npm run build` 两段 `Build complete`,`lib/client.js` 含 `randomId`、`getRandomValues` 与 `newId = randomId`,不再有 `() => crypto.randomUUID()` 默认值。**未做**:`test:engine`/`test:install`/`test:pack`(本轮不改引擎面、peer 范围或包清单)、真实 NAS/局域网端到端验证(本机是安全上下文,只能以去掉 `randomUUID` 的方式模拟)、推送与发版、回复上游 issue。 -7. **2026-10-27 19:24**:文档同步。`CHANGELOG.md` 的 `[Unreleased]` → `Fixed` 新增 1 条并补用例与验证边界;[客户端与设置](agent-reference/client-and-settings.md) 新增「页面身份不依赖安全上下文」条目;[仓库地图](agent-reference/repository-map.md) 补 `src/client/random-id.ts` 与 `tests/random-id.test.ts`;[《决策记录》第四章第 9 条](决策记录.md#四账号开通过程)记录现象、定因、两种未采用的方案与边界;本节与总览。**未提交前的计划**:按「源码 + 用例」与「构建产物 + 文档」分两个提交(`fix(client):` / `chore(release-artifacts):` 口径),只提交不推送。 +7. **2026-10-27 19:24**:文档同步。`CHANGELOG.md` 的 `[Unreleased]` → `Fixed` 新增 1 条并补用例与验证边界;[客户端与设置](agent-reference/client-and-settings.md) 新增「页面身份不依赖安全上下文」条目;[仓库地图](agent-reference/repository-map.md) 补 `src/client/random-id.ts` 与 `tests/random-id.test.ts`;[《决策记录》第四章第 9 条](决策记录.md#四账号开通过程)记录现象、定因、两种未采用的方案与边界;本节与总览。**未提交前的计划**:按「源码 + 用例」与「构建产物 + 文档」分两个提交(`fix(client):` / `chore(release-artifacts):` 口径),只提交不推送。实际落地为 `2e4d85b`(源码 + 用例 + 构建产物)与 `96c7d9a`(文档)。 +8. **2026-10-27 20:30**:同一范围内的质量复查(用户要求只查本次改动,范围外的不动)。**dead code**:`randomId()` 里的 `typeof source?.randomUUID` 是永不可达的防御——`globalThis.crypto` 在所有浏览器里都存在,可选链只能把抛错点从「取属性」挪到下一行 `source.getRandomValues`,与自己随后直接解引用 `source` 相矛盾;改为 `typeof source.randomUUID`,并把「为什么不给 `crypto` 对象本身设兜底」写成两行注释(该行内注释经确认不进 `lib/` 产物,只有文件头 JSDoc 会被保留)。**冗余代码**:`newId: () => string = randomId` 的类型标注与 `randomId` 的返回类型完全一致(`() => string` 由默认值推论可得),删掉后该行回到「只换表达式」的最小 diff。**无效用例**:原第一条「v4 形态与互不相同」与第二条「兜底后的 v4」断言同一条正则,关掉快速路径它照样通过——不具判别力;改为「安全上下文直接用平台 `randomUUID`」的哨兵用例(`withRandomUuid(() => 'stub-page-id', randomId)` 必须原样返回;兜底产出的 v4 必然不同,故能分辨分支),兜底形态与唯一性完全由第二条承担。**测试卫生**:`withRandomUuid` 先取 `Object.getOwnPropertyDescriptor`、`finally` 里按描述符还原(原写法在「属性本就是自有属性」的环境里会把它直接 `delete` 掉);第三条用例的五个「未使用」抛错桩合并成一个 `unused`。**三处定向变异,各只命中一条**:关掉快速路径 → 只第一条失败;版本位 `| 0x40` 改 `| 0x00` → 只后两条失败;控制器默认值改回 `() => crypto.randomUUID()` → 只第三条失败。**有意不做**:把 `Array.from(...).padStart` 换成查表拼装——每次挂载只调用一到两次,省下的时间不可观测,属提前优化;也不缓存页面身份,那会破坏「每次开通一个身份」的语义;兜底不降到 `Math.random`(`getRandomValues` 在非安全上下文本就可用)。**验证**:`typecheck` 退出码 0;`npm test` **1129/1129**(用例条数不变,只换了分工);`build` 两段 `Build complete`,`lib/client.js` 相对 `2e4d85b` 只差 1 行(`?.` 去掉),`lib/index.js` 与 `lib/index.d.ts` 不变。