Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/enterprise-login-mock.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 客户端合同和错误处理。
Expand Down
6 changes: 3 additions & 3 deletions scripts/mock-bisheng-enterprise.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/main/enterprise/enterprise-desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface EnterpriseDesktopRuntime {
}

export async function startEnterpriseDesktop(options: {
desktopVersion?: string
activateDesktop?: () => Promise<void> | void
userDataPath: string
safeStorage: SafeStorageCryptoAdapter
fetchImpl: EnterpriseFetch
Expand All @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/main/enterprise/enterprise-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export interface EnterpriseServiceNote {
}

export interface EnterpriseServiceOptions {
desktopVersion?: string
activateDesktop?: () => Promise<void> | void
vault: SecureEnterpriseCredentialVault
fetchImpl: EnterpriseFetch
allowInsecureLoopback: boolean
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions src/main/enterprise/legacy-settings-migration.ts
Original file line number Diff line number Diff line change
@@ -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<LegacyEnterpriseSettingsFileIo> = {}
): Promise<LegacyEnterpriseSettingsMigration> {
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 }
}
20 changes: 20 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1530,6 +1531,18 @@ function launchHarness(): Promise<void> {
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')
Expand Down Expand Up @@ -3374,6 +3387,13 @@ async function bootstrap(): Promise<void> {
})
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)),
Expand Down
173 changes: 173 additions & 0 deletions test/enterprise-legacy-settings-migration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string[]> {
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([])
})
})
Loading