index.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * Tool-independent shell environment plugin: owns the `ctx.shellEnv` registry of
  3. * trusted, per-execution `DSH_*` variables consumed by the model-facing shell
  4. * tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by
  5. * the registry itself while plugins can register additional, enumerable facts
  6. * with effect-scoped disposal.
  7. *
  8. * @module @deepseek-ai/dsh-shell-env
  9. */
  10. import { Service, type Context } from '@deepseek-ai/cordis'
  11. import z from '@deepseek-ai/schemastery'
  12. import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell'
  13. import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell'
  14. import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
  15. import type { ToolExecution } from '@deepseek-ai/dsh-tools'
  16. import type {} from '@deepseek-ai/dsh-session-persistence'
  17. declare module '@deepseek-ai/cordis' {
  18. interface Context {
  19. shellEnv: ShellEnvRegistry
  20. }
  21. }
  22. export const name = 'shell-env'
  23. export const inject: string[] = []
  24. /** Plugin config (all optional — the built-in facts resolve without defaults). */
  25. export interface Config {
  26. /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
  27. dshHome?: string
  28. }
  29. /** Runtime configuration schema for the shell-env plugin. */
  30. export const Config: z<Config> = z.object({
  31. dshHome: z.string(),
  32. })
  33. /** Model-visible metadata for one managed `DSH_*` environment variable. */
  34. export interface BashEnvVariable {
  35. /** Concise description of the environment fact represented by the variable. */
  36. description: string
  37. }
  38. /**
  39. * A plugin contribution to the managed environment of each model shell call.
  40. * Declared keys make ownership conflicts detectable before the first command;
  41. * `resolve` computes only the values available for the current execution.
  42. */
  43. export interface BashEnvContributor {
  44. /** Stable contributor name used in diagnostics and duplicate detection. */
  45. name: string
  46. /** Complete set of `DSH_*` keys this contributor may return. */
  47. variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
  48. /**
  49. * Resolve this contributor's available values for one tool execution.
  50. * @param execution - the shell tool execution and its optional calling agent.
  51. * @returns a partial map containing only keys declared in {@link variables}.
  52. */
  53. resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
  54. }
  55. /** An enumerable declaration returned by {@link ShellEnvRegistry.list}. */
  56. export interface BashEnvVariableInfo extends BashEnvVariable {
  57. /** Contributor that owns the variable. */
  58. contributor: string
  59. /** Declared `DSH_*` environment variable name. */
  60. key: DshEnvironmentKey
  61. }
  62. const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
  63. const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
  64. const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
  65. const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
  66. DSH_HOME_ENV,
  67. DSH_SHELL_KEY,
  68. DSH_SESSION_ID_KEY,
  69. ])
  70. const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
  71. /**
  72. * Registry (`ctx.shellEnv`) for trusted, per-execution `DSH_*` variables.
  73. * The namespace is rebuilt for every model shell call: ambient `DSH_*` values
  74. * are discarded by the executor, then the registry's current snapshot is
  75. * injected. Built-in shell facts remain owned by the registry itself while
  76. * plugins can register additional, enumerable facts with effect-scoped
  77. * disposal.
  78. */
  79. export class ShellEnvRegistry extends Service {
  80. private readonly contributors = new Map<string, BashEnvContributor>()
  81. private readonly keyOwners = new Map<DshEnvironmentKey, string>()
  82. private readonly dshHome: string
  83. /**
  84. * Create and install the `ctx.shellEnv` service.
  85. * @param ctx - Cordis context that owns the service and registrations.
  86. * @param config - home-directory configuration for the built-in variables.
  87. */
  88. constructor(ctx: Context, config: Config = {}) {
  89. super(ctx, 'shellEnv')
  90. this.dshHome = resolveDshHome(config.dshHome)
  91. }
  92. /**
  93. * Register one environment contributor. Names and keys are unique; built-in
  94. * keys are reserved. Registration is disposed with the calling plugin fiber.
  95. * @param contributor - declared key ownership and per-execution resolver.
  96. * @returns the disposer that unregisters the contribution.
  97. */
  98. register(contributor: BashEnvContributor): () => void {
  99. const dispose = this.ctx.effect(function* (this: ShellEnvRegistry) {
  100. if (contributor.name.trim().length === 0) {
  101. throw new Error('bash env contributor name must be non-empty')
  102. }
  103. if (this.contributors.has(contributor.name)) {
  104. throw new Error(`bash env contributor "${contributor.name}" is already registered`)
  105. }
  106. const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
  107. for (const [key, variable] of variables) {
  108. if (!key.startsWith(DSH_ENV_PREFIX)
  109. || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
  110. throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
  111. }
  112. if (RESERVED_BASH_ENV_KEYS.has(key)) {
  113. throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
  114. }
  115. if (variable.description.trim().length === 0) {
  116. throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
  117. }
  118. const owner = this.keyOwners.get(key)
  119. if (owner !== undefined) {
  120. throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
  121. }
  122. }
  123. this.contributors.set(contributor.name, contributor)
  124. for (const [key] of variables) this.keyOwners.set(key, contributor.name)
  125. yield () => {
  126. this.contributors.delete(contributor.name)
  127. for (const [key] of variables) this.keyOwners.delete(key)
  128. }
  129. }.bind(this), 'bashEnv.register()')
  130. return () => void dispose()
  131. }
  132. /**
  133. * Build the trusted `DSH_*` snapshot for one shell tool execution.
  134. * @param execution - the current tool execution.
  135. * @returns an immutable environment overlay containing built-ins and current contributions.
  136. */
  137. collect(execution: ToolExecution): DshEnvironment {
  138. const values: Record<DshEnvironmentKey, string> = {
  139. [DSH_HOME_ENV]: this.dshHome,
  140. [DSH_SHELL_KEY]: '1',
  141. }
  142. if (execution.agent !== undefined) {
  143. values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
  144. }
  145. for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
  146. const resolved = contributor.resolve(execution)
  147. for (const [rawKey, value] of Object.entries(resolved)) {
  148. const key = rawKey as DshEnvironmentKey
  149. if (!Object.hasOwn(contributor.variables, key)) {
  150. throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
  151. }
  152. if (typeof value !== 'string') {
  153. throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
  154. }
  155. values[key] = value
  156. }
  157. }
  158. return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
  159. }
  160. // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
  161. // prompt, or UI code treats list() as an exhaustive environment catalog.
  162. /**
  163. * Enumerate plugin-contributed variables without executing their resolvers.
  164. * @returns declarations sorted by environment variable name.
  165. */
  166. list(): BashEnvVariableInfo[] {
  167. return [...this.contributors.values()]
  168. .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
  169. contributor: contributor.name,
  170. description: variable.description,
  171. key: key as DshEnvironmentKey,
  172. })))
  173. .sort((left, right) => left.key.localeCompare(right.key))
  174. }
  175. }
  176. /**
  177. * Load the shell-env plugin: register the `ctx.shellEnv` service and the
  178. * shell-agnostic persistence contributor (`DSH_SESSION_JSONL`).
  179. * @param ctx - Cordis context that owns the service and registrations.
  180. * @param config - home-directory configuration for the built-in variables.
  181. */
  182. export function apply(ctx: Context, config: Config = {}): void {
  183. const registry = new ShellEnvRegistry(ctx, config)
  184. registry.register({
  185. name: 'session-persistence',
  186. variables: {
  187. [DSH_SESSION_JSONL_KEY]: {
  188. description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
  189. },
  190. },
  191. resolve(execution) {
  192. const agent = execution.agent
  193. if (agent === undefined) return {}
  194. const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
  195. return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
  196. },
  197. })
  198. }