@moguiyu/dsh-tavily 0.3.0 → 0.3.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/lib/client.js CHANGED
@@ -249,6 +249,10 @@ window.__ModuleLoader__.load({
249
249
  react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
250
250
  'All keys are listed below; the green dot marks the first (primary) key. The tavily_search tool uses all keys according to the strategy. Built-in web_search is unaffected.'
251
251
  ),
252
+ server !== null && server.writable !== undefined && server.writable.keys === false &&
253
+ react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
254
+ 'Read-only: these keys come from the environment that launched dsh, so this card cannot change them. Unset TAVILY_API_KEYS / TAVILY_API_KEY in that shell and restart dsh to manage keys here.'
255
+ ),
252
256
  loadError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(loadError)),
253
257
  react.createElement('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: 13 } },
254
258
  react.createElement('thead', null,
@@ -304,11 +308,15 @@ window.__ModuleLoader__.load({
304
308
  !isRemoved && react.createElement(IconButton, { icon: 'pencil', title: 'Edit', onClick: () => startReplace(masked), disabled: busy }),
305
309
  react.createElement(IconButton, {
306
310
  icon: isRemoved ? 'restore' : (confirm[masked] === true ? 'check' : 'trash'),
307
- title: isRemoved ? 'Restore' : (confirm[masked] === true ? 'Click again to confirm' : 'Delete'),
311
+ // A key the launching environment supplies is read-only, so the
312
+ // server would refuse. Say so on the control instead.
313
+ title: key.removable === false
314
+ ? 'Read-only: this key comes from the environment that launched dsh. Unset it there and restart dsh to change it here.'
315
+ : (isRemoved ? 'Restore' : (confirm[masked] === true ? 'Click again to confirm' : 'Delete')),
308
316
  danger: !isRemoved,
309
317
  className: isRemoved ? 'dts-icon-btn' : 'dts-icon-btn dts-icon-btn-danger',
310
318
  onClick: () => isRemoved ? restore(masked) : markRemoved(masked),
311
- disabled: busy
319
+ disabled: busy || (key.removable === false && !isRemoved)
312
320
  })
313
321
  )
314
322
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moguiyu/dsh-tavily",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl) with a key and usage management card on the Plugins page. The built-in web_search is never replaced.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/backend.js CHANGED
@@ -98,27 +98,74 @@ async function fetchUsageFor(key) {
98
98
  return details.usage !== null ? details.usage : details.planUsage
99
99
  }
100
100
 
101
+ /** Resolve one credential ref to its string value, or `null` when absent. */
102
+ async function resolveRef(credentials, ref) {
103
+ try {
104
+ const hit = await credentials.resolve(ref)
105
+ return hit !== undefined && hit !== null && typeof hit.value === 'string' ? hit.value : null
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Whether a ref can be written here. The launching environment's variables are
113
+ * inherited READ-ONLY: `set`/`unset` throw for them (`describe().writable === false`).
114
+ * Assume writable when the service cannot say.
115
+ */
116
+ async function refWritable(credentials, ref) {
117
+ try {
118
+ const info = await credentials.describe(ref)
119
+ return info === undefined || info === null || info.writable !== false
120
+ } catch {
121
+ return true
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The managed key list, and where it came from.
127
+ *
128
+ * `TAVILY_API_KEYS` is AUTHORITATIVE. `TAVILY_API_KEY` — the derived primary — is
129
+ * read only as a fallback, for a deployment that sets nothing else. The two used to
130
+ * be unioned, and that was a bug with two faces: an environment-supplied
131
+ * `TAVILY_API_KEY` re-added its key on every read, so that key could never be deleted
132
+ * from the card; and every write then tried to re-sync the primary, which the
133
+ * launching environment owns, so the write threw AFTER the list had already changed.
134
+ */
101
135
  async function collectStoredKeys(credentials) {
136
+ const fromList = parseKeyList(await resolveRef(credentials, 'TAVILY_API_KEYS') ?? '')
137
+ if (fromList.length > 0) return { values: dedupe([...fromList]), source: 'list' }
138
+ const fromPrimary = parseKeyList(await resolveRef(credentials, 'TAVILY_API_KEY') ?? '')
139
+ return { values: dedupe([...fromPrimary]), source: fromPrimary.length > 0 ? 'primary' : 'list' }
140
+ }
141
+
142
+ function dedupe(values) {
102
143
  const out = []
103
144
  const seen = new Set()
104
- for (const ref of REFS) {
105
- let hit
106
- try {
107
- hit = await credentials.resolve(ref)
108
- } catch {
109
- continue
110
- }
111
- if (hit === undefined || typeof hit.value !== 'string') continue
112
- for (const key of parseKeyList(hit.value)) {
113
- if (!seen.has(key)) {
114
- seen.add(key)
115
- out.push(key)
116
- }
145
+ for (const key of values) {
146
+ if (!seen.has(key)) {
147
+ seen.add(key)
148
+ out.push(key)
117
149
  }
118
150
  }
119
151
  return out
120
152
  }
121
153
 
154
+ /**
155
+ * Keep the derived primary in step with the list, best effort. The tools read
156
+ * `TAVILY_API_KEYS`, so a primary the environment owns must never fail the request.
157
+ */
158
+ async function syncPrimary(credentials, value) {
159
+ if (!(await refWritable(credentials, 'TAVILY_API_KEY'))) return false
160
+ try {
161
+ if (value === null) await credentials.unset('TAVILY_API_KEY')
162
+ else await credentials.set('TAVILY_API_KEY', value)
163
+ return true
164
+ } catch {
165
+ return false
166
+ }
167
+ }
168
+
122
169
  /**
123
170
  * Register the Tavily key/usage routes on `ctx.webServer`:
124
171
  * `/api/tavily-usage` and `/api/tavily-manager`.
@@ -135,7 +182,7 @@ export function installBackend(ctx) {
135
182
  }
136
183
 
137
184
  // ── key manager payload ────────────────────────────────────────────────────
138
- async function buildManagerPayload(keys) {
185
+ async function buildManagerPayload(keys, source = 'list') {
139
186
  const state = readState(MANAGER_STATE)
140
187
  const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
141
188
  if (Object.keys(keySavedAt).length === 0 && credentials !== undefined) {
@@ -168,13 +215,22 @@ export function installBackend(ctx) {
168
215
  if (b.savedAt === null) return -1
169
216
  return a.savedAt < b.savedAt ? -1 : 1
170
217
  })
218
+ // The card needs to know what it may change. A ref inherited from the
219
+ // launching environment is read-only, and saying so beats a raw 500 from
220
+ // `set` after the list has already been written.
221
+ const keysWritable = credentials === undefined ? true : await refWritable(credentials, 'TAVILY_API_KEYS')
222
+ const primaryWritable = credentials === undefined ? true : await refWritable(credentials, 'TAVILY_API_KEY')
223
+ const removable = source === 'list' ? keysWritable : primaryWritable
171
224
  return {
172
225
  keys: display.map((entry) => ({
173
226
  masked: entry.masked,
174
227
  savedAt: entry.savedAt,
175
228
  primary: entry.masked === primaryMasked,
229
+ removable,
176
230
  })),
177
231
  strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
232
+ source,
233
+ writable: { keys: keysWritable, primary: primaryWritable },
178
234
  }
179
235
  }
180
236
 
@@ -184,7 +240,7 @@ export function installBackend(ctx) {
184
240
  handler: async (_req, res) => {
185
241
  if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
186
242
  try {
187
- const keys = await collectStoredKeys(credentials)
243
+ const { values: keys } = await collectStoredKeys(credentials)
188
244
  if (keys.length === 0) return send(res, 200, { ok: false, error: 'no Tavily API key configured' })
189
245
  const state = readState(MANAGER_STATE)
190
246
  const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
@@ -234,13 +290,13 @@ export function installBackend(ctx) {
234
290
  const url = new URL(req.url || '/', 'http://x')
235
291
  if (req.method === 'GET') {
236
292
  const reveal = url.searchParams.get('reveal')
237
- const keys = await collectStoredKeys(credentials)
293
+ const { values: keys, source } = await collectStoredKeys(credentials)
238
294
  if (reveal !== null) {
239
295
  const match = keys.find((key) => maskValue(key) === reveal)
240
296
  if (match === undefined) return send(res, 404, { ok: false, error: 'key not found' })
241
297
  return send(res, 200, { ok: true, value: match })
242
298
  }
243
- return send(res, 200, { ok: true, ...(await buildManagerPayload(keys)) })
299
+ return send(res, 200, { ok: true, ...(await buildManagerPayload(keys, source)) })
244
300
  }
245
301
  if (req.method === 'POST') {
246
302
  const body = await readBody(req)
@@ -252,7 +308,7 @@ export function installBackend(ctx) {
252
308
  ? body.remove.filter((item) => typeof item === 'string' && item.length > 0)
253
309
  : []
254
310
 
255
- let stored = await collectStoredKeys(credentials)
311
+ let { values: stored, source } = await collectStoredKeys(credentials)
256
312
  if (removeMasked.length > 0) {
257
313
  stored = stored.filter((key) => !removeMasked.includes(maskValue(key)))
258
314
  }
@@ -260,9 +316,32 @@ export function installBackend(ctx) {
260
316
  for (const value of addValues) {
261
317
  if (!values.includes(value)) values.push(value)
262
318
  }
319
+
320
+ // A ref the launching environment supplies is inherited READ-ONLY: set and
321
+ // unset throw for it. Decide that BEFORE writing anything. The old order
322
+ // wrote `TAVILY_API_KEYS` first and then threw on the derived-primary sync,
323
+ // so the card reported a failure for a write that had half happened — and
324
+ // the unioned read then put the deleted key straight back.
325
+ const canWriteList = await refWritable(credentials, 'TAVILY_API_KEYS')
326
+ const canWritePrimary = await refWritable(credentials, 'TAVILY_API_KEY')
327
+ const READONLY_LIST = 'TAVILY_API_KEYS is supplied read-only by the launching environment, so the key list cannot be changed here. Unset it in the shell you start dsh from, or manage keys in ~/.dsh/.credentials.yaml.'
328
+ const READONLY_PRIMARY = 'That key comes from TAVILY_API_KEY, which the launching environment supplies read-only. Unset it in the shell you start dsh from.'
329
+ if (!canWriteList && (addValues.length > 0 || removeMasked.length > 0)) {
330
+ return send(res, 409, { ok: false, error: READONLY_LIST })
331
+ }
332
+ if (!canWritePrimary && source === 'primary' && removeMasked.length > 0) {
333
+ return send(res, 409, { ok: false, error: READONLY_PRIMARY })
334
+ }
335
+ // Emptying the list while an inherited primary still carries a key would
336
+ // read straight back as that key, so refuse rather than appear to undo itself.
337
+ if (values.length === 0 && !canWritePrimary) {
338
+ const inherited = parseKeyList(await resolveRef(credentials, 'TAVILY_API_KEY') ?? '')
339
+ if (inherited.length > 0) return send(res, 409, { ok: false, error: READONLY_PRIMARY })
340
+ }
341
+
263
342
  if (values.length === 0) {
264
343
  await credentials.unset('TAVILY_API_KEYS')
265
- await credentials.unset('TAVILY_API_KEY')
344
+ await syncPrimary(credentials, null)
266
345
  } else {
267
346
  if (strategy !== 'rotate') {
268
347
  const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
@@ -273,7 +352,7 @@ export function installBackend(ctx) {
273
352
  values = orderKeys(values, strategy, usageOf)
274
353
  }
275
354
  await credentials.set('TAVILY_API_KEYS', values.join(','))
276
- await credentials.set('TAVILY_API_KEY', values[0])
355
+ await syncPrimary(credentials, values[0])
277
356
  }
278
357
 
279
358
  const state = readState(MANAGER_STATE)
@@ -285,7 +364,7 @@ export function installBackend(ctx) {
285
364
  }
286
365
  writeState(MANAGER_STATE, { keySavedAt, strategy })
287
366
 
288
- return send(res, 200, { ok: true, ...(await buildManagerPayload(values)) })
367
+ return send(res, 200, { ok: true, ...(await buildManagerPayload(values, source)) })
289
368
  }
290
369
  return send(res, 405, { ok: false, error: 'method not allowed' })
291
370
  } catch (error) {
package/src/client.js CHANGED
@@ -254,6 +254,10 @@ export function factory(require) {
254
254
  react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
255
255
  'All keys are listed below; the green dot marks the first (primary) key. The tavily_search tool uses all keys according to the strategy. Built-in web_search is unaffected.'
256
256
  ),
257
+ server !== null && server.writable !== undefined && server.writable.keys === false &&
258
+ react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
259
+ 'Read-only: these keys come from the environment that launched dsh, so this card cannot change them. Unset TAVILY_API_KEYS / TAVILY_API_KEY in that shell and restart dsh to manage keys here.'
260
+ ),
257
261
  loadError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(loadError)),
258
262
  react.createElement('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: 13 } },
259
263
  react.createElement('thead', null,
@@ -309,11 +313,15 @@ export function factory(require) {
309
313
  !isRemoved && react.createElement(IconButton, { icon: 'pencil', title: 'Edit', onClick: () => startReplace(masked), disabled: busy }),
310
314
  react.createElement(IconButton, {
311
315
  icon: isRemoved ? 'restore' : (confirm[masked] === true ? 'check' : 'trash'),
312
- title: isRemoved ? 'Restore' : (confirm[masked] === true ? 'Click again to confirm' : 'Delete'),
316
+ // A key the launching environment supplies is read-only, so the
317
+ // server would refuse. Say so on the control instead.
318
+ title: key.removable === false
319
+ ? 'Read-only: this key comes from the environment that launched dsh. Unset it there and restart dsh to change it here.'
320
+ : (isRemoved ? 'Restore' : (confirm[masked] === true ? 'Click again to confirm' : 'Delete')),
313
321
  danger: !isRemoved,
314
322
  className: isRemoved ? 'dts-icon-btn' : 'dts-icon-btn dts-icon-btn-danger',
315
323
  onClick: () => isRemoved ? restore(masked) : markRemoved(masked),
316
- disabled: busy
324
+ disabled: busy || (key.removable === false && !isRemoved)
317
325
  })
318
326
  )
319
327
  )