feat(config)!: one ordering for configuration sources, and a bootstrap deny rule
$DSH_HOME/.env had just become an ordinary environment layer, which left the harness resolving user-facing values from a flattened process.env that could no longer say where a value came from. A key stored through the web page stayed shadowed by an older key in the user's own .env. An endpoint could be redirected by the project: the invoking directory's .env is materialized like every other layer, and a base URL decides where a resolved API key is sent, so a DEEPSEEK_BASE_URL written into a model-editable workspace would send the user's credential — and the prompts carrying their code — to whatever host that file named. Give every user-facing value one ordering, with four kinds of source: explicit for this run per-operation override, CLI argument > authored by deployment --config / --config-replace > this launch's shell inherited process environment > product-managed store settings.yaml, .credentials.yaml > discovered file $DSH_HOME/.env > defaults schema default, shipped base, public default The domains differ only in which tiers exist. The earlier split — credentials ranking the environment over the managed file while settings ranked over the environment — was inconsistent: the distinguishing fact is who authored the source, not the domain. packages/util/environment owns an immutable snapshot with per-layer provenance. getFrom(name, sources) searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for ['process', 'user-env'], so no reordering can let a project file back into a decision it was excluded from. isBootstrapOnly rejects, before anything is materialized, any .env setting a variable that governs how a process launches (PATH, SHELL, NODE_OPTIONS, LD_PRELOAD), where code or model-visible instructions load from (the whole DSH_* namespace, HOME, XDG_*), or how the network is reached (proxy and CA variables). The namespace is denied wholesale so a switch added later cannot become settable by being forgotten, and there is no opt-out. verify-config-source-ownership keeps both rules: no unregistered process.env read under packages/*/*/src (26 allowlisted with reasons), and no apiKey, baseURL, or headers inlined from the environment in shipped Cordis config — removing those inlines is what makes the deployment tier meaningful.
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
|
||||
"@deepseek-ai/dsh-credentials": "^0.0.1",
|
||||
"@deepseek-ai/dsh-environment": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -41,6 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
/**
|
||||
* File-backed credentials provider layering the live process environment over
|
||||
* a `$DSH_HOME/.credentials.yaml` document. The environment is authoritative
|
||||
* and read-only (a launch-time override must win, and must be visibly
|
||||
* read-only rather than silently shadow writes); the file is the
|
||||
* provider-managed writable source: every write re-reads the document under a
|
||||
* cross-process writer lock before patching only its own key — comments and
|
||||
* the formatting of every untouched entry survive — external edits
|
||||
* hot-publish through the seam, and each reload replaces the snapshot
|
||||
* File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
|
||||
* against the environment by how much each layer is trusted:
|
||||
*
|
||||
* ```text
|
||||
* inherited process environment (read-only, wins)
|
||||
* > $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
* > $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; it cannot be
|
||||
* edited from inside, so it must be *visibly* read-only rather than silently
|
||||
* shadow writes. Everything below it loses to the managed store, so a key the
|
||||
* web page or TUI writes takes effect immediately even when an older key sits
|
||||
* in the user's `.env`.
|
||||
*
|
||||
* The invoking directory's `.env` supplies no credential at all. A project
|
||||
* directory can be written by the model, and a substituted key would send
|
||||
* every request — prompts included — through an account someone else reads;
|
||||
* that decision belongs to the launching shell, not to a discovered file.
|
||||
*
|
||||
* The file is the provider-managed writable source: every write re-reads the
|
||||
* document under a cross-process writer lock before patching only its own key
|
||||
* — comments and the formatting of every untouched entry survive — external
|
||||
* edits hot-publish through the seam, and each reload replaces the snapshot
|
||||
* wholesale so a deleted entry never lingers in memory.
|
||||
*
|
||||
* The document holds nothing but credentials, which is why it is a strict
|
||||
@@ -25,8 +42,10 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
||||
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
||||
import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment'
|
||||
|
||||
/** Basename of the credentials document inside the harness home. */
|
||||
export const CREDENTIALS_FILENAME = '.credentials.yaml'
|
||||
@@ -169,6 +188,18 @@ export class CredentialsLocal extends Credentials {
|
||||
this.spec = resolveSpec(config)
|
||||
}
|
||||
|
||||
/** The inherited-environment value for a reference, or `undefined` when empty or unset. */
|
||||
private inherited(ref: CredentialRef): string | undefined {
|
||||
const entry = environmentOf(this.ctx).getFrom(ref, ['process'])
|
||||
return entry !== undefined && entry.value.length > 0 ? entry.value : undefined
|
||||
}
|
||||
|
||||
/** The user `.env` fallback for a reference — below the managed store, never above it. */
|
||||
private userEnvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
|
||||
const entry = environmentOf(this.ctx).getFrom(ref, ['user-env'])
|
||||
return entry !== undefined && entry.value.length > 0 ? entry : undefined
|
||||
}
|
||||
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
yield async () => {
|
||||
// Drain: refuse new operations, then settle the queued ones so disposal
|
||||
@@ -214,20 +245,27 @@ export class CredentialsLocal extends Credentials {
|
||||
}
|
||||
|
||||
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
|
||||
const inherited = this.inherited(ref)
|
||||
if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' })
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
|
||||
const fallback = this.userEnvFallback(ref)
|
||||
if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: 'user-env' })
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
override describe(ref: CredentialRef): Promise<CredentialInfo> {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) {
|
||||
// Only the inherited environment is unwritable: it is the one layer this
|
||||
// process cannot edit. A user `.env` value is writable in the sense that
|
||||
// matters — storing a key replaces it as the effective one.
|
||||
if (this.inherited(ref) !== undefined) {
|
||||
return Promise.resolve({ configured: true, source: 'env', writable: false })
|
||||
}
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
|
||||
if (this.userEnvFallback(ref) !== undefined) {
|
||||
return Promise.resolve({ configured: true, source: 'user-env', writable: true })
|
||||
}
|
||||
return Promise.resolve({ configured: false, writable: true })
|
||||
}
|
||||
|
||||
@@ -303,13 +341,16 @@ export class CredentialsLocal extends Credentials {
|
||||
})
|
||||
}
|
||||
|
||||
/** Reject a write the live environment would shadow into apparent no-effect. */
|
||||
/**
|
||||
* Reject a write the inherited environment would shadow into apparent
|
||||
* no-effect. Only that layer can shadow a write: everything else this
|
||||
* provider resolves ranks below the document being written.
|
||||
*/
|
||||
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) {
|
||||
if (this.inherited(ref) !== undefined) {
|
||||
throw new Error(
|
||||
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
|
||||
+ ' shadowed; unset it in the launching environment (or in a loaded .env) instead',
|
||||
`credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be`
|
||||
+ ' shadowed; unset it in the shell you start dsh from instead',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-environment'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
|
||||
|
||||
@@ -100,6 +101,74 @@ describe('layering and reads', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('layer ladder', () => {
|
||||
// inherited process env > .credentials.yaml > $DSH_HOME/.env, and the
|
||||
// invoking directory's .env supplies no credential at all.
|
||||
async function bootLayered(
|
||||
path: string,
|
||||
layers: Parameters<typeof createEnvironmentSnapshot>[0],
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.provide(DSH_ENVIRONMENT_KEY, createEnvironmentSnapshot(layers))
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path, watch: false })
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await bootLayered(path, [
|
||||
{ source: 'process', values: {} },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } },
|
||||
])
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
|
||||
// The old dead end is gone: a key sitting in the user's .env no longer
|
||||
// makes the stored one unwritable.
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
|
||||
await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined()
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' })
|
||||
})
|
||||
|
||||
it('serves the user .env only when nothing is stored', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await bootLayered(join(dir, '.credentials.yaml'), [
|
||||
{ source: 'process', values: {} },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
|
||||
])
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' })
|
||||
// Writable: storing a key replaces it as the effective one.
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true })
|
||||
})
|
||||
|
||||
it('ignores the invoking directory .env entirely', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await bootLayered(join(dir, '.credentials.yaml'), [
|
||||
{ source: 'process', values: {} },
|
||||
{ source: 'project-env', path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } },
|
||||
])
|
||||
// A project directory can be written by the model, and a substituted key
|
||||
// would route every request through an account someone else reads.
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
|
||||
})
|
||||
|
||||
it('lets only the inherited environment shadow the store, read-only', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await bootLayered(path, [
|
||||
{ source: 'process', values: { DSH_CRED_TEST: 'from-shell' } },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
|
||||
])
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' })
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
|
||||
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document validation', () => {
|
||||
// Every rejection below is a boot failure rather than a skipped entry: this
|
||||
// document holds nothing but credentials, so an ignored key would read as
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../util/environment"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user