ds-bot 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,530 @@
1
+ // Secrets: keys the user types into a card for a Bot. A value lives only in
2
+ // secrets.json (mode 600) and in this process; Bots use it as $DSH_SECRET_<NAME> in
3
+ // their shell, tool results lose it before they are saved, and every model request
4
+ // has it replaced by [secret:<NAME>] before it leaves.
5
+ import { randomUUID } from 'node:crypto'
6
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
7
+ import { join } from 'node:path'
8
+ import { defineTool } from '@deepseek-ai/dsh-tools'
9
+ import { output } from './tools.js'
10
+
11
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/
12
+ export const ENV_PREFIX = 'DSH_SECRET_'
13
+ // A short value (a PIN, a short password) would also hit ordinary words if it were
14
+ // replaced wherever it appears, so below WHOLE_VALUE it is replaced only where it
15
+ // stands alone: no letter or digit right before or after it.
16
+ export const MIN_VALUE = 4
17
+ export const WHOLE_VALUE = 8
18
+ export const MAX_VALUE = 16384
19
+ const MAX_REQUESTS = 20
20
+ const USED_SAVE_MS = 5000
21
+
22
+ export const envName = name => `${ENV_PREFIX}${name}`
23
+ export const canUse = (secret, botId) => secret !== undefined && botId !== undefined
24
+ && (secret.scope === 'all' || (Array.isArray(secret.scope) && secret.scope.includes(botId)))
25
+
26
+ export function normalizeName(raw) {
27
+ return String(raw ?? '').trim().replace(/^\$(env:)?/i, '').replace(/^DSH_SECRET_/i, '').replace(/[\s-]+/g, '_').toUpperCase()
28
+ }
29
+
30
+ // One function that replaces every known value in a string, longest first so a value
31
+ // that contains another is replaced whole. JSON-escaped forms are covered too.
32
+ export function redactor(entries) {
33
+ const pairs = []
34
+ for (const { name, value } of entries) {
35
+ if (typeof value !== 'string' || value.length < MIN_VALUE) continue
36
+ const label = `[secret:${name}]`
37
+ const short = value.length < WHOLE_VALUE
38
+ pairs.push([value, label, short])
39
+ const escaped = JSON.stringify(value).slice(1, -1)
40
+ if (escaped !== value) pairs.push([escaped, label, short])
41
+ }
42
+ pairs.sort((a, b) => b[0].length - a[0].length)
43
+ if (pairs.length === 0) return text => text
44
+ // One pass, so a label already written is never matched again.
45
+ const labels = new Map()
46
+ for (const [value, label] of pairs) if (!labels.has(value)) labels.set(value, label)
47
+ const quote = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
48
+ const pattern = new RegExp(pairs.map(([value, , short]) => (short ? `(?<![\\p{L}\\p{N}])${quote(value)}(?![\\p{L}\\p{N}])` : quote(value))).join('|'), 'gu')
49
+ return (text) => {
50
+ if (typeof text !== 'string' || !pairs.some(([value]) => text.includes(value))) return text
51
+ return text.replace(pattern, match => labels.get(match) ?? match)
52
+ }
53
+ }
54
+
55
+ // Returns the same reference when nothing changed, so callers can tell cheaply.
56
+ export function redactDeep(value, redact) {
57
+ if (typeof value === 'string') return redact(value)
58
+ if (Array.isArray(value)) {
59
+ let changed = false
60
+ const next = value.map((item) => {
61
+ const replaced = redactDeep(item, redact)
62
+ if (replaced !== item) changed = true
63
+ return replaced
64
+ })
65
+ return changed ? next : value
66
+ }
67
+ if (value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
68
+ let changed = false
69
+ const next = {}
70
+ for (const [key, item] of Object.entries(value)) {
71
+ const replaced = redactDeep(item, redact)
72
+ if (replaced !== item) changed = true
73
+ next[key] = replaced
74
+ }
75
+ return changed ? next : value
76
+ }
77
+ return value
78
+ }
79
+
80
+ export function install(rt) {
81
+ const { ctx, home, state } = rt
82
+ const path = join(home, 'secrets.json')
83
+
84
+ /** @type {Map<string, {name:string, value:string, purpose:string, scope:'all'|string[], requestedBy:string|null, createdAt:number, updatedAt:number, lastUsedAt?:number}>} */
85
+ const secrets = new Map()
86
+ let loaded = null
87
+ let writing = Promise.resolve()
88
+ let redact = text => text
89
+ let refreshEnv = () => {}
90
+
91
+ const changed = () => {
92
+ redact = redactor([...secrets.values()])
93
+ refreshEnv()
94
+ rt.secretsChanged?.()
95
+ }
96
+
97
+ // Error messages from JSON.parse quote the file, so only the code or name is logged.
98
+ const reason = error => error?.code ?? error?.name ?? 'error'
99
+
100
+ // While another dsh holds the team, that host writes secrets.json, so a reading host
101
+ // reads it again each time; a copy read then is also read again after taking over,
102
+ // before this host saves anything.
103
+ let loadedReadOnly = false
104
+ const loadSecrets = () => {
105
+ if (loaded !== null && (loadedReadOnly || rt.readOnly() !== undefined)) loaded = null
106
+ return loaded ??= readSecrets()
107
+ }
108
+ const readSecrets = async () => {
109
+ loadedReadOnly = rt.readOnly() !== undefined
110
+ try {
111
+ const parsed = JSON.parse(await readFile(path, 'utf8'))
112
+ secrets.clear()
113
+ for (const entry of Array.isArray(parsed?.secrets) ? parsed.secrets : []) {
114
+ if (!SECRET_NAME.test(entry?.name ?? '') || typeof entry.value !== 'string') continue
115
+ secrets.set(entry.name, {
116
+ name: entry.name,
117
+ value: entry.value,
118
+ purpose: typeof entry.purpose === 'string' ? entry.purpose : '',
119
+ scope: entry.scope === 'all' ? 'all' : Array.isArray(entry.scope) ? entry.scope.filter(id => typeof id === 'string') : 'all',
120
+ requestedBy: typeof entry.requestedBy === 'string' ? entry.requestedBy : null,
121
+ createdAt: Number(entry.createdAt) || Date.now(),
122
+ updatedAt: Number(entry.updatedAt) || Date.now(),
123
+ ...(Number.isFinite(entry.lastUsedAt) ? { lastUsedAt: entry.lastUsedAt } : {}),
124
+ })
125
+ }
126
+ if (!loadedReadOnly) await chmod(path, 0o600).catch(() => {})
127
+ } catch (error) {
128
+ if (error?.code === 'ENOENT') secrets.clear()
129
+ else rt.warn('ds-bot: unreadable secrets file %s (%s)', path, reason(error))
130
+ }
131
+ changed()
132
+ }
133
+
134
+ // Resolves once secrets.json holds the current map; rejects when the write failed.
135
+ const persist = () => {
136
+ const readOnly = rt.readOnly()
137
+ if (readOnly !== undefined) return Promise.reject(new Error(readOnly))
138
+ const snapshot = JSON.stringify({ version: 1, secrets: [...secrets.values()] }, null, 2)
139
+ const job = writing.then(async () => {
140
+ await mkdir(home, { recursive: true })
141
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`
142
+ try {
143
+ await writeFile(temp, snapshot, { encoding: 'utf8', mode: 0o600 })
144
+ await chmod(temp, 0o600)
145
+ await rename(temp, path)
146
+ } catch (error) {
147
+ await unlink(temp).catch(() => {})
148
+ throw error
149
+ }
150
+ })
151
+ writing = job.catch(error => rt.warn('ds-bot: secrets write failed (%s)', reason(error)))
152
+ return job
153
+ }
154
+
155
+ let usedTimer = null
156
+ const noteUsed = () => {
157
+ if (usedTimer !== null) return
158
+ usedTimer = setTimeout(() => {
159
+ usedTimer = null
160
+ if (loadedReadOnly || rt.readOnly() !== undefined) return
161
+ void persist().then(() => rt.save()).catch(() => {})
162
+ }, USED_SAVE_MS)
163
+ usedTimer.unref?.()
164
+ }
165
+
166
+ const requestsOf = botId => ((state.secretRequests ??= {})[botId] ??= [])
167
+ const findRequest = (id) => {
168
+ for (const [botId, list] of Object.entries(state.secretRequests ?? {})) {
169
+ const request = list.find(entry => entry.id === id)
170
+ if (request !== undefined) return { botId, request }
171
+ }
172
+ return undefined
173
+ }
174
+ const trimRequests = (list) => {
175
+ while (list.length > MAX_REQUESTS) {
176
+ const settled = list.findIndex(entry => entry.status !== 'pending')
177
+ list.splice(settled === -1 ? 0 : settled, 1)
178
+ }
179
+ }
180
+
181
+ const scopeText = (scope) => {
182
+ if (scope === 'all') return 'every Bot'
183
+ const names = scope.map(id => rt.botOf(id)?.name).filter(Boolean)
184
+ return names.length === 0 ? 'no Bot' : names.join(', ')
185
+ }
186
+ const usage = name => `$${envName(name)} in bash, or $env:${envName(name)} in PowerShell`
187
+
188
+ async function tell(botId, text) {
189
+ if (rt.botOf(botId) === undefined) return
190
+ await rt.deliver({ toId: botId, role: 'secret', text })
191
+ .catch(error => rt.warn('ds-bot: secret note to %s failed: %s', botId, error))
192
+ }
193
+ const savedNote = (name, lead) => [
194
+ `${lead} Use it as ${usage(name)}.`,
195
+ 'You cannot see the value. Never print, echo, or log it, and never ask the user to paste it into the chat. Carry on with what you needed it for.',
196
+ ].join(' ')
197
+
198
+ function settle(botId, request, status) {
199
+ request.status = status
200
+ request.settledAt = Date.now()
201
+ if (status === 'saved' || status === 'allowed') request.scope = secrets.get(request.name)?.scope ?? 'all'
202
+ }
203
+
204
+ // After any change to `name`, every pending card for it either resolves (its Bot can
205
+ // use the key now) or shows the form that still applies.
206
+ function reconcile(name, notes, except) {
207
+ const secret = secrets.get(name)
208
+ for (const [botId, list] of Object.entries(state.secretRequests ?? {})) {
209
+ for (const request of list) {
210
+ if (request.status !== 'pending' || request.name !== name || request === except) continue
211
+ if (canUse(secret, botId)) {
212
+ settle(botId, request, 'saved')
213
+ notes.push([botId, savedNote(name, `The user saved ${name}, and you may use it now.`)])
214
+ } else {
215
+ request.kind = secret ? 'allow' : 'fill'
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ async function commit(notes) {
222
+ changed()
223
+ await persist()
224
+ await rt.save()
225
+ for (const [botId, text] of notes) await tell(botId, text)
226
+ }
227
+
228
+ const cleanValue = (raw) => {
229
+ if (typeof raw !== 'string') throw new Error('Type the key first')
230
+ const value = raw.trim()
231
+ if (value.length < MIN_VALUE) throw new Error(`A key must have at least ${MIN_VALUE} characters`)
232
+ if (value.length > MAX_VALUE) throw new Error(`A key can have at most ${MAX_VALUE} characters`)
233
+ return value
234
+ }
235
+ const cleanScope = (raw) => {
236
+ if (raw === 'all') return 'all'
237
+ if (!Array.isArray(raw)) throw new Error('Choose every Bot or a list of Bots')
238
+ return [...new Set(raw.filter(id => typeof id === 'string' && rt.botOf(id) !== undefined))]
239
+ }
240
+ const pendingRequest = (id) => {
241
+ const found = findRequest(String(id ?? ''))
242
+ if (found === undefined) throw new Error('This request is gone')
243
+ if (found.request.status !== 'pending') throw new Error('This request was already answered')
244
+ return found
245
+ }
246
+
247
+ const secretView = secret => ({
248
+ name: secret.name,
249
+ purpose: secret.purpose,
250
+ scope: secret.scope,
251
+ requestedBy: secret.requestedBy,
252
+ createdAt: secret.createdAt,
253
+ updatedAt: secret.updatedAt,
254
+ ...(secret.lastUsedAt !== undefined ? { lastUsedAt: secret.lastUsedAt } : {}),
255
+ })
256
+ const secretsView = () => [...secrets.values()].sort((a, b) => a.name.localeCompare(b.name)).map(secretView)
257
+
258
+ const endpoints = {
259
+ // From a card (requestId) or from the settings page (name).
260
+ async 'secret-set'(body) {
261
+ const value = cleanValue(body.value)
262
+ const now = Date.now()
263
+ const notes = []
264
+ if (body.requestId !== undefined) {
265
+ const { botId, request } = pendingRequest(body.requestId)
266
+ const existing = secrets.get(request.name)
267
+ let scope
268
+ if (existing === undefined || request.kind === 'fill') scope = body.scope === 'bot' ? [botId] : 'all'
269
+ else scope = existing.scope === 'all' ? 'all' : [...new Set([...existing.scope, botId])]
270
+ secrets.set(request.name, {
271
+ name: request.name,
272
+ value,
273
+ purpose: existing?.purpose || request.purpose,
274
+ scope,
275
+ requestedBy: existing?.requestedBy ?? botId,
276
+ createdAt: existing?.createdAt ?? now,
277
+ updatedAt: now,
278
+ ...(existing?.lastUsedAt !== undefined ? { lastUsedAt: existing.lastUsedAt } : {}),
279
+ })
280
+ settle(botId, request, 'saved')
281
+ notes.push([botId, savedNote(request.name, `The user saved ${request.name} for ${scopeText(scope)}.`)])
282
+ reconcile(request.name, notes, request)
283
+ await commit(notes)
284
+ return secretView(secrets.get(request.name))
285
+ }
286
+ const name = normalizeName(body.name)
287
+ if (!SECRET_NAME.test(name)) throw new Error('A name uses capitals, digits, and underscores, and starts with a letter')
288
+ const existing = secrets.get(name)
289
+ secrets.set(name, {
290
+ name,
291
+ value,
292
+ purpose: typeof body.purpose === 'string' && body.purpose.trim() !== '' ? body.purpose.trim().slice(0, 300) : existing?.purpose ?? '',
293
+ scope: body.scope !== undefined ? cleanScope(body.scope) : existing?.scope ?? 'all',
294
+ requestedBy: existing?.requestedBy ?? null,
295
+ createdAt: existing?.createdAt ?? now,
296
+ updatedAt: now,
297
+ ...(existing?.lastUsedAt !== undefined ? { lastUsedAt: existing.lastUsedAt } : {}),
298
+ })
299
+ reconcile(name, notes)
300
+ await commit(notes)
301
+ return secretView(secrets.get(name))
302
+ },
303
+ async 'secret-allow'(body) {
304
+ const { botId, request } = pendingRequest(body.requestId)
305
+ const secret = secrets.get(request.name)
306
+ if (secret === undefined) throw new Error(`${request.name} is not saved yet`)
307
+ if (!canUse(secret, botId)) secret.scope = [...new Set([...secret.scope, botId])]
308
+ secret.updatedAt = Date.now()
309
+ settle(botId, request, 'allowed')
310
+ const notes = [[botId, savedNote(request.name, `The user let you use ${request.name}.`)]]
311
+ reconcile(request.name, notes, request)
312
+ await commit(notes)
313
+ return secretView(secret)
314
+ },
315
+ async 'secret-cancel'(body) {
316
+ const { botId, request } = pendingRequest(body.requestId)
317
+ settle(botId, request, 'canceled')
318
+ await rt.save()
319
+ await tell(botId, `The user canceled your request for ${request.name}. Do not ask for it again unless the user brings it up; carry on without it, or tell the user what you cannot do without it.`)
320
+ return true
321
+ },
322
+ async 'secret-update'(body) {
323
+ const secret = secrets.get(normalizeName(body.name))
324
+ if (secret === undefined) throw new Error('No such key')
325
+ secret.scope = cleanScope(body.scope)
326
+ secret.updatedAt = Date.now()
327
+ const notes = []
328
+ reconcile(secret.name, notes)
329
+ await commit(notes)
330
+ return secretView(secret)
331
+ },
332
+ async 'secret-delete'(body) {
333
+ const name = normalizeName(body.name)
334
+ if (!secrets.delete(name)) throw new Error('No such key')
335
+ const notes = []
336
+ reconcile(name, notes)
337
+ await commit(notes)
338
+ return true
339
+ },
340
+ }
341
+
342
+ async function secretEndpoint(endpoint, body) {
343
+ await loadSecrets()
344
+ return endpoints[endpoint](body ?? {})
345
+ }
346
+
347
+ // A deleted Bot leaves every list it was on, and its cards go with it.
348
+ async function forgetBotSecrets(botId) {
349
+ await loadSecrets()
350
+ if (state.secretRequests !== undefined) delete state.secretRequests[botId]
351
+ let touched = false
352
+ for (const secret of secrets.values()) {
353
+ if (Array.isArray(secret.scope) && secret.scope.includes(botId)) {
354
+ secret.scope = secret.scope.filter(id => id !== botId)
355
+ touched = true
356
+ }
357
+ }
358
+ if (touched) {
359
+ changed()
360
+ await persist().catch(() => {})
361
+ }
362
+ }
363
+
364
+ const callerOf = exec => rt.selfOf(exec.agent?.session?.header?.id ?? exec.agent?.id)
365
+
366
+ ctx.tools.register(defineTool({
367
+ name: 'request_secret',
368
+ description: 'Ask the user for a key, token, or password through a secure card in your own chat. The user types it into the card; you never see the value. It reaches your shell as an environment variable. When the key is already saved for you, this only tells you its variable. Showing a card ends your turn; a note arrives when the user saves or cancels.',
369
+ parameters: {
370
+ name: { type: 'string', required: true, description: 'Key name in capitals, digits, and underscores, e.g. GITHUB_TOKEN' },
371
+ purpose: { type: 'string', required: true, description: 'One short line for the user: what you need it for' },
372
+ },
373
+ output,
374
+ async execute(args, exec) {
375
+ await rt.load()
376
+ await loadSecrets()
377
+ const id = exec.agent?.id
378
+ const bot = rt.botOf(callerOf(exec))
379
+ if (bot === undefined) return 'Only Bots on the team can use request_secret.'
380
+ const name = normalizeName(args.name)
381
+ if (!SECRET_NAME.test(name)) return 'Name the key with capitals, digits, and underscores, starting with a letter (for example GITHUB_TOKEN).'
382
+ const secret = secrets.get(name)
383
+ if (canUse(secret, bot.id)) return `${name} is already saved and you may use it: ${usage(name)}. You cannot see the value; never print it.`
384
+ if (rt.groupOwners.has(id)) return 'Secret cards only work in your own chat. Tell the group you will ask the user in your own chat, and call request_secret there.'
385
+ if (!rt.chatOwners.has(id)) return 'Secret cards only work in your current own chat.'
386
+ const readOnly = rt.readOnly()
387
+ if (readOnly !== undefined) return `No card can be shown now. ${readOnly}`
388
+ const list = requestsOf(bot.id)
389
+ const now = Date.now()
390
+ for (const request of list) {
391
+ if (request.status === 'pending' && request.name === name) {
392
+ request.status = 'canceled'
393
+ request.settledAt = now
394
+ }
395
+ }
396
+ list.push({
397
+ id: randomUUID(),
398
+ ...(exec.callId ? { callId: exec.callId } : {}),
399
+ sessionId: id,
400
+ name,
401
+ purpose: String(args.purpose ?? '').trim().slice(0, 300),
402
+ kind: secret ? 'allow' : 'fill',
403
+ status: 'pending',
404
+ askedAt: now,
405
+ })
406
+ trimRequests(list)
407
+ await rt.save()
408
+ exec.concludeTurn?.()
409
+ return secret
410
+ ? `The card asking the user to let you use ${name} is on screen. A note arrives when the user answers; you will not see the value.`
411
+ : `The card asking the user for ${name} is on screen. A note arrives when the user saves or cancels; you will not see the value.`
412
+ },
413
+ }))
414
+
415
+ ctx.tools.register(defineTool({
416
+ name: 'list_secrets',
417
+ description: 'List the keys the user saved for the team: name, purpose, which Bots may use them, and whether you may. Values are never shown.',
418
+ parameters: {},
419
+ output,
420
+ async execute(_args, exec) {
421
+ await rt.load()
422
+ await loadSecrets()
423
+ const self = callerOf(exec)
424
+ if (rt.botOf(self) === undefined) return 'Only Bots on the team can use list_secrets.'
425
+ if (secrets.size === 0) return 'No keys are saved yet. Ask for one with request_secret.'
426
+ return [
427
+ 'Saved keys (values are never shown):',
428
+ ...secretsView().map(secret => `- ${secret.name}${secret.purpose ? ` — ${secret.purpose}` : ''} — for ${scopeText(secret.scope)} — ${canUse(secrets.get(secret.name), self) ? `you may use it as $${envName(secret.name)}` : 'you may not use it; ask with request_secret'}`),
429
+ ].join('\n')
430
+ },
431
+ }))
432
+
433
+ // Tool results lose the values before the registry renders and saves them: a fresh
434
+ // result object is re-rendered from its value, so the model content, the saved
435
+ // `tool/result`, and the tool card's presentation all come from the redacted value.
436
+ ctx.on('tools/execute', async (exec, next) => {
437
+ const result = await next()
438
+ if (secrets.size === 0 || result === null || typeof result !== 'object') return result
439
+ if (result.isError) {
440
+ const content = redactDeep(result.content, redact)
441
+ const meta = redactDeep(result.meta, redact)
442
+ const error = redactDeep(result.error, redact)
443
+ if (content === result.content && meta === result.meta && error === result.error) return result
444
+ return { ...result, content, error, ...(meta !== undefined ? { meta } : {}) }
445
+ }
446
+ const value = redactDeep(result.value, redact)
447
+ if (value === result.value) return result
448
+ return { isError: false, value, ...(result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}) }
449
+ })
450
+
451
+ // Anything that still carries a value (the user pasted it, an earlier part kept it)
452
+ // is replaced before the request leaves. `next` cannot take new options, so the
453
+ // request starts over; it then carries no value and passes through here.
454
+ // A value that is part of its own label would be replaced again on every pass, so a
455
+ // request this listener rewrote passes straight through.
456
+ const rewritten = new WeakSet()
457
+ ctx.on('llm/stream', (options, next) => {
458
+ if (secrets.size === 0 || rewritten.has(options.messages)) return next()
459
+ const messages = redactDeep(options.messages, redact)
460
+ const system = redactDeep(options.system, redact)
461
+ if (messages === options.messages && system === options.system) return next()
462
+ const fresh = [...(messages ?? [])]
463
+ rewritten.add(fresh)
464
+ rt.carryUsageTag(options.messages, fresh)
465
+ return ctx.llm.stream({ ...options, messages: fresh, ...(system !== undefined ? { system } : {}) })
466
+ })
467
+
468
+ function resolveEnv(execution) {
469
+ const botId = rt.selfOf(execution?.agent?.session?.header?.id ?? execution?.agent?.id)
470
+ if (rt.botOf(botId) === undefined) return {}
471
+ const command = typeof execution.arguments?.command === 'string' ? execution.arguments.command : ''
472
+ const values = {}
473
+ let used = false
474
+ for (const secret of secrets.values()) {
475
+ if (!canUse(secret, botId)) continue
476
+ values[envName(secret.name)] = secret.value
477
+ if (command.includes(envName(secret.name))) {
478
+ secret.lastUsedAt = Date.now()
479
+ used = true
480
+ }
481
+ }
482
+ if (used) noteUsed()
483
+ return values
484
+ }
485
+
486
+ // Keys must be declared up front, so the contribution is registered again whenever
487
+ // the set of names (or a purpose, which describes the key) changes.
488
+ function attachShellEnv(sub) {
489
+ let unregister = null
490
+ let declared = ''
491
+ refreshEnv = () => {
492
+ const list = [...secrets.values()].sort((a, b) => a.name.localeCompare(b.name))
493
+ const key = list.map(secret => `${secret.name}\u0000${secret.purpose}`).join('\n')
494
+ if (key === declared) return
495
+ unregister?.()
496
+ unregister = null
497
+ declared = key
498
+ if (list.length === 0) return
499
+ try {
500
+ unregister = sub.shellEnv.register({
501
+ name: 'ds-bot-secrets',
502
+ variables: Object.fromEntries(list.map(secret => [envName(secret.name), { description: `DS Bot secret ${secret.name}${secret.purpose ? `: ${secret.purpose}` : ''}` }])),
503
+ resolve: resolveEnv,
504
+ })
505
+ } catch (error) {
506
+ declared = ''
507
+ rt.warn('ds-bot: secrets not offered to the shell: %s', error?.message ?? error)
508
+ }
509
+ }
510
+ refreshEnv()
511
+ sub.effect(() => () => {
512
+ unregister?.()
513
+ unregister = null
514
+ declared = ''
515
+ refreshEnv = () => {}
516
+ }, 'ds-bot: secrets shell env')
517
+ }
518
+ if (typeof ctx.inject === 'function') {
519
+ ctx.inject(['shellEnv'], sub => attachShellEnv(sub))
520
+ } else if (ctx.get?.('shellEnv') !== undefined) {
521
+ attachShellEnv({ shellEnv: ctx.get('shellEnv'), effect: (execute, label) => ctx.effect(execute, label) })
522
+ }
523
+
524
+ ctx.effect(() => {
525
+ void loadSecrets()
526
+ return () => { if (usedTimer !== null) clearTimeout(usedTimer) }
527
+ }, 'ds-bot: load secrets')
528
+
529
+ Object.assign(rt, { secretsView, secretValue: name => secrets.get(name)?.value, secretEndpoint, forgetBotSecrets, loadSecrets, redactSecrets: text => redact(text) })
530
+ }