Skip to content

Credentials in dsh follow a reference-not-value model: settings and composition files carry references to secrets (environment-variable names), while providers own the actual values and their storage. Consumption surfaces describe a reference without ever seeing its value.

PackageRole
packages/credentials/credentialsctx.credentials service definition + CredentialRef/ResolvedCredential
packages/credentials/credentials-localFile-backed provider over $DSH_HOME/.credentials.yaml
packages/llm/llmassertUsableApiKey — the shared key-validity diagnosis
packages/llm/llm-pi-aipi-ai adapter that resolves apiKeyEnv per request

The credential model ​

A CredentialRef (packages/credentials/credentials/src/types.ts) is a branded POSIX shell identifier such as DEEPSEEK_API_KEY, validated by credentialRef(value) against /^[A-Za-z_][A-Za-z0-9_]*$/. The service definition (packages/credentials/credentials/src/index.ts) is an abstract CredentialProvider registered as ctx.credentials with four operations:

OperationSignatureMeaning
resolve(ref)Promise<ResolvedCredential | undefined>Current value + source; undefined while unconfigured
describe(ref)Promise<CredentialInfo>configured, source, writable — never the value (safe for UIs)
set(ref, value)Promise<void>Durably store a non-empty value
unset(ref)Promise<void>Remove the value; removing an absent ref is a no-op

ResolvedCredential is { value, source }; the local provider's source is one of env, file, project-env, or user-env. One seam-wide rule binds all providers: an empty stored value is absent everywhere — resolve skips it, describe reports it unconfigured, so a blank never masquerades as a configured secret.

Whenever a reference's value changes, CredentialProvider.notifyUpdated(ref) fans out the credentials/reference-updated event with contained listener failures — a broken observer can never make a durable write look failed (an INVARIANT-coded failure still rethrows).

The seam gained a record half alongside the reference half: records are addressed by CredentialKey, a branded <scope>/<id> pair (credentialKey(scope, id)). In addition to the four reference operations, CredentialProvider declares readRecord(key), describeRecord(key), listRecords(), modifyRecord(key, fn) — the only record write path, a cross-process serialized read-modify-write — and deleteRecord(key). Stored records fan out credentials/record-updated on change (notifyRecordUpdated(key)).

The local file backend (credentials-local) ​

LocalCredentialProvider (packages/credentials/credentials-local/src/index.ts) is backed by <harness home>/.credentials.yaml (CREDENTIALS_FILENAME), layered against the environment by trust:

text
inherited process environment      (read-only, wins)
> $DSH_HOME/.credentials.yaml      (provider-managed, writable)
> <invocation cwd>/.env            (read-only fallback)
> $DSH_HOME/.env                   (read-only fallback)
  • The inherited environment wins because DEEPSEEK_API_KEY=… dsh, a CI secret, or a container -e is this run's explicit intent and cannot be edited from inside — so it is visibly read-only (source: 'env', writable: false) rather than silently shadowing writes.
  • The .env fallbacks sit below the managed store, so a key written through the Models page takes effect immediately even when an older key sits in a user's .env; the project .env ranks over the user one (the more specific location wins).
  • The document itself is a strict CredentialRef-to-string YAML mapping (not a dotenv file): a store dsh owns and never materializes into the environment cannot double as the user's environment layer.

Storage security ​

  • Permissions: files are created/replaced at 0600, directories at 0700. assertOwnerOnly rejects (before reading) any document that already exists with group or other bits set (0o077) — skip on Windows, which has no POSIX mode to inspect.
  • Atomic, comment-preserving writes: every write re-reads the document under a cross-process writer lock (withFileLock) before patching only its own key, so comments and the formatting of every untouched entry survive; writes use writeFileAtomic.
  • Hot reload: a chokidar watcher (watch: true, default OK) re-reads the document; a changed value propagates through the seam as a credentials/reference-updated fan-out. Each reload replaces the snapshot wholesale.
  • Fail-closed: an invalid document at boot fails the plugin (a file that exists but cannot be trusted is never treated as "no credentials stored"); a reload that fails keeps the last good snapshot and warns.

