mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
* ui: show locked API key environment reference * fix(ui-settings-models): skip unused pi-ai credential reference * fix(ui-settings-models): show active provider auth source * test(llm): cover provider auth check failures * docs(ui-settings-models): clarify active provider authentication * test(web): update model key copy snapshots * fix(ui-settings-models): clarify native authentication source hint * fix: address PR 5483 review feedback * docs(llm): state empty auth lookup behavior * fix: refresh auth hints with credential snapshots * fix: require credential refresh revision * test: cover auth status projection * fix: refresh catalog and browser expectations after rebase * fix: keep auth status projection internal and explicit * fix: preserve credential source checks with signed-in account rows * test: pin Models authentication instead of inheriting host keys * fix: prioritize stored credentials across providers * fix: use one environment fallback hint * docs: refresh credential contract * docs: refresh config catalog * test: align provider hint verification * fix: use provider-native auth hints and endpoints * fix: cover oauth-capable provider auth hints * fix: show canonical endpoint defaults * fix: avoid misleading endpoint fallback * fix: label catalog endpoint defaults * fix: shorten auth hints with hover details * fix: clarify native auth resolution * test: align DeepSeek endpoint hint * fix: pair help button corner shape * fix: address credential precedence review Record the stored-credential precedence in a superseding Agent Note and an upgrade guide, and align the credentials subsystem page, package READMEs, and JSDoc with the order file > launch env > $cwd/.env > $DSH_HOME/.env. Report each pi-ai route's catalog endpoint and native-auth environment names through the provider directory, check the names against the installed pi-ai resolvers, and remove the llm/checkAuth and credential describe operations that no Models page code calls. Restore the route-derived key reference and the home expansion that reads the home directory only for a leading ~. * fix: clarify model authentication guidance * fix: explain native provider OAuth requirements * fix: keep authentication guidance method neutral * fix: show credential reference source in model editor * fix: use environment authentication in headless profile * fix: validate headless authentication and current provider metadata * test: retain authentication guidance coverage with account catalog updates * test: retain writable Models credentials with the pinned environment
157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
/**
|
|
* Host owner of the `credentials` Remote namespace: the reference half of
|
|
* `ctx.credentials` as a browser configuration page reads and writes it.
|
|
*
|
|
* @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts
|
|
*/
|
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
|
|
import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
|
|
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { z } from 'zod'
|
|
|
|
/**
|
|
* Fan-out bound on one remote `describe` batch. A settings page asks about the
|
|
* references its own rows name, so this is far above any real page and still
|
|
* keeps one authenticated request from starting unbounded provider work.
|
|
*/
|
|
const MAX_DESCRIBE_REFS = 64
|
|
|
|
const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
const describeRequestSchema = z.object({
|
|
refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS),
|
|
})
|
|
const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) })
|
|
const unsetRequestSchema = z.object({ ref: credentialRefSchema })
|
|
|
|
/** Parse the domain constraints that are more specific than generated TypeScript codecs. */
|
|
function parseRequest<T>(method: string, schema: z.ZodType<T>, value: unknown): T {
|
|
const parsed = schema.safeParse(value)
|
|
if (!parsed.success) {
|
|
throw new RemoteError('gateway/bad-request', `invalid payload for ${method}`, { issues: parsed.error.issues })
|
|
}
|
|
return parsed.data
|
|
}
|
|
|
|
/**
|
|
* Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns
|
|
* a business result without decoding it, so a provider whose `describe` carried
|
|
* extra enumerable properties would otherwise serialize them to the caller.
|
|
* @param info - the provider's answer for one reference.
|
|
* @returns the same facts with nothing else attached.
|
|
*/
|
|
function projectCredentialInfo(info: CredentialInfo): CredentialInfo {
|
|
return {
|
|
configured: info.configured,
|
|
...info.source === undefined ? {} : { source: info.source },
|
|
writable: info.writable,
|
|
}
|
|
}
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
interface Context {
|
|
/** Host owner of the `credentials` Remote namespace. */
|
|
credentialsController: CredentialsController
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Host service backing the generated `ctx.remote.credentials` namespace. It
|
|
* carries every wire obligation the credential seam itself does not: the batch
|
|
* fan-out bound, the field-by-field view projection, the reference-grammar
|
|
* guard, and the refusal mapping. Secret values cross in one direction only —
|
|
* no method here returns one.
|
|
*/
|
|
export class CredentialsController extends TypertRemoteService {
|
|
/** @param ctx - Host context where a credential provider may be mounted. */
|
|
constructor(ctx: Context) {
|
|
super(ctx, 'credentialsController', { namespace: 'credentials' })
|
|
}
|
|
|
|
/**
|
|
* Describe several references for one configuration surface. Batched because
|
|
* a settings page describes every reference its rows name at once, and one
|
|
* round trip keeps those rows from settling separately.
|
|
* @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar
|
|
* rejects the whole call as `gateway/bad-request`.
|
|
* @returns one view per requested name, keyed by that name.
|
|
* @throws RemoteError when the request is invalid or no credential provider is mounted.
|
|
*/
|
|
@Remote
|
|
async describe(refs: string[]): Promise<Record<string, CredentialInfo>> {
|
|
const request = parseRequest('credentials.describe', describeRequestSchema, { refs })
|
|
const branded = request.refs.map(ref => [ref, credentialRef(ref)] as const)
|
|
const credentials = this.provider()
|
|
const entries = await Promise.all(branded.map(async ([ref, key]) =>
|
|
[ref, projectCredentialInfo(await credentials.describe(key))] as const))
|
|
return Object.fromEntries(entries)
|
|
}
|
|
|
|
/**
|
|
* Store one value from a configuration surface. The value crosses the wire in
|
|
* this direction only: no read path returns it.
|
|
* @param ref - reference name to store under.
|
|
* @param value - the non-empty secret value.
|
|
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
|
|
*/
|
|
@Remote
|
|
async set(ref: string, value: string): Promise<void> {
|
|
const request = parseRequest('credentials.set', setRequestSchema, { ref, value })
|
|
const branded = credentialRef(request.ref)
|
|
const credentials = this.provider()
|
|
await this.write(request.ref, () => credentials.set(branded, request.value))
|
|
}
|
|
|
|
/**
|
|
* Remove one reference from a configuration surface.
|
|
* @param ref - reference name to remove.
|
|
* @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
|
|
*/
|
|
@Remote
|
|
async unset(ref: string): Promise<void> {
|
|
const request = parseRequest('credentials.unset', unsetRequestSchema, { ref })
|
|
const branded = credentialRef(request.ref)
|
|
const credentials = this.provider()
|
|
await this.write(request.ref, () => credentials.unset(branded))
|
|
}
|
|
|
|
/** Resolve the optional provider or report how to supply it. */
|
|
private provider(): CredentialProvider {
|
|
const credentials = this.ctx.get('credentials')
|
|
if (credentials === undefined) {
|
|
throw new RemoteError(
|
|
'gateway/internal',
|
|
'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
|
|
{},
|
|
)
|
|
}
|
|
return credentials
|
|
}
|
|
|
|
/**
|
|
* Run one remote write and report every refusal as `credential/rejected`
|
|
* carrying the seam's own message: a provider refusal, such as a stored
|
|
* document that no longer parses, is what a settings page must show
|
|
* verbatim. Callers brand the reference before entering, so a name outside
|
|
* the grammar never reaches this path and fails the same way it does on the
|
|
* read side. The details name only the reference, so no failure path can
|
|
* carry the value back out.
|
|
*/
|
|
private async write(ref: string, write: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await write()
|
|
} catch (error: unknown) {
|
|
throw new RemoteError(
|
|
'credential/rejected',
|
|
error instanceof Error ? error.message : String(error),
|
|
{ ref },
|
|
{ cause: error },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export default CredentialsController
|