@eduwork/dsh-oidc 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.en.md +40 -1
  2. package/CHANGELOG.md +40 -1
  3. package/README.en.md +23 -21
  4. package/README.md +17 -15
  5. package/THIRD_PARTY_NOTICES.en.md +7 -5
  6. package/THIRD_PARTY_NOTICES.md +7 -5
  7. package/cordis.patch.yml +2 -1
  8. package/docs/EDUWORK-MIGRATION.en.md +14 -2
  9. package/docs/EDUWORK-MIGRATION.md +14 -2
  10. package/docs/account-extensions.en.md +40 -0
  11. package/docs/account-extensions.md +42 -0
  12. package/docs/architecture.en.md +9 -9
  13. package/docs/architecture.md +11 -10
  14. package/docs/compatibility.en.md +11 -2
  15. package/docs/compatibility.md +11 -2
  16. package/docs/desktop-host.en.md +69 -0
  17. package/docs/desktop-host.md +79 -0
  18. package/docs/development.en.md +42 -0
  19. package/docs/development.md +42 -0
  20. package/docs/dsh-integration.en.md +23 -7
  21. package/docs/dsh-integration.md +23 -7
  22. package/docs/ecnu-reference.en.md +3 -3
  23. package/docs/ecnu-reference.md +3 -3
  24. package/docs/enterprise-profile.en.md +19 -11
  25. package/docs/enterprise-profile.md +18 -10
  26. package/docs/getting-started.en.md +12 -12
  27. package/docs/getting-started.md +16 -14
  28. package/docs/key-binding-protocol.en.md +2 -2
  29. package/docs/key-binding-protocol.md +2 -2
  30. package/docs/oidc-interoperability.en.md +3 -3
  31. package/docs/oidc-interoperability.md +2 -2
  32. package/docs/public-resource-protocol.en.md +51 -0
  33. package/docs/public-resource-protocol.md +50 -0
  34. package/docs/release-checklist.en.md +4 -2
  35. package/docs/release-checklist.md +4 -2
  36. package/docs/server-integration-contract.en.md +124 -8
  37. package/docs/server-integration-contract.md +136 -8
  38. package/examples/ecnu.enterprise-profile.example.json +2 -2
  39. package/examples/enterprise-profile.example.json +78 -77
  40. package/examples/identity-only.example.json +10 -0
  41. package/examples/resources-discovery.example.json +20 -0
  42. package/lib/callback-page.js +42 -0
  43. package/lib/client.js +934 -294
  44. package/lib/desktop-oidc.js +205 -0
  45. package/lib/index.js +148 -18
  46. package/lib/oidc.js +314 -58
  47. package/lib/profile.js +53 -24
  48. package/lib/provider/core.js +76 -15
  49. package/lib/provider/index.js +33 -20
  50. package/lib/provider/transform-adapter.js +31 -10
  51. package/lib/resources.js +23 -0
  52. package/lib/typert-schemas.js +34 -3
  53. package/lib/typert.host.js +42 -36
  54. package/lib/typert.remote-client.js +41 -35
  55. package/package.json +100 -29
  56. package/protocol/openapi.yaml +2 -2
  57. package/protocol/resources.openapi.yaml +51 -0
  58. package/schema/enterprise-profile.v1alpha1.schema.json +193 -188
