Skip to main content
PathDocs

Credential Management

Short version: three iron rules govern DSH's credential system ctx.credentials: configuration only stores references, never the keys themselves; resolution happens per operation; an empty stored value equals "not configured". Where the key actually lives is decided by the provider (under ~/.dsh by default).

This section explains "where keys live, how they are read, and how to rotate them".

1. Three principles (source README)

  1. Configuration carries references to keys, never the keys themselves. apiKeyEnv: DEEPSEEK_API_KEY is a reference; the value lives in the credential provider. So settings/cordis documents can be safely synced and rendered, and describe() can answer "configured or not, where it comes from, whether it can be written" — all while never holding the value; rotating a key touches no configuration file.
  2. Consumers resolve per operation. resolve(ref) is called at the start of each operation (the LLM adapter resolves once per model request), and is not cached across operations: this makes a changed key take effect on the next request immediately, without restarting the plugin.
  3. An empty stored value = missing. resolve skips it and describe reports it as not configured. An empty string can never masquerade as a configured key.

2. Public API

import type { Context } from '@deepseek-ai/cordis'
import { credentialRef } from '@deepseek-ai/dsh-credentials'

declare const ctx: Context

const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
const info = await ctx.credentials.describe(ref)// { configured, source?, writable } — never the value
await ctx.credentials.set(ref, 'sk-…') // rejected when masked by a read-only source
await ctx.credentials.unset(ref) // missing is a no-op; same masking rule as above

credentials/updated (ref) fires after a committed change (used by the config UI to refresh the "configured" badge). Consumers don't need it, because they re-resolve per operation.

3. Resolution flow and priority

The local provider (dsh-credentials-local) stacks the four layers following one honest priority:

LayerSource idWritableWho wins
Inherited process envenvnoalways
$DSH_HOME/.credentials.yaml documentfileyes (set/unset)overrides both .env layers
<invoking dir>/.envproject-envnot writable hereoverrides the user .env
$DSH_HOME/.envuser-envnot writable herefallback

Reading it line by line:

  • The startup environment always wins: DEEPSEEK_API_KEY=… dsh, CI secrets, container -e are the operator's intent for this run; and because it cannot be rewritten from inside, it must be visibly read-onlydescribe() reports source: 'env', writable: false, and set/unset are rejected outright.
  • Managed storage overrides the .env fallback: a key written on the Models page takes effect immediately, even if an old key still sits in .env; the two .env layers resolve only when "nothing is stored" — storing a key replaces them as the actually-effective source.
  • A snapshot, not process.env: under the product CLI, resolution reads a snapshot of the environment frozen by the launcher; only it can say whether a value came from the startup shell or from a file.

4. Masking rules (fail-loud)

set/unset have deliberately fail-loud behavior: when a read-only source (the active process env for the local provider) currently supplies this ref, a write would "appear to succeed" yet resolution still returns the masked value: the seam rejects outright; describe().writable lets the UI render this ref as read-only in advance.

ScenarioResult
DEEPSEEK_API_KEY present in the process env, then code calls set on itRejected; resolution still returns the old value from the env
An old key in .env, set writes it into the storeSucceeds; the store immediately overrides .env as the effective source
set(ref, '') (empty string)Rejected; an empty stored value = missing, unset is what removes a key

5. Provider

dsh-credentials-local stacks the inherited process env on top of the $DSH_HOME/.credentials.yaml document it manages, with the launcher's project/user .env layers as fallback.

llm-pi-ai provider-native discovery is a separate path: a provider without a credential reference reads the process environment directly and cannot see the Harness-managed store. For AWS, explicitly export AWS_PROFILE or access-key variables; ~/.aws/credentials alone is insufficient.

Config optionDefaultMeaning
path<harness home>/.credentials.yamlLocation of the credential document
dshHome$DSH_HOME or ~/.dshHarness home used when path is omitted
watchtrueHot-publish external edits
debounceMs100Watcher write settle window

The document is a "credential-reference → value" YAML map, and nothing more; a non-map root, keys that aren't POSIX identifiers, non-string values, empty strings, duplicate keys, and broken YAML are all rejected in full (a loud failure at startup; on hot reload it warns and keeps the last good snapshot). Writes modify patch-style: they re-read and merge the resolved document (preserving comments and untouched-item formatting), then atomically commit under the cross-process write lock of dsh-atomic-write, at 0600 (directory 0700). External edits publish credentials/updated per changed ref after an whole-document replacement, and entries deleted on disk never linger in memory.

The seam leaves room for a keyring / helper-command / KMS-backed provider; a remote settings provider never needs to carry keys.

6. Security boundaries and known limitations

The boundary stops at "other OS users" and does not block the model: 0600 + 0700 keeps out other users; tool processes (bash, filesystem tools) run as the same user, and the workspace-write file policy constrains writes, not reads. What the harness actually guards is narrower: it never hands the document path to the model, and it never loads process.env into it ($DSH_HOME/.env is the ordinary env layer) — this is restraint, not a boundary.

LimitationDescription
Concurrent writes to the same reflast-write-wins: the write lock + read-modify-write prevent lost entries, but when two writers change the same ref the later one still wins
Readable by same-UID processesthe file-effect sandbox doesn't refuse reads; true isolation needs an OS-keychain provider (storage the model process can't read, deferred)
Env changes not visiblethe snapshot is frozen at startup; variables exported at runtime don't enter resolution; changing an env-sourced credential requires a restart
Atomic, not crash-durableinherits dsh-atomic-write; the store re-reads at startup

7. Rotation walkthrough

Rotating a key never requires editing any configuration file — because configuration only stores references. Three paths, depending on "where it comes from now":

Current source (describe().source)Rotation steps
envChange the startup environment (new shell / CI secret / container -e), restart to take effect
fileset(ref, newValue) writes 0600 atomically and takes effect immediately; or edit the file directly and the watcher hot-publishes it
project-env / user-envWrite directly into the store with set to override .env; or clear the old value in .env first, then set

When done, check with describe(ref) that configured: true and source points at the expected layer; it's fine if the old value still sits in .env — as long as the store has a value, .env drops back to being a fallback.

8. Impact on the model

Indirect, via the LLM adapter that consumes them: the resolved value authorizes provider requests, and the adapter owns the model-visible surface.

9. Verification

# Inspect the credential file (0600)
ls -la ~/.dsh/.credentials.yaml
# Check whether credentials are mounted in the composition
dsh web --dump-config | grep -i credential

Next steps