From d73888478ab3dd568f499a8b8a6e7fd9649df00f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:17:46 +0800 Subject: [PATCH] feat(bash-env): extract the shared DSH_* environment registry into its own package --- packages/bash/bash-env/README.i18n.yaml | 6 + packages/bash/bash-env/README.md | 51 ++++ packages/bash/bash-env/README.zh.md | 51 ++++ packages/bash/bash-env/package.json | 50 ++++ packages/bash/bash-env/src/index.ts | 217 ++++++++++++++++ packages/bash/bash-env/src/invariant.ts | 30 +++ packages/bash/bash-env/tests/bash-env.spec.ts | 237 ++++++++++++++++++ packages/bash/bash-env/tsconfig.json | 36 +++ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- python/sdk-runtime/package.json | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + vitest.config.ts | 3 +- 14 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 packages/bash/bash-env/README.i18n.yaml create mode 100644 packages/bash/bash-env/README.md create mode 100644 packages/bash/bash-env/README.zh.md create mode 100644 packages/bash/bash-env/package.json create mode 100644 packages/bash/bash-env/src/index.ts create mode 100644 packages/bash/bash-env/src/invariant.ts create mode 100644 packages/bash/bash-env/tests/bash-env.spec.ts create mode 100644 packages/bash/bash-env/tsconfig.json diff --git a/packages/bash/bash-env/README.i18n.yaml b/packages/bash/bash-env/README.i18n.yaml new file mode 100644 index 0000000000..90d43daa7b --- /dev/null +++ b/packages/bash/bash-env/README.i18n.yaml @@ -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 diff --git a/packages/bash/bash-env/README.md b/packages/bash/bash-env/README.md new file mode 100644 index 0000000000..7b939326d4 --- /dev/null +++ b/packages/bash/bash-env/README.md @@ -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=`. 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. diff --git a/packages/bash/bash-env/README.zh.md b/packages/bash/bash-env/README.zh.md new file mode 100644 index 0000000000..aeb33629de --- /dev/null +++ b/packages/bash/bash-env/README.zh.md @@ -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()` 当作完整的环境目录。 diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json new file mode 100644 index 0000000000..9ea29ad57c --- /dev/null +++ b/packages/bash/bash-env/package.json @@ -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" + } +} diff --git a/packages/bash/bash-env/src/index.ts b/packages/bash/bash-env/src/index.ts new file mode 100644 index 0000000000..c7caa89f08 --- /dev/null +++ b/packages/bash/bash-env/src/index.ts @@ -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 = 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> + /** + * 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>> +} + +/** 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([ + 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() + private readonly keyOwners = new Map() + 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 = { + [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 } : {} + }, + }) +} diff --git a/packages/bash/bash-env/src/invariant.ts b/packages/bash/bash-env/src/invariant.ts new file mode 100644 index 0000000000..31f842c56d --- /dev/null +++ b/packages/bash/bash-env/src/invariant.ts @@ -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 */ diff --git a/packages/bash/bash-env/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts new file mode 100644 index 0000000000..c93a768f80 --- /dev/null +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -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') + }) +}) diff --git a/packages/bash/bash-env/tsconfig.json b/packages/bash/bash-env/tsconfig.json new file mode 100644 index 0000000000..bcf5eb5229 --- /dev/null +++ b/packages/bash/bash-env/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..e7dc0187ea 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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[]', diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 5af9f4fc8c..65c79bda58 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -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:^", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index ed723c26f5..041972cb9f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -42,6 +42,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 44fc039f8a..a21de0d323 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"], diff --git a/tsconfig.host.json b/tsconfig.host.json index da821faa10..70869ede22 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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" }, diff --git a/vitest.config.ts b/vitest.config.ts index 0fa2a6dd53..14d5be103f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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',