fix(config): trust the invoking project, and stop leaking what it must not decide

Review found five real defects in the configuration-source work, all confirmed
against the code rather than argued:

1. The note claimed --config outranks settings.yaml. It does not: the settings
   seam registers a plugin's cordis entry config as the `base` layer and the
   user section layers over it, and the seam cannot tell a shipped value from a
   --config one. The note now states shipped reality and names --config-replace
   as the lever for a deployment that must win. Separately, a literal `apiKey`
   in settings outranked both the environment and .credentials.yaml — the field
   is removed, so configuration carries a reference and nothing else.
2. DEEPSEEK_SEARCH_BASE_URL was functionally deleted: the shipped inline went
   away without the provider learning to read it. It now resolves from the
   environment snapshot, as the README always claimed.
3. The bootstrap deny list missed the interpreter start-up hooks. BASH_ENV is
   the sharpest: `bash -c` sources it on every bash tool call, so a project
   .env could run a file of its choosing before every command. The list now
   covers BASH_ENV and its per-language siblings, the Git hook commands, and
   the remaining preload and CA variables, organised by what a variable does
   rather than which runtime owns it.
4. YAML parse errors quoted the offending source line — which in a credentials
   document is the secret — into boot stderr and the watcher's logger. Only the
   error code and position are reported now, in credentials-local and
   settings-local alike, pinned by a test that asserts the secret is absent.
5. 0600 governed only files the harness wrote. A hand-created 0644 document was
   read normally. POSIX now checks the mode before reading contents, at boot
   and on every reload; Windows has no mode to inspect and is skipped rather
   than faked.

The project a session is launched in is trusted by default, with no prompt and
no stored trust record: it may supply its own endpoint, ordinary variables, and
a key ranked below the managed store. Trust stops at the harness itself — a
discovered file still cannot set DSH_PERMISSION_MODE, PATH, BASH_ENV, or the
rest, because those take effect with no user action, before any turn, outside
the permission policy and the sandbox.
This commit is contained in:
Yichen Jiang
2026-08-04 17:16:11 +08:00
parent 0512b12714
commit 8c2970e70e
31 changed files with 366 additions and 207 deletions

View File

