From 5ee544081ade777d4d72b2fd59f3d4c51cfb1ba3 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:56:31 -0700 Subject: [PATCH 1/7] fix(web): unblock remote welcome onboarding --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 +- ...-07-30-versioned-gui-welcome-onboarding.md | 8 +-- ...-30-versioned-gui-welcome-onboarding.zh.md | 8 +-- apps/web/tests/remote-welcome.e2e.ts | 53 +++++++++++++++++++ apps/web/tests/scaffold.ts | 15 ++++-- apps/web/tsconfig.json | 1 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../ui-settings-general/src/client/index.ts | 14 ++++- .../src/client/welcome-store.ts | 34 +++++++++--- .../ui-settings-general/tests/apply.spec.ts | 18 ++++++- .../tests/welcome-store.spec.ts | 15 ++++++ tsconfig.host.json | 1 + 14 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 apps/web/tests/remote-welcome.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 579586f30a..6a109260c1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af -2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7 +2026-07-30-versioned-gui-welcome-onboarding.md: 1199cec532dd23930c70b236e6fcac80832204ff +2026-07-30-versioned-gui-welcome-onboarding.zh.md: d59e8971689fad1836127fea79cd392d3d5787c3 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 8155838f3b..1199cec532 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -14,15 +14,15 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. -**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. +**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. -**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. +**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. **Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists. ## Alternatives considered -**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. +**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. Non-loopback fallback therefore remains process-local rather than browser-profile-local. **A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list. @@ -32,4 +32,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, ## Consequences -A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. +A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. On loopback, reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. On non-loopback, Continue advances the live process without a privileged settings request and reload presents the notice again. Focused store and React tests pin both persistence modes, exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index c221a6d663..d59e897168 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -14,15 +14,15 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 -**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。 +**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 -**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 +**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 **引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。 ## 曾考虑的替代方案 -**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。 +**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。因此,非 loopback 的回退保持为进程内状态,而不是浏览器 profile 状态。 **在 `ui-settings-general` 中再增加一个独立模态窗口**:不予采用,因为欢迎通知和凭据就绪状态同时为真时,list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。 @@ -32,4 +32,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 ## 后果 -全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 +全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。在 loopback 上,点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。在非 loopback 上,「继续」会在不发起受保护 settings 请求的情况下推进当前进程,重新加载则再次显示通知。针对性的 store 与 React 测试固化了两种持久化模式、精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts new file mode 100644 index 0000000000..483d6c369c --- /dev/null +++ b/apps/web/tests/remote-welcome.e2e.ts @@ -0,0 +1,53 @@ +// Trusted non-loopback Web access must not wedge on the loopback-only +// settings API while the mandatory product notice owns the viewport. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' +import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general' + +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('#root', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('advances process-locally and presents the notice again after reload', async () => { + const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }) + await welcome.waitFor({ timeout: 15_000 }) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) + + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + await expect.poll( + () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), + { timeout: 15_000 }, + ).toBe(false) + const reloadWarnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, reloadWarnings) + await welcome.waitFor({ timeout: 15_000 }) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1b8afbb247..62a1d31d0d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -89,7 +89,7 @@ const REPLAY_PROVIDERS = [{ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ mode: WebSnapshotMode - /** Browser-facing origin (http://127.0.0.1:). */ + /** Browser-facing origin for the bound test server. */ baseUrl: string /** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */ ctx: Context @@ -166,6 +166,8 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** Browse through this trusted non-loopback hostname while the test server stays bound to loopback. */ + remoteAuthority?: string } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -185,6 +187,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste export async function launchWebScaffold(options: LaunchOptions = {}): Promise { requireDist() const mode = webSnapshotMode() + const browserHost = options.remoteAuthority ?? '127.0.0.1' if (mode === 'record') { // Both owning vitest configs (web unconditionally, snapshot in record // mode) load the repo-root .env before this file runs. @@ -261,7 +264,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +function welcomePersistence(): 'host' | 'memory' { + return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' +} + /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT @@ -61,7 +73,7 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle - const welcomeController = new WelcomeNoticeStore(connection.api) + const welcomeController = new WelcomeNoticeStore(connection.api, welcomePersistence()) const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index ad0e18305c..fdff28f052 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -1,4 +1,4 @@ -/** Durable welcome-notice state over the Host settings document. */ +/** Welcome-notice state, durable when the browser may use Host settings. */ import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function acknowledgementOf(view: SettingsNamespaceView): string | undefined { return typeof value === 'string' ? value : undefined } -/** Coordinates welcome acknowledgement reads and the sole durable write. */ +/** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ @@ -33,12 +33,22 @@ export class WelcomeNoticeStore { private generation = 0 - /** @param api - settings wire face used for durable reads and writes. */ - constructor(private readonly api: Pick) {} + /** + * @param api - settings wire face used for durable reads and writes. + * @param persistence - remote browsers use memory because settings is loopback-only. + */ + constructor( + private readonly api: Pick, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} - /** Load the current acknowledgement from the Host settings document. */ + /** Load the acknowledgement from Host settings or initialize process-local state. */ async load(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { state.status = 'ready'; state.error = null }) + return + } this.store.update((state) => { state.status = 'loading'; state.error = null }) try { const response = await this.api.settings.describe({}) @@ -64,12 +74,20 @@ export class WelcomeNoticeStore { } /** - * Persist this copy version. The path mutation is idempotent across tabs and - * preserves every sibling setting; failure leaves the step unacknowledged. - * @returns true only when the Host committed the acknowledgement. + * Acknowledge this copy version. The Host path mutation is idempotent across + * tabs and preserves sibling settings; remote fallback changes only this store. + * @returns true when the selected persistence mode accepted the acknowledgement. */ async acknowledge(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = true + state.error = null + }) + return true + } this.store.update((state) => { state.status = 'saving'; state.error = null }) try { const response = await this.api.settings.mutate({ diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 81a56a9a4a..17e4ced2b2 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,6 +1,6 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -16,6 +16,8 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') +afterEach(() => { vi.unstubAllGlobals() }) + /** The five seats this plugin fills (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], @@ -159,6 +161,20 @@ describe('ui-settings-general apply', () => { await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) }) + it('keeps remote welcome acknowledgement process-local', async () => { + vi.stubGlobal('location', { hostname: '192.0.2.20' }) + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.onboarding')[0]! + const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() + + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + expect(b.settingsDescribe).not.toHaveBeenCalled() + }) + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { const b = await bench() const redeclare = declare(b.slots) diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.spec.ts index 28c7b0509c..45e4ca5590 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.spec.ts @@ -30,6 +30,21 @@ function deferred() { } describe('WelcomeNoticeStore', () => { + it('acknowledges in memory without calling loopback-only settings APIs', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory') + + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) + it('acknowledges only the exact current copy version', async () => { for (const [version, acknowledged] of [ [undefined, false], diff --git a/tsconfig.host.json b/tsconfig.host.json index 6e0860317c..a88257a8ff 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -23,6 +23,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", From 7a71ab9a92cbb36f5e5e08fe6629ee245b84ac45 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:24:49 -0700 Subject: [PATCH 2/7] fix(web): share loopback hostname policy --- .../client/connection/src/api-request-trust.ts | 9 +-------- packages/client/connection/src/client/index.ts | 2 ++ .../client/connection/src/loopback-hostname.ts | 12 ++++++++++++ .../connection/tests/loopback-hostname.spec.ts | 18 ++++++++++++++++++ .../ui-settings-general/src/client/index.ts | 10 +--------- 5 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 packages/client/connection/src/loopback-hostname.ts create mode 100644 packages/client/connection/tests/loopback-hostname.spec.ts diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 8c1bddd631..ecb180dca7 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -14,6 +14,7 @@ */ import type { IncomingHttpHeaders } from 'node:http' +import { isLoopbackHostname } from './loopback-hostname.ts' /** The request facts the fence reads (structural subset of IncomingMessage). */ interface ApiTrustRequest { @@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined return typeof value === 'string' ? value : undefined } -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ function parseAuthority(authority: string): URL | undefined { try { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a7ebfbbd86..daa20f1672 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -9,6 +9,8 @@ import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +export { isLoopbackHostname } from '../loopback-hostname.ts' + // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts new file mode 100644 index 0000000000..8fd30445bd --- /dev/null +++ b/packages/client/connection/src/loopback-hostname.ts @@ -0,0 +1,12 @@ +/** + * Whether a normalized URL hostname names the local loopback authority. + * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). + * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} diff --git a/packages/client/connection/tests/loopback-hostname.spec.ts b/packages/client/connection/tests/loopback-hostname.spec.ts new file mode 100644 index 0000000000..d0eaf3e0c4 --- /dev/null +++ b/packages/client/connection/tests/loopback-hostname.spec.ts @@ -0,0 +1,18 @@ +/** Shared loopback-hostname semantics for the Host fence and browser UI. */ + +import { describe, expect, it } from 'vitest' +import { isLoopbackHostname } from '../src/loopback-hostname.ts' + +describe('isLoopbackHostname', () => { + it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => { + for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) { + expect(isLoopbackHostname(hostname)).toBe(true) + } + }) + + it('refuses malformed and non-loopback hostnames', () => { + for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) { + expect(isLoopbackHostname(hostname)).toBe(false) + } + }) +}) diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index f2449391ce..c0c0fb569f 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,7 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { isLoopbackHostname, type ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -41,14 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell chrome + General copy). */ const NS = 'settings' -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - function welcomePersistence(): 'host' | 'memory' { return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' } From 57343cfeb8eaa3bea4151b4d59a54509a914483a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:30:53 -0700 Subject: [PATCH 3/7] fix(web): expose loopback policy safely --- packages/client/connection/package.json | 1 + packages/client/connection/src/client/index.ts | 2 -- packages/client/tsdown.client.ts | 2 +- packages/client/ui-settings-general/src/client/index.ts | 3 ++- scripts/client-bundle-purity.spec.ts | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d86b2bdf2a..f2842ec061 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -19,6 +19,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./loopback-hostname": "./src/loopback-hostname.ts", "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index daa20f1672..a7ebfbbd86 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -9,8 +9,6 @@ import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -export { isLoopbackHostname } from '../loopback-hostname.ts' - // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 2ff1856b3d..fad00fe4f0 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -28,7 +28,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|session|llm|tools|brand)(?:\/|$)|@deepseek-ai\/dsh-client-connection\/loopback-hostname$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index c0c0fb569f..6a7510d4ac 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,8 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import { isLoopbackHostname, type ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { isLoopbackHostname } from '@deepseek-ai/dsh-client-connection/loopback-hostname' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index d70964bdba..2eef1ba5ef 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -53,6 +53,7 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-client-connection/loopback-hostname')).toBeNull() }) it('throws on any other @deepseek-ai leak', () => { From 59d923395a76db74846be329bbeb4b2bf1112db1 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:14:35 -0700 Subject: [PATCH 4/7] fix(web): document remote welcome constraints --- apps/web/tests/scaffold.ts | 6 +++++- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/loopback-hostname.ts | 9 +++++++++ .../ui-settings-general/src/client/welcome-store.ts | 4 +++- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 62a1d31d0d..e1bb5b58c4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -166,7 +166,11 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean - /** Browse through this trusted non-loopback hostname while the test server stays bound to loopback. */ + /** + * Browse through a trusted non-loopback hostname that the browser resolves + * to loopback (for example `*.localhost`). The test server stays bound to + * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. + */ remoteAuthority?: string } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 974e3014d6..e159696db3 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d -README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45 +README.md: 522ae6a14a3b4b07e7f2917133d16a5e83433f69 +README.zh.md: 4eaed862df678997b328d7a4ddcab1f5254c7c60 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index c8b7c4787c..522ae6a14a 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The dedicated `./loopback-hostname` source subpath exposes the zero-dependency predicate shared by the `/api` Host fence and browser welcome-persistence selection; client bundlers inline this source entry, while plain Node cannot load it directly, so it must remain browser-safe and dependency-free. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 693420183f..4eaed862df 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。专用的 `./loopback-hostname` 源码子路径导出 `/api` Host fence 与浏览器欢迎页持久化选择共用的零依赖判定函数;客户端 bundler 会内联这一源码入口,而 plain Node 无法直接加载它,因此它必须保持浏览器安全且零依赖。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts index 8fd30445bd..5666f0714d 100644 --- a/packages/client/connection/src/loopback-hostname.ts +++ b/packages/client/connection/src/loopback-hostname.ts @@ -1,3 +1,12 @@ +/** + * Browser-safe, zero-dependency loopback classification shared by the `/api` + * Host fence and browser welcome-persistence selection. The dedicated + * `./loopback-hostname` source subpath is inlined into client bundles instead + * of loaded by plain Node, so this module must not add Node-only or runtime + * dependencies. + * @module @deepseek-ai/dsh-client-connection/loopback-hostname + */ + /** * Whether a normalized URL hostname names the local loopback authority. * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index fdff28f052..c95c9e46d8 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -117,7 +117,9 @@ export class WelcomeNoticeStore { } /** - * Refresh only after the welcome step has begun reading durable state. + * Refresh only after welcome state has left idle. A memory-mode load retains + * acknowledgement so reconnect and settings-change refreshes do not reopen a + * process-local notice. * @param controller - welcome state owner whose current status decides whether to load. */ export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void { From fc5ee9f786fb15b612474202f97ec83ec2c6fdfd Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 3 Aug 2026 18:34:00 +0800 Subject: [PATCH 5/7] refactor(client): expose loopback state through connection --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 ++-- ...-07-30-versioned-gui-welcome-onboarding.md | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 2 +- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/package.json | 1 - .../client/connection/src/client/index.ts | 7 ++++++- .../connection/src/loopback-hostname.ts | 7 ++----- .../connection/tests/client-apply.spec.ts | 20 +++++++++++++------ .../client/runtime/tests/client-apply.spec.ts | 1 + .../client/runtime/tests/wire-events.spec.ts | 1 + packages/client/tsdown.client.ts | 2 +- .../ui-settings-general/src/client/index.ts | 7 +------ .../ui-settings-general/tests/apply.spec.ts | 11 ++++------ scripts/client-bundle-purity.spec.ts | 1 - 16 files changed, 38 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 6a109260c1..10de05ea42 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 1199cec532dd23930c70b236e6fcac80832204ff -2026-07-30-versioned-gui-welcome-onboarding.zh.md: d59e8971689fad1836127fea79cd392d3d5787c3 +2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be +2026-07-30-versioned-gui-welcome-onboarding.zh.md: c9d2e6274c476c59abc077aa3255ffe57a8a48bc diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 1199cec532..4707769d4f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -14,7 +14,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. -**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. +**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. **Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index d59e897168..c9d2e6274c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -14,7 +14,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 -**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 +**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定函数留在 connection 包内,其他客户端插件只消费服务状态,不跨插件导入实现函数。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 **并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index e159696db3..a636d8bc49 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 522ae6a14a3b4b07e7f2917133d16a5e83433f69 -README.zh.md: 4eaed862df678997b328d7a4ddcab1f5254c7c60 +README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 +README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 522ae6a14a..f537fee327 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The dedicated `./loopback-hostname` source subpath exposes the zero-dependency predicate shared by the `/api` Host fence and browser welcome-persistence selection; client bundlers inline this source entry, while plain Node cannot load it directly, so it must remain browser-safe and dependency-free. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 4eaed862df..a29d2c00e7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。专用的 `./loopback-hostname` 源码子路径导出 `/api` Host fence 与浏览器欢迎页持久化选择共用的零依赖判定函数;客户端 bundler 会内联这一源码入口,而 plain Node 无法直接加载它,因此它必须保持浏览器安全且零依赖。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index f2842ec061..d86b2bdf2a 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -19,7 +19,6 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./loopback-hostname": "./src/loopback-hostname.ts", "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a7ebfbbd86..9e33e63ce2 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +import { isLoopbackHostname } from '../loopback-hostname.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { @@ -48,6 +49,8 @@ export const inject: string[] = [] export interface ConnectionHandle { /** Shared api client (fixture or real, decided at boot from the page URL). */ readonly api: IApiClient + /** Whether the current page authority is loopback; non-browser contexts default to true. */ + readonly isLoopback: boolean /** * Start the connect/pump/reconnect loop with the consumer's frame sinks. * One consumer owns the streams (the runtime object layer); a second call @@ -64,11 +67,13 @@ export interface ConnectionHandle { * @param ctx - client cordis context. */ export function apply(ctx: Context): void { - const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture') + const pageLocation = typeof location === 'undefined' ? undefined : location + const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() let started = false const handle: ConnectionHandle = { api, + isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts index 5666f0714d..fe2fe93fc9 100644 --- a/packages/client/connection/src/loopback-hostname.ts +++ b/packages/client/connection/src/loopback-hostname.ts @@ -1,10 +1,7 @@ /** * Browser-safe, zero-dependency loopback classification shared by the `/api` - * Host fence and browser welcome-persistence selection. The dedicated - * `./loopback-hostname` source subpath is inlined into client bundles instead - * of loaded by plain Node, so this module must not add Node-only or runtime - * dependencies. - * @module @deepseek-ai/dsh-client-connection/loopback-hostname + * Host fence and the package's `ctx.connection` state. The predicate stays + * package-internal; client plugins consume the derived state through Cordis. */ /** diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6892dc7721..43c71dffb7 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,7 +8,7 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { search: string } } +type Win = { location?: { hostname: string; search: string } } afterEach(() => { delete (globalThis as Win).location @@ -24,20 +24,28 @@ async function mount(): Promise { describe('connection client apply', () => { it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) }) it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } expect((await mount()).api).toBeInstanceOf(FixtureApiClient) delete (globalThis as Win).location - expect((await mount()).api).toBeInstanceOf(WebApiClient) + const handle = await mount() + expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) + }) + + it('reports non-loopback page authority through the connection handle', async () => { + ;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' } + expect((await mount()).isLoopback).toBe(false) }) it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() // config omitted: the `config ?? {}` default arm is part of the surface. const loop = handle.start({}) @@ -46,7 +54,7 @@ describe('connection client apply', () => { }) it('WebApiClient carries requests over globalThis.fetch', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch const seen: string[] = [] diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d389efe319..e5691a0619 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,6 +26,7 @@ async function mount(): Promise { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index fd7858d60c..21e7f1fc06 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,6 +20,7 @@ async function mount(): Promise { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index fad00fe4f0..2ff1856b3d 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -28,7 +28,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|session|llm|tools|brand)(?:\/|$)|@deepseek-ai\/dsh-client-connection\/loopback-hostname$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 6a7510d4ac..87b05e86cb 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,6 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import { isLoopbackHostname } from '@deepseek-ai/dsh-client-connection/loopback-hostname' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -42,10 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell chrome + General copy). */ const NS = 'settings' -function welcomePersistence(): 'host' | 'memory' { - return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' -} - /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT @@ -66,7 +61,7 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle - const welcomeController = new WelcomeNoticeStore(connection.api, welcomePersistence()) + const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 17e4ced2b2..73f6d8207e 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,6 +1,6 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -16,8 +16,6 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -afterEach(() => { vi.unstubAllGlobals() }) - /** The five seats this plugin fills (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], @@ -27,7 +25,7 @@ const SEATS = [ ['settings.onboarding', WelcomeNotice], ] as const -async function bench() { +async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) @@ -49,7 +47,7 @@ async function bench() { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never) + ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } } @@ -162,8 +160,7 @@ describe('ui-settings-general apply', () => { }) it('keeps remote welcome acknowledgement process-local', async () => { - vi.stubGlobal('location', { hostname: '192.0.2.20' }) - const b = await bench() + const b = await bench(false) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding')[0]! diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 2eef1ba5ef..d70964bdba 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -53,7 +53,6 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() - expect(resolveId('@deepseek-ai/dsh-client-connection/loopback-hostname')).toBeNull() }) it('throws on any other @deepseek-ai leak', () => { From 3770d947cf0c6b16ecd640d3e44e4e6bf683fcab Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 3 Aug 2026 18:43:18 +0800 Subject: [PATCH 6/7] docs(web): correct welcome acknowledgement contract --- .../client/ui-settings-general/src/client/WelcomeNotice.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx index 0c381e02c9..c25b1bf3aa 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -31,7 +31,7 @@ export interface WelcomeNoticeInjected { export type WelcomeNoticeProps = PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected -/** Render the mandatory notice until its current version commits durably. */ +/** Render the mandatory notice until its current version is acknowledged. */ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { const { complete, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) From aec9e3145e078ecb3078b19a0c839378584f8adc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:25 +0800 Subject: [PATCH 7/7] chore: enable Issue management automation --- .github/issue-management/policy.mjs | 16 ++++++- .github/issue-management/policy.test.mjs | 19 ++++++++ .github/workflows/issue-lifecycle.yml | 58 ++++++++++++++++++++++++ .github/workflows/issue-policy.yml | 27 +++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-lifecycle.yml create mode 100644 .github/workflows/issue-policy.yml diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index bc8c881eda..4c9242bab5 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -180,6 +180,20 @@ export function parseReferences({ body, repository }) { } } +/** + * Retain only references that resolve to Issues rather than pull requests. + * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references. + * @param {Map} issues Resolved same-repository Issues. + * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references. + */ +export function retainIssueReferences(references, issues) { + return { + all: references.all.filter((number) => issues.has(number)), + resolving: references.resolving.filter((number) => issues.has(number)), + related: references.related.filter((number) => issues.has(number)), + } +} + /** * Validate one Issue with its Project status. * @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot. @@ -472,7 +486,7 @@ async function pullRequestSnapshot(number) { reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, reviewCount: reviews.length, labels: pull.labels.map((label) => label.name), - references, + references: retainIssueReferences(references, issues), issues, } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 890247db29..8e0c253796 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' import { countVisibleUnits, parseReferences, + retainIssueReferences, requiresPullRequestPolicy, validateBody, validateIssue, @@ -117,6 +118,24 @@ test('separates resolving and informational references', () => { ) }) +test('does not treat pull request references as Issue associations', () => { + const references = { + all: [123, 1180, 1181], + resolving: [123, 1180], + related: [1181], + } + const issues = new Map([ + [1180, {}], + [1181, {}], + ]) + + assert.deepEqual(retainIssueReferences(references, issues), { + all: [1180, 1181], + resolving: [1180], + related: [1181], + }) +}) + test('allows informational references without cross-object constraints', () => { const errors = validatePullRequest({ isDraft: false, diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml new file mode 100644 index 0000000000..4dc6869e27 --- /dev/null +++ b/.github/workflows/issue-lifecycle.yml @@ -0,0 +1,58 @@ +name: Issue lifecycle + +on: + issues: + types: + - opened + - edited + - assigned + - unassigned + - labeled + - unlabeled + - closed + - reopened + - field_added + - field_removed + pull_request: + types: + - opened + - edited + - synchronize + - reopened + - labeled + - unlabeled + - ready_for_review + - review_requested + pull_request_review: + types: + - submitted + +permissions: + contents: read + +concurrency: + group: issue-lifecycle-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + +jobs: + lifecycle: + name: Issue lifecycle + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Create project token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }} + private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }} + owner: deepseek-harness + repositories: deepseek-harness + - name: Handle repository event + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: node .github/issue-management/policy.mjs lifecycle diff --git a/.github/workflows/issue-policy.yml b/.github/workflows/issue-policy.yml new file mode 100644 index 0000000000..dde9462c33 --- /dev/null +++ b/.github/workflows/issue-policy.yml @@ -0,0 +1,27 @@ +name: Issue policy + +on: + pull_request: + types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested] + pull_request_review: + types: [submitted] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + policy: + name: Issue policy + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Validate pull request + env: + GITHUB_TOKEN: ${{ github.token }} + run: node .github/issue-management/policy.mjs pr