index.ts 8.1 KB

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