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, writablenever 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 everywhereresolve skips it, describe reports it unconfigured, so a blank never masquerades as a configured secret.

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

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/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.

Does the API proxy inject credentials?

The web aggregate proxy (packages/host/apiproxy) exposes a credentials RPC domain (src/api/credentials.ts, schema at src/api/credentials.schema.ts) 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 schemas mirror the seam's credentialRef guard (a bad name fails bad-request before reaching the service), and the describe view carries only { configured, source, writable }. The proxy does not inject credentials into outbound requests — that is the LLM adapter's job through ctx.credentials. (The proxy config surface does reference apiKeyEnv/credential-shaped names for its own settings schema, but resolution and redaction live at the provider layer.)

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.tsresolveApiKey, the per-request credential fetch.
  • packages/host/apiproxy/src/api/credentials.schema.ts — the one-direction wire for credential values.