diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index c2a9360f64..6d052edc45 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -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) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2f9b50a717..42102507c0 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -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` diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index ea1e025257..ecd2fca34b 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/README.i18n.yaml @@ -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 diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index 1bb444bc21..1df857851f 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -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. diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index a46adf0bee..41ea1af3a6 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -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` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。 diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 6f051603e6..a86689e809 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -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, } } diff --git a/packages/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts index 7083c9891d..8ed3823832 100644 --- a/packages/util/environment/tests/environment.spec.ts +++ b/packages/util/environment/tests/environment.spec.ts @@ -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() } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 60b5a64692..5e55e12457 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -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, diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index d5c8b938ac..2ecb71336a 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -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, diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index c8088a3c23..e1fe6a2606 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -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,