@@ -3,9 +3,10 @@
* 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)
* inherited process environment (read-only, wins)
* > $DSH_HOME/.credentials.yaml (provider-managed, writable)
* > <invocation cwd>/.env (read-only fallback)
* > $DSH_HOME/.env (read-only fallback)
* ```
*
* The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
@@ -15,10 +16,10 @@
* 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 invoking project may supply a key, because the product trusts the
* project it is launched in. It ranks below the managed store, so a key stored
* through the web page or TUI is never displaced by one a checkout happens to
* carry.
*
* 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
@@ -37,7 +38,7 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile } from 'node:fs/promises'
import { mkdir, readFile, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
@@ -83,11 +84,56 @@ export function resolveSpec(config: Config): ResolvedSpec {
}
}
/** Permission bits outside the owner; a credentials document must have none of them. */
const GROUP_OTHER_BITS = 0o077
/**
* Reject a credentials document other OS users can read, before its contents
* are read at all. The provider creates and replaces the file at `0600`, but a
* hand-written or externally generated one carries whatever umask produced it,
* and silently serving secrets out of a world-readable file would make the
* mode the provider promises meaningless.
*
* POSIX only: Windows has no mode to inspect — its ACLs are not expressible
* here — so the check is skipped rather than faked, and the file's protection
* there is whatever the create and replace APIs express.
* @param filename - absolute path of the document.
* @throws when the file exists with group or other permission bits set.
*/
async function assertOwnerOnly(filename: string): Promise<void> {
if (process.platform === 'win32') return
let mode: number
try {
mode = (await stat(filename)).mode
} catch (error) {
if (!isENOENT(error)) throw error
return
}
const offending = mode & GROUP_OTHER_BITS
if (offending === 0) return
throw new Error(
`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
+ ` run "chmod 600 ${filename}" before starting again`,
)
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/**
* Describe one YAML parse failure without quoting the source. The parser's own
* message embeds the offending line, which here holds a secret.
* @param error - the parser's error.
* @returns the error code with its line and column.
*/
function describeYamlError(error: { code?: string; linePos?: [{ line: number; col: number }, ...unknown[]] }): string {
const at = error.linePos?.[0]
const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`
return `${error.code ?? 'YAML_ERROR'}${where}`
}
/**
* Parse one credentials document into its entries. The document is a strict
* mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
@@ -101,10 +147,15 @@ function isENOENT(error: unknown): boolean {
* @returns the parsed entries, keyed by reference.
*/
export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
// `prettyErrors` is on only for `linePos`; `error.message` is never used,
// because the parser quotes the offending source line and in this document
// that line is a secret. Only the code and position leave this function, and
// the same rule governs every other diagnostic here — a key name is safe to
// print, a value is not.
const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
if (document.errors.length > 0) {
throw new Error(`credentials-local: invalid document at ${filename}: ${
document.errors.map(error => error.message).join('; ')}`)
document.errors.map(describeYamlError).join('; ')}`)
}
const root: unknown = document.toJS() ?? {}
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
@@ -116,6 +167,8 @@ export function parseCredentialsDocument(text: string, filename: string): Map<st
// is exactly the constraint a stored reference must satisfy to be
// addressable through the seam.
credentialRef(key)
// The key name is quoted, never the value: a wrong-typed entry is still a
// secret the user meant to store.
if (typeof value !== 'string') {
throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
}
@@ -194,9 +247,13 @@ export class CredentialsLocal extends Credentials {
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'])
/**
* The `.env` fallback for a reference — below the managed store, never above
* it. The invoking project ranks over the user's home file, matching the
* environment layering: the more specific location wins.
*/
private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
return entry !== undefined && entry.value.length > 0 ? entry : undefined
}
@@ -249,8 +306,8 @@ export class CredentialsLocal extends Credentials {
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' })
const fallback = this.dotenvFallback(ref)
if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source })
return Promise.resolve(undefined)
}
@@ -263,9 +320,8 @@ export class CredentialsLocal extends Credentials {
}
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 })
}
const fallback = this.dotenvFallback(ref)
if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true })
return Promise.resolve({ configured: false, writable: true })
}
@@ -361,6 +417,7 @@ export class CredentialsLocal extends Credentials {
* cannot be trusted must never be treated as "no credentials stored".
*/
private async loadInitial(): Promise<void> {
await assertOwnerOnly(this.spec.filename)
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
@@ -401,6 +458,9 @@ export class CredentialsLocal extends Credentials {
* overwriting a document it could not understand.
*/
private async reconcileFromDisk(): Promise<void> {
// Re-checked on every reload and before every write: an external editor or
// a restored backup can loosen the mode after boot.
await assertOwnerOnly(this.spec.filename)
let text: string | undefined
try {
text = await readFile(this.spec.filename, 'utf8')

View File

@@ -8,6 +8,11 @@ import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
function writeCredentials(file: string, text: string): Promise<void> {
return writeFile(file, text, { mode: 0o600 })
}
const KEY = credentialRef('DSH_CRED_TEST')
const OTHER = credentialRef('DSH_CRED_OTHER')
@@ -65,7 +70,7 @@ describe('layering and reads', () => {
it('serves file entries alongside comments and quoted values', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n')
await writeCredentials(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n')
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
@@ -75,7 +80,7 @@ describe('layering and reads', () => {
it('lets a non-empty process environment win read-only over the file', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: from-file\n')
await writeCredentials(path, 'DSH_CRED_TEST: from-file\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', 'from-env')
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
@@ -85,7 +90,7 @@ describe('layering and reads', () => {
it('treats an empty environment value as absent, falling through to the file', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: stored\n')
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', '')
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
@@ -119,7 +124,7 @@ describe('layer ladder', () => {
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')
await writeCredentials(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' } },
@@ -143,22 +148,41 @@ describe('layer ladder', () => {
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true })
})
it('ignores the invoking directory .env entirely', async () => {
it('serves the invoking project .env over the user one, but never over the store', 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 })
const path = join(dir, '.credentials.yaml')
// The product trusts the project it is launched in, so a checkout may
// carry its own key — ranked above the user's home file (more specific
// wins) and below the managed store, which a stored key must never lose to.
const layers = [
{ source: 'process' as const, values: {} },
{ source: 'project-env' as const, path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } },
{ source: 'user-env' as const, path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user' } },
]
const bare = await bootLayered(path, layers)
expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' })
expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true })
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const stored = await bootLayered(path, layers)
expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
})
it('refuses a document other OS users can read', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 })
const ctx = new Context()
// Before the contents are read at all: serving secrets out of a
// world-readable file would make the 0600 the provider writes meaningless.
await expect(ctx.plugin(CredentialsLocal, { path, watch: false }))
.rejects.toThrow(/readable beyond its owner \(mode 644\)/)
})
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')
await writeCredentials(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' } },
@@ -184,15 +208,36 @@ describe('document validation', () => {
])('fails boot on %s', async (_case, text, message) => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, text)
await writeCredentials(path, text)
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message)
})
it('never puts a credential value in a diagnostic', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
const secret = 'sk-live-DO-NOT-LOG-abcdef123456'
// The yaml parser's own message quotes the offending source line, which in
// this document is the secret itself. Boot stderr and the watcher's logger
// both receive whatever this throws.
await writeCredentials(path, `DSH_CRED_TEST: "${secret}\n`)
let failure: unknown
try {
await new Context().plugin(CredentialsLocal, { path, watch: false })
} catch (error) {
failure = error
}
expect(String(failure)).toMatch(/invalid document/)
// The position survives; the line's contents do not.
expect(String(failure)).toMatch(/line 2, column 1/)
expect(String(failure)).not.toContain(secret)
expect((failure as Error).stack ?? '').not.toContain(secret)
})
it('reads an empty document as an empty store', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, '# nothing stored yet\n')
await writeCredentials(path, '# nothing stored yet\n')
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
@@ -214,7 +259,7 @@ describe('document writes', () => {
it('patches one entry, preserving comments and every untouched entry', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
await writeCredentials(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(KEY, 'new value!')
expect(await readFile(path, 'utf8')).toBe(
@@ -242,7 +287,7 @@ describe('document writes', () => {
// Comments above an entry are that entry's annotation and go with it when
// it is removed — including anything above the document's first entry.
// Every other entry keeps its own comments.
await writeFile(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n')
await writeCredentials(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n')
const ctx = await boot({ path, watch: false })
const seen = updates(ctx)
await ctx.credentials.unset(KEY)
@@ -254,7 +299,7 @@ describe('document writes', () => {
it('rejects empty values and writes the environment would shadow', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: stored\n')
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
const ctx = await boot({ path, watch: false })
await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
@@ -267,7 +312,7 @@ describe('document writes', () => {
it('leaves an empty mapping after unsetting the only entry', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: only\n')
await writeCredentials(path, 'DSH_CRED_TEST: only\n')
const ctx = await boot({ path, watch: false })
await ctx.credentials.unset(KEY)
expect(await readFile(path, 'utf8')).toBe('{}\n')
@@ -282,7 +327,7 @@ describe('document writes', () => {
const ctx = await boot({ path, watch: false })
// An external editor left the document unparsable: the read-modify-write
// must refuse rather than overwrite content it cannot understand.
await writeFile(path, 'DSH_CRED_TEST: "unterminated\n')
await writeCredentials(path, 'DSH_CRED_TEST: "unterminated\n')
await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/)
})
@@ -326,17 +371,17 @@ describe('real hot reload', () => {
const path = join(dir, '.credentials.yaml')
// Watching starts on an existing document: creation racing watcher setup
// is a chokidar readiness gap, not the reload contract under test.
await writeFile(path, 'DSH_CRED_TEST: boot\n')
await writeCredentials(path, 'DSH_CRED_TEST: boot\n')
const ctx = await boot({ path, debounceMs: 10 })
const seen = updates(ctx)
await writeFile(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n')
await writeCredentials(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
})
// Wholesale replacement: an entry deleted on disk never lingers in memory.
await writeFile(path, 'DSH_CRED_TEST: live\n')
await writeCredentials(path, 'DSH_CRED_TEST: live\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
})

View File

@@ -10,6 +10,11 @@ import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
function writeCredentials(file: string, text: string): Promise<void> {
return writeFile(file, text, { mode: 0o600 })
}
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
const BETA = credentialRef('DSH_REVIEW_BETA')
const INNER = credentialRef('DSH_REVIEW_INNER')
@@ -44,7 +49,7 @@ describe('read-modify-write', () => {
await ctx.credentials.set(ALPHA, 'one')
// The external edit has landed on disk but no watcher reported it (watch
// is off — the same blind spot as a debounce window or a missed event).
await writeFile(path, `${ALPHA}: one\n${BETA}: external\n`)
await writeCredentials(path, `${ALPHA}: one\n${BETA}: external\n`)
await ctx.credentials.set(ALPHA, 'two')
const text = await readFile(path, 'utf8')
expect(text).toContain(`${BETA}: external`)
@@ -124,7 +129,7 @@ describe('document editor', () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n`
await writeFile(path, wrapped)
await writeCredentials(path, wrapped)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`)

View File

@@ -6,6 +6,11 @@ import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
function writeCredentials(file: string, text: string): Promise<void> {
return writeFile(file, text, { mode: 0o600 })
}
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
@@ -80,7 +85,7 @@ describe('watcher pipeline', () => {
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
await writeFile(path, 'DSH_CRED_PIPE: arrived\n')
await writeCredentials(path, 'DSH_CRED_PIPE: arrived\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
@@ -90,7 +95,7 @@ describe('watcher pipeline', () => {
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: good\n')
await writeCredentials(path, 'DSH_CRED_PIPE: good\n')
const ctx = await boot({ path, debounceMs: 5 })
await chmod(path, 0o000)
@@ -113,7 +118,7 @@ describe('watcher pipeline', () => {
})
const [instance] = await fakeInstances()
await writeFile(path, 'DSH_CRED_PIPE: first\n')
await writeCredentials(path, 'DSH_CRED_PIPE: first\n')
instance!.watcher.emit('all', 'change', path)
// The snapshot commits before the fan-out, so the value lands even though
// the listener threw out of the refresh.
@@ -122,7 +127,7 @@ describe('watcher pipeline', () => {
})
arm = false
await writeFile(path, 'DSH_CRED_PIPE: second\n')
await writeCredentials(path, 'DSH_CRED_PIPE: second\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
@@ -132,7 +137,7 @@ describe('watcher pipeline', () => {
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: initial\n')
await writeCredentials(path, 'DSH_CRED_PIPE: initial\n')
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
await fiber
@@ -142,7 +147,7 @@ describe('watcher pipeline', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'DSH_CRED_PIPE: changed\n')
await writeCredentials(path, 'DSH_CRED_PIPE: changed\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
@@ -159,7 +164,7 @@ describe('watcher pipeline', () => {
it('empties the snapshot when the document is deleted and emits the removals', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: doomed\n')
await writeCredentials(path, 'DSH_CRED_PIPE: doomed\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
@@ -178,7 +183,7 @@ describe('watcher pipeline', () => {
it('keeps the last good snapshot when an external edit makes the document invalid', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: a\n')
await writeCredentials(path, 'DSH_CRED_PIPE: a\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
@@ -189,7 +194,7 @@ describe('watcher pipeline', () => {
// this document holds nothing but credentials. A live reload must warn
// and keep serving the last good snapshot rather than take the process
// down or silently drop the entry it could not validate.
await writeFile(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
await writeCredentials(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
await new Promise(resolve => setTimeout(resolve, 50))
@@ -197,7 +202,7 @@ describe('watcher pipeline', () => {
expect(seen).toEqual([])
// Repairing the document resumes publishing.
await writeFile(path, 'DSH_CRED_PIPE: b\n')
await writeCredentials(path, 'DSH_CRED_PIPE: b\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
@@ -218,11 +223,11 @@ describe('watcher pipeline', () => {
it('reconciles at watcher ready so a change during setup is not missed', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, `${KEY}: a\n`)
await writeCredentials(path, `${KEY}: a\n`)
const ctx = await boot({ path, debounceMs: 5 })
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, `${KEY}: written-before-ready\n`)
await writeCredentials(path, `${KEY}: written-before-ready\n`)
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(async () => {

View File

@@ -47,12 +47,11 @@ export interface DeepSeekConnectionOptions {
/** Endpoint base; `/chat/completions` is appended. */
baseURL: string
/**
* Literal API key of this same resolution, when the configuration carried
* one. Travelling with the endpoint is the point: a request can never pair
* one generation's URL with another generation's secret.
* Credential reference of this same resolution, resolved per request.
* Travelling with the endpoint is the point: a request can never pair one
* generation's URL with another generation's secret. Configuration carries
* only this name — a literal key is not a configuration value.
*/
apiKey?: string
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
apiKeyEnv: CredentialRef
/** Request defaults applied to every call (thinking mode, effort). */
defaults: RequestDefaults

View File

@@ -59,8 +59,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
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 from a trusted environment layer, then the public API. */
@@ -89,7 +87,6 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
})
export const Config: z<Config> = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
@@ -147,9 +144,9 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
* 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.
* the product CLI. Every layer may supply an endpoint: the product trusts the
* project it is launched in, so a checkout can point its own agent at the
* gateway that checkout is meant to use.
* @returns validated connection facts plus the credential reference.
*/
export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions {
@@ -175,10 +172,9 @@ export function resolveAdapterOptions(config: Config, environment?: EnvironmentS
)
}
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL
?? environment?.getFrom(BASE_URL_ENV, ['process', 'user-env'])?.value
?? environment?.getFrom(BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value
?? PUBLIC_BASE_URL,
defaults: {
thinking: config.thinking,
@@ -220,7 +216,6 @@ export function apply(ctx: Context, config: Config): void {
const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
// Every credential fact comes from the caller's snapshot, so a rejected
// settings generation cannot leak its key onto the previous endpoint.
if (connection.apiKey !== undefined) return connection.apiKey
const ref = connection.apiKeyEnv
const credentials = ctx.get('credentials')
if (credentials !== undefined) {
@@ -228,16 +223,13 @@ export function apply(ctx: Context, config: Config): void {
if (hit !== undefined) return hit.value
} else {
// 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
// environment is the whole credential plane.
const ambient = environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])
if (ambient !== undefined && ambient.value.length > 0) return ambient.value
}
throw new LlmError(
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
+ ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
+ ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
+ ` service (the web Models page writes it), or export ${ref} in the launching environment`,
'MISSING_CREDENTIAL',
)
}

View File

@@ -13,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, PUBLIC_BASE_URL, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, 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'
@@ -26,9 +26,12 @@ afterEach(async () => {
})
async function harness(baseURL: string, config: object = {}) {
// Configuration carries only the reference; the key comes from the
// environment, which is the whole credential plane without a mounted seam.
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
await ctx.plugin(LlmDeepSeek, { baseURL, ...config })
return ctx
}
@@ -567,7 +570,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: server.url,
})
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
@@ -586,7 +588,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
retryPolicy: {
mode: 'always',
@@ -605,7 +606,7 @@ describe('plugin registration and config', () => {
it('owns the deepseek provider and advertises the default models', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
@@ -633,7 +634,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
reasoningEffort: effort,
})
@@ -654,7 +654,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort: 'off',
@@ -674,7 +673,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort,
@@ -704,7 +702,7 @@ describe('plugin registration and config', () => {
it('uses the default model catalog when apply is called directly', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
@@ -715,7 +713,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [
{ id: 'private-fast', contextWindow: 32_000 },
@@ -749,7 +746,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaultContextWindow: 256_000,
models: [
@@ -770,7 +766,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [],
})
@@ -787,7 +782,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [...models],
})).rejects.toThrow(message)
@@ -799,7 +793,6 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
expect(() => {
LlmDeepSeek.apply(ctx, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [{ id: 'invalid-context', contextWindow: 0 }],
})
@@ -816,7 +809,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaultContextWindow,
})).rejects.toThrow(/defaultContextWindow/)
@@ -833,7 +825,6 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
maxTokens,
})).rejects.toThrow(/maxTokens/)
@@ -864,7 +855,7 @@ describe('plugin registration and config', () => {
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and mentions a literal key last.
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s)
})
it('reads the ambient variable when no credentials seam is mounted', async () => {
@@ -900,25 +891,26 @@ describe('plugin registration and config', () => {
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
await ctx.plugin(LlmDeepSeek, {})
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1)
})
it('takes DEEPSEEK_BASE_URL from the launching shell or the user .env, never from the project', () => {
it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => {
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.
// The product trusts the project it is launched in, so a checkout can
// point its own agent at the gateway that checkout is meant to use.
const project = createEnvironmentSnapshot([
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://attacker.example' } },
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } },
])
expect(resolveAdapterOptions({}, project).baseURL).toBe(PUBLIC_BASE_URL)
expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example')
// An explicitly configured endpoint outranks every environment layer, so a
// stale shell value cannot rewrite a deployment's own gateway.
const shell = createEnvironmentSnapshot([
@@ -966,12 +958,10 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: 0,
})).rejects.toThrow(/streamIdleTimeoutMs/)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(/streamIdleTimeoutMs/)
@@ -982,7 +972,6 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
retryPolicy: { mode: 'normal', maxRetries: -1 },
})).rejects.toThrow(/retryPolicy/)

View File

@@ -61,7 +61,7 @@ describe('request-level dynamic configuration', () => {
it('routes the next request with the freshly resolved base URL and credential', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: serverA.url })
@@ -78,16 +78,21 @@ describe('request-level dynamic configuration', () => {
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
})
it('prefers a literal settings apiKey over the credential layers', async () => {
it('refuses a literal apiKey in settings and keeps serving the stored credential', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n', { mode: 0o600 })
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
// Configuration carries a reference, never a value. The namespace has no
// `apiKey` field, so writing one is dropped by the schema rather than
// rejected (no adapter namespace is strict); what matters is that a
// settings document cannot become a second credential store outranking
// `.credentials.yaml` and the environment.
await ctx.settings.update(NS, { apiKey: 'literal-key' })
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer literal-key')
expect(server.headers[0]?.authorization).toBe('Bearer file-key')
})
it('starts keyless and serves the next request once the key arrives', async () => {
@@ -152,17 +157,16 @@ describe('request-level dynamic configuration', () => {
])
})
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => {
const dir = await home()
const good = await mockServer([{ kind: 'sse', events: textEvents }])
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
vi.stubEnv('DEEPSEEK_API_KEY', 'good-key')
const { ctx } = await boot(dir, { baseURL: good.url })
// One snapshot moves the endpoint AND the literal key, and fails the
// resolve step beyond the schema (duplicate catalog ids).
// One snapshot moves the endpoint and fails the resolve step beyond the
// schema (duplicate catalog ids).
await ctx.settings.update(NS, {
apiKey: 'rejected-key',
baseURL: rejected.url,
models: [{ id: 'dup' }, { id: 'dup' }],
})
@@ -178,7 +182,7 @@ describe('request-level dynamic configuration', () => {
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })

View File

@@ -51,7 +51,7 @@ async function loadComposition(
const credentialsPath = join(root, '.credentials.yaml')
if (options.withDynamic && fresh) {
await writeFile(settingsPath, '# personal settings\n')
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n')
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
}
const configPath = join(root, 'cordis.yml')
@@ -76,7 +76,6 @@ async function loadComposition(
" name: '@deepseek-ai/dsh-llm-deepseek'",
' config:',
` baseURL: ${JSON.stringify(options.baseURL)}`,
...options.withDynamic ? [] : [' apiKey: entry-key'],
'',
].join('\n'))
@@ -122,7 +121,7 @@ describe('llm-deepseek real dynamic composition', () => {
await vi.waitFor(() => {
expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
}, { timeout: 5000 })
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n')
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
await vi.waitFor(async () => {
expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
}, { timeout: 5000 })
@@ -161,8 +160,10 @@ describe('llm-deepseek real dynamic composition', () => {
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
})
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => {
// No settings and no credentials provider: configuration carries only the
// reference, so the environment is the whole credential plane here.
vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })

View File

@@ -100,9 +100,8 @@ 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 the launching environment is the whole credential
// plane — but only that layer, never a discovered project file.
: environmentOf(ctx).getFrom(ref, ['process'])?.value
// Without the seam the environment is the whole credential plane.
: environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])?.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

@@ -53,7 +53,7 @@ describe('request-level dynamic profiles', () => {
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n')
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n', { mode: 0o600 })
const server = await mockServer([{ events: textEvents }])
// The exact product posture: `- id: llm-pi-ai` with no config at all.
const ctx = await boot(dir, {})
@@ -112,7 +112,7 @@ describe('request-level dynamic profiles', () => {
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n')
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },

View File

@@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, '# personal settings\n')
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n')
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [

View File

@@ -1,7 +1,7 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -34,10 +34,10 @@ async function harness(
baseURL: string,
options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {},
): Promise<Context> {
vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key')
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'mock-key',
baseURL,
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
retryPolicy: {

View File

@@ -242,10 +242,16 @@ export class SettingsLocal extends Settings {
private parse(text: string): Record<string, unknown> {
let root: unknown
if (this.spec.format === 'yaml') {
// `prettyErrors` is on only for `linePos`; `error.message` is never
// used, because the parser quotes the offending source line and a
// settings document can hold a `role('secret')` value.
const document = parseDocument(text, { prettyErrors: true })
if (document.errors.length > 0) {
throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
document.errors.map(error => error.message).join('; ')}`)
document.errors.map((error) => {
const at = error.linePos?.[0]
return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}`
}).join('; ')}`)
}
root = document.toJS() ?? {}
} else {

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: f642aa715c87878b2eaab9f034fb18163a6fbd2e
README.zh.md: a095730dbc8c2a4e7dc8dc57dc2930685c8fb453
README.md: 526c7263106962cdbc19ec58c00b06e58849a258
README.zh.md: 203b8252d2e96235ec083481ccafda129902cd38

View File

@@ -7,7 +7,7 @@ This run's environment as one immutable snapshot that remembers **which layer su
| 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 |
| `<invocation cwd>/.env` | `project-env` | The project the harness was launched in, which the product trusts to configure its own agent |
| `$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.
@@ -16,14 +16,14 @@ Values do also reach `process.env` — a user's `--config` tree and third-party
`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.
**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.
```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
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', '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.
@@ -32,11 +32,13 @@ const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'us
`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.
Trusting a project to configure the agent's work is not the same as letting it change the harness. A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, `DYLD_*`), **what code a runtime executes before the program it was asked to run** (`BASH_ENV` and its per-language siblings — `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS` — plus the Git hook commands), **where model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or **how the network is reached and trusted** (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
These take effect with no user action, before any turn, outside the permission policy and the sandbox: `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful, and `BASH_ENV` runs a file of the project's choosing on every `bash -c` the bash tool issues.
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.
- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. That is intended for ordinary variables; the code-loading hooks that would abuse it are rejected at load instead, and the deny list is the thing to extend when a new runtime hook appears.
- **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

@@ -7,7 +7,7 @@
| 层 | 来源 id | 它是什么 |
|---|---|---|
| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 |
| `<invocation cwd>/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 |
| `<invocation cwd>/.env` | `project-env` | harness 被启动于其中的项目;产品信任它配置自己的 agent |
| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 |
这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。
@@ -16,14 +16,14 @@
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。
**省略某一层是拒绝,不是降级** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询
**省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的
```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
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', 'user-env'])?.value
```
当产品 CLI命令行界面启动了这棵树时`environmentOf(ctx)` 返回启动器的快照否则返回只含继承环境的那一层。该回退并不削弱规则SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。
@@ -32,11 +32,13 @@ const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'us
`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` 不是绕过手段。
信任一个项目配置 agent 的工作,不等于让它改变 harness 本身。bootstrap 变量决定**进程如何启动**`PATH``SHELL``NODE_OPTIONS``LD_PRELOAD``DYLD_*`)、**运行时在执行被要求运行的程序之前先执行哪些代码**`BASH_ENV` 及其各语言同类——`PERL5OPT``PYTHONSTARTUP``RUBYOPT``JAVA_TOOL_OPTIONS`——以及 Git 的钩子命令)、**模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME``XDG_*`),或者**网络如何抵达与信任**proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
这些变量无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效:`DSH_PERMISSION_MODE` 会关掉让「信任项目」有意义的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件。
整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经过审查的子集harness 自己的开关——权限模式、agents home、内置 skill技能根目录——恰恰是敌意项目最想要的而后来新增的开关不能因为忘记登记就变得可设置。
## Known Limitations and Deferred Work
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。这对普通变量是有意为之;会滥用这一点的代码加载钩子改为在加载时拒绝,新的运行时钩子出现时该扩展的是那份拒绝清单
- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。

View File

@@ -146,29 +146,51 @@ 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',
'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT',
// Interpreter start-up hooks: each of these makes a runtime execute a file
// of the setter's choosing on every invocation, before the program runs.
// `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources
// it every time — but every runtime an agent shells out to has one.
'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS',
'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB',
'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS',
// Version-control hooks that run a command on the setter's behalf.
'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR',
'EDITOR', 'VISUAL', 'PAGER',
// Network reach and trust.
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
])
/** Name prefixes no discovered file may set. */
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_']
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
/**
* 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 invoking project is trusted to *configure* the agent's work — its
* endpoints, its ordinary variables, even a credential. It is not trusted to
* change the harness itself, and that is what a bootstrap variable does: it
* decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what
* code a runtime executes before the program it was asked to run (`BASH_ENV`
* and its per-language siblings, the Git hook commands), where 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).
*
* 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.
* The distinction is that these take effect with no user action, before any
* turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE`
* would switch off the approvals that make trusting a project meaningful at
* all, and `BASH_ENV` runs a file of the project's choosing on every single
* `bash -c` the tool issues. Trusting a project's code to run under the
* agent's policy is not the same as letting it rewrite that policy.
*
* They are therefore rejected at load rather than ranked below another layer:
* a user who wrote one into a file believes it applies, and silently ignoring
* it is its own failure. The whole `DSH_*` namespace is denied rather than an
* audited subset, because a switch added later must not become settable by
* being forgotten.
* @param name - the variable name.
* @returns true when only the inherited environment may supply it.
*/

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/web/web-search-deepseek/README.md
README.md: 9046934de209ed0787efa50332e5be16bfdf55c6
README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd
README.md: 95340314fe08d0963b899f4a1d704a98f85963a5
README.zh.md: efd02af805faa96781654b4a4a0dd69a6b8ed3e4

View File

@@ -20,7 +20,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not*
|---|---|---|
| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Falls back to `$DEEPSEEK_SEARCH_BASE_URL` from any environment layer; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
| `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. |
@@ -31,7 +31,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not*
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
baseURL: https://gateway.internal/anthropic/v1
```
## Mapping

View File

@@ -20,7 +20,7 @@ Exa 和 Perplexity 提供专用搜索端点DeepSeek 则没有。该提供方
|---|---|---|
| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`缺省时回退到任一环境层中的 `$DEEPSEEK_SEARCH_BASE_URL`;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
| `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 |
| `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 |
| `maxTokens` | `4096` | Messages 请求生成 token 的正整数上限。 |
@@ -31,7 +31,7 @@ Exa 和 Perplexity 提供专用搜索端点DeepSeek 则没有。该提供方
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
baseURL: https://gateway.internal/anthropic/v1
```
## 映射

View File

@@ -68,6 +68,14 @@ export const Config: z<Config> = z.object({
maxUses: z.number().step(1).min(1),
})
/**
* Environment variable naming this provider's endpoint. Deliberately distinct
* from `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions adapter:
* search speaks the Anthropic-compatible Messages API, so one variable cannot
* serve both.
*/
const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL'
/** Register the DeepSeek search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
@@ -81,13 +89,14 @@ export function apply(ctx: Context, config: Config): void {
resolveApiKey: async () => {
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
// 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
// Without the seam the environment is the whole credential plane.
const ambient = environmentOf(ctx).getFrom(apiKeyEnv, ['process', 'project-env', 'user-env'])
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined
},
apiKeyEnv,
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
baseURL: config.baseURL
?? environmentOf(ctx).getFrom(SEARCH_BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value
?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens,

View File

@@ -59,10 +59,9 @@ 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({
// 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 ?? '',
// 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 ?? '',
baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL,
searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE,
highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,

View File

@@ -53,10 +53,9 @@ 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({
// 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 ?? '',
// 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 ?? '',
baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL,
model: config.model ?? PERPLEXITY_DEFAULT_MODEL,
maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS,