@dsh-plus/secret-env 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * secret-env 服务主体(host 半):
3
+ * - 全局臂:值经 dsh-credentials seam 持久($DSH_HOME/.credentials.yaml),
4
+ * 服务内存中维护同步镜像(contributor.resolve 是同步签名,无法现查异步 seam);
5
+ * - 会话臂:Map<SessionId, Map<后缀, 值>> 纯内存,session/disposed 清除;
6
+ * - 注入臂:每个变量名一个 dsh-shell-env contributor,每次 shell 执行由
7
+ * 注册表 collect 现取——写入/删除下一条命令即生效,不进消息流、不动前缀。
8
+ *
9
+ * 红线:值只流经「端点 → 本服务 → dshEnv」,任何日志/事件/返回值不带值。
10
+ * @module secret-env/service
11
+ */
12
+ import { Context, Service } from '@deepseek-ai/cordis'
13
+ import { type CredentialInfo, credentialRef } from '@deepseek-ai/dsh-credentials'
14
+ import type {} from '@deepseek-ai/dsh-session'
15
+ import type {} from '@deepseek-ai/dsh-settings'
16
+ import type {} from '@deepseek-ai/dsh-shell-env'
17
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
18
+
19
+ import { registerSecretEnvApi } from './api.ts'
20
+ import { Config, type SecretEnvConfig, type SecretMeta } from './config.ts'
21
+ import { SecretEnvError } from './errors.ts'
22
+ import { envNameOf, suffixOf } from './names.ts'
23
+ import { SETTINGS_NS } from './ns.ts'
24
+
25
+ /** 受管键(结构等价 dsh-shell 的 DshEnvironmentKey,避免仅为类型多挂一个依赖)。 */
26
+ type ManagedKey = `DSH_${string}`
27
+
28
+ /** 一条会话级密钥(内存态;once = 首次注入后自毁)。 */
29
+ export interface SessionSecret {
30
+ value: string
31
+ description: string
32
+ once: boolean
33
+ createdAt: string
34
+ }
35
+
36
+ /** 列表端点的全局条目(describe 视图,绝无值)。 */
37
+ export interface GlobalEntry {
38
+ name: string
39
+ envName: string
40
+ description: string
41
+ configured: boolean
42
+ source?: string
43
+ writable: boolean
44
+ }
45
+
46
+ /** 列表端点的会话条目。 */
47
+ export interface SessionEntry {
48
+ name: string
49
+ envName: string
50
+ description: string
51
+ once: boolean
52
+ createdAt: string
53
+ }
54
+
55
+ interface SettingsLike {
56
+ get(ns: string): unknown
57
+ update(ns: string, patch: Record<string, unknown>): Promise<void>
58
+ installSection(
59
+ ownerCtx: Context,
60
+ ns: string,
61
+ schema: unknown,
62
+ base: unknown,
63
+ hooks: { setSource(source: () => unknown): void; onChange(): void },
64
+ ): void
65
+ }
66
+
67
+ function asMeta(value: unknown): SecretMeta[] {
68
+ if (value === undefined || value === null || typeof value !== 'object') return []
69
+ const list = (value as { secrets?: unknown }).secrets
70
+ if (!Array.isArray(list)) return []
71
+ return list
72
+ .filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
73
+ .map((item) => ({
74
+ name: typeof item.name === 'string' ? item.name : '',
75
+ description: typeof item.description === 'string' ? item.description : '',
76
+ createdAt: typeof item.createdAt === 'string' ? item.createdAt : '',
77
+ }))
78
+ .filter((item) => item.name.length > 0)
79
+ }
80
+
81
+ export class SecretEnvService extends Service {
82
+ static [Context.inject] = ['credentials', 'shellEnv']
83
+
84
+ /** 全局值镜像(seam resolve 为异步,contributor 同步读这里)。 */
85
+ private readonly globalMirror = new Map<string, string>()
86
+ /** 会话桶:sessionId → 后缀 → 条目。 */
87
+ private readonly buckets = new Map<string, Map<string, SessionSecret>>()
88
+ /** 变量名 → contributor 注销器(shell-env 要求一键一主)。 */
89
+ private readonly contributors = new Map<string, () => void>()
90
+ /** 行级 config(settings 缺席时的索引来源)。 */
91
+ private readonly base: SecretEnvConfig
92
+ /** installSection 给的实时 getter(scope.get 的活视图,读即最新,无时序竞态)。 */
93
+ private readIndex: (() => unknown) | undefined
94
+ /** 上次调和时的索引快照(onChange 差量用)。 */
95
+ private lastMeta: SecretMeta[] = []
96
+ private settingsRef: SettingsLike | undefined
97
+ /** 启动镜像建立完成(测试与需要确定性的调用方可等待)。 */
98
+ readonly ready: Promise<void>
99
+
100
+ constructor(ctx: Context, config: SecretEnvConfig | undefined) {
101
+ super(ctx, 'secretEnv')
102
+ this.base = config ?? { secrets: [] }
103
+ // 官方 installSection 范式(同 usage-panel):setSource 收到的是
104
+ // () => scope.get() 活视图——索引读取永远走 currentMeta() 现取,
105
+ // onChange 仅触发差量调和,因此用户层加载早晚都不会被写覆盖。
106
+ ctx.inject(['settings'], (settingsCtx) => {
107
+ const settings = settingsCtx.settings as unknown as SettingsLike
108
+ settings.installSection(ctx, SETTINGS_NS, Config, this.base, {
109
+ setSource: (source) => {
110
+ this.readIndex = source
111
+ },
112
+ onChange: () => {
113
+ void this.reconcile()
114
+ },
115
+ })
116
+ this.settingsRef = settings
117
+ void this.reconcile()
118
+ })
119
+ ctx.root.on('session/disposed', (session: { id: string }) => {
120
+ this.dropBucket(session.id)
121
+ })
122
+ ctx.on('credentials/reference-updated', (ref) => {
123
+ const suffix = suffixOf(String(ref))
124
+ // 非索引名不注入:索引是"本插件管理哪些全局名"的唯一事实源。
125
+ if (suffix !== undefined && this.isIndexed(suffix)) void this.refreshMirror(suffix)
126
+ })
127
+ ctx.inject(['webServer'], (webCtx) => {
128
+ registerSecretEnvApi(webCtx as Context, this)
129
+ })
130
+ this.ready = this.reconcile()
131
+ }
132
+
133
+ /** 当前生效索引(settings 活视图优先,缺席回落行级 config)。 */
134
+ private currentMeta(): SecretMeta[] {
135
+ return asMeta(this.readIndex !== undefined ? this.readIndex() : this.base)
136
+ }
137
+
138
+ /** 索引成员判定(索引是全局名管理面的唯一事实源)。 */
139
+ private isIndexed(suffix: string): boolean {
140
+ return this.currentMeta().some((item) => item.name === suffix)
141
+ }
142
+
143
+ /** 索引差量调和:新名建镜像,移出名撤镜像并回收其独占的 contributor。 */
144
+ private async reconcile(): Promise<void> {
145
+ const next = this.currentMeta()
146
+ const removed = this.lastMeta.filter((item) => !next.some((n) => n.name === item.name))
147
+ const added = next.filter((item) => !this.lastMeta.some((p) => p.name === item.name))
148
+ this.lastMeta = next
149
+ for (const item of removed) {
150
+ this.globalMirror.delete(item.name)
151
+ this.syncContributor(item.name)
152
+ }
153
+ for (const item of added) {
154
+ await this.refreshMirror(item.name)
155
+ }
156
+ }
157
+
158
+ /** 重新 resolve 一个全局名并同步镜像与 contributor(外部热编辑亦走此路)。 */
159
+ private async refreshMirror(suffix: string): Promise<void> {
160
+ const resolved = await this.ctx.credentials.resolve(credentialRef(envNameOf(suffix)))
161
+ if (resolved === undefined) {
162
+ this.globalMirror.delete(suffix)
163
+ } else {
164
+ this.globalMirror.set(suffix, resolved.value)
165
+ }
166
+ this.syncContributor(suffix)
167
+ }
168
+
169
+ /** contributor 登记簿:有任一来源(全局镜像或任一会话桶)则注册,否则注销。 */
170
+ private syncContributor(suffix: string): void {
171
+ const envName = envNameOf(suffix)
172
+ const hasGlobal = this.globalMirror.has(suffix)
173
+ let hasSession = false
174
+ for (const bucket of this.buckets.values()) {
175
+ if (bucket.has(suffix)) {
176
+ hasSession = true
177
+ break
178
+ }
179
+ }
180
+ const registered = this.contributors.has(envName)
181
+ if ((hasGlobal || hasSession) && !registered) {
182
+ this.registerContributor(suffix, envName)
183
+ return
184
+ }
185
+ if (!hasGlobal && !hasSession && registered) {
186
+ this.contributors.get(envName)?.()
187
+ this.contributors.delete(envName)
188
+ }
189
+ }
190
+
191
+ private registerContributor(suffix: string, envName: string): void {
192
+ // 注册表拒绝空描述("must describe");空白描述回落为通用文案。
193
+ const found = this.currentMeta().find((item) => item.name === suffix)?.description
194
+ const description = found !== undefined && found.trim() !== '' ? found : 'secret variable'
195
+ try {
196
+ const dispose = this.ctx.shellEnv.register({
197
+ name: `secret-env:${suffix}`,
198
+ variables: { [envName as ManagedKey]: { description } },
199
+ resolve: (execution: ToolExecution) => {
200
+ const value = this.resolveFor(suffix, execution)
201
+ return value === undefined ? {} : { [envName as ManagedKey]: value }
202
+ },
203
+ })
204
+ this.contributors.set(envName, dispose)
205
+ } catch (error) {
206
+ throw new SecretEnvError(
207
+ 'conflict',
208
+ `variable ${envName} conflicts with an existing contributor: ${
209
+ error instanceof Error ? error.message : String(error)
210
+ }`,
211
+ )
212
+ }
213
+ }
214
+
215
+ /** 注入解析:会话值优先,未命中回落全局镜像;once 命中即焚(注销推迟到微任务,避免 collect 迭代中改注册表)。 */
216
+ private resolveFor(suffix: string, execution: ToolExecution): string | undefined {
217
+ const sessionId = execution.agent?.id
218
+ const bucket = sessionId === undefined ? undefined : this.buckets.get(String(sessionId))
219
+ const entry = bucket?.get(suffix)
220
+ if (entry !== undefined) {
221
+ if (entry.once && bucket !== undefined) {
222
+ bucket.delete(suffix)
223
+ queueMicrotask(() => this.syncContributor(suffix))
224
+ }
225
+ return entry.value
226
+ }
227
+ return this.globalMirror.get(suffix)
228
+ }
229
+
230
+ /** 会话终结:整桶清除并回收其独占的 contributor。 */
231
+ private dropBucket(sessionId: string): void {
232
+ const bucket = this.buckets.get(sessionId)
233
+ if (bucket === undefined) return
234
+ this.buckets.delete(sessionId)
235
+ for (const suffix of bucket.keys()) {
236
+ this.syncContributor(suffix)
237
+ }
238
+ }
239
+
240
+ private async persistMeta(next: SecretMeta[]): Promise<void> {
241
+ if (this.settingsRef === undefined) return
242
+ await this.settingsRef.update(SETTINGS_NS, { secrets: next })
243
+ }
244
+
245
+ /** 列表(值永不出现;global 条目来自 credentials.describe 的安全视图)。 */
246
+ async list(sessionId?: string): Promise<{ global: GlobalEntry[]; session: SessionEntry[] }> {
247
+ const global = await Promise.all(
248
+ this.currentMeta().map(async (item) => {
249
+ const info: CredentialInfo = await this.ctx.credentials.describe(
250
+ credentialRef(envNameOf(item.name)),
251
+ )
252
+ return {
253
+ name: item.name,
254
+ envName: envNameOf(item.name),
255
+ description: item.description,
256
+ configured: info.configured,
257
+ source: info.source,
258
+ writable: info.writable,
259
+ }
260
+ }),
261
+ )
262
+ const bucket = sessionId === undefined ? undefined : this.buckets.get(sessionId)
263
+ const session: SessionEntry[] = [...(bucket?.entries() ?? [])].map(([name, entry]) => ({
264
+ name,
265
+ envName: envNameOf(name),
266
+ description: entry.description,
267
+ once: entry.once,
268
+ createdAt: entry.createdAt,
269
+ }))
270
+ return { global, session }
271
+ }
272
+
273
+ /** 写入全局值(seam 拒绝继承环境遮蔽与空值,此处先行给出结构化错误)。 */
274
+ async setGlobal(suffix: string, value: string, description: string): Promise<void> {
275
+ if (value.length === 0) throw new SecretEnvError('empty-value', 'value must not be empty')
276
+ try {
277
+ await this.ctx.credentials.set(credentialRef(envNameOf(suffix)), value)
278
+ } catch (error) {
279
+ throw new SecretEnvError('shadowed', error instanceof Error ? error.message : String(error))
280
+ }
281
+ const meta = this.currentMeta()
282
+ const existing = meta.find((item) => item.name === suffix)
283
+ const next =
284
+ existing === undefined
285
+ ? [...meta, { name: suffix, description, createdAt: new Date().toISOString() }]
286
+ : meta.map((item) => (item.name === suffix ? { ...item, description } : item))
287
+ await this.persistMeta(next)
288
+ this.lastMeta = next
289
+ await this.refreshMirror(suffix)
290
+ }
291
+
292
+ /** 删除全局值与元数据。 */
293
+ async unsetGlobal(suffix: string): Promise<void> {
294
+ await this.ctx.credentials.unset(credentialRef(envNameOf(suffix)))
295
+ const next = this.currentMeta().filter((item) => item.name !== suffix)
296
+ await this.persistMeta(next)
297
+ this.lastMeta = next
298
+ this.globalMirror.delete(suffix)
299
+ this.syncContributor(suffix)
300
+ }
301
+
302
+ /** 写入会话级值(纯内存;host 重启或会话终结即失)。 */
303
+ setSession(
304
+ sessionId: string,
305
+ suffix: string,
306
+ value: string,
307
+ description: string,
308
+ once: boolean,
309
+ ): void {
310
+ if (sessionId.length === 0) throw new SecretEnvError('no-session', 'sessionId required')
311
+ if (value.length === 0) throw new SecretEnvError('empty-value', 'value must not be empty')
312
+ const bucket = this.buckets.get(sessionId) ?? new Map<string, SessionSecret>()
313
+ bucket.set(suffix, { value, description, once, createdAt: new Date().toISOString() })
314
+ this.buckets.set(sessionId, bucket)
315
+ this.syncContributor(suffix)
316
+ }
317
+
318
+ /** 删除会话级值。 */
319
+ unsetSession(sessionId: string, suffix: string): void {
320
+ const bucket = this.buckets.get(sessionId)
321
+ if (bucket === undefined || !bucket.delete(suffix)) return
322
+ if (bucket.size === 0) this.buckets.delete(sessionId)
323
+ this.syncContributor(suffix)
324
+ }
325
+
326
+ dispose(): void {
327
+ for (const disposeContributor of this.contributors.values()) {
328
+ disposeContributor()
329
+ }
330
+ this.contributors.clear()
331
+ }
332
+ }