Merge remote-tracking branch 'origin/master' into feature/issue-1470-skill-invoke
# Conflicts: # docs/module-graph.md
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
"@deepseek-ai/dsh-frontend-static": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026
|
||||
README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf
|
||||
README.md: c7c7b2aa231d4c9f4b3fbf31663237c8457eb051
|
||||
README.zh.md: 5439aa78b74415c8e6264d21f5c52e5cee5b38ee
|
||||
|
||||
@@ -59,7 +59,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
|
||||
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ dsh web --dump-config
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
|
||||
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { parseDshArgs } from './args.ts'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
@@ -24,13 +24,13 @@ function readVersion(): string {
|
||||
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
|
||||
}
|
||||
|
||||
loadEnv('dsh')
|
||||
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
|
||||
|
||||
switch (invocation.mode) {
|
||||
case 'profile': {
|
||||
const { runProfile } = await import('./profile-boot.ts')
|
||||
await runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
...invocation.task !== undefined && { task: invocation.task },
|
||||
@@ -39,7 +39,7 @@ switch (invocation.mode) {
|
||||
}
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation)
|
||||
await runWeb(invocation, loadLayeredEnv('dsh'))
|
||||
break
|
||||
}
|
||||
case 'plugin': {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
@@ -163,6 +164,8 @@ export interface RunProfileOptions {
|
||||
task?: string
|
||||
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
|
||||
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
|
||||
/** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +229,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// application must not mutate the objects later reloads recompose from.
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
|
||||
app.current = hostCtx
|
||||
// Before any config-tree entry mounts, so a plugin that resolves a
|
||||
// user-facing value at construction already sees this run's layers.
|
||||
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
|
||||
if (options.task !== undefined) {
|
||||
const io: HeadlessIo = {
|
||||
stdout: process.stdout,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { runProfile, type ProfileRows } from './profile-boot.ts'
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
@@ -124,9 +125,11 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
|
||||
* every boot. The URL line is printed by the web-app bundle's runtime row
|
||||
* after Loader settlement.
|
||||
* @param flags - the parsed `dsh web` flag family.
|
||||
* @param environment - this run's frozen environment snapshot.
|
||||
*/
|
||||
export async function runWeb(flags: WebFlags): Promise<void> {
|
||||
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
|
||||
await runProfile({
|
||||
environment,
|
||||
profile: 'web',
|
||||
patchFiles: flags.patches,
|
||||
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi
|
||||
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
env: Readonly<Record<string, string | undefined>> = {},
|
||||
cwd?: string,
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const childEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env })
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
)
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
env: childEnv,
|
||||
extendEnv: false,
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -127,6 +135,44 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
})
|
||||
}
|
||||
|
||||
function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
const pluginFile = join(project, 'environment-probe.mjs')
|
||||
writeFileSync(pluginFile, [
|
||||
"export const name = 'environment-probe'",
|
||||
"export const inject = ['llm']",
|
||||
'export function apply(ctx) {',
|
||||
' void ctx.loader.await().then(async () => {',
|
||||
" let text = ''",
|
||||
' for await (const chunk of ctx.llm.stream({',
|
||||
" provider: 'deepseek-official',",
|
||||
" model: 'deepseek-v4-flash',",
|
||||
' messages: [],',
|
||||
' maxTokens: 32,',
|
||||
' })) {',
|
||||
" if (chunk.type === 'text-delta') text += chunk.text",
|
||||
' }',
|
||||
' process.stdout.write(`${text}\\n`)',
|
||||
" process.kill(process.pid, 'SIGTERM')",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const profileDir = join(home, 'profiles', 'environment-probe')
|
||||
mkdirSync(profileDir, { recursive: true })
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-environment-probe',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: environment-probe',
|
||||
` name: ${pathToFileURL(pluginFile).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
@@ -144,6 +190,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('does not load a project environment for --version', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
|
||||
writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
|
||||
try {
|
||||
const result = await runBuiltBin(['--version'], {}, project)
|
||||
expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
|
||||
try {
|
||||
@@ -156,6 +213,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('uses the Harness-home environment and managed credential through the published entry', async () => {
|
||||
const apiKey = 'built-home-layer-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'home environment reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
|
||||
writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
|
||||
writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
|
||||
createEnvironmentProbeProfile(home, project)
|
||||
try {
|
||||
const result = await runBuiltBin(
|
||||
['--profile', 'environment-probe'],
|
||||
{
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: undefined,
|
||||
DEEPSEEK_BASE_URL: undefined,
|
||||
},
|
||||
project,
|
||||
)
|
||||
expect(
|
||||
result.code,
|
||||
`${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
|
||||
).toBe(0)
|
||||
expect(result.stdout).toBe('home environment reached the mock')
|
||||
expect(result.stdout).not.toContain(apiKey)
|
||||
expect(result.stderr).not.toContain(apiKey)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
expect(server.requests[0]?.path).toBe('/chat/completions')
|
||||
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
|
||||
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
|
||||
} finally {
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
|
||||
@@ -20,7 +20,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The profile lands in settings.yaml with only the derived reference, the
|
||||
// key value lands in the harness home's .env, the dormant route
|
||||
// key value lands in the harness home's .credentials.yaml, the dormant route
|
||||
// registers, and the topology frame invalidates the page into the row.
|
||||
await expect.poll(
|
||||
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
|
||||
@@ -146,11 +146,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(document).toContain('minimax-cn:')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
expect(document).not.toContain('sk-e2e-minimax')
|
||||
const credentialFile = join(scaffold.harnessHome, '.env')
|
||||
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
|
||||
await expect.poll(
|
||||
async () => readFile(credentialFile, 'utf8').catch(() => ''),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
|
||||
expect(await page.content()).not.toContain('sk-e2e-minimax')
|
||||
}, 60_000)
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).not.toContain('minimax-cn:')
|
||||
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
|
||||
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
|
||||
.not.toContain('MINIMAX_CN_API_KEY')
|
||||
await expect.poll(
|
||||
async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(),
|
||||
|
||||
@@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
|
||||
@@ -32,7 +32,7 @@ import { expect } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import {
|
||||
addHarnessSourceSection,
|
||||
assertEntriesLoaded,
|
||||
@@ -528,11 +528,14 @@ function rawSessionLog(session: Session): string {
|
||||
export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise<void> {
|
||||
const agent = scaffold.ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
|
||||
const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
|
||||
const fresh = scrubRequestHeaders(rawSessionLog(agent.session))
|
||||
.split(sessionId).join('{{sessionId}}')
|
||||
.split(scaffold.workspaceCwd).join('{{cwd}}')
|
||||
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
|
||||
await writeFile(fixturePath, tokenized)
|
||||
const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : ''
|
||||
const stable = stabilizeFixtureMessageIds([fresh], [existing])[0]
|
||||
if (stable === undefined) throw new Error('record harvest: no stabilized fixture')
|
||||
await writeFile(fixturePath, stable)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user