cleanup(environment): keep one lookup order

This commit is contained in:
Tianyi Cui
2026-08-07 23:33:11 +08:00
parent 2c532f3b2c
commit d0e052dd83
10 changed files with 25 additions and 29 deletions

View File

@@ -182,7 +182,7 @@ export function resolveAdapterOptions(config: Config, environment?: EnvironmentS
return {
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL
?? environment?.getFrom(BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value
?? environment?.get(BASE_URL_ENV)?.value
?? PUBLIC_BASE_URL,
defaults: {
thinking: config.thinking,
@@ -232,7 +232,7 @@ export function apply(ctx: Context, config: Config): void {
} else {
// Without the seam there is no managed store to rank against, so the
// environment is the whole credential plane.
const ambient = environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])
const ambient = environmentOf(ctx).get(ref)
if (ambient !== undefined && ambient.value.length > 0) {
return assertUsableApiKey(ambient.value, 'llm-deepseek', ref)
}

View File

@@ -143,7 +143,7 @@ export function apply(ctx: Context, config: Config): void {
const hit = credentials !== undefined
? (await credentials.resolve(ref))?.value
// Without the seam the environment is the whole credential plane.
: environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])?.value
: environmentOf(ctx).get(ref)?.value
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
throw new LlmError(
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`

View File

@@ -2,5 +2,5 @@
# 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: 1bb444bc217ce1a01fb98f954d6e1c2bbc3db957
README.zh.md: a46adf0beeb0fb2069e198c99e4c00c2e8c09c6c
README.md: 1df857851f0f0a5a5ac52365c5e563a1c001bfca
README.zh.md: 41ea1af3a6eb95d58456f43e0f7f0a90e4fe7ac6

View File

@@ -14,7 +14,7 @@ Values do also reach `process.env` — a user's `--config` tree and third-party
## Resolving
`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts.
`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the named layers without changing that trust order.
**Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true.
@@ -25,7 +25,7 @@ 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', 'project-env', 'user-env'])?.value
const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.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.

View File

@@ -14,7 +14,7 @@
## 解析
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索指定的层,不改变这一可信顺序
**省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。
@@ -25,7 +25,7 @@ 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', 'project-env', 'user-env'])?.value
const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
```
当产品 CLI命令行界面启动了这棵树时`environmentOf(ctx)` 返回启动器的快照否则返回只含继承环境的那一层。该回退并不削弱规则SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。

View File

@@ -22,8 +22,8 @@ import type { Context } from 'cordis'
*/
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']
/** Layer order, most trusted first. */
const SOURCE_ORDER: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env']
/** One resolved variable and the layer it came from. */
export interface EnvironmentEntry {
@@ -54,7 +54,7 @@ export interface EnvironmentSnapshot {
* 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.
* @param sources - the layers allowed in the canonical trust order.
* @returns the first matching entry, or `undefined`.
*/
getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined
@@ -81,7 +81,7 @@ export interface EnvironmentLayerInput {
/**
* Build the snapshot from each layer's contents.
* @param layers - the layers in any order; the result searches them by {@link ENVIRONMENT_SOURCES}.
* @param layers - the layers in any order; the result searches them by canonical trust order.
* @returns the immutable snapshot.
*/
export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot {
@@ -101,7 +101,8 @@ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput
}
const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => {
const key = lookupKey(name)
for (const source of sources) {
for (const source of SOURCE_ORDER) {
if (!sources.includes(source)) continue
const layer = bySource.get(source)
const value = layer?.values.get(key)
if (value === undefined) continue
@@ -110,7 +111,7 @@ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput
return undefined
}
return {
get: name => getFrom(name, ENVIRONMENT_SOURCES),
get: name => getFrom(name, SOURCE_ORDER),
getFrom,
}
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, ENVIRONMENT_SOURCES, environmentOf, isBootstrapOnly,
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, isBootstrapOnly,
} from '../src/index.ts'
const layered = createEnvironmentSnapshot([
@@ -18,13 +18,12 @@ describe('createEnvironmentSnapshot', () => {
expect(layered.get('ABSENT')).toBeUndefined()
})
it('treats an omitted layer as invisible, not merely lower', () => {
it('filters layers without changing their trust order', () => {
// 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', ['user-env', 'process']))
.toEqual({ value: 'from-process', source: 'process' })
expect(layered.getFrom('SHARED', [])).toBeUndefined()
})
@@ -42,12 +41,11 @@ describe('createEnvironmentSnapshot', () => {
expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' })
})
it('orders lookups by ENVIRONMENT_SOURCES regardless of construction order', () => {
it('orders lookups canonically 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' })
})
})
@@ -64,9 +62,6 @@ describe('environmentOf', () => {
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')
} finally {
vi.unstubAllEnvs()
}

View File

@@ -90,12 +90,12 @@ export function apply(ctx: Context, config: Config): void {
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
// Without the seam the environment is the whole credential plane.
const ambient = environmentOf(ctx).getFrom(apiKeyEnv, ['process', 'project-env', 'user-env'])
const ambient = environmentOf(ctx).get(apiKeyEnv)
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined
},
apiKeyEnv,
baseURL: config.baseURL
?? environmentOf(ctx).getFrom(SEARCH_BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value
?? environmentOf(ctx).get(SEARCH_BASE_URL_ENV)?.value
?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,

View File

@@ -61,7 +61,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new ExaSearchProvider({
// Every environment layer may name this key: the product trusts the
// project it is launched in, and the managed store is not involved here.
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '',
apiKey: config.apiKey ?? environmentOf(ctx).get('EXA_API_KEY')?.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

@@ -55,7 +55,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new PerplexitySearchProvider({
// Every environment layer may name this key: the product trusts the
// project it is launched in, and the managed store is not involved here.
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '',
apiKey: config.apiKey ?? environmentOf(ctx).get('PERPLEXITY_API_KEY')?.value ?? '',
baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL,
model: config.model ?? PERPLEXITY_DEFAULT_MODEL,
maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS,