dsh-workspace-api-key 0.2.14 → 0.2.16

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/README.md CHANGED
@@ -36,7 +36,7 @@ Host half (`lib/index.js`):
36
36
  - matches that directory against `workspaceRegistry.list()` after normalizing both sides (absolute, trailing separator removed, lower-cased on Windows);
37
37
  - derives a stable ref per level — `DEEPSEEK_API_KEY_WS_<first 16 hex chars of sha1(normalized path), upper-cased>` for a workspace and `DEEPSEEK_API_KEY_SS_<first 16 hex chars of sha1(session id), upper-cased>` for a session (a session id contains `-` and cannot be a ref name) — writes the key through the credentials service, and serves that ref's value;
38
38
  - rewrites only refs a provider actually reads: the default ref, every adapter's configured `apiKeyEnv`, and its own generated refs. Every other ref passes through untouched;
39
- - tells search credentials apart from chat credentials. The official new Search/Fetch API (`web.searchProvider === 'deepseek-search-api'`) resolves its key itself, and it defaults to the same `DEEPSEEK_API_KEY` the chat uses, so the ref alone cannot say why a key is being asked for. The host half therefore wraps `ctx.web.search` / `ctx.web.fetch` in an `AsyncLocalStorage` scope and applies the search rule only when `credentials.resolve` runs inside it — a session's own key first (always), then, if that workspace ticked `同步搜索 API`, the workspace key and then the system default, otherwise its own search default ref when the panel holds one, and only then the ref the search plugin configured. The legacy Messages-based search keeps the plain chat cascade, because that route *is* a messages request;
39
+ - tells search credentials apart from chat credentials **by ref, not by wrapping the web seam**. The official new Search/Fetch API resolves its key under `web-deepseek-api.apiKeyEnv`, which this plugin points at its own `DEEPSEEK_API_KEY_SEARCH` at startup and whenever the panel saves a search key, so a search resolve is recognisable from the ref alone: chat never asks for it. Earlier 0.2.x releases wrapped `ctx.web.search` / `ctx.web.fetch` in an `AsyncLocalStorage` scope instead; that patch is gone from 0.2.15 on, because a wrapper around the web seam can leave `web_search` / `web_fetch` waiting until their 30/60 s tool budget expires (it hangs even for addresses that fail instantly, e.g. `http://127.0.0.1:1/`). The rule itself is unchanged: a session's own key first (always), then, if the workspace ticked `同步搜索 API`, the workspace key and then the system default, otherwise the panel's search default ref when it holds one, and only then the ref the search plugin configured. The legacy Messages-based search keeps the plain chat cascade, because that route *is* a messages request;
40
40
  - builds the session list from the `sessionController` service (`list()`, unwrapping its `{items: […]}` envelope) and falls back to the live `sessions` service (`ctx.get('sessions').list()`), so the panel still has sessions when the controller is missing, throws, or returns an empty list. It hides the two kinds of session the sidebar hides: subagent sessions (`origin: 'subagent'`, or a `parentSession`) and *blank* sessions (created but never prompted — the sidebar shows only the one currently open). Without those two filters one workspace with a single real session listed thirteen;
41
41
  - reports its own version and a session probe (`available`, `shape`, `source`, `count`, `withoutCwd`, `subagents`, `samples`, `error`, plus sample workspace paths) in `/wsk-api/state`, so the panel can say *why* no sessions are listed: a stale host half (only a page reload instead of an app restart), a missing `sessionController`, a throwing `list()`, an unexpected envelope shape, or a `cwd` that matches no workspace path;
42
42
  - lists a directory that is not a registered workspace only if the sidebar would show it. It cannot decide that itself, because "only the currently open blank session counts" is knowledge the browser side holds (`retainedBy.mainView`), so it sends every such directory plus a `blank` flag per session and a `hiddenBlankDirs` list naming the ones whose sessions are all blank — the browser half applies the same verdict the sidebar does;
@@ -79,7 +79,7 @@ The command forwards its arguments to pnpm inside the profile directory, so the
79
79
  pnpm add dsh-workspace-api-key # or: npm install dsh-workspace-api-key
80
80
  ```
81
81
 
82
- Pin a version with `dsh-workspace-api-key@0.2.14`, or install straight from the repository with `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key` (a git-hosted plugin is built from source on install).
82
+ Pin a version with `dsh-workspace-api-key@0.2.16`, or install straight from the repository with `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key` (a git-hosted plugin is built from source on install).
83
83
 
84
84
  Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `package.json` and restart DSH. For local development, use a link instead:
85
85
 
package/README.zh.md CHANGED
@@ -36,7 +36,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
36
36
  - 把该目录与 `workspaceRegistry.list()` 做规范化后比对(绝对化、去掉尾部分隔符、Windows 折大小写);
37
37
  - 由每一层派生出稳定的 ref —— 工作区是 `DEEPSEEK_API_KEY_WS_<sha1(规范化路径) 前 16 位十六进制大写>`,会话是 `DEEPSEEK_API_KEY_SS_<sha1(会话 id) 前 16 位十六进制大写>`(会话 id 含 `-`,不能直接当 ref 名)——通过凭据服务写入 key,并返回这个 ref 的值;
38
38
  - 只改写 provider 真正会读的 ref:默认 ref、每个适配器配置的 `apiKeyEnv`,以及插件自己生成的 ref;其它 ref 原样透传;
39
- - 把「搜索请求」和「聊天请求」分开。官方新版 Search/Fetch API(`web.searchProvider === 'deepseek-search-api'`)自己解析 key,而它默认读的又是聊天用的 `DEEPSEEK_API_KEY`,所以光看 ref 分不出这次要 key 是为了什么。宿主半边因此把 `ctx.web.search` / `ctx.web.fetch` 包进一个 `AsyncLocalStorage` 作用域,只有当 `credentials.resolve` 在这个作用域里被调用时才套用搜索规则:先看会话自己的 key(永远最优先),再看该工作区有没有勾「同步搜索 API」,勾了就依次用工作区 key、系统默认 key;没勾时先用插件面板自己持有的搜索默认 ref(若配了),最后才用搜索插件配置的那个 ref。旧版基于 Messages 的搜索仍然走原来的聊天层级,因为那条路线本身就是一次 messages 请求;
39
+ - 把「搜索请求」和「聊天请求」分开——**按 ref 分辨,而不是包住 web 服务**。官方新版 Search/Fetch API 自己解析 key,用的是 `web-deepseek-api.apiKeyEnv` 指向的凭据;本插件在启动时(以及面板每次保存搜索 key 时)把它指到插件自有的 `DEEPSEEK_API_KEY_SEARCH`,于是只看 ref 就能认出「这是搜索在要 key」:聊天永远不会解析这个 ref。0.2.15 起删掉了原先「把 `ctx.web.search` / `ctx.web.fetch` 包进 `AsyncLocalStorage`」的做法——包住 web 服务可能让 `web_search` / `web_fetch` 一直挂到工具 30/60 秒预算耗尽(连 `http://127.0.0.1:1/` 这种必然立刻失败的地址也一样挂)。规则本身没变:先看会话自己的 key(永远最优先),再看该工作区有没有勾「同步搜索 API」,勾了就依次用工作区 key、系统默认 key;没勾时先用插件面板自己持有的搜索默认 ref(若配了),最后才用搜索插件配置的那个 ref。旧版基于 Messages 的搜索仍然走原来的聊天层级,因为那条路线本身就是一次 messages 请求;
40
40
  - 会话列表来自 `sessionController` 服务(`list()`,会拆开它 `{items: […]}` 的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(`ctx.get('sessions').list()`),所以那种情况下面板仍能列出会话;侧栏藏起来的两种会话这里也藏:子代理会话(`origin: 'subagent'` 或带 `parentSession`)和**空会话**(建了但一句话都没发过,侧栏只显示当前开着的那个)。两种都不过滤的话,一个只有 1 个真实会话的工作区会列出 13 个;
41
41
  - 不是注册工作区的目录,只有侧栏真的会显示它才会被列出来。这件事宿主判不了:「空会话里只有当前开着的那个算数」是浏览器端才知道的事(`retainedBy.mainView`),所以宿主把这种目录连同每个会话的 `blank` 标记一起发出去,并用 `hiddenBlankDirs` 点名「里面全是空会话」的目录,由浏览器半边套用与侧栏一致的判据;
42
42
  - `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`subagents`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
@@ -79,7 +79,7 @@ dsh plugin --profile web add dsh-workspace-api-key
79
79
  pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api-key
80
80
  ```
81
81
 
82
- 锁版本用 `dsh-workspace-api-key@0.2.14`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
82
+ 锁版本用 `dsh-workspace-api-key@0.2.16`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
83
83
 
84
84
  然后把 `"dsh-workspace-api-key"` 加进该 profile `package.json` 的 `dsh.profile.bundles`,重启 DSH。本地开发用 link 代替:
85
85
 
package/lib/index.js CHANGED
@@ -32,7 +32,6 @@
32
32
 
33
33
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
34
34
  import { createHash } from 'node:crypto'
35
- import { AsyncLocalStorage } from 'node:async_hooks'
36
35
  import { homedir } from 'node:os'
37
36
  import { join, dirname, basename, resolve as pathResolve } from 'node:path'
38
37
 
@@ -72,13 +71,55 @@ const SEARCH_FETCH_PROVIDER_ID = 'deepseek-fetch-api'
72
71
  const SEARCH_DEFAULT_REF = 'DEEPSEEK_API_KEY_SEARCH'
73
72
 
74
73
  /**
75
- * 搜索通道标记:包住 `ctx.web.search` / `ctx.web.fetch`,在它们的异步调用链里
76
- * 打一个 AsyncLocalStorage 标记。`credentials.resolve` 的 wrapper 据此知道
77
- * 「这次取 key 是搜索/抓取在要,还是聊天在要」——两者用的是同一把
78
- * `DEEPSEEK_API_KEY` 时,除此之外没有别的可靠区分方式(resolve 只收到 ref)。
74
+ * 官方「网页搜索」配置的缓存。
75
+ *
76
+ * 早期版本包过 `ctx.web.search` / `ctx.web.fetch`(AsyncLocalStorage)来分辨
77
+ * 「这次取 key 的是搜索还是聊天」。那样做把插件挂进了 web 工具的调用链里:一旦
78
+ * wrapper 出问题,`web_search` / `web_fetch` 会一直挂到工具的 30/60 秒超时
79
+ * (连 `http://127.0.0.1:1/` 这种必然立刻失败的地址也一样)。0.2.15 起不再包
80
+ * `ctx.web`,改成**按 ref 分辨**:搜索 provider 解析的是搜索自己的 ref
81
+ * (官方页的 `apiKeyEnv`,被本插件接管后就是 `DEEPSEEK_API_KEY_SEARCH`),
82
+ * 聊天通道永远不会解析这个 ref。
83
+ *
84
+ * 配置本身用缓存:`credentials.resolve` 是热路径,不能每次都去 `settings.describe()`。
79
85
  */
80
- const searchScope = new AsyncLocalStorage()
81
- const WEB_SCOPE_MARK = Symbol.for('dsh-workspace-api-key/web-scope')
86
+ let webCache
87
+
88
+ /** 重新读一次官方「网页搜索」配置(只在请求/面板刷新这类「非热路径」里调用)。 */
89
+ function refreshWebCache(ctx) {
90
+ try {
91
+ webCache = readWebSettings(ctx)
92
+ } catch {
93
+ webCache = webCache ?? webDefaults()
94
+ }
95
+ return webCache
96
+ }
97
+
98
+ /**
99
+ * 取官方「网页搜索」配置——**只读缓存,绝不在 resolve 热路径里重新解析**。
100
+ *
101
+ * `settings.describe()` 会 interpolate 各条目的配置,而配置里可能有 `credential-ref`,
102
+ * 那等于从 credentials 服务内部再调回 credentials。0.2.15 就是在 resolve 里调 describe:
103
+ * 搜索 → resolve(搜索 ref) → describe → describe 又去 resolve → 服务自己等自己,
104
+ * promise 永远不 settle。表现就是 `web_search` 卡满 60 秒,面板接口也一起挂住。
105
+ * 缓存由 apply()、每个 HTTP 请求入口、面板刷新时更新。
106
+ */
107
+ function currentWeb(ctx) {
108
+ return webCache ?? webDefaults()
109
+ }
110
+
111
+ /**
112
+ * 这次 `credentials.resolve` 是不是搜索/抓取在要 key。
113
+ * 只看 ref:搜索 ref 由官方页的 `apiKeyEnv` 决定,聊天通道不会用到它。
114
+ */
115
+ function isSearchRefFor(ctx, ref) {
116
+ if (typeof ref !== 'string' || ref === '') return false
117
+ const web = currentWeb(ctx)
118
+ if (web.apiChannel !== true) return false
119
+ // 官方页还没被接管时,搜索用的是聊天那把 ref:这时分辨不了,按聊天处理(安全)。
120
+ if (web.searchKeyEnv === DEFAULT_API_KEY_REF) return ref === SEARCH_DEFAULT_REF
121
+ return ref === SEARCH_DEFAULT_REF || ref === web.searchKeyEnv || ref === web.legacyKeyEnv
122
+ }
82
123
 
83
124
  // 幂等标记:同一进程里可能被 apply 多次(热重载),只装一层 wrapper。
84
125
  const INSTALL_KEY = Symbol.for('dsh-workspace-api-key/installed')
@@ -1262,8 +1303,18 @@ async function probeSearch(ctx, state, key, web) {
1262
1303
  export function apply(ctx) {
1263
1304
  const state = loadState()
1264
1305
 
1265
- // ── 1. 包装 credentials.resolve + 标记搜索/抓取通道 ─────────────────
1266
- installWebScope(ctx)
1306
+ // ── 1. 包装 credentials.resolve(按 ref 分辨搜索/抓取通道)──────────
1307
+ refreshWebCache(ctx)
1308
+ // 官方页改了「网页搜索」配置就丢掉缓存:解析热路径只读缓存,不能自己去 describe。
1309
+ try {
1310
+ ctx.effect(() =>
1311
+ ctx.on('settings/document-updated', () => {
1312
+ webCache = undefined
1313
+ }),
1314
+ )
1315
+ } catch (err) {
1316
+ log('settings listener unavailable: %s', String(err?.message ?? err))
1317
+ }
1267
1318
  installResolver(ctx, state)
1268
1319
  // 「直接禁用官方页面里的搜索 key 设置」:把官方 apiKeyEnv 指到插件自有 ref(异步、失败不影响启动)。
1269
1320
  void reconcileOfficialSearchRef(ctx)
@@ -1316,54 +1367,6 @@ export function apply(ctx) {
1316
1367
  )
1317
1368
  }
1318
1369
 
1319
- /**
1320
- * 装上搜索/抓取通道标记:把 `ctx.web.search` / `ctx.web.fetch` 包一层
1321
- * AsyncLocalStorage,让 resolve wrapper 知道「这次取 key 的是搜索,不是聊天」。
1322
- * 装不上就静默跳过——不影响任何既有行为(只是无法区分通道)。
1323
- */
1324
- function installWebScope(ctx) {
1325
- if (globalThis[WEB_SCOPE_MARK] !== undefined) {
1326
- log('web scope already installed; skipping')
1327
- return
1328
- }
1329
- let web
1330
- try {
1331
- web = ctx.get('web')
1332
- } catch {
1333
- web = undefined
1334
- }
1335
- if (web === undefined) return
1336
-
1337
- const originals = {}
1338
- for (const method of ['search', 'fetch']) {
1339
- if (typeof web[method] !== 'function') continue
1340
- const original = web[method].bind(web)
1341
- originals[method] = original
1342
- try {
1343
- web[method] = (...args) => searchScope.run(true, () => original(...args))
1344
- } catch {
1345
- delete originals[method]
1346
- }
1347
- }
1348
- if (Object.keys(originals).length === 0) return
1349
-
1350
- globalThis[WEB_SCOPE_MARK] = { web, originals }
1351
- ctx.effect(
1352
- () => () => {
1353
- for (const [method, original] of Object.entries(originals)) {
1354
- try {
1355
- web[method] = original
1356
- } catch {
1357
- /* ignore */
1358
- }
1359
- }
1360
- delete globalThis[WEB_SCOPE_MARK]
1361
- },
1362
- 'workspace-api-key: restore web.search/fetch',
1363
- )
1364
- log(`web scope wrapped: ${Object.keys(originals).join(',')}`)
1365
- }
1366
-
1367
1370
  /**
1368
1371
  * 装上 resolve wrapper。装不上也不能影响宿主:任何异常都吞掉并退回原行为。
1369
1372
  */
@@ -1393,8 +1396,9 @@ function installResolver(ctx, state) {
1393
1396
 
1394
1397
  // 搜索 / 抓取通道:官方「新版 Web 搜索 API」用的是独立的一套 key 配置,
1395
1398
  // 语义与聊天不同,因此单独处理(旧 Messages 通道仍走下面的聊天层级)。
1396
- if (searchScope.getStore() === true) {
1397
- const web = readWebSettings(ctx)
1399
+ // 分辨方式改成「看 ref」:搜索 provider 解析的一定是搜索自己的 ref,聊天不会解析它。
1400
+ if (isSearchRefFor(ctx, ref)) {
1401
+ const web = currentWeb(ctx)
1398
1402
  if (web.apiChannel) {
1399
1403
  // ① 会话单独设置的 key 永远优先——哪怕官方搜索插件里配了别的 key。
1400
1404
  const sessionKey = sessionOverrideRef(state, sessionId)
@@ -1651,6 +1655,8 @@ function hasVisibleSession(rows) {
1651
1655
  }
1652
1656
 
1653
1657
  async function snapshot(ctx, state) {
1658
+ // 官方「网页搜索」配置:每次面板刷新都重读一次(缓存只在请求入口更新,解析路径只读)。
1659
+ refreshWebCache(ctx)
1654
1660
  const credentials = ctx.get('credentials')
1655
1661
  const defaultRefName = 'DEEPSEEK_API_KEY'
1656
1662
  const defaultInfo = await describeKey(ctx, defaultRefName)
@@ -1745,7 +1751,8 @@ async function snapshot(ctx, state) {
1745
1751
  }
1746
1752
 
1747
1753
  // ── 搜索 / 抓取通道总览 ──────────────────────────────────────────────
1748
- const web = readWebSettings(ctx)
1754
+ // 每次面板刷新都重读一次:开关改过之后,缓存必须跟着变。
1755
+ const web = refreshWebCache(ctx)
1749
1756
  const searchKeyRef = web.searchKeyEnv
1750
1757
  const searchKeyInfo = await describeKey(ctx, searchKeyRef)
1751
1758
  const searchKeyHit = await resolveKey(ctx, searchKeyRef)
@@ -1952,6 +1959,20 @@ async function searchStateOf(ctx, state, { sessionId, cwd }) {
1952
1959
  }
1953
1960
 
1954
1961
  async function handle(ctx, state, req, res) {
1962
+ // 每个请求进来先刷新一次网页搜索配置:解析热路径只读缓存。
1963
+ refreshWebCache(ctx)
1964
+ // 看门狗:任何一次 await 卡住都不该让面板/测试按钮一直转圈。
1965
+ const watchdog = setTimeout(() => {
1966
+ if (res.writableEnded === true) return
1967
+ try {
1968
+ res.writeHead(504, { 'content-type': 'application/json; charset=utf-8' })
1969
+ res.end(JSON.stringify({ ok: false, error: '面板请求超时(8 秒):DSH 的某个服务没有响应' }))
1970
+ } catch {
1971
+ /* 已经写过了就忽略 */
1972
+ }
1973
+ }, 8000)
1974
+ watchdog.unref?.()
1975
+ if (typeof res.on === 'function') res.on('close', () => clearTimeout(watchdog))
1955
1976
  if (!isTrusted(req)) {
1956
1977
  res.writeHead(403)
1957
1978
  res.end('forbidden')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "A DeepSeek Harness plugin that binds a separate DeepSeek API key to each workspace, or to a single session, so the spend of one key stays with the sessions you choose.",
5
5
  "type": "module",
6
6
  "license": "MIT",