@@ -0,0 +1,205 @@
1
+ import { createServer } from 'node:http'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { WebOidcBackend, OIDC_CALLBACK_PATH, sessionRef } from './oidc.js'
4
+ import { callbackLanguage, callbackPage } from './callback-page.js'
5
+
6
+ const MAX_FLOWS = 32
7
+ const MAX_HISTORY = 128
8
+ const error = (code, message) => Object.assign(new Error(message), { code })
9
+ const safeCodes = new Set(['oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid'])
10
+
11
+ function reply(response, status, profile, outcome, language) {
12
+ const page = callbackPage(profile, outcome, language)
13
+ response.writeHead(status, page.headers)
14
+ response.end(page.html)
15
+ }
16
+
17
+ /** Same identity/resource implementation as Web; only the callback transport differs. */
18
+ export class DesktopOidcBackend extends WebOidcBackend {
19
+ constructor(ctx, profiles, config = {}, options = {}) {
20
+ super(ctx, profiles, config, { ...options, transport: 'desktop' })
21
+ if (Object.keys(config).some(key => !['callbackPort', 'flowTimeoutMs'].includes(key))) throw new Error('unsupported desktop OIDC callback setting')
22
+ this.port = config.callbackPort ?? 0
23
+ this.ttl = config.flowTimeoutMs ?? 600_000
24
+ if (!Number.isInteger(this.port) || this.port < 0 || this.port > 65535) throw new Error('desktop callbackPort must be an integer from 0 to 65535')
25
+ if (!Number.isInteger(this.ttl) || this.ttl < 1000 || this.ttl > 600_000) throw new Error('desktop flowTimeoutMs must be between 1000 and 600000')
26
+ this.openExternal = options.openExternal
27
+ this.attempts = new Map()
28
+ this.starting = new Set()
29
+ this.disposed = false
30
+ ctx.effect(() => () => this.dispose(), 'dsh-oidc: temporary desktop callbacks')
31
+ }
32
+
33
+ async begin(profileID) {
34
+ const profile = this.profile(profileID)
35
+ if (this.disposed) throw error('oidc_host_unavailable', 'OIDC host has stopped')
36
+ if (typeof this.openExternal !== 'function') throw error('oidc_browser_unavailable', 'The desktop browser service is unavailable')
37
+ if (this.starting.has(profileID)) throw error('oidc_login_pending', 'Organization sign-in is already starting')
38
+ this.starting.add(profileID)
39
+ let attempt
40
+ try {
41
+ for (const previous of this.attempts.values()) if (previous.profileID === profileID && previous.state === 'pending') await this.cancelLogin(previous.loginID)
42
+ const discovery = await this.discover(profile)
43
+ if (this.disposed) throw error('oidc_host_unavailable', 'OIDC host has stopped')
44
+ if ([...this.attempts.values()].filter(value => value.state === 'pending').length >= MAX_FLOWS) throw error('oidc_flow_limit', 'Too many pending organization sign-ins')
45
+ const loginID = randomBytes(24).toString('base64url')
46
+ attempt = { loginID, profileID, state: 'pending', expiresAt: new Date(this.now() + this.ttl).toISOString(), consumed: false }
47
+ const server = createServer((request, response) => { void this.callbackFor(attempt, request, response) })
48
+ server.requestTimeout = 10_000
49
+ server.headersTimeout = 10_000
50
+ server.maxHeadersCount = 32
51
+ attempt.server = server
52
+ this.attempts.set(loginID, attempt)
53
+ await new Promise((resolve, reject) => {
54
+ server.once('error', reject)
55
+ server.listen({ host: '127.0.0.1', port: this.port, exclusive: true }, resolve)
56
+ }).catch(() => { throw error('oidc_callback_unavailable', 'The local sign-in callback could not start; check whether its configured port is in use') })
57
+ server.unref()
58
+ if (this.disposed || attempt.state !== 'pending') {
59
+ await new Promise(resolve => { server.close(resolve); server.closeAllConnections() })
60
+ throw error('oidc_host_unavailable', 'OIDC host has stopped')
61
+ }
62
+ attempt.origin = `http://127.0.0.1:${server.address().port}`
63
+ const authorization = this.createAuthorization(profile, discovery, `${attempt.origin}${OIDC_CALLBACK_PATH}`)
64
+ Object.assign(attempt, { oauthState: authorization.state, flow: authorization.flow })
65
+ attempt.timer = setTimeout(() => { void this.cancelLogin(loginID, 'expired').catch(() => {}) }, this.ttl)
66
+ attempt.timer.unref()
67
+ // This Host-only method receives a URL derived from reviewed discovery, never an RPC URL.
68
+ try { await this.openExternal(authorization.authorizationURL) }
69
+ catch { throw error('oidc_browser_unavailable', 'The sign-in browser could not be opened') }
70
+ return { mode: 'external', loginID, expiresAt: attempt.expiresAt }
71
+ } catch (cause) {
72
+ if (attempt) await this.cancelLogin(attempt.loginID, 'failed')
73
+ throw cause
74
+ } finally {
75
+ this.starting.delete(profileID)
76
+ this.pruneAttempts()
77
+ }
78
+ }
79
+
80
+ pruneAttempts() {
81
+ for (const [id, attempt] of this.attempts) {
82
+ if (attempt.state !== 'pending' && (this.attempts.size > MAX_HISTORY || Date.parse(attempt.expiresAt) + this.ttl < this.now())) this.attempts.delete(id)
83
+ }
84
+ }
85
+
86
+ async closeCallback(attempt, force = false) {
87
+ clearTimeout(attempt.timer)
88
+ this.flows.delete(attempt.oauthState)
89
+ if (!attempt.server) return
90
+ const server = attempt.server
91
+ attempt.server = undefined
92
+ const closed = new Promise(resolve => server.close(resolve))
93
+ if (force) server.closeAllConnections()
94
+ else server.closeIdleConnections()
95
+ await closed
96
+ }
97
+
98
+ loginStatus(loginID) {
99
+ const attempt = this.attempts.get(loginID)
100
+ if (!attempt) throw error('oidc_login_unknown', 'This sign-in attempt is no longer available')
101
+ return { loginID, profileID: attempt.profileID, state: attempt.state, expiresAt: attempt.expiresAt,
102
+ ...(attempt.status ? { status: attempt.status } : {}), ...(attempt.errorCode ? { errorCode: attempt.errorCode } : {}) }
103
+ }
104
+
105
+ async cancelLogin(loginID, state = 'cancelled') {
106
+ const attempt = this.attempts.get(loginID)
107
+ if (!attempt) throw error('oidc_login_unknown', 'This sign-in attempt is no longer available')
108
+ if (attempt.state === 'pending') {
109
+ attempt.state = state
110
+ await this.closeCallback(attempt, true)
111
+ }
112
+ await attempt.processing
113
+ return this.loginStatus(loginID)
114
+ }
115
+
116
+ async callbackFor(attempt, request, response) {
117
+ const profile = this.profile(attempt.profileID), language = callbackLanguage(request.headers['accept-language'])
118
+ let requested
119
+ try {
120
+ requested = new URL(request.url, attempt.origin)
121
+ if (request.method !== 'GET' || request.headers.host !== new URL(attempt.origin).host
122
+ || (request.headers.origin && request.headers.origin !== attempt.origin)
123
+ || requested.origin !== attempt.origin || requested.username || requested.password || requested.hash || requested.pathname !== OIDC_CALLBACK_PATH
124
+ || requested.searchParams.getAll('state').length !== 1 || requested.searchParams.get('state') !== attempt.oauthState
125
+ || attempt.state !== 'pending' || attempt.consumed) {
126
+ reply(response, 400, profile, 'failed', language); return
127
+ }
128
+ if (Date.parse(attempt.expiresAt) <= this.now()) {
129
+ reply(response, 410, profile, 'expired', language); await this.cancelLogin(attempt.loginID, 'expired'); return
130
+ }
131
+ } catch { reply(response, 400, profile, 'failed', language); return }
132
+ attempt.consumed = true
133
+ this.flows.delete(attempt.oauthState)
134
+ attempt.processing = this.finishAuthorization(attempt, requested).catch(() => {
135
+ attempt.state = 'failed'
136
+ attempt.errorCode = 'oidc_credentials_unavailable'
137
+ this.ctx.logger.warn('Desktop OIDC host credentials are unavailable')
138
+ })
139
+ await attempt.processing
140
+ if (!response.destroyed) reply(response, attempt.state === 'completed' ? 200 : 400, profile,
141
+ attempt.state === 'completed' ? (attempt.status?.state === 'authenticated' ? 'credential-required' : 'completed') : 'failed', language)
142
+ await this.closeCallback(attempt)
143
+ }
144
+
145
+ async finishAuthorization(attempt, requested) {
146
+ const profile = this.profile(attempt.profileID)
147
+ const previous = new Map()
148
+ let wrote = false
149
+ try {
150
+ const session = await this.exchangeAuthorization(requested, attempt.flow)
151
+ if (attempt.state !== 'pending') return
152
+ const refs = [sessionRef(profile), ...(profile.keyBinding ? [profile.keyBinding.credentialRef] : [])]
153
+ for (const ref of refs) previous.set(ref, await this.ctx.credentials.resolve(ref))
154
+ if (attempt.state !== 'pending') return
155
+ wrote = true
156
+ await this.saveSession(profile, session, attempt.flow.epoch)
157
+ if (attempt.state !== 'pending') return
158
+ // A resource outage must not turn a successful identity login into a failed login.
159
+ let status
160
+ try { status = await this.reconcile(profile.id, { allowProvision: false }) }
161
+ catch { status = await this.status(profile.id) }
162
+ if (attempt.state !== 'pending') return
163
+ attempt.status = status
164
+ attempt.state = 'completed'
165
+ this.accountChanged(status)
166
+ } catch (cause) {
167
+ if (attempt.state === 'pending') {
168
+ attempt.state = 'failed'
169
+ attempt.errorCode = safeCodes.has(cause?.code) ? cause.code : 'oidc_login_failed'
170
+ }
171
+ this.ctx.logger.warn('Desktop OIDC sign-in did not complete', { code: attempt.errorCode ?? 'oidc_login_cancelled' })
172
+ } finally {
173
+ // Cancellation/disposal while the host vault writes must not resurrect a login.
174
+ if (wrote && attempt.state !== 'completed') {
175
+ await this.writeCredentials(profile, attempt.flow.epoch, async () => {
176
+ const currentRecord = await this.ctx.credentials.resolve(sessionRef(profile))
177
+ let currentSession
178
+ try { currentSession = JSON.parse(currentRecord?.value) } catch { /* No proven runtime-key ownership. */ }
179
+ const ownsCurrentKey = await this.boundCredential(profile, currentSession)
180
+ for (const [ref, record] of previous) {
181
+ // A different profile may have rebound the shared key during login.
182
+ if (ref !== sessionRef(profile) && !ownsCurrentKey) continue
183
+ if (record?.value !== undefined) await this.ctx.credentials.set(ref, record.value)
184
+ else await this.ctx.credentials.unset(ref)
185
+ }
186
+ })
187
+ this.resourceStates.delete(profile.id)
188
+ }
189
+ // Retain only the safe public result once the authorization is consumed.
190
+ attempt.flow = undefined
191
+ attempt.oauthState = undefined
192
+ }
193
+ }
194
+
195
+ async logout(profileID) {
196
+ for (const attempt of this.attempts.values()) if (attempt.profileID === profileID && attempt.state === 'pending') await this.cancelLogin(attempt.loginID)
197
+ return super.logout(profileID)
198
+ }
199
+
200
+ async dispose() {
201
+ this.disposed = true
202
+ await Promise.all([...this.attempts.values()].filter(value => value.state === 'pending').map(value => this.cancelLogin(value.loginID)))
203
+ await Promise.all([...this.attempts.values()].map(value => value.processing))
204
+ }
205
+ }
package/lib/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
2
2
  import { apply as applyEnterpriseProvider } from './provider/index.js'
