feat(bash-env): extract the shared DSH_* environment registry into its own package
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
|
||||
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
|
||||
README.zh.md: aeb33629def3fcc10294bbca19b37d43dcc73a0c
|
||||
@@ -0,0 +1,51 @@
|
||||
# @deepseek-ai/dsh-bash-env
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly.
|
||||
|
||||
The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
config:
|
||||
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
|
||||
```
|
||||
|
||||
## Managed environment
|
||||
|
||||
Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog.
|
||||
@@ -0,0 +1,51 @@
|
||||
# @deepseek-ai/dsh-bash-env
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。
|
||||
|
||||
包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
config:
|
||||
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
|
||||
```
|
||||
|
||||
## Managed environment
|
||||
|
||||
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent 的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
|
||||
|
||||
`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-env",
|
||||
"description": "Tool-independent managed DSH_* shell environment registry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of
|
||||
* trusted, per-execution `DSH_*` variables consumed by the model-facing shell
|
||||
* tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by
|
||||
* the registry itself while plugins can register additional, enumerable facts
|
||||
* with effect-scoped disposal.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-env
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
bashEnv: BashEnvRegistry
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'bash-env'
|
||||
export const inject: string[] = []
|
||||
|
||||
/** Plugin config (all optional — the built-in facts resolve without defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the bash-env plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Model-visible metadata for one managed `DSH_*` environment variable. */
|
||||
export interface BashEnvVariable {
|
||||
/** Concise description of the environment fact represented by the variable. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin contribution to the managed environment of each model shell call.
|
||||
* Declared keys make ownership conflicts detectable before the first command;
|
||||
* `resolve` computes only the values available for the current execution.
|
||||
*/
|
||||
export interface BashEnvContributor {
|
||||
/** Stable contributor name used in diagnostics and duplicate detection. */
|
||||
name: string
|
||||
/** Complete set of `DSH_*` keys this contributor may return. */
|
||||
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
|
||||
/**
|
||||
* Resolve this contributor's available values for one tool execution.
|
||||
* @param execution - the shell tool execution and its optional calling agent.
|
||||
* @returns a partial map containing only keys declared in {@link variables}.
|
||||
*/
|
||||
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
|
||||
}
|
||||
|
||||
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
|
||||
export interface BashEnvVariableInfo extends BashEnvVariable {
|
||||
/** Contributor that owns the variable. */
|
||||
contributor: string
|
||||
/** Declared `DSH_*` environment variable name. */
|
||||
key: DshEnvironmentKey
|
||||
}
|
||||
|
||||
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
|
||||
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
|
||||
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
|
||||
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
|
||||
DSH_HOME_ENV,
|
||||
DSH_SHELL_KEY,
|
||||
DSH_SESSION_ID_KEY,
|
||||
])
|
||||
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
|
||||
* The namespace is rebuilt for every model shell call: ambient `DSH_*` values
|
||||
* are discarded by the executor, then the registry's current snapshot is
|
||||
* injected. Built-in shell facts remain owned by the registry itself while
|
||||
* plugins can register additional, enumerable facts with effect-scoped
|
||||
* disposal.
|
||||
*/
|
||||
export class BashEnvRegistry extends Service {
|
||||
private readonly contributors = new Map<string, BashEnvContributor>()
|
||||
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
|
||||
private readonly dshHome: string
|
||||
|
||||
/**
|
||||
* Create and install the `ctx.bashEnv` service.
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'bashEnv')
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one environment contributor. Names and keys are unique; built-in
|
||||
* keys are reserved. Registration is disposed with the calling plugin fiber.
|
||||
* @param contributor - declared key ownership and per-execution resolver.
|
||||
* @returns the disposer that unregisters the contribution.
|
||||
*/
|
||||
register(contributor: BashEnvContributor): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
|
||||
if (contributor.name.trim().length === 0) {
|
||||
throw new Error('bash env contributor name must be non-empty')
|
||||
}
|
||||
if (this.contributors.has(contributor.name)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
|
||||
}
|
||||
|
||||
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
|
||||
for (const [key, variable] of variables) {
|
||||
if (!key.startsWith(DSH_ENV_PREFIX)
|
||||
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
|
||||
}
|
||||
if (RESERVED_BASH_ENV_KEYS.has(key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
|
||||
}
|
||||
if (variable.description.trim().length === 0) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
|
||||
}
|
||||
const owner = this.keyOwners.get(key)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
|
||||
}
|
||||
}
|
||||
|
||||
this.contributors.set(contributor.name, contributor)
|
||||
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
|
||||
yield () => {
|
||||
this.contributors.delete(contributor.name)
|
||||
for (const [key] of variables) this.keyOwners.delete(key)
|
||||
}
|
||||
}.bind(this), 'bashEnv.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trusted `DSH_*` snapshot for one shell tool execution.
|
||||
* @param execution - the current tool execution.
|
||||
* @returns an immutable environment overlay containing built-ins and current contributions.
|
||||
*/
|
||||
collect(execution: ToolExecution): DshEnvironment {
|
||||
const values: Record<DshEnvironmentKey, string> = {
|
||||
[DSH_HOME_ENV]: this.dshHome,
|
||||
[DSH_SHELL_KEY]: '1',
|
||||
}
|
||||
if (execution.agent !== undefined) {
|
||||
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
|
||||
}
|
||||
|
||||
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const resolved = contributor.resolve(execution)
|
||||
for (const [rawKey, value] of Object.entries(resolved)) {
|
||||
const key = rawKey as DshEnvironmentKey
|
||||
if (!Object.hasOwn(contributor.variables, key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
|
||||
}
|
||||
|
||||
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
|
||||
// prompt, or UI code treats list() as an exhaustive environment catalog.
|
||||
/**
|
||||
* Enumerate plugin-contributed variables without executing their resolvers.
|
||||
* @returns declarations sorted by environment variable name.
|
||||
*/
|
||||
list(): BashEnvVariableInfo[] {
|
||||
return [...this.contributors.values()]
|
||||
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
|
||||
contributor: contributor.name,
|
||||
description: variable.description,
|
||||
key: key as DshEnvironmentKey,
|
||||
})))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the bash-env plugin: register the `ctx.bashEnv` service and the
|
||||
* shell-agnostic persistence contributor (`DSH_SESSION_JSONL`).
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const registry = new BashEnvRegistry(ctx, config)
|
||||
registry.register({
|
||||
name: 'session-persistence',
|
||||
variables: {
|
||||
[DSH_SESSION_JSONL_KEY]: {
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
},
|
||||
},
|
||||
resolve(execution) {
|
||||
const agent = execution.agent
|
||||
if (agent === undefined) return {}
|
||||
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
|
||||
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`.
|
||||
* @module @deepseek-ai/dsh-bash-env/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'bash-env-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the environment registry validates ownership and collected values at each
|
||||
* registration/collection; it publishes no independent snapshot that a companion could cross-check.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor
|
||||
* ownership and validation, collection ordering, effect-scoped disposal, and
|
||||
* the explicit disposer contract.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
function execution(sessionId?: string): ToolExecution {
|
||||
return {
|
||||
signal: testToolSignal,
|
||||
token: Symbol('bash-env-test') as ToolExecution['token'],
|
||||
callId: CallId('bash-env-call'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
...(sessionId === undefined
|
||||
? {}
|
||||
: { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('BashEnvRegistry', () => {
|
||||
it('collects unconditional shell facts and the current agent session id', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
|
||||
expect(registry.collect(execution())).toEqual({
|
||||
DSH_HOME: resolve('./test-dsh-home'),
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
expect(registry.collect(execution('session-a'))).toEqual({
|
||||
DSH_HOME: resolve('./test-dsh-home'),
|
||||
DSH_SESSION_ID: 'session-a',
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves DSH_HOME from the ambient override or the user-home default', () => {
|
||||
vi.stubEnv('DSH_HOME', './ambient-dsh-home')
|
||||
const fromEnvironment = new BashEnvRegistry(new Context())
|
||||
expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home'))
|
||||
|
||||
vi.stubEnv('DSH_HOME', undefined)
|
||||
const fromDefault = new BashEnvRegistry(new Context())
|
||||
expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh'))
|
||||
})
|
||||
|
||||
it('collects declared contributor variables and omits unavailable values', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'optional-session-fact',
|
||||
variables: {
|
||||
DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' },
|
||||
},
|
||||
resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id },
|
||||
})
|
||||
registry.register({
|
||||
name: 'always-available-fact',
|
||||
variables: {
|
||||
DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' },
|
||||
},
|
||||
resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }),
|
||||
})
|
||||
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL')
|
||||
expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes')
|
||||
expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b')
|
||||
expect(registry.list()).toEqual([
|
||||
{
|
||||
contributor: 'always-available-fact',
|
||||
description: 'Always-available test fact.',
|
||||
key: 'DSH_ALWAYS_AVAILABLE',
|
||||
},
|
||||
{
|
||||
contributor: 'optional-session-fact',
|
||||
description: 'Optional session-scoped test fact.',
|
||||
key: 'DSH_SESSION_OPTIONAL',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects duplicate variable ownership at registration time', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'first',
|
||||
variables: { DSH_SHARED: { description: 'First owner.' } },
|
||||
resolve: () => ({ DSH_SHARED: 'first' }),
|
||||
})
|
||||
|
||||
expect(() => registry.register({
|
||||
name: 'second',
|
||||
variables: { DSH_SHARED: { description: 'Second owner.' } },
|
||||
resolve: () => ({ DSH_SHARED: 'second' }),
|
||||
})).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/)
|
||||
})
|
||||
|
||||
it('rejects duplicate contributor names and malformed declarations', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'declared',
|
||||
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
|
||||
resolve: () => ({}),
|
||||
})
|
||||
|
||||
expect(() => registry.register({
|
||||
name: 'declared',
|
||||
variables: { DSH_ANOTHER: { description: 'Another fact.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/already registered/)
|
||||
expect(() => registry.register({
|
||||
name: ' ',
|
||||
variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/name must be non-empty/)
|
||||
expect(() => registry.register({
|
||||
name: 'invalid-key',
|
||||
variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>,
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/invalid key/)
|
||||
expect(() => registry.register({
|
||||
name: 'reserved-key',
|
||||
variables: { DSH_HOME: { description: 'Reserved key.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/reserved key/)
|
||||
expect(() => registry.register({
|
||||
name: 'blank-description',
|
||||
variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/must describe/)
|
||||
})
|
||||
|
||||
it('rejects undeclared variables returned by a contributor', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'drifted-provider',
|
||||
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
|
||||
resolve: () => ({ DSH_UNDECLARED: 'bad' }),
|
||||
})
|
||||
|
||||
expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/)
|
||||
})
|
||||
|
||||
it('rejects non-string values returned by a contributor', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'wrong-value-type',
|
||||
variables: { DSH_STRING: { description: 'String fact.' } },
|
||||
resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>,
|
||||
})
|
||||
|
||||
expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/)
|
||||
})
|
||||
|
||||
it('removes an effect-scoped contributor when its plugin is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
const fiber = await ctx.plugin({
|
||||
inject: ['bashEnv'],
|
||||
apply(inner: Context) {
|
||||
inner.bashEnv.register({
|
||||
name: 'temporary',
|
||||
variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } },
|
||||
resolve: () => ({ DSH_TEMPORARY: 'present' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present')
|
||||
await fiber.dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY')
|
||||
})
|
||||
|
||||
it('returns an explicit contributor disposer', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
const dispose = registry.register({
|
||||
name: 'explicit-disposal',
|
||||
variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } },
|
||||
resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }),
|
||||
})
|
||||
|
||||
expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present')
|
||||
dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
|
||||
})
|
||||
|
||||
it('the plugin registers the service and the persistence contributor on load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry)
|
||||
expect(ctx.bashEnv.list()).toEqual([
|
||||
{
|
||||
contributor: 'session-persistence',
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
key: 'DSH_SESSION_JSONL',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
ctx.provide('sessionPersistence', {
|
||||
locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }),
|
||||
})
|
||||
expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl')
|
||||
})
|
||||
|
||||
it('the persistence contributor omits the variable for a non-jsonl backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
ctx.provide('sessionPersistence', {
|
||||
locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }),
|
||||
})
|
||||
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
|
||||
})
|
||||
|
||||
it('the persistence contributor omits the variable without a persistence backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'collect(execution: ToolExecution): DshEnvironment',
|
||||
jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one bash tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */',
|
||||
jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one shell tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(): BashEnvVariableInfo[]',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
|
||||
@@ -42,6 +42,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
*/
|
||||
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service surfaces managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' },
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
|
||||
"@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"],
|
||||
"@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"],
|
||||
"@deepseek-ai/dsh-bash-env": ["./packages/bash/bash-env/src/index.ts"],
|
||||
"@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"],
|
||||
"@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
|
||||
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
{ "path": "./packages/llm/llm-deepseek" },
|
||||
{ "path": "./packages/llm/llm-pi-ai" },
|
||||
{ "path": "./packages/bash/bash-local" },
|
||||
{ "path": "./packages/bash/bash-env" },
|
||||
{ "path": "./packages/bash/pwsh-local" },
|
||||
{ "path": "./packages/bash/tool-pwsh" },
|
||||
{ "path": "./packages/sandbox/sandbox" },
|
||||
|
||||
+2
-1
@@ -14,10 +14,11 @@ const windowsUnsupportedPackages = process.platform === 'win32'
|
||||
// Bash-requiring suites (a real POSIX shell is unavailable on Windows).
|
||||
// The pwsh-requiring suites (pwsh-local, tool-pwsh) deliberately stay
|
||||
// INCLUDED: PowerShell ships with Windows, so they run natively here.
|
||||
// Replacing the old 'packages/bash/*' glob with this explicit list also
|
||||
// newly INCLUDES packages/bash/bash (the pure seam package) on Windows.
|
||||
'packages/bash/bash-local',
|
||||
'packages/bash/bash-sandbox',
|
||||
'packages/bash/tool-bash',
|
||||
'packages/bash/tool-bash-persistent',
|
||||
'packages/hooks/*',
|
||||
'packages/subprocess/*',
|
||||
'packages/pty/pty-local',
|
||||
|
||||
Reference in New Issue
Block a user