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.
- package/LICENSE +201 -0
- package/README.md +236 -3
- package/client.js +12618 -0
- package/cordis.patch.yml +17 -0
- package/index.js +54 -0
- package/package.json +67 -6
- package/src/host/agents.js +186 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +320 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +62 -0
- package/src/host/delivery.js +59 -0
- package/src/host/diagnostics.js +247 -0
- package/src/host/dsh-update.js +168 -0
- package/src/host/groups.js +444 -0
- package/src/host/home-lock.js +91 -0
- package/src/host/images.js +174 -0
- package/src/host/memory.js +1351 -0
- package/src/host/models.js +202 -0
- package/src/host/parts.js +902 -0
- package/src/host/permissions.js +42 -0
- package/src/host/prompt.js +67 -0
- package/src/host/roster.js +410 -0
- package/src/host/schedules.js +151 -0
- package/src/host/secrets.js +530 -0
- package/src/host/steward.js +164 -0
- package/src/host/store.js +259 -0
- package/src/host/text.js +47 -0
- package/src/host/tools.js +374 -0
- package/src/host/turns.js +250 -0
- package/src/host/update.js +210 -0
- package/src/host/usage.js +594 -0
- package/web-search.js +748 -0
|
@@ -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
|
+
}
|