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:
Yichen Jiang
2026-08-04 16:17:32 +08:00
parent 8ddc53f7a0
commit 0512b12714
59 changed files with 1241 additions and 165 deletions

View File

@@ -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"

View File

@@ -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',
)
}
}

View File

@@ -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

View File

@@ -20,6 +20,9 @@
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/environment"
},
{
"path": "../../util/paths"
},

View File

@@ -28,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -40,6 +41,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",

View File

@@ -16,6 +16,7 @@ import z from 'schemastery'
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
@@ -62,7 +63,7 @@ export interface Config {
apiKey?: string
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
baseURL?: string
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
@@ -103,6 +104,9 @@ export const Config: z<Config> = z.object({
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Environment variable naming this provider's endpoint, honored only from trusted layers. */
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
/**
* One resolution's complete request facts. Connection and credential facts
* are one value on purpose: a snapshot the resolver rejects keeps the whole
@@ -142,9 +146,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
* every default and bound is re-judged here — for the composition entry at
* load (fail loud) and for each settings snapshot at its first use.
* @param config - raw plugin config or resolved settings snapshot.
* @param environment - this run's environment layers, or `undefined` outside
* the product CLI. Only the launching shell and the user's own `.env` may
* supply an endpoint: a base URL decides where the resolved API key is sent,
* so a file inside the workspace must not be able to redirect it.
* @returns validated connection facts plus the credential reference.
*/
export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions {
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
&& config.reasoningEffort !== 'off') {
@@ -169,7 +177,9 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
baseURL: config.baseURL
?? environment?.getFrom(BASE_URL_ENV, ['process', 'user-env'])?.value
?? PUBLIC_BASE_URL,
defaults: {
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
@@ -190,7 +200,7 @@ export function apply(ctx: Context, config: Config): void {
const raw = current()
if (raw === lastRaw && lastGood !== undefined) return lastGood
try {
const next = resolveAdapterOptions(raw)
const next = resolveAdapterOptions(raw, environmentOf(ctx))
lastRaw = raw
lastGood = next
return next
@@ -217,10 +227,12 @@ export function apply(ctx: Context, config: Config): void {
const hit = await credentials.resolve(ref)
if (hit !== undefined) return hit.value
} else {
// Without the seam, keep the historical ambient fallback so a plain
// cordis.yml composition works from the environment alone.
const ambient = process.env[ref]
if (ambient !== undefined && ambient.length > 0) return ambient
// Without the seam there is no managed store to rank against, so the
// launching environment is the whole credential plane — but only that
// layer: a key from a discovered project file would route this request
// through an account the launch never chose.
const inherited = environmentOf(ctx).getFrom(ref, ['process'])
if (inherited !== undefined && inherited.value.length > 0) return inherited.value
}
throw new LlmError(
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import LlmService, { createUserMessage,
CONTEXT_WINDOW_EXCEEDED_CODE,
errorChain,
@@ -12,7 +13,7 @@ import LlmService, { createUserMessage,
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, PUBLIC_BASE_URL, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { httpErrorCode } from '../src/adapter.ts'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
@@ -906,6 +907,25 @@ describe('plugin registration and config', () => {
expect(server.requests).toHaveLength(1)
})
it('takes DEEPSEEK_BASE_URL from the launching shell or the user .env, never from the project', () => {
const trusted = createEnvironmentSnapshot([
{ source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } },
])
expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example')
// A base URL decides where the resolved API key is sent, so a file inside
// a model-writable workspace must not be able to redirect it.
const project = createEnvironmentSnapshot([
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://attacker.example' } },
])
expect(resolveAdapterOptions({}, project).baseURL).toBe(PUBLIC_BASE_URL)
// An explicitly configured endpoint outranks every environment layer, so a
// stale shell value cannot rewrite a deployment's own gateway.
const shell = createEnvironmentSnapshot([
{ source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } },
])
expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal')
})
it('defaults to the public base URL without config or env', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)

View File

@@ -23,6 +23,9 @@
{
"path": "../../credentials/credentials"
},
{
"path": "../../util/environment"
},
{
"path": "../../settings/settings"
},

View File

@@ -28,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -40,6 +41,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",

View File

@@ -29,6 +29,7 @@
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
@@ -99,9 +100,9 @@ export function apply(ctx: Context, config: Config): void {
const credentials = ctx.get('credentials')
const hit = credentials !== undefined
? (await credentials.resolve(ref))?.value
// Without the seam, read exactly the named variable so a plain
// cordis.yml composition works from the environment alone.
: process.env[ref]
// Without the seam the launching environment is the whole credential
// plane — but only that layer, never a discovered project file.
: environmentOf(ctx).getFrom(ref, ['process'])?.value
if (hit !== undefined && hit.length > 0) return hit
throw new LlmError(
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},

View File

@@ -27,12 +27,14 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"dotenv": "^17.2.0",
"js-yaml": "^4.2.0"
},
"peerDependencies": {
"@cordisjs/plugin-hmr": "^1.0.15",
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -48,6 +50,7 @@
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -9,11 +9,13 @@
import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { basename, dirname, resolve } from 'node:path'
import { parse as parseDotenv } from 'dotenv'
import * as yaml from 'js-yaml'
import { Context, type FiberState } from 'cordis'
import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { createEnvironmentSnapshot, isBootstrapOnly, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type {} from '@cordisjs/plugin-hmr'
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -66,12 +68,57 @@ export function loadEnv(
}
/**
* Load the dsh product CLI's user environment: the invoking directory's `.env`
* Parse one directory's `.env` without applying it, rejecting any bootstrap
* variable it declares. A discovered file must not decide how this process
* launches, where its code and model-visible instructions come from, or how it
* reaches the network, so a violation fails the launch BEFORE anything is
* materialized — reporting it afterwards would leave the process already
* running under the value it refused.
* @param binName - the diagnostic prefix on the thrown error.
* @param dir - the directory whose `.env` to read.
* @param warn - sink for the one-line unreadable-file diagnostic.
* @returns the parsed entries, or `undefined` when the file is absent or unreadable.
* @throws when the file declares a name {@link isBootstrapOnly} rejects.
*/
function readEnvLayer(
binName: string, dir: string, warn: (line: string) => void,
): { path: string; values: Record<string, string> } | undefined {
const path = resolve(dir, '.env')
let content: string
try {
content = readFileSync(path, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
warn(`${binName}: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
return undefined
}
const values = parseDotenv(content)
for (const name of Object.keys(values)) {
if (!isBootstrapOnly(name)) continue
throw new Error(
`${binName}: ${path} sets "${name}", which only the launching environment may set`
+ ' (it decides how this process starts, where its code and instructions load from, or how it'
+ ` reaches the network); export ${name} instead of putting it in a .env file`,
)
}
return { path, values }
}
/**
* Load the dsh product CLI's user environment and return it as a snapshot that
* remembers which layer supplied each value: the invoking directory's `.env`
* over the Harness home's `.env`, both under the inherited process
* environment. `process.loadEnvFile` never replaces a name that is already
* set, so loading the project file first and the user file second is what
* makes the layering `user < project < inherited`; the app-boot tests pin all
* three layers because that ordering is the whole contract.
* environment.
*
* Each layer is parsed and checked before anything is applied, then applied in
* the order that makes the layering `user < project < inherited` —
* `process.loadEnvFile` never replaces a name already set. Values do reach
* `process.env`, because a user's own `--config` tree and third-party
* libraries read it; the returned snapshot is the authority for everything the
* harness itself resolves, since `process.env` alone cannot say whether a
* value came from the launching shell or from a file inside the workspace.
*
* The Harness home is resolved from the inherited environment *before* either
* file loads, so a project `.env` can never redirect which user document is
@@ -82,17 +129,28 @@ export function loadEnv(
* These are ordinary environment values with ordinary environment reach. A
* secret the Harness should own and isolate belongs in the credentials
* document, which is never materialized here.
* @param binName - the diagnostic prefix on the warn lines.
* @param binName - the diagnostic prefix on the diagnostics.
* @param cwd - the invoking directory whose `.env` is the project layer.
* @param warn - sink for the one-line misconfiguration diagnostics.
* @returns this run's frozen environment snapshot.
* @throws when either file declares a bootstrap-only variable.
*/
export function loadLayeredEnv(
binName: string, cwd: string = process.cwd(),
warn: (line: string) => void = line => void process.stderr.write(line),
): void {
): EnvironmentSnapshot {
const home = resolveDshHome()
loadEnv(binName, cwd, warn)
loadEnv(binName, home, warn)
const inherited = { ...process.env } as Record<string, string>
// Parse both layers first: a rejection must not leave one file applied.
const project = readEnvLayer(binName, cwd, warn)
const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn)
if (project !== undefined) process.loadEnvFile(project.path)
if (user !== undefined) process.loadEnvFile(user.path)
return createEnvironmentSnapshot([
{ source: 'process', values: inherited },
...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }],
...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }],
])
}
/**

View File

@@ -87,7 +87,7 @@ describe('loadEnv', () => {
})
describe('loadLayeredEnv', () => {
const NAMES = ['DSH_APP_BOOT_LAYERED_SHARED', 'DSH_APP_BOOT_LAYERED_USER', 'DSH_APP_BOOT_LAYERED_PROJECT'] as const
const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const
function clear(): void {
for (const name of NAMES) Reflect.deleteProperty(process.env, name)
@@ -99,18 +99,18 @@ describe('loadLayeredEnv', () => {
writeFileSync(join(home, '.env'), [
`${NAMES[0]}=user`,
`${NAMES[1]}=user-only`,
'DSH_APP_BOOT_LAYERED_INHERITED=user-loses',
'APP_BOOT_LAYERED_INHERITED=user-loses',
'',
].join('\n'))
writeFileSync(join(project, '.env'), [
`${NAMES[0]}=project`,
`${NAMES[2]}=project-only`,
'DSH_APP_BOOT_LAYERED_INHERITED=project-loses',
'APP_BOOT_LAYERED_INHERITED=project-loses',
'',
].join('\n'))
clear()
vi.stubEnv('DSH_HOME', home)
vi.stubEnv('DSH_APP_BOOT_LAYERED_INHERITED', 'inherited')
vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
const warn = vi.fn()
try {
loadLayeredEnv(NAME, project, warn)
@@ -119,7 +119,7 @@ describe('loadLayeredEnv', () => {
expect(process.env[NAMES[0]]).toBe('project')
expect(process.env[NAMES[1]]).toBe('user-only')
expect(process.env[NAMES[2]]).toBe('project-only')
expect(process.env['DSH_APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
expect(warn).not.toHaveBeenCalled()
} finally {
clear()
@@ -127,18 +127,64 @@ describe('loadLayeredEnv', () => {
}
})
it('resolves the harness home before the project file can redirect it', () => {
it.each([
['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'],
['the executable search path', 'PATH=/tmp/evil\n'],
['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'],
['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'],
['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'],
['a lowercase network proxy', 'https_proxy=http://attacker.example\n'],
])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => {
const home = tmp()
const project = tmp()
writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
// Rejected BEFORE materialization: reporting the violation after the
// file was applied would leave the process running under what it refused.
expect(process.env[NAMES[1]]).toBeUndefined()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('reports each layer with its absolute path', () => {
const home = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`)
writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
const snapshot = loadLayeredEnv(NAME, project, vi.fn())
expect(snapshot.layers).toEqual([
{ source: 'process' },
{ source: 'project-env', path: join(project, '.env') },
{ source: 'user-env', path: join(home, '.env') },
])
expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') })
// getFrom is a refusal, not a demotion: an omitted layer is invisible.
expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('resolves the harness home from the inherited environment, never from a file', () => {
const home = tmp()
const decoy = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`)
writeFileSync(join(decoy, '.env'), `${NAMES[1]}=decoy-home\n`)
writeFileSync(join(project, '.env'), `DSH_HOME=${decoy}\n`)
writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
loadLayeredEnv(NAME, project, vi.fn())
expect(process.env[NAMES[1]]).toBe('real-home')
expect(process.env[NAMES[2]]).toBe('set-by-project')
} finally {
clear()
vi.unstubAllEnvs()

View File

@@ -26,6 +26,9 @@
{
"path": "../../core/system-prompt"
},
{
"path": "../../util/environment"
},
{
"path": "../../util/paths"
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/environment/README.md
README.md: f642aa715c87878b2eaab9f034fb18163a6fbd2e
README.zh.md: a095730dbc8c2a4e7dc8dc57dc2930685c8fb453

View File

@@ -0,0 +1,42 @@
# dsh-environment
English | [中文](README.zh.md)
This run's environment as one immutable snapshot that remembers **which layer supplied each value**. Consumers resolve user-facing values against it instead of `process.env`, because the layers are not equally trusted and a flattened view cannot tell them apart.
| Layer | Source id | What it is |
|---|---|---|
| Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent |
| `<invocation cwd>/.env` | `project-env` | Whatever the project directory happens to contain; a model working in that workspace can write it |
| `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults |
Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves.
## Resolving
`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts.
**Omitting a layer is a refusal, not a demotion.** A base URL decides where a resolved API key is sent, so the LLM adapters ask for `['process', 'user-env']`: no future reordering can let a project file redirect a credential, because that layer is never consulted at all.
```ts
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
declare const ctx: Context
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value
```
`environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with.
## Bootstrap variables
`isBootstrapOnly(name)` names the variables only the inherited environment may set. The launcher rejects a `.env` that declares one, before applying anything.
A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), **where code or model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `USERPROFILE`, `XDG_*`), or **how the network is reached and trusted** (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`). Matching is case-insensitive, so `https_proxy` is not a bypass.
The whole `DSH_*` namespace is denied rather than an audited subset: the harness's own switches — the permission mode, the agents home, the bundled skill root — are exactly what a hostile project would want, and a switch added later must not become settable by forgetting to list it.
## Known Limitations and Deferred Work
- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables still reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. Bootstrap variables cannot come from a file at all, but a project `.env` can still set, say, `GIT_SSH_COMMAND` for the tools an agent runs.
- **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session.

View File

@@ -0,0 +1,42 @@
# dsh-environment
[English](README.md) | 中文
把本次运行的环境冻结为一份不可变快照,并记住**每个值来自哪一层**。消费方用它而不是 `process.env` 解析面向用户的值,因为各层的可信程度并不相同,而压平后的视图无法区分它们。
| 层 | 来源 id | 它是什么 |
|---|---|---|
| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 |
| `<invocation cwd>/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 |
| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 |
这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。
## 解析
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。
**省略某一层是拒绝,不是降级。** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询。
```ts
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
declare const ctx: Context
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value
```
当产品 CLI命令行界面启动了这棵树时`environmentOf(ctx)` 返回启动器的快照否则返回只含继承环境的那一层。该回退并不削弱规则SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。
## bootstrap 变量
`isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。
bootstrap 变量决定**进程如何启动**`PATH``SHELL``NODE_OPTIONS``NODE_PATH``LD_PRELOAD``LD_LIBRARY_PATH``DYLD_*`)、**代码或模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME``USERPROFILE``XDG_*`),或者**网络如何抵达与信任**`HTTP_PROXY``HTTPS_PROXY``ALL_PROXY``NO_PROXY``SSL_CERT_FILE``SSL_CERT_DIR``NODE_EXTRA_CA_CERTS`)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经过审查的子集harness 自己的开关——权限模式、agents home、内置 skill技能根目录——恰恰是敌意项目最想要的而后来新增的开关不能因为忘记登记就变得可设置。
## Known Limitations and Deferred Work
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量仍会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量。
- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-environment",
"description": "Immutable launch-time environment snapshot with per-layer provenance for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,178 @@
/**
* The launch-time environment as one immutable snapshot that remembers which
* layer supplied each value. The harness resolves user-facing values against
* this rather than against `process.env`, because the layers differ in how
* much they are trusted: an inherited variable is this run's explicit intent,
* a file discovered under the invoking directory is whatever the project
* happens to contain, and a consumer that cannot tell them apart cannot make
* that distinction.
*
* Values still reach `process.env` as well — a user's own `--config` tree and
* third-party libraries read it — but that flattened view is not the
* authority for anything the harness itself resolves.
* @module @deepseek-ai/dsh-environment
*/
import type { Context } from 'cordis'
/**
* Which layer supplied a value, from most to least trusted: the environment
* this process inherited, the invoking directory's `.env`, the Harness home's
* `.env`.
*/
export type EnvironmentSource = 'process' | 'project-env' | 'user-env'
/** Layer order, most trusted first — the default search order of {@link EnvironmentSnapshot.get}. */
export const ENVIRONMENT_SOURCES: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env']
/** One resolved variable and the layer it came from. */
export interface EnvironmentEntry {
/** The value as the layer supplied it; may be empty, which each owner judges for itself. */
value: string
/** The layer that supplied it. */
source: EnvironmentSource
/** Absolute path of the file that supplied it; absent for `process`. */
path?: string
}
/** One environment layer's identity, for diagnostics. */
export interface EnvironmentLayer {
source: EnvironmentSource
/** Absolute path of the file behind this layer; absent for `process`. */
path?: string
}
/**
* The frozen environment of one launch. Construct through
* {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a
* later `chdir`, workspace switch, or resumed session observes the same
* values a consumer resolved at boot.
*/
export interface EnvironmentSnapshot {
/**
* Resolve one name across every layer, most trusted first.
* @param name - the variable name.
* @returns the winning entry, or `undefined` when no layer supplies it.
*/
get(name: string): EnvironmentEntry | undefined
/**
* Resolve one name across only the layers the caller trusts for this
* decision. Omitting a layer is a refusal, not a demotion: a routing field
* that must never come from a project directory omits `project-env` so no
* ordering change can let it back in.
* @param name - the variable name.
* @param sources - the layers to search, in the caller's own priority order.
* @returns the first matching entry, or `undefined`.
*/
getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined
/** The layers this snapshot was built from, most trusted first. */
readonly layers: readonly EnvironmentLayer[]
}
/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */
export interface EnvironmentLayerInput {
source: EnvironmentSource
/** Absolute path of the file behind this layer; omit for `process`. */
path?: string
values: Readonly<Record<string, string>>
}
/**
* Build the snapshot from each layer's contents.
* @param layers - the layers in any order; the result searches them by {@link ENVIRONMENT_SOURCES}.
* @returns the immutable snapshot.
*/
export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot {
// Copied per layer so a later mutation of `process.env` — or of a caller's
// own object — cannot change what this snapshot reports.
const bySource = new Map<EnvironmentSource, { path?: string; values: Map<string, string> }>()
for (const layer of layers) {
bySource.set(layer.source, {
...layer.path === undefined ? {} : { path: layer.path },
values: new Map(Object.entries(layer.values)),
})
}
const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => {
for (const source of sources) {
const layer = bySource.get(source)
const value = layer?.values.get(name)
if (value === undefined) continue
return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } }
}
return undefined
}
return {
get: name => getFrom(name, ENVIRONMENT_SOURCES),
getFrom,
layers: ENVIRONMENT_SOURCES
.filter(source => bySource.has(source))
.map((source): EnvironmentLayer => {
const path = bySource.get(source)?.path
return { source, ...path === undefined ? {} : { path } }
}),
}
}
/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */
export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment'
/**
* The snapshot to resolve against, whatever booted this tree: the launcher's
* when the product CLI provided one, otherwise the inherited environment
* alone.
*
* The fallback does not weaken the layer rules — it applies the same rules to
* a host that has exactly one layer. An SDK embedder or a bare `cordis.yml`
* never discovered a project or user file, so everything it has really is the
* environment it was launched with, and `getFrom(..., ['process'])` is exactly
* right for it.
* @param ctx - the consuming plugin's context.
* @returns the snapshot to resolve user-facing values against.
*/
export function environmentOf(ctx: Context): EnvironmentSnapshot {
return ctx.get(DSH_ENVIRONMENT_KEY)
?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
}
declare module 'cordis' {
interface Context {
/** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
launcherEnvironment?: EnvironmentSnapshot
}
}
/** Exact names no discovered file may set. */
const BOOTSTRAP_NAMES = new Set([
// Process launch and module resolution.
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
'LD_PRELOAD', 'LD_LIBRARY_PATH',
// Network reach and trust.
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
])
/** Name prefixes no discovered file may set. */
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_']
/**
* Whether a variable may come only from the inherited process environment.
*
* A bootstrap variable decides how a process launches (`PATH`, `NODE_OPTIONS`,
* `LD_PRELOAD`), where code or model-visible instructions load from (`DSH_*`
* covers the Harness home, the agents home, and the bundled skill root), or
* how the network is reached and trusted (proxy and CA variables). A file the
* harness merely finds — including one a model can write inside the workspace
* — must never set them, so they are rejected at load rather than ranked
* below another layer.
*
* The whole `DSH_*` namespace is denied rather than an audited subset: the
* harness's own switches are exactly the ones a hostile project would want,
* and a new switch must not become settable by forgetting to list it.
* @param name - the variable name.
* @returns true when only the inherited environment may supply it.
*/
export function isBootstrapOnly(name: string): boolean {
const upper = name.toUpperCase()
return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-environment`.
* @module @deepseek-ai/dsh-environment/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-environment'
/** Cordis companion plugin name. */
export const name = 'environment-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no
* event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, ENVIRONMENT_SOURCES, environmentOf, isBootstrapOnly,
} from '../src/index.ts'
const layered = createEnvironmentSnapshot([
{ source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } },
{ source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } },
{ source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } },
])
describe('createEnvironmentSnapshot', () => {
it('resolves across every layer, most trusted first, and reports the winning source', () => {
expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' })
expect(layered.get('ONLY_PROJECT')).toEqual({ value: 'j', source: 'project-env', path: '/work/.env' })
expect(layered.get('ONLY_USER')).toEqual({ value: 'u', source: 'user-env', path: '/home/.dsh/.env' })
expect(layered.get('ABSENT')).toBeUndefined()
})
it('treats an omitted layer as invisible, not merely lower', () => {
// The point of getFrom: a routing field that must never come from a
// project directory cannot be reached by reordering, only by listing it.
expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined()
expect(layered.getFrom('SHARED', ['user-env', 'process'])).toEqual({
value: 'from-user', source: 'user-env', path: '/home/.dsh/.env',
})
expect(layered.getFrom('SHARED', [])).toBeUndefined()
})
it('lists its layers in trust order with their paths', () => {
expect(layered.layers).toEqual([
{ source: 'process' },
{ source: 'project-env', path: '/work/.env' },
{ source: 'user-env', path: '/home/.dsh/.env' },
])
expect(createEnvironmentSnapshot([{ source: 'process', values: {} }]).layers).toEqual([{ source: 'process' }])
})
it('copies each layer, so a later mutation of the source object cannot change it', () => {
const values: Record<string, string> = { KEY: 'first' }
const snapshot = createEnvironmentSnapshot([{ source: 'process', values }])
values.KEY = 'second'
values.LATE = 'added'
expect(snapshot.get('KEY')).toEqual({ value: 'first', source: 'process' })
expect(snapshot.get('LATE')).toBeUndefined()
})
it('keeps an empty value as a present value, for its owner to judge', () => {
const snapshot = createEnvironmentSnapshot([{ source: 'process', values: { EMPTY: '' } }])
expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' })
})
it('orders lookups by ENVIRONMENT_SOURCES regardless of construction order', () => {
const reversed = createEnvironmentSnapshot([
{ source: 'user-env', path: '/u', values: { K: 'u' } },
{ source: 'process', values: { K: 'p' } },
])
expect(ENVIRONMENT_SOURCES).toEqual(['process', 'project-env', 'user-env'])
expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' })
})
})
describe('environmentOf', () => {
it('returns the launcher snapshot when the product CLI provided one', () => {
const ctx = new Context()
ctx.provide(DSH_ENVIRONMENT_KEY, layered)
expect(environmentOf(ctx)).toBe(layered)
})
it('falls back to the inherited environment as the only layer', () => {
vi.stubEnv('DSH_ENV_SPEC_FALLBACK', 'ambient')
try {
const snapshot = environmentOf(new Context())
expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' })
// A host that discovered no files has exactly one layer, so the trusted
// lookups every consumer makes still find what it was launched with.
expect(snapshot.getFrom('DSH_ENV_SPEC_FALLBACK', ['process', 'user-env'])?.value).toBe('ambient')
expect(snapshot.layers).toEqual([{ source: 'process' }])
} finally {
vi.unstubAllEnvs()
}
})
})
describe('isBootstrapOnly', () => {
it.each([
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
'LD_PRELOAD', 'LD_LIBRARY_PATH',
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
])('rejects %s, which decides how the process starts or reaches the network', (name) => {
expect(isBootstrapOnly(name)).toBe(true)
})
it.each([
['DSH_HOME', 'the harness home'],
['DSH_PERMISSION_MODE', 'the permission mode'],
['DSH_AGENTS_HOME', 'a model-visible instruction root'],
['DSH_ANYTHING_ADDED_LATER', 'a switch that does not exist yet'],
['XDG_CONFIG_HOME', 'a state root'],
['DYLD_INSERT_LIBRARIES', 'a library preload'],
])('rejects the whole namespace: %s (%s)', (name) => {
expect(isBootstrapOnly(name)).toBe(true)
})
it('matches case-insensitively, so a lowercase proxy name is not a bypass', () => {
expect(isBootstrapOnly('https_proxy')).toBe(true)
expect(isBootstrapOnly('dsh_permission_mode')).toBe(true)
})
it('allows ordinary variables, including provider credentials and endpoints', () => {
for (const name of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'EXA_API_KEY', 'MY_PROJECT_FLAG', 'PATHS']) {
expect(isBootstrapOnly(name)).toBe(false)
}
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}

View File

@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^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-session": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
@@ -41,6 +42,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",

View File

@@ -9,6 +9,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
import {
@@ -80,8 +81,10 @@ export function apply(ctx: Context, config: Config): void {
resolveApiKey: async () => {
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
const ambient = process.env[apiKeyEnv]
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
// Without the seam the launching environment is the whole credential
// plane — but only that layer, never a discovered project file.
const inherited = environmentOf(ctx).getFrom(apiKeyEnv, ['process'])
return inherited !== undefined && inherited.value.length > 0 ? inherited.value : undefined
},
apiKeyEnv,
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},

View File

@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -35,6 +36,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -9,6 +9,7 @@
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-web'
import {
@@ -58,7 +59,10 @@ export const Config: z<Config> = z.object({
/** Register the Exa search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new ExaSearchProvider({
apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '',
// Only the launching shell and the user's own `.env` may name this key:
// a project directory can be written by the model, and a substituted key
// would route every request through an account someone else reads.
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'user-env'])?.value ?? '',
baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL,
searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE,
highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},

View File

@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -35,6 +36,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -8,6 +8,7 @@
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-web'
import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts'
@@ -52,7 +53,10 @@ export const Config: z<Config> = z.object({
/** Register the Perplexity search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new PerplexitySearchProvider({
apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '',
// Only the launching shell and the user's own `.env` may name this key:
// a project directory can be written by the model, and a substituted key
// would route every request through an account someone else reads.
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'user-env'])?.value ?? '',
baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL,
model: config.model ?? PERPLEXITY_DEFAULT_MODEL,
maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS,

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},