3
3
  import { NativeOidcBackend, WebOidcBackend } from './oidc.js'
4
+ import { DesktopOidcBackend } from './desktop-oidc.js'
4
5
  import { enterpriseProviderConfig, loadEnterpriseProfiles, publicProfile } from './profile.js'
5
6
 
6
7
  export const name = 'dsh-oidc'
7
8
  const remoteInitializers = []
8
9
 
9
10
  export class OidcAccountService extends TypertRemoteService {
10
- static inject = ['credentials', 'llm', 'webServer']
11
+ static inject = ['credentials', 'llm']
11
12
 
12
13
  constructor(ctx, config = {}) {
13
14
  super(ctx, 'oidcAccounts')
@@ -15,36 +16,164 @@ export class OidcAccountService extends TypertRemoteService {
15
16
  this.profiles = loadEnterpriseProfiles(config)
16
17
  if (this.profiles.size === 0 && config.allowEmptyProfiles !== true) throw new Error('dsh-oidc requires at least one Enterprise Profile')
17
18
  this.uiMode = ['external', 'models-only'].includes(config.uiMode) ? config.uiMode : 'standard'
18
- applyEnterpriseProvider(ctx, enterpriseProviderConfig(this.profiles))
19
- this.backend = config.backend === 'native'
20
- ? new NativeOidcBackend(ctx, this.profiles)
21
- : new WebOidcBackend(ctx, this.profiles, config.web ?? {})
19
+ this.manageProductBrand = config.manageProductBrand !== false
20
+ if (config.configFile !== undefined) {
21
+ const source = config.configFile
22
+ if (!source || typeof source.path !== 'string' || !source.path || typeof source.examplesPath !== 'string' || !source.examplesPath) throw new Error('Invalid enterprise configuration file location')
23
+ this.configFile = { path: source.path, examplesPath: source.examplesPath }
24
+ ctx.inject(['desktopServices'], inner => {
25
+ if (typeof inner.desktopServices.openConfiguration !== 'function') return
26
+ const open = target => inner.desktopServices.openConfiguration(target)
27
+ this.openConfigTarget = open
28
+ inner.effect(() => () => {
29
+ if (this.openConfigTarget === open) this.openConfigTarget = undefined
30
+ }, 'dsh-oidc: configuration file service')
31
+ })
32
+ }
33
+ this.accountStates = new Map()
34
+ const provider = applyEnterpriseProvider(ctx, {
35
+ ...enterpriseProviderConfig(this.profiles),
36
+ resolveCredential: async providerID => {
37
+ const profile = [...this.profiles.values()].find(value => value.provider?.id === providerID)
38
+ if (!profile) return undefined
39
+ if (this.backend instanceof NativeOidcBackend) return ctx.credentials.resolve(profile.keyBinding.credentialRef)
40
+ if (this.backend instanceof WebOidcBackend) return this.backend.resolveBoundCredential(profile.id, {
41
+ credentialRef: profile.keyBinding.credentialRef, runtimeBaseURL: profile.provider.baseURL,
42
+ })
43
+ },
44
+ })
45
+ const backendOptions = {
46
+ authorizedOrigins: config.authorizedOrigins,
47
+ accountChanged: status => this.notifyAccount(status),
48
+ updateProvider: profile => {
49
+ const profiles = new Map(this.profiles)
50
+ profiles.set(profile.id, profile)
51
+ provider.replaceConfig(enterpriseProviderConfig(profiles))
52
+ },
53
+ }
54
+ if (config.backend === 'native') this.backend = new NativeOidcBackend(ctx, this.profiles)
55
+ else if (config.backend === 'desktop') {
56
+ this.backend = new DesktopOidcBackend(ctx, this.profiles, config.desktop ?? {}, backendOptions)
57
+ ctx.inject(['desktopServices'], inner => {
58
+ this.backend.openExternal = url => inner.desktopServices.openExternal(url)
59
+ inner.effect(() => () => {
60
+ this.backend.openExternal = undefined
61
+ return Promise.all([...this.backend.attempts.values()].filter(value => value.state === 'pending').map(value => this.backend.cancelLogin(value.loginID)))
62
+ }, 'dsh-oidc: external browser service')
63
+ })
64
+ } else {
65
+ this.backendReady = new Promise(resolve => {
66
+ ctx.inject(['webServer'], inner => {
67
+ const backend = new WebOidcBackend(inner, this.profiles, config.web ?? {}, backendOptions)
68
+ this.backend = backend
69
+ resolve()
70
+ inner.effect(() => () => { if (this.backend === backend) this.backend = undefined }, 'dsh-oidc: web backend')
71
+ })
72
+ })
73
+ }
74
+ }
75
+
76
+ async callBackend(method, ...args) {
77
+ if (this.configFile && ['activate', 'configure', 'addCustom', 'updateCustom', 'removeProfile', 'configureModels'].includes(method)) throw new Error('Organization services are managed in the configuration file; edit it and restart the application')
78
+ await this.backendReady
79
+ if (typeof this.backend?.[method] !== 'function') throw new Error('This OIDC operation is unavailable in the configured host')
80
+ return this.backend[method](...args)
22
81
  }
23
82
 
24
83
  configuration() {
25
84
  return Promise.resolve({
26
85
  schemaVersion: 'dsh-oidc/v1alpha1',
27
86
  uiMode: this.uiMode,
87
+ manageProductBrand: this.manageProductBrand,
88
+ ...(this.configFile ? { configFile: { ...this.configFile, canOpen: typeof this.openConfigTarget === 'function' } } : {}),
28
89
  profiles: [...this.profiles.values()].map(publicProfile),
29
90
  })
30
91
  }
31
92
 
32
- status(profileID) { return this.backend.status(profileID) }
33
- begin(profileID) { return this.backend.begin(profileID) }
34
- reconcile(profileID, options) { return this.backend.reconcile(profileID, options) }
35
- logout(profileID) { return this.backend.logout(profileID) }
36
- management() { return this.backend.management() }
37
- activate(profileID) { return this.backend.activate(profileID) }
38
- configure(profileID) { return this.backend.configure(profileID) }
39
- addCustom(baseURL) { return this.backend.addCustom(baseURL) }
40
- updateCustom(profileID, baseURL) { return this.backend.updateCustom(profileID, baseURL) }
41
- removeProfile(profileID) { return this.backend.removeProfile(profileID) }
42
- configureModels(profileID, modelMode, models) { return this.backend.configureModels(profileID, modelMode, models) }
43
- restart() { return this.backend.restart() }
93
+ async openConfiguration(target) {
94
+ if (!['config', 'examples'].includes(target)) throw new Error('只能打开应用配置文件或示例目录。')
95
+ if (!this.configFile || typeof this.openConfigTarget !== 'function') throw new Error('当前宿主不支持直接打开配置文件,请按配置路径手动打开。')
96
+ try { await this.openConfigTarget(target) }
97
+ catch { throw new Error('未能打开配置文件或示例目录,请重试或检查系统文件关联。') }
98
+ return { opened: true }
99
+ }
100
+
101
+ notifyAccount(status) {
102
+ if (this.accountStates.get(status.profileID) !== status.state) {
103
+ this.accountStates.set(status.profileID, status.state)
104
+ this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state })
105
+ }
106
+ return status
107
+ }
108
+ async status(profileID) { return this.notifyAccount(await this.callBackend('status', profileID)) }
109
+ resources(profileID) { return this.callBackend('resources', profileID) }
110
+ // Host-only capability: deliberately absent from Remote markers and Typert.
111
+ authorizedFetch(profileID, endpoint, init) {
112
+ return this.callBackend('authorizedFetch', profileID, endpoint, init)
113
+ }
114
+ modelResourceFetch(profileID, relativePath, options) {
115
+ return this.callBackend('modelResourceFetch', profileID, relativePath, options)
116
+ }
117
+ // Host-only credential snapshot for optional adapters; never exposed via RPC.
118
+ async resolveBoundCredential(profileID, expected) {
119
+ await this.backendReady
120
+ return this.backend instanceof WebOidcBackend ? this.backend.resolveBoundCredential(profileID, expected) : undefined
121
+ }
122
+ async begin(profileID) {
123
+ const result = await this.callBackend('begin', profileID)
124
+ if (result.mode === 'completed') this.notifyAccount(result.status)
125
+ return result
126
+ }
127
+ loginStatus(loginID) { return this.callBackend('loginStatus', loginID) }
128
+ cancelLogin(loginID) { return this.callBackend('cancelLogin', loginID) }
129
+ async reconcile(profileID, options) { return this.notifyAccount(await this.callBackend('reconcile', profileID, options)) }
130
+ async selectEnterpriseModel(profileID, options = {}) {
131
+ const status = await this.callBackend('status', profileID)
132
+ const profile = this.profiles.get(profileID)
133
+ if (!profile?.provider || status.state !== 'connected' || !status.credentialReady) throw new Error('请先完成企业登录和模型凭据连接,再选择企业模型。')
134
+ const defaults = this.ctx.get('agentDefaultModel')
135
+ if (typeof defaults?.saveSelection !== 'function') throw new Error('已连接企业模型;当前宿主不支持自动选择,请从模型菜单中选择。')
136
+ const original = defaults.currentSelection()
137
+ if (options.onlyIfMissing === true) {
138
+ const current = original
139
+ if (current?.provider && current?.model) {
140
+ try { await this.ctx.llm.resolveCallConfig(current); return { changed: false } }
141
+ catch { /* The old edition's provider may no longer be assembled. */ }
142
+ }
143
+ }
144
+ const models = await this.ctx.llm.listModels(profile.provider.id)
145
+ for (const model of models) {
146
+ let resolved
147
+ try { resolved = await this.ctx.llm.resolveCallConfig({ provider: profile.provider.id, model: model.id }) }
148
+ catch { continue }
149
+ const selection = { provider: resolved.provider, model: resolved.model,
150
+ ...(resolved.reasoningEffort === undefined ? {} : { reasoningEffort: String(resolved.reasoningEffort) }) }
151
+ // A slow discovery must not overwrite a newer personal choice or finish
152
+ // activation after the account has been signed out elsewhere.
153
+ if (options.onlyIfMissing === true && JSON.stringify(defaults.currentSelection()) !== JSON.stringify(original)) return { changed: false }
154
+ const latest = await this.callBackend('status', profileID)
155
+ if (latest.state !== 'connected' || !latest.credentialReady) throw new Error('企业连接状态已变化,请重新检查连接后选择模型。')
156
+ await defaults.saveSelection(selection)
157
+ return { selection, changed: true }
158
+ }
159
+ throw new Error('企业登录已完成,但尚未找到可用模型。请刷新模型目录或联系服务管理员。')
160
+ }
161
+ async logout(profileID) { return this.notifyAccount(await this.callBackend('logout', profileID)) }
162
+ async management() {
163
+ const result = await this.callBackend('management')
164
+ return this.configFile ? { ...result, capabilities: { ...result.capabilities, manageProfiles: false, manageModels: false } } : result
165
+ }
166
+ activate(profileID) { return this.callBackend('activate', profileID) }
167
+ configure(profileID) { return this.callBackend('configure', profileID) }
168
+ addCustom(baseURL) { return this.callBackend('addCustom', baseURL) }
169
+ updateCustom(profileID, baseURL) { return this.callBackend('updateCustom', profileID, baseURL) }
170
+ removeProfile(profileID) { return this.callBackend('removeProfile', profileID) }
171
+ configureModels(profileID, modelMode, models) { return this.callBackend('configureModels', profileID, modelMode, models) }
172
+ restart() { return this.callBackend('restart') }
44
173
  }
45
174
 
46
175
  for (const method of [
47
- 'configuration', 'status', 'begin', 'reconcile', 'logout', 'management',
176
+ 'configuration', 'openConfiguration', 'status', 'resources', 'begin', 'loginStatus', 'cancelLogin', 'reconcile', 'selectEnterpriseModel', 'logout', 'management',
48
177
  'activate', 'configure', 'addCustom', 'updateCustom', 'removeProfile', 'configureModels', 'restart',
49
178
  ]) {
50
179
  Remote(method)(OidcAccountService.prototype[method], {
@@ -56,4 +185,5 @@ for (const method of [
56
185
  export default OidcAccountService
57
186
  export { enterpriseProviderConfig, loadEnterpriseProfiles, normalizeEnterpriseProfile, publicProfile } from './profile.js'
58
187
  export { NativeOidcBackend, WebOidcBackend } from './oidc.js'
188
+ export { DesktopOidcBackend } from './desktop-oidc.js'
59
189
  export { EnterpriseModelTransforms } from './provider/transforms.js'