From 4dd4cc0d32cbfb1b28e3fa826b1ebb31e13d8bb7 Mon Sep 17 00:00:00 2001 From: fairy <3054158379@qq.com> Date: Mon, 21 Sep 2026 14:47:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(enterprise):=20=E8=A1=A5=E9=BD=90=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=85=83=E6=95=B0=E6=8D=AE=E3=80=81=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=94=A4=E5=9B=9E=E4=B8=8E=E6=97=A7=E8=AE=BE=E7=BD=AE=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 #492 干净重投登录版本字段、登录后唤回桌面和旧预览设置清理,并加固迁移失败路径,避免把 0.9.1 历史或 #484 已合内容带进 V0.9.2。 Co-authored-by: Cursor --- docs/enterprise-login-mock.zh.md | 7 + scripts/mock-bisheng-enterprise.mjs | 6 +- src/main/enterprise/enterprise-desktop.ts | 4 + src/main/enterprise/enterprise-service.ts | 11 +- .../enterprise/legacy-settings-migration.ts | 94 ++++++++++ src/main/index.ts | 20 ++ ...terprise-legacy-settings-migration.test.ts | 173 ++++++++++++++++++ test/enterprise-service.test.ts | 63 ++++++- 8 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 src/main/enterprise/legacy-settings-migration.ts create mode 100644 test/enterprise-legacy-settings-migration.test.ts diff --git a/docs/enterprise-login-mock.zh.md b/docs/enterprise-login-mock.zh.md index 17318c4f5..52b9a4cee 100644 --- a/docs/enterprise-login-mock.zh.md +++ b/docs/enterprise-login-mock.zh.md @@ -36,6 +36,13 @@ http://127.0.0.1:17860 5. 点击「可用模型」右侧的刷新图标,刚调用的模型用量与百分比应增加。 6. 点击「退出登录」,企业 provider 立即移除,本地加密凭证清空,Mock 会话被撤销。 +## 旧版接续核验 + +- 主进程从 Electron `app.getVersion()` 读取应用版本,在授权请求中发送可选 `client_version`,设备名固定为 `DSH Desktop`。Mock 保存授权事务及会话的 `clientVersion`;真实 Gateway 需要支持该字段。不增加未知字段降级重试。 +- 登录成功并同步模型后唤回桌面窗口;唤醒失败只记日志,不撤销已完成的登录,刷新会话不重复唤醒。 +- Harness 启动前清理旧预览版写入的企业 provider 和账号快照,保留其他设置及用户自行配置的 provider。 +- `enterprise-credentials.v1` 不迁移到 v2,使用旧预览版的用户需要重新登录。 + ## 证据边界 - 本地自动化与 Mock:可验证 DSH 客户端合同和错误处理。 diff --git a/scripts/mock-bisheng-enterprise.mjs b/scripts/mock-bisheng-enterprise.mjs index a1abc9382..fbaaa13bd 100644 --- a/scripts/mock-bisheng-enterprise.mjs +++ b/scripts/mock-bisheng-enterprise.mjs @@ -237,12 +237,12 @@ export function createMockEnterpriseServer(options = {}) { if (request.method === 'POST' && url.pathname === '/api/dsh/authorizations') { const body = await readJson(request) - if (!exactFields(body, ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'state'], ['device_name']) || body.client_id !== CLIENT_ID || body.code_challenge_method !== 'S256' || !validCallback(body.redirect_uri) || !/^[A-Za-z0-9_-]{43}$/u.test(body.code_challenge) || !/^[A-Za-z0-9_-]{43}$/u.test(body.state)) { + if (!exactFields(body, ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'state'], ['device_name', 'client_version']) || body.client_version != null && (typeof body.client_version !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/u.test(body.client_version)) || body.client_id !== CLIENT_ID || body.code_challenge_method !== 'S256' || !validCallback(body.redirect_uri) || !/^[A-Za-z0-9_-]{43}$/u.test(body.code_challenge) || !/^[A-Za-z0-9_-]{43}$/u.test(body.state)) { return sendError(response, 400, 'invalid_authorization_request', 'Authorization request is invalid.', 'invalid_request_error', id) } const authId = opaque('auth_') state.authorizations.set(authId, { - id: authId, redirectUri: body.redirect_uri, challenge: body.code_challenge, + id: authId, redirectUri: body.redirect_uri, challenge: body.code_challenge, clientVersion: body.client_version ?? null, state: body.state, expiresAt: Date.now() + AUTH_TTL_MS, consumed: false }) const authorize = new URL('/desktop-login', state.origin) @@ -287,7 +287,7 @@ export function createMockEnterpriseServer(options = {}) { } ticket.consumed = true const user = state.users.get(ticket.userId) - const session = { id: opaque('session_'), userId: user.id, expiresAt: Date.now() + REFRESH_TTL_SECONDS * 1000, revoked: false } + const session = { id: opaque('session_'), userId: user.id, clientVersion: auth.clientVersion, expiresAt: Date.now() + REFRESH_TTL_SECONDS * 1000, revoked: false } const models = new Map(user.models.map((model) => [model, INITIAL_MODEL_USAGE[model] ?? 0])) const total = [...models.values()].reduce((sum, value) => sum + value, 0) state.sessions.set(session.id, session) diff --git a/src/main/enterprise/enterprise-desktop.ts b/src/main/enterprise/enterprise-desktop.ts index e46ca755a..e7264b90a 100644 --- a/src/main/enterprise/enterprise-desktop.ts +++ b/src/main/enterprise/enterprise-desktop.ts @@ -19,6 +19,8 @@ export interface EnterpriseDesktopRuntime { } export async function startEnterpriseDesktop(options: { + desktopVersion?: string + activateDesktop?: () => Promise | void userDataPath: string safeStorage: SafeStorageCryptoAdapter fetchImpl: EnterpriseFetch @@ -30,6 +32,8 @@ export async function startEnterpriseDesktop(options: { options.safeStorage ) const service = new EnterpriseService({ + desktopVersion: options.desktopVersion, + activateDesktop: options.activateDesktop, vault, fetchImpl: options.fetchImpl, allowInsecureLoopback: options.allowInsecureLoopback, diff --git a/src/main/enterprise/enterprise-service.ts b/src/main/enterprise/enterprise-service.ts index 47591f7c4..10b911d4a 100644 --- a/src/main/enterprise/enterprise-service.ts +++ b/src/main/enterprise/enterprise-service.ts @@ -102,6 +102,8 @@ export interface EnterpriseServiceNote { } export interface EnterpriseServiceOptions { + desktopVersion?: string + activateDesktop?: () => Promise | void vault: SecureEnterpriseCredentialVault fetchImpl: EnterpriseFetch allowInsecureLoopback: boolean @@ -275,7 +277,8 @@ export class EnterpriseService { code_challenge: pkce.codeChallenge, code_challenge_method: 'S256', state: pkce.state, - device_name: 'DSH Desktop' + device_name: 'DSH Desktop', + ...(this.options.desktopVersion ? { client_version: this.options.desktopVersion } : {}) } }) ).body, @@ -439,6 +442,12 @@ export class EnterpriseService { await this.commitSession(token as EnterpriseCredentialSession, expectedEpoch) await this.syncCatalog(expectedEpoch, { includeUsage: true }) this.note('login_committed', { originHash: hashEnterpriseOrigin(pending.base) }) + try { + await this.options.activateDesktop?.() + } catch { + // A window activation failure must not invalidate a committed login. + this.note('desktop_activation_failed') + } } catch (error) { if (this.epoch !== expectedEpoch) return this.rememberRequest(error) diff --git a/src/main/enterprise/legacy-settings-migration.ts b/src/main/enterprise/legacy-settings-migration.ts new file mode 100644 index 000000000..cad5d6b75 --- /dev/null +++ b/src/main/enterprise/legacy-settings-migration.ts @@ -0,0 +1,94 @@ +import { readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { isMap, parseDocument } from 'yaml' + +const LEGACY_PROVIDER = 'bisheng-enterprise' +const LEGACY_API_KEY_ENV = 'BISHENG_ENTERPRISE_TOKEN' +const LEGACY_SETTINGS = 'dsh-desktop-enterprise' + +export interface LegacyEnterpriseSettingsMigration { + changed: boolean + removed: string[] +} + +export interface LegacyEnterpriseSettingsFileIo { + readFile: typeof readFile + writeFile: typeof writeFile + rename: typeof rename + unlink: typeof unlink +} + +const defaultFileIo: LegacyEnterpriseSettingsFileIo = { + readFile, + writeFile, + rename, + unlink +} + +/** + * Retire the settings written by the original Desktop enterprise preview. + * + * That preview registered BiSheng through llm-pi-ai and persisted its public + * account snapshot in settings.yaml. The current built-in integration owns the + * same provider route and stores the session in the OS credential vault. The + * exact legacy API-key marker keeps this migration scoped to preview-owned data. + */ +export async function migrateLegacyEnterpriseSettings( + dshHome: string, + fileIo: Partial = {} +): Promise { + const io = { ...defaultFileIo, ...fileIo } + const path = join(dshHome, 'settings.yaml') + let source: string + try { + source = await io.readFile(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { changed: false, removed: [] } + } + throw error + } + + const document = parseDocument(source) + if (document.errors.length > 0 || !isMap(document.contents)) { + return { changed: false, removed: [] } + } + + const removed: string[] = [] + const llmPiAi = document.contents.get('llm-pi-ai', true) + if (isMap(llmPiAi)) { + const providers = llmPiAi.get('providers', true) + if (isMap(providers)) { + const provider = providers.get(LEGACY_PROVIDER, true) + if (isMap(provider) && provider.get('apiKeyEnv') === LEGACY_API_KEY_ENV) { + providers.delete(LEGACY_PROVIDER) + removed.push(`llm-pi-ai.providers.${LEGACY_PROVIDER}`) + } + } + } + + const legacySettings = document.contents.get(LEGACY_SETTINGS, true) + if ( + isMap(legacySettings) && + ( + typeof legacySettings.get('platformUrl') === 'string' || + typeof legacySettings.get('modelBaseUrl') === 'string' || + typeof legacySettings.get('accountId') === 'string' + ) + ) { + document.contents.delete(LEGACY_SETTINGS) + removed.push(LEGACY_SETTINGS) + } + + if (removed.length === 0) return { changed: false, removed } + const temporary = `${path}.${process.pid}.${Date.now()}.enterprise-migration` + try { + await io.writeFile(temporary, String(document), { encoding: 'utf8', mode: 0o600 }) + await io.rename(temporary, path) + } catch (error) { + // Leave the original settings.yaml in place if the replacement fails. + await io.unlink(temporary).catch(() => undefined) + throw error + } + return { changed: true, removed } +} diff --git a/src/main/index.ts b/src/main/index.ts index 27e1ea21c..e796c4e97 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -79,6 +79,7 @@ import { } from './gpu-fallback' import { secureWindow } from './security' import { startEnterpriseDesktop, type EnterpriseDesktopRuntime } from './enterprise/enterprise-desktop' +import { migrateLegacyEnterpriseSettings } from './enterprise/legacy-settings-migration' import { allowInsecureEnterpriseLoopback, enterpriseLoginFromArgv, @@ -1530,6 +1531,18 @@ function launchHarness(): Promise { maintenanceAllowedRestoreId = undefined runtime.note('[desktop] profile maintenance done') await refreshMigrationRecoveryLock(dshHome) + try { + const legacyEnterprise = await migrateLegacyEnterpriseSettings(dshHome) + if (legacyEnterprise.changed) { + runtime.note(`[enterprise] retired legacy settings: ${legacyEnterprise.removed.join(', ')}`) + } + } catch (error) { + runtime.note( + `[enterprise] legacy settings migration failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } void auditInstalledLaunchAgents(dshHome) .then(() => { runtime.note('[desktop] LaunchAgent audit done') @@ -3374,6 +3387,13 @@ async function bootstrap(): Promise { }) try { enterpriseDesktop = await startEnterpriseDesktop({ + desktopVersion: app.getVersion(), + activateDesktop: async () => { + const snapshot = runtime.snapshot() + if (snapshot.phase === 'ready' && snapshot.url) { + await openHarness(snapshot.url, 'user') + } + }, userDataPath: app.getPath('userData'), safeStorage, fetchImpl: createElectronEnterpriseFetch(net.fetch.bind(net)), diff --git a/test/enterprise-legacy-settings-migration.test.ts b/test/enterprise-legacy-settings-migration.test.ts new file mode 100644 index 000000000..ae8d08412 --- /dev/null +++ b/test/enterprise-legacy-settings-migration.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { parse } from 'yaml' +import { migrateLegacyEnterpriseSettings } from '../src/main/enterprise/legacy-settings-migration' + +let directory: string | undefined + +afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }) + directory = undefined +}) + +async function fixture(source: string): Promise { + directory = await mkdtemp(join(tmpdir(), 'dsh-enterprise-settings-')) + const path = join(directory, 'settings.yaml') + await writeFile(path, source, 'utf8') + return path +} + +async function leftoverMigrationFiles(root: string): Promise { + return (await readdir(root)).filter((name) => name.includes('.enterprise-migration')) +} + +describe('legacy enterprise settings migration', () => { + it('removes the preview-owned provider and account snapshot while preserving other settings', async () => { + const path = await fixture(` +llm-pi-ai: + providers: + existing: + apiKeyEnv: EXISTING_KEY + bisheng-enterprise: + apiKeyEnv: BISHENG_ENTERPRISE_TOKEN + baseURL: http://127.0.0.1:17860/api/v1/models/openai +agent-default-model: + provider: existing + model: example +dsh-desktop-enterprise: + platformUrl: http://127.0.0.1:17860 + accountId: user-alice +`) + + await expect(migrateLegacyEnterpriseSettings(directory!)).resolves.toEqual({ + changed: true, + removed: [ + 'llm-pi-ai.providers.bisheng-enterprise', + 'dsh-desktop-enterprise' + ] + }) + + const settings = parse(await readFile(path, 'utf8')) + expect(settings['llm-pi-ai'].providers).toEqual({ + existing: { apiKeyEnv: 'EXISTING_KEY' } + }) + expect(settings['agent-default-model']).toEqual({ provider: 'existing', model: 'example' }) + expect(settings).not.toHaveProperty('dsh-desktop-enterprise') + const migrated = await readFile(path, 'utf8') + await expect(migrateLegacyEnterpriseSettings(directory!)).resolves.toEqual({ changed: false, removed: [] }) + expect(await readFile(path, 'utf8')).toBe(migrated) + }) + + it('keeps a user-managed provider that lacks the preview credential marker', async () => { + const path = await fixture(` +llm-pi-ai: + providers: + bisheng-enterprise: + apiKeyEnv: USER_MANAGED_KEY + baseURL: https://enterprise.example.com/v1 +`) + + await expect(migrateLegacyEnterpriseSettings(directory!)).resolves.toEqual({ + changed: false, + removed: [] + }) + expect(await readFile(path, 'utf8')).toContain('USER_MANAGED_KEY') + }) + + it('leaves an invalid settings document unchanged', async () => { + const path = await fixture('llm-pi-ai: [\n') + const before = await readFile(path, 'utf8') + + await expect(migrateLegacyEnterpriseSettings(directory!)).resolves.toEqual({ + changed: false, + removed: [] + }) + expect(await readFile(path, 'utf8')).toBe(before) + }) + + it('returns unchanged when settings.yaml is missing', async () => { + directory = await mkdtemp(join(tmpdir(), 'dsh-enterprise-settings-')) + + await expect(migrateLegacyEnterpriseSettings(directory)).resolves.toEqual({ + changed: false, + removed: [] + }) + expect(await leftoverMigrationFiles(directory)).toEqual([]) + }) + + it('removes a preview account snapshot without touching other providers', async () => { + const path = await fixture(` +llm-pi-ai: + providers: + existing: + apiKeyEnv: EXISTING_KEY +dsh-desktop-enterprise: + modelBaseUrl: http://127.0.0.1:17860/api/v1/models/openai +`) + + await expect(migrateLegacyEnterpriseSettings(directory!)).resolves.toEqual({ + changed: true, + removed: ['dsh-desktop-enterprise'] + }) + const settings = parse(await readFile(path, 'utf8')) + expect(settings['llm-pi-ai'].providers).toEqual({ + existing: { apiKeyEnv: 'EXISTING_KEY' } + }) + expect(settings).not.toHaveProperty('dsh-desktop-enterprise') + }) + + it('cleans the temporary file and keeps the original settings when write fails', async () => { + const path = await fixture(` +dsh-desktop-enterprise: + accountId: user-alice +`) + const before = await readFile(path, 'utf8') + + await expect(migrateLegacyEnterpriseSettings(directory!, { + writeFile: async (target, contents, options) => { + await writeFile(target, contents, options) + throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }) + } + })).rejects.toMatchObject({ code: 'ENOSPC' }) + expect(await readFile(path, 'utf8')).toBe(before) + expect(await leftoverMigrationFiles(directory!)).toEqual([]) + }) + + it('cleans the temporary file and keeps the original settings when replace fails', async () => { + const path = await fixture(` +dsh-desktop-enterprise: + platformUrl: http://127.0.0.1:17860 +`) + const before = await readFile(path, 'utf8') + + await expect(migrateLegacyEnterpriseSettings(directory!, { + rename: async () => { + throw Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' }) + } + })).rejects.toMatchObject({ code: 'EBUSY' }) + expect(await readFile(path, 'utf8')).toBe(before) + expect(await leftoverMigrationFiles(directory!)).toEqual([]) + }) + + it('replaces the original settings file after a successful write', async () => { + const path = await fixture(` +dsh-desktop-enterprise: + platformUrl: http://127.0.0.1:17860 +`) + let renamed = false + await expect(migrateLegacyEnterpriseSettings(directory!, { + rename: async (from, to) => { + renamed = true + await rename(from, to) + } + })).resolves.toEqual({ + changed: true, + removed: ['dsh-desktop-enterprise'] + }) + expect(renamed).toBe(true) + expect(parse(await readFile(path, 'utf8'))).not.toHaveProperty('dsh-desktop-enterprise') + expect(await leftoverMigrationFiles(directory!)).toEqual([]) + }) +}) diff --git a/test/enterprise-service.test.ts b/test/enterprise-service.test.ts index 4b04d6697..63b314d26 100644 --- a/test/enterprise-service.test.ts +++ b/test/enterprise-service.test.ts @@ -1,9 +1,9 @@ import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createEnterpriseFetch, type EnterpriseFetch } from '../src/main/enterprise/platform-fetch' -import { EnterpriseService } from '../src/main/enterprise/enterprise-service' +import { EnterpriseService, type EnterpriseServiceOptions } from '../src/main/enterprise/enterprise-service' import { ENTERPRISE_VAULT_FILENAME, type SafeStorageCryptoAdapter, @@ -26,11 +26,15 @@ function memorySafeStorage(): SafeStorageCryptoAdapter { } } -async function createService(fetchImpl: EnterpriseFetch = createEnterpriseFetch(globalThis.fetch)) { +async function createService( + fetchImpl: EnterpriseFetch = createEnterpriseFetch(globalThis.fetch), + options: Pick = {} +) { const directory = await mkdtemp(join(tmpdir(), 'enterprise-service-')) const vault = new SecureEnterpriseCredentialVault(join(directory, ENTERPRISE_VAULT_FILENAME), memorySafeStorage()) const notes: string[] = [] const service = new EnterpriseService({ + ...options, vault, fetchImpl, allowInsecureLoopback: true, @@ -71,6 +75,59 @@ async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + it.each([false, true])('reports the app version and commits login even when activation fails: %s', async (failActivation) => { + const platform = createMockEnterpriseServer({ port: 0 }) + const origin = await platform.listen() + cleanups.push(async () => platform.close()) + const requests: Record[] = [] + const fetchImpl: EnterpriseFetch = async (input, init) => { + if (String(input).endsWith('/api/dsh/authorizations')) requests.push(JSON.parse(String(init?.body))) + return globalThis.fetch(input, init) + } + const activateDesktop = vi.fn(() => { + expect(service.snapshot().phase).toBe('connected') + if (failActivation) throw new Error('window unavailable') + }) + const { service, notes } = await createService(createEnterpriseFetch(fetchImpl), { + desktopVersion: '0.1.1-beta.2+build.7', activateDesktop + }) + const started = await service.startLogin(origin) + expect(requests[0]).toMatchObject({ client_version: '0.1.1-beta.2+build.7', device_name: 'DSH Desktop' }) + expect(activateDesktop).not.toHaveBeenCalled() + await completeBrowserLogin(origin, started.authorizationUrl) + await waitFor(() => activateDesktop.mock.calls.length === 1) + expect(service.snapshot().phase).toBe('connected') + expect(notes).toContain('login_committed') + expect(notes.includes('desktop_activation_failed')).toBe(failActivation) + expect(notes).not.toContain('login_failed') + const sessions = platform.state.sessions as Map + expect([...sessions.values()][0]?.clientVersion).toBe('0.1.1-beta.2+build.7') + await service.refresh() + expect(activateDesktop).toHaveBeenCalledTimes(1) + }) + + it('omits an unavailable version and does not activate Desktop when token exchange fails', async () => { + const platform = createMockEnterpriseServer({ port: 0 }) + const origin = await platform.listen() + cleanups.push(async () => platform.close()) + const requests: Record[] = [] + const fetchImpl: EnterpriseFetch = async (input, init) => { + if (String(input).endsWith('/api/dsh/authorizations')) requests.push(JSON.parse(String(init?.body))) + if (String(input).endsWith('/api/dsh/token')) { + return Response.json({ error: { message: 'Invalid ticket', code: 'invalid_ticket' } }, { status: 401 }) + } + return globalThis.fetch(input, init) + } + const activateDesktop = vi.fn() + const { service, notes } = await createService(createEnterpriseFetch(fetchImpl), { activateDesktop }) + const started = await service.startLogin(origin) + expect(requests[0]).not.toHaveProperty('client_version') + await completeBrowserLogin(origin, started.authorizationUrl) + await waitFor(() => notes.includes('login_failed')) + expect(service.snapshot().connected).toBe(false) + expect(activateDesktop).not.toHaveBeenCalled() + }) + it('completes PKCE login, refreshes once, and logs out without exposing tokens', async () => { const platform = createMockEnterpriseServer({ port: 0 }) const origin = await platform.listen()