Plugin config: path (default <dshHome>/.credentials.yaml), dshHome (default $DSH_HOME or ~/.dsh), watch (default true), debounceMs (default 100).

How LLM providers fetch credentials ​

The LLM packages resolve through the seam per request, never cache across operations.

llm (packages/llm/llm/src/index.ts) contributes assertUsableApiKey(raw, pkg, ref) — the shared validity diagnosis beside LlmError. It silently trims surrounding whitespace (a stored key may arrive from the credentials seam, a .env line, or a shell export) and rejects a blank or header-unsafe key with INVALID_CREDENTIAL_CODE, naming the ref to fix and never echoing the key. The key never enters a message or a UI.

llm-pi-ai (packages/llm/llm-pi-ai/src/index.ts) shows the actual fetch path in resolveApiKey(provider, profile):

ts
const ref = profile.apiKeyEnv
if (ref === undefined) return undefined  // defer to provider-native discovery
const credentials = ctx.get('credentials')
const hit = credentials !== undefined
  ? (await credentials.resolve(ref))?.value
  : launchEnvironmentOf(ctx).get(ref)?.value   // no seam ⇒ environment is the whole plane
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
throw new LlmError(/* … */, 'MISSING_CREDENTIAL')

Each provider profile declares an apiKeyEnv (e.g. OPENAI_API_KEY). Only a profile that names no credential defers to pi-ai's provider-native discovery — once named, a miss fails loud rather than letting pi-ai pick up an unrelated ambient key (OPENAI_API_KEY and friends) that would bill another tenant. The adapter also exposes storedApiKey(provider) for interrogating a draft endpoint, where the requesting surface edits a redacted descriptor and never holds a stored secret.

The Host credential RPC ​

The old web aggregate proxy (packages/host/apiproxy) was deleted; the credentials RPC domain now lives in packages/api/settings-controller/src/credentials.ts. It exposes the credentialsController Remote namespace built strictly on credentials.describe / credentials.set / credentials.unset — the value crosses this wire exactly once, inbound, on set, and never leaves back out. The zod schemas mirror the seam's credentialRef guard (a bad name fails bad-request before reaching the service), the describe view carries only { configured, source, writable }, and the RPC itself runs over @deepseek-ai/dsh-typert-protocol. The Host does not inject credentials into outbound requests — that is the LLM adapter's job through ctx.credentials.

What is redacted ​

  • On the wire: the credential value appears only in a credentials.set request payload; describe and resolve-without-set surfaces never emit it.
  • From logs: assertUsableApiKey never echoes the key; YAML parse errors are reported as code+position, never the offending source line (describeYamlError quotes code and line/column only because the line holds a secret); the child-process environment scrub in packages/subprocess/subprocess/src/index.ts drops SENSITIVE_ENV_PATTERN (KEY|PASSWORD|SECRET|TOKEN) and all DSH_* names from scrubbedParentEnv, so a harness secret never leaks into a spawned child implicitly.
  • From the model transcript: credentials events are session-log facts, not model-facing content; providers describe configuration state, not values.

Further reading ​

  • Permissions & approval — the approval/sandbox pipe beside the credential seam; both feed the same ctx.approval/ctx.credentials service model.
  • Guards: timeout & repeat reminders — the invariants safety-net pattern also used by credentials' notifyUpdated fan-out.
  • packages/credentials/credentials/src/index.ts — the abstract CredentialProvider and the reference model.
  • packages/credentials/credentials-local/src/index.ts — the layered provider and its assertOwnerOnly storage check.
  • packages/llm/llm-pi-ai/src/index.ts — resolveApiKey, the per-request credential fetch.
  • packages/api/settings-controller/src/credentials.ts — the Host credentialsController RPC domain.