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.
| Package | Role |
|---|---|
packages/credentials/credentials | ctx.credentials service definition + CredentialRef/ResolvedCredential |
packages/credentials/credentials-local | File-backed provider over $DSH_HOME/.credentials.yaml |
packages/llm/llm | assertUsableApiKey — the shared key-validity diagnosis |
packages/llm/llm-pi-ai | pi-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:
| Operation | Signature | Meaning |
|---|---|---|
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 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:
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-eis 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
.envfallbacks 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.envranks 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 at0700.assertOwnerOnlyrejects (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 usewriteFileAtomic. - Hot reload: a chokidar watcher (
watch: true, default OK) re-reads the document; a changed value propagates through the seam as acredentials/updatedfan-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):
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.setrequest payload; describe and resolve-without-set surfaces never emit it. - From logs:
assertUsableApiKeynever echoes the key; YAML parse errors are reported as code+position, never the offending source line (describeYamlErrorquotes code and line/column only because the line holds a secret); the child-process environment scrub inpackages/subprocess/subprocess/src/index.tsdropsSENSITIVE_ENV_PATTERN(KEY|PASSWORD|SECRET|TOKEN) and allDSH_*names fromscrubbedParentEnv, 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.credentialsservice model. - Guards: timeout & repeat reminders — the invariants safety-net pattern also used by
credentials'notifyUpdatedfan-out. packages/credentials/credentials/src/index.ts— the abstractCredentialProviderand the reference model.packages/credentials/credentials-local/src/index.ts— the layered provider and itsassertOwnerOnlystorage check.packages/llm/llm-pi-ai/src/index.ts—resolveApiKey, the per-request credential fetch.packages/host/apiproxy/src/api/credentials.schema.ts— the one-direction wire for credential values.