dsh-search-plugin 0.1.5 → 0.2.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/README.md CHANGED
@@ -1,103 +1,81 @@
1
- # 联网搜索(Web search)
1
+ # 联网搜索与网页抓取
2
2
 
3
- 将互联网作为智能体的信息来源,为分析与判断补充外部依据。
4
-
5
- TypeScript ESM 搜索组合包,目标运行版本 **DSH 0.2.1-rc.4**。注册 `codex` 搜索 Provider,复用官方 `web_search`;网页抓取复用本机 `http` Provider 与 `web_fetch`,不重复注册工具。
3
+ 面向 **DSH 0.2.1-rc.4** 的 TypeScript ESM 插件。复用官方 `web_search`、`web_fetch` 工具,通过实时配置选择搜索协议,增强本机 HTTP 抓取的地址授权。设计参考 [dsh-tool-web-enhanced](https://github.com/Yurzi/dsh-tool-web-enhanced),本仓库实现仅覆盖 Alpha Search、DeepSeek Search API 与本机抓取。
6
4
 
7
5
  ```text
8
- web_search → ctx.web → codex → POST /v1/alpha/search
9
- web_fetch → ctx.web → http → 本机直接请求公开网页
6
+ web_search → ctx.web → codex(稳定路由)→ protocol factory → Alpha / DeepSeek Search API
7
+ web_fetch → ctx.web → search-http → 官方 HttpFetchProvider + CIDR resolver
10
8
  ```
11
9
 
12
- ## 安装与构建
10
+ ## 构建与安装
13
11
 
14
12
  ```sh
15
13
  pnpm install
16
14
  pnpm check
17
- # 在父目录执行,将 <name> 替换为目标 profile:
18
15
  dsh plugin --profile <name> add ./dsh-search-plugin
19
16
  ```
20
17
 
21
- 本地 checkout 安装需要保留插件目录及构建产物。`pnpm build` 构建 Host 和浏览器 lazy-CJS 模块;修改后需重新构建。首次安装或更新 bundle 后需要重启现有 DSH profile,再刷新网页;加载新版后,UI 的即时配置与凭据更新对下一次搜索生效。插件不会自动更改正在运行的 profile。
22
-
23
- bundle 要求标准 DSH base 已有 `web`、`web-fetch-http` 与工具消费者,并选择 `searchProvider: codex`、`fetchProvider: http`。不覆盖已有 HTTP fetch 安全限制、超时或 User-Agent;其他后应用的 patch 可覆盖路由。
24
-
25
- ## 默认配置:无需填写端点或模型
26
-
27
- 默认 `authMode: openai`、`openaiProvider: openai`,复用所选 OpenAI Provider 的实时 `baseURL` 与显式 `apiKeyEnv` 引用。例如:
28
-
29
- ```yaml
30
- baseURL: https://your-api.example/v1
31
- apiKeyEnv: OPENAI_API_KEY
32
- ```
33
-
34
- 搜索自动请求 `https://your-api.example/v1/alpha/search`,通过官方 Credentials 服务解析同一个密钥引用。**不要求填写搜索模型,不调用 Responses,不做协议探测或失败回退。** 上游必须实现 Alpha 搜索接口,普通 OpenAI 兼容聊天服务不一定支持。
18
+ 本地 checkout 安装需要保留目录及构建产物。更新已经安装的包后需要重启 DSH profile,再刷新网页。仅构建本仓库不会更新已经运行的 GUI。bundle 选择 `searchProvider: codex`、`fetchProvider: search-http`,标准 base 需已有 web 服务与工具消费者;旧 `http` provider 保留,其配置不被修改,但默认路由改由本插件接管。
35
19
 
36
- 端点与凭据一起继承。复用模式始终使用派生地址,忽略独立模式的 `endpoint`(包括旧版残留值);不同地址请切换独立凭据模式,明确授权该地址使用的凭据引用。每次搜索捕获当前配置并解析一次凭据,不复制存储,不跨请求缓存。
20
+ ## 配置 UI
37
21
 
38
- 仅支持复用显式 `apiKeyEnv` 引用,不读取 Provider 私有对象、不提取 OAuth token、不绕过 stored-record 优先级、不实现 OAuth 刷新。自定义认证 headers 不会复制;这些连接请改用独立凭据。插件发送标准 Bearer API Key,不支持其他认证协议。
22
+ 进入 **插件 → 联网搜索 → 配置**,或组件行的配置页面。
39
23
 
40
- ## 配置 UI
24
+ - 搜索协议选择器动态显示对应协议字段;切换协议保留各自配置,保存后下一次操作生效。
25
+ - Alpha 可复用 OpenAI provider,或使用独立端点与凭据。仅 Alpha 显示超时、响应和答案上限。
26
+ - DeepSeek 显示 API 基础地址、独立凭据引用和结果数量;使用官方 Search API 实现和传输限制。
27
+ - 网页抓取独立分组:CIDR 多行授权、超时与正文限制。可将常见代理网段填入草稿,再保存生效。
28
+ - 保存使用官方 ConfigForms 与 revision 冲突检查,支持放弃编辑、重置覆盖、只读与失败提示。
29
+ - 密钥走官方 Credentials API 单独保存、删除和刷新状态;不回显、不放入公开配置和浏览器持久存储。删除托管值后,环境变量等其他来源仍可能提供同名凭据。
41
30
 
42
- 进入 **插件 → 联网搜索** 的组合包详情,或点击 **dsh-search-plugin 组件行 → 配置**。两处共用同一份实时配置:
31
+ ## 搜索配置
43
32
 
44
- - 复用模式只显示 OpenAI Provider 路由;无需填写端点、模型或密钥。
45
- - 独立模式显示完整搜索端点 URL、凭据引用与密钥管理。
46
- - 两种模式均可设置超时、响应字节上限、答案字符上限。
33
+ | 字段 | 默认值 | 含义 |
34
+ | --- | --- | --- |
35
+ | `protocol` | `codex-alpha` | `codex-alpha` 或 `deepseek-search-api` |
36
+ | `authMode` | `openai` | Alpha 复用 OpenAI,或 `credential` 独立认证 |
37
+ | `openaiProvider` | `openai` | Alpha 复用的官方 LLM route |
38
+ | `credentialRef` | `DSH_CODEX_API_KEY` | Alpha 独立凭据引用 |
39
+ | `endpoint` | 空 | Alpha 独立模式完整 POST URL |
40
+ | `apiKey` | 无 | 兼容旧版 Host 密钥字段,仅 Alpha 独立模式使用,建议迁移到 Credentials |
41
+ | `timeoutMs` | `60000` | Alpha 请求超时 |
42
+ | `maxResponseBytes` | `2000000` | Alpha 响应上限 |
43
+ | `maxAnswerChars` | `16000` | Alpha 答案字符上限 |
44
+ | `deepseekEndpoint` | `https://api.deepseek.com/v1/tools` | DeepSeek API 基础地址,追加 `/search` |
45
+ | `deepseekCredentialRef` | `DEEPSEEK_API_KEY` | DeepSeek 独立凭据引用 |
46
+ | `deepseekCount` | `10` | 1–50,单次 `maxResults` 更小时使用更小值 |
47
47
 
48
- 配置页使用官方 ConfigForms 服务和 revision 冲突检测,提供保存、重置与放弃编辑。重置只重置公开配置,不删除凭据或修改旧版密钥字段。密钥单独通过官方 Credentials API 管理:不返回明文、不进入普通配置、不写入浏览器存储;输入留空不会清除已有密钥。删除只删除该引用的托管值,环境变量或其他来源仍可能提供同名凭据。
48
+ Alpha 复用模式每次读取选中 provider 的实时 `baseURL` 与显式 `apiKeyEnv`,派生 `/alpha/search`,不将复用密钥发送到独立 `endpoint`。不支持 OAuth、私有 token 提取或自定义鉴权 headers 复制。独立模式必须填写完整 URL。保留 `DSH_CODEX_AUTH_MODE`、`DSH_CODEX_ENDPOINT`、`DSH_CODEX_API_KEY` 启动环境兼容。
49
49
 
50
- 组合包与组件提供中英文名称和描述;包名与 Provider id 保持不变。自建最小 profile 需要挂载官方 Settings、ConfigEditor、Credentials 及对应 Web 客户端服务;仅 Host schema 不会自动生成页面。
50
+ Alpha 使用内置路由模型 `gpt-5.6-luna`,发送原生 Alpha request,读取 `output/results`,不回退 Responses 或聊天协议。DeepSeek 使用官方 `DeepSeekSearchApiProvider`,请求 `query/count/need_content: false`,映射结构化结果。两个协议的凭据独立,不静默尝试其他协议。
51
51
 
52
- ## 配置字段
52
+ ## 代理网段抓取
53
53
 
54
54
  | 字段 | 默认值 | 含义 |
55
55
  | --- | --- | --- |
56
- | `authMode` | `openai` | `openai` 复用模式或 `credential` 独立模式 |
57
- | `openaiProvider` | `openai` | 复用模式的官方 LLM Provider id |
58
- | `credentialRef` | `DSH_CODEX_API_KEY` | 独立模式的官方凭据引用 / 环境变量名 |
59
- | `endpoint` | 空 | 复用模式自动派生 Alpha 搜索 URL;独立模式必须填写完整 POST URL |
60
- | `apiKey` | 无 | 兼容旧版的 Host-only 密钥,仅独立模式使用,优先于引用;推荐改用凭据管理 |
61
- | `timeoutMs` | `60000` | 单次搜索请求超时,毫秒 |
62
- | `maxResponseBytes` | `2000000` | 搜索响应主体的字节上限 |
63
- | `maxAnswerChars` | `16000` | 返回答案的字符上限 |
64
-
65
- 以上字段为即时配置。上限仅控制搜索,不控制本机 HTTP fetch。旧版 `model` 不再使用。复用模式忽略旧 `endpoint`;独立模式升级后需把原 `/responses` 端点改为实际的 Alpha 搜索 URL,插件不会自动改写独立端点。
56
+ | `fetchAllowedCidrs` | `[]` | 额外授权的 IPv4/IPv6 网络 CIDR |
57
+ | `fetchTimeoutMs` | `30000` | 抓取超时 |
58
+ | `fetchMaxResponseBytes` | `5000000` | 响应字节上限 |
59
+ | `fetchMaxContentChars` | `100000` | 正文字符上限 |
66
60
 
67
- bundle 可从启动进程环境读取 `DSH_CODEX_AUTH_MODE`(默认 `openai`)、`DSH_CODEX_ENDPOINT` 和旧版 `DSH_CODEX_API_KEY`。用环境变量直接提供搜索 API Key 时需同时设置 `DSH_CODEX_AUTH_MODE=credential`。不再需要 `DSH_CODEX_MODEL`。
68
-
69
- 独立模式的 profile patch 示例:
61
+ 公网默认可访问;非公开地址仅在显式 CIDR 授权内可访问。对于 Fake-IP DNS,可在 UI 填入并保存:
70
62
 
71
63
  ```yaml
72
- - id: dsh-search-plugin
73
- config:
74
- authMode: credential
75
- endpoint: https://your-search.example/v1/alpha/search
76
- credentialRef: SEARCH_API_KEY
64
+ fetchAllowedCidrs:
65
+ - 198.18.0.0/15
66
+ - 192.168.96.0/20
77
67
  ```
78
68
 
79
- **patch 替换整份 `config`,不是深度合并。** 修改时保留需要的字段,省略的字段使用 schema 默认值。不要把真实密钥提交进仓库或放入普通配置。插件自身不额外解析 `.env`。
80
-
81
- ## 协议与安全边界
82
-
83
- 只支持 **POST `/v1/alpha/search`**,不支持 Responses、Chat Completions 或直接连接 ChatGPT 私有后端;独立模式的 `endpoint` 是完整 URL,不自动补路径。协议按 [Codex 官方 SearchRequest/SearchResponse](https://github.com/openai/codex/blob/main/codex-rs/codex-api/src/search.rs) 实现,而不是替换 Responses URL:
84
-
85
- - 每次请求生成 `id`,发送 `commands.search_query: [{ q: 查询文本 }]`、用户 `input`、`settings: { allowed_callers: ['direct'], external_web_access: true }` 和 `max_output_tokens: 2048`,不发送 Responses `tools`、`tool_choice`、`include`、`stream` 或 `store`。
86
- - Alpha 协议自身要求 `model`。适配器内置路由模型 `gpt-5.6-luna`([已公开 Alpha 适配器的默认值](https://unpkg.com/@caiqy/dsh-plugin-web-search@0.1.0/lib/providers.js)),不继承聊天模型,也不要求用户填写。目标服务必须支持此路由模型。
87
- - 响应读取文本 `output` 和可选 `results`(缺失、`null`、空数组均支持)。`results` 是可扩展的不透明条目,只提取有合法 HTTP(S) `url` 的来源及可选 `title`,不要求存在这些字段,不输出 `encrypted_output`。清理 Alpha 私有引用标记,来源去重后交给 DSH 管理数量限制。
88
-
89
- 搜索端点会收到密钥与查询,请仅配置可信服务,推荐 HTTPS。配置缺失、鉴权错误、上游协议错误、超时等明确失败,不静默切换 Provider,不返回伪造的空结果。不自动重试,不跟随携带认证的重定向。来源只取自结构化响应,不从自然语言猜测或编造链接。DSH 的 `truncated` 表示来源被裁剪,不表示答案字符裁剪。
90
-
91
- 网页与答案均属于不可信外部数据,不能作为系统指令或工具授权。排错时不要打印未脱敏请求头、真实密钥或完整远端错误正文。
69
+ 授权是对整个网段的授权,不区分代理 Fake-IP 和该段内真实服务。默认不自动开启。CIDR 必须使用网络基址;支持 IPv4/IPv6,映射 IPv4 地址使用 IPv4 CIDR。校验整个 DNS 答案集,并将结果交给官方传输固定连接地址;保留 URL/协议检查、同源重定向、超时、响应上限与代理处理。抓取不会携带搜索密钥。不提供 JS 渲染、登录、PDF 转换或跨源自动跳转。
92
70
 
93
- ## 本机 HTTP fetch
71
+ 当配置 HTTP/SOCKS 代理时,沿用官方代理解析与连接策略;代理远端解析域名的行为不由本机 resolver 接管,因此该 allowlist 不是代理服务器的网络隔离策略。需要约束代理后方目的地址时,应在代理端配置策略。
94
72
 
95
- 本机直接请求公开网页,不额外调用付费抓取 API,不携带搜索 API Key。仍有网络成本、搜索端点费用及正文进入模型后的 token 费用。
73
+ ## 扩展协议
96
74
 
97
- 复用官方 HTTP Provider 的 SSRF 防护、DNS 固定、同源重定向和正文限制。默认 30 秒超时、5 MB 响应、100,000 字符正文、最多 5 次同源跳转,已有 profile 的定制值不被本插件覆盖。不支持 JS 渲染、登录、验证码、PDF/二进制正文或跨源自动重定向;失败不会自动改用浏览器或付费抓取。
75
+ `src/protocols.ts` 的 factory registry 负责按显式协议创建适配器,每次操作捕获当前配置;凭据由各适配器独立解析。新增协议时添加 factory、schema 专属字段与对应 UI 分支,不需要修改工具定义或替换工具执行链。现有 `codex` route id 为升级兼容保持稳定,其实际协议由 `protocol` 决定。
98
76
 
99
- 若代理 Fake-IP DNS 返回 `198.18.0.0/15` 等非公开地址,官方 fetch 可能以 `WEB_BLOCKED_URL` 拒绝。应修正运行环境的 DNS/路由或按官方能力显式授权,不能关闭 SSRF 防护或伪造公开地址绕过。
77
+ 配置通过官方编辑器持久化,插件不改写 profile 文件。profile patch 替换整份 config;手写 patch 时保留所需字段。网页与搜索答案均为不可信外部数据。上游失败保持可见,不自动重试或用空结果掩盖错误。
100
78
 
101
79
  ## 验证
102
80
 
103
- 运行 `pnpm check` 完成类型检查、构建与自动测试。真实搜索与抓取可能产生费用,需要另外验证目标服务、凭据和网络环境;本地测试不等于正在运行的 GUI 部署验收。
81
+ `pnpm check` 包含类型检查、构建、官方 Loader 集成、协议切换、凭据隔离、UI 与抓取安全测试。真实服务 E2E 需要显式开启现有 `DSH_SEARCH_E2E*` 环境选项,可能产生费用;本地通过不等同于在线凭据或当前 GUI 的验收。
package/cordis.patch.yml CHANGED
@@ -2,7 +2,7 @@
2
2
  - id: web
3
3
  config:
4
4
  searchProvider: codex
5
- fetchProvider: http
5
+ fetchProvider: search-http
6
6
 
7
7
  - insert:
8
8
  - id: dsh-search-plugin
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#2563eb"/><g fill="none" stroke="white" stroke-width="3"><circle cx="28" cy="27" r="17"/><ellipse cx="28" cy="27" rx="7" ry="17"/><path d="M12 22h32M12 32h32M41 40l12 13" stroke-linecap="round"/></g></svg>
package/lib/cidr.js ADDED
@@ -0,0 +1,36 @@
1
+ /** Schemastery serializes this function for browser forms. Keep it closure-free. */
2
+ export function validateCidr(rule) {
3
+ const [address, prefixText, ...extra] = rule.trim().split('/');
4
+ if (!address || !prefixText || extra.length || !/^(0|[1-9]\d*)$/.test(prefixText)) {
5
+ throw new Error(`Invalid CIDR: ${JSON.stringify(rule)}`);
6
+ }
7
+ let bytes;
8
+ if (address.includes(':')) {
9
+ if (!/^[\da-f:.]+$/i.test(address))
10
+ throw new Error(`Invalid IPv6 CIDR: ${JSON.stringify(rule)}`);
11
+ // URL uses the same IPv6 parser in Node and browsers, including embedded IPv4.
12
+ const normalized = new URL(`http://[${address}]/`).hostname.slice(1, -1);
13
+ const halves = normalized.split('::').map(part => part ? part.split(':') : []);
14
+ const groups = halves.length === 1 ? halves[0] : [...halves[0], ...Array(8 - halves[0].length - halves[1].length).fill('0'), ...halves[1]];
15
+ bytes = groups.flatMap(group => { const n = parseInt(group, 16); return [n >>> 8, n & 255]; });
16
+ }
17
+ else {
18
+ const parts = address.split('.');
19
+ if (parts.length !== 4 || parts.some(part => !/^(0|[1-9]\d{0,2})$/.test(part) || Number(part) > 255)) {
20
+ throw new Error(`Invalid IPv4 CIDR: ${JSON.stringify(rule)}`);
21
+ }
22
+ bytes = parts.map(Number);
23
+ }
24
+ const prefix = Number(prefixText);
25
+ if (prefix > bytes.length * 8)
26
+ throw new Error(`Invalid CIDR prefix: ${JSON.stringify(rule)}`);
27
+ if (bytes.some((byte, i) => (byte & (255 >>> Math.max(0, Math.min(8, prefix - i * 8)))) !== 0)) {
28
+ throw new Error(`CIDR must use its network base: ${JSON.stringify(rule)}`);
29
+ }
30
+ const embeddedV4 = bytes.length === 16 && bytes.slice(0, 10).every(b => b === 0)
31
+ && ((bytes[10] === 255 && bytes[11] === 255) || (bytes[10] === 0 && bytes[11] === 0));
32
+ if (embeddedV4 && bytes.some(Boolean) && !(bytes.slice(0, 15).every(b => b === 0) && bytes[15] === 1)) {
33
+ throw new Error(`Use an IPv4 CIDR for IPv4-mapped or compatible addresses: ${JSON.stringify(rule)}`);
34
+ }
35
+ return rule;
36
+ }
package/lib/client.js CHANGED
@@ -30,11 +30,22 @@ module.exports = __toCommonJS(index_exports);
30
30
  // src/client/SearchConfig.tsx
31
31
  var import_react = require("react");
32
32
  var import_jsx_runtime = require("react/jsx-runtime");
33
- var fields = ["authMode", "openaiProvider", "credentialRef", "endpoint", "timeoutMs", "maxResponseBytes", "maxAnswerChars"];
34
- var defaults = { authMode: "openai", openaiProvider: "openai", credentialRef: "DSH_CODEX_API_KEY", endpoint: "", timeoutMs: "60000", maxResponseBytes: "2000000", maxAnswerChars: "16000" };
35
- var numeric = /* @__PURE__ */ new Set(["timeoutMs", "maxResponseBytes", "maxAnswerChars"]);
33
+ var defaults = { protocol: "codex-alpha", authMode: "openai", openaiProvider: "openai", credentialRef: "DSH_CODEX_API_KEY", endpoint: "", timeoutMs: "60000", maxResponseBytes: "2000000", maxAnswerChars: "16000", deepseekEndpoint: "https://api.deepseek.com/v1/tools", deepseekCredentialRef: "DEEPSEEK_API_KEY", deepseekCount: "10", fetchAllowedCidrs: "", fetchTimeoutMs: "30000", fetchMaxResponseBytes: "5000000", fetchMaxContentChars: "100000" };
34
+ var fields = Object.keys(defaults);
35
+ var alphaLimits = ["timeoutMs", "maxResponseBytes", "maxAnswerChars"];
36
+ var fetchLimits = ["fetchTimeoutMs", "fetchMaxResponseBytes", "fetchMaxContentChars"];
37
+ var numeric = /* @__PURE__ */ new Set([...alphaLimits, ...fetchLimits, "deepseekCount"]);
38
+ var cidrsOf = (text) => text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
36
39
  function valuesOf(value) {
37
- return Object.fromEntries(fields.map((field) => [field, String(value[field] ?? defaults[field])]));
40
+ return Object.fromEntries(fields.map((field) => [field, field === "fetchAllowedCidrs" ? value[field]?.join("\n") ?? "" : String(value[field] ?? defaults[field])]));
41
+ }
42
+ function validEndpoint(value) {
43
+ try {
44
+ const url = new URL(value);
45
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash;
46
+ } catch {
47
+ return false;
48
+ }
38
49
  }
39
50
  function SearchConfig(props) {
40
51
  const { t } = props;
@@ -50,26 +61,25 @@ function SearchConfig(props) {
50
61
  if (state.status !== "ready" || !state.value) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", children: t(state.status === "loading" ? "loading" : "unavailable") });
51
62
  const accepted = valuesOf(state.value);
52
63
  const values = draft?.values ?? accepted;
53
- const keyText = key?.ref === values.credentialRef ? key.text : "";
64
+ const deepseek = values.protocol === "deepseek-search-api";
65
+ const independent = deepseek || values.authMode === "credential";
66
+ const refField = deepseek ? "deepseekCredentialRef" : "credentialRef";
67
+ const ref = values[refField];
68
+ const keyText = key?.ref === ref ? key.text : "";
54
69
  const conflict = draft !== null && draft.revision !== state.revision;
55
70
  const disabled = !state.writable || busy;
56
- const independent = values.authMode === "credential";
57
- const visibleFields = fields.filter((field) => numeric.has(field) || (independent ? field === "credentialRef" || field === "endpoint" : field === "openaiProvider"));
58
- const validRef = /^[A-Za-z_][A-Za-z0-9_]*$/.test(values.credentialRef);
59
- const credentialCurrent = credential.ref === values.credentialRef;
71
+ const validRef = /^[A-Za-z_][A-Za-z0-9_]*$/.test(ref);
72
+ const credentialCurrent = credential.ref === ref;
60
73
  const info = credentialCurrent ? credential.info : void 0;
61
- const credentialWritable = info?.writable === true && !keyBusy;
74
+ const credentialWritable = validRef && info?.writable === true && !keyBusy;
62
75
  const edit = (field, value) => {
63
- setDraft({ values: { ...values, [field]: value }, revision: draft ? draft.revision : state.revision });
76
+ const next = { ...values, [field]: value };
77
+ setDraft({ values: next, revision: draft ? draft.revision : state.revision });
64
78
  setMessage(null);
65
- if (field === "authMode") {
79
+ if (field === "protocol" || field === "authMode" || field === refField) {
66
80
  setKey(null);
67
81
  setKeyMessage(null);
68
- }
69
- if (field === "credentialRef") {
70
- setKey(null);
71
- setKeyMessage(null);
72
- props.describeCredential(value);
82
+ props.describeCredential(next.protocol === "deepseek-search-api" ? next.deepseekCredentialRef : next.credentialRef);
73
83
  }
74
84
  };
75
85
  const save = async (reset) => {
@@ -78,29 +88,22 @@ function SearchConfig(props) {
78
88
  return;
79
89
  }
80
90
  if (!reset) {
81
- let valid = fields.filter((field) => numeric.has(field)).every((field) => Number.isSafeInteger(Number(values[field])) && Number(values[field]) > 0);
82
- if (independent) {
83
- try {
84
- const endpoint = new URL(values.endpoint);
85
- valid &&= validRef && ["http:", "https:"].includes(endpoint.protocol) && !endpoint.username && !endpoint.password && !endpoint.hash;
86
- } catch {
87
- valid = false;
88
- }
89
- } else valid &&= Boolean(values.openaiProvider.trim());
91
+ const activeNumeric = [...fetchLimits, ...deepseek ? ["deepseekCount"] : alphaLimits];
92
+ let valid = activeNumeric.every((field) => Number.isSafeInteger(Number(values[field])) && Number(values[field]) > 0);
93
+ valid &&= deepseek ? Number(values.deepseekCount) <= 50 && validRef && validEndpoint(values.deepseekEndpoint) : independent ? validRef && validEndpoint(values.endpoint) : Boolean(values.openaiProvider.trim());
90
94
  if (!valid) {
91
95
  setMessage("invalid");
92
96
  return;
93
97
  }
94
98
  }
95
- const savedValues = independent ? values : { ...values, endpoint: "" };
96
- const savedFields = ["authMode", ...visibleFields, ...!independent ? ["endpoint"] : []];
97
- const ops = reset ? fields.map((field) => ({ op: "unset", path: [field] })) : savedFields.filter((field) => savedValues[field] !== accepted[field]).map((field) => ({ op: "set", path: [field], value: numeric.has(field) ? Number(savedValues[field]) : savedValues[field] }));
99
+ const ops = reset ? fields.map((field) => ({ op: "unset", path: [field] })) : fields.filter((field) => values[field] !== accepted[field]).map((field) => ({ op: "set", path: [field], value: numeric.has(field) ? Number(values[field]) : field === "fetchAllowedCidrs" ? cidrsOf(values[field]) : values[field] }));
98
100
  setBusy(true);
99
101
  setMessage(null);
100
102
  try {
101
103
  if (await props.mutateConfig(ops, draft ? draft.revision : state.revision)) {
102
104
  setDraft(null);
103
105
  setMessage("saved");
106
+ setKey(null);
104
107
  } else setMessage("failed");
105
108
  } catch {
106
109
  setMessage("failed");
@@ -109,7 +112,6 @@ function SearchConfig(props) {
109
112
  }
110
113
  };
111
114
  const writeKey = async (remove) => {
112
- const ref = values.credentialRef;
113
115
  setKeyBusy(true);
114
116
  setKeyMessage(null);
115
117
  try {
@@ -123,68 +125,79 @@ function SearchConfig(props) {
123
125
  setKeyBusy(false);
124
126
  }
125
127
  };
128
+ const input = (field) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
129
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t(field) }),
130
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { name: field, type: numeric.has(field) ? "number" : "text", min: numeric.has(field) ? 1 : void 0, max: field === "deepseekCount" ? 50 : void 0, step: numeric.has(field) ? 1 : void 0, value: values[field], onChange: (event) => edit(field, event.target.value), disabled: field === refField && keyBusy })
131
+ ] }, field);
126
132
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-search-config", children: [
127
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-search-hint", children: t("inherit") }),
128
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-search-hint", children: t("trust") }),
129
133
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("form", { onSubmit: (event) => {
130
134
  event.preventDefault();
131
135
  void save(false);
132
136
  }, children: [
133
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", { disabled, children: [
137
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", { disabled: disabled || keyBusy, children: [
138
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("legend", { children: t("searchSettings") }),
134
139
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
140
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("protocol") }),
141
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", { name: "protocol", value: values.protocol, onChange: (event) => edit("protocol", event.target.value), children: [
142
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "codex-alpha", children: t("codexAlpha") }),
143
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "deepseek-search-api", children: t("deepseekSearchApi") })
144
+ ] })
145
+ ] }),
146
+ !deepseek && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
135
147
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("authMode") }),
136
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", { value: values.authMode, onChange: (event) => edit("authMode", event.target.value), children: [
148
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", { name: "authMode", value: values.authMode, onChange: (event) => edit("authMode", event.target.value), children: [
137
149
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "openai", children: t("openai") }),
138
150
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "credential", children: t("credential") })
139
151
  ] })
140
152
  ] }),
141
- visibleFields.map((field) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
142
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t(field) }),
143
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
144
- "input",
145
- {
146
- type: numeric.has(field) ? "number" : "text",
147
- min: numeric.has(field) ? 1 : void 0,
148
- step: numeric.has(field) ? 1 : void 0,
149
- value: values[field],
150
- onChange: (event) => edit(field, event.target.value),
151
- disabled: field === "credentialRef" && keyBusy
152
- }
153
- )
154
- ] }, field))
153
+ (deepseek ? ["deepseekEndpoint", "deepseekCredentialRef", "deepseekCount"] : independent ? ["endpoint", "credentialRef"] : ["openaiProvider"]).map(input)
154
+ ] }),
155
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-search-hint", children: t(deepseek ? "deepseekHint" : "inherit") }),
156
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-search-hint", children: t("trust") }),
157
+ !deepseek && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", { disabled, children: [
158
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("legend", { children: t("searchLimits") }),
159
+ alphaLimits.map(input)
160
+ ] }),
161
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", { disabled, children: [
162
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("legend", { children: t("fetchSettings") }),
163
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dsh-search-wide", children: [
164
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("fetchAllowedCidrs") }),
165
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("textarea", { name: "fetchAllowedCidrs", rows: 4, value: values.fetchAllowedCidrs, onChange: (event) => edit("fetchAllowedCidrs", event.target.value), "aria-describedby": "dsh-search-cidr-hint" })
166
+ ] }),
167
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { id: "dsh-search-cidr-hint", className: "dsh-search-hint dsh-search-wide", children: t("cidrHint") }),
168
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-search-wide", onClick: () => edit("fetchAllowedCidrs", [.../* @__PURE__ */ new Set([...cidrsOf(values.fetchAllowedCidrs), "198.18.0.0/15", "192.168.96.0/20"])].join("\n")), children: t("cidrPreset") }),
169
+ fetchLimits.map(input)
155
170
  ] }),
156
171
  !state.writable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", children: t("readOnly") }),
157
172
  conflict && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "alert", children: t("conflict") }),
158
173
  message && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", children: t(message) }),
159
174
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-search-actions", children: [
160
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "submit", disabled: disabled || conflict || !draft, children: t(busy ? "saving" : "save") }),
161
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: disabled || conflict, onClick: () => {
175
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "submit", disabled: disabled || keyBusy || conflict || !draft, children: t(busy ? "saving" : "save") }),
176
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: disabled || keyBusy || conflict, onClick: () => {
162
177
  void save(true);
163
178
  }, children: t("reset") }),
164
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: busy || !draft, onClick: () => {
179
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: busy || keyBusy || !draft, onClick: () => {
165
180
  setDraft(null);
166
181
  setMessage(null);
167
182
  setKey(null);
168
- props.describeCredential(accepted.credentialRef);
183
+ setKeyMessage(null);
184
+ props.describeCredential(accepted.protocol === "deepseek-search-api" ? accepted.deepseekCredentialRef : accepted.credentialRef);
169
185
  }, children: t("discard") })
170
186
  ] })
171
187
  ] }),
172
- values.authMode === "credential" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "dsh-search-secret", children: [
188
+ independent && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "dsh-search-secret", children: [
189
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { children: t("credentials") }),
190
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: "dsh-search-hint", children: [
191
+ t("credentialTarget"),
192
+ ": ",
193
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: ref })
194
+ ] }),
173
195
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
174
196
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("key") }),
175
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
176
- "input",
177
- {
178
- type: "password",
179
- autoComplete: "new-password",
180
- value: keyText,
181
- disabled: !credentialWritable,
182
- onChange: (event) => {
183
- setKey({ ref: values.credentialRef, text: event.target.value });
184
- setKeyMessage(null);
185
- }
186
- }
187
- )
197
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { name: "key", type: "password", autoComplete: "new-password", value: keyText, disabled: !credentialWritable, onChange: (event) => {
198
+ setKey({ ref, text: event.target.value });
199
+ setKeyMessage(null);
200
+ } })
188
201
  ] }),
189
202
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-search-hint", children: t("keyHint") }),
190
203
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", children: t(!validRef ? "invalidRef" : credentialCurrent && credential.error ? "keyFailed" : !info ? "keyLoading" : info.configured ? "keySet" : "keyUnset") }),
@@ -197,7 +210,7 @@ function SearchConfig(props) {
197
210
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: !credentialWritable || !info?.configured, onClick: () => {
198
211
  void writeKey(true);
199
212
  }, children: t("keyRemove") }),
200
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: !validRef || keyBusy, onClick: () => props.describeCredential(values.credentialRef), children: t("keyRefresh") })
213
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: !validRef || keyBusy, onClick: () => props.describeCredential(ref), children: t("keyRefresh") })
201
214
  ] })
202
215
  ] })
203
216
  ] });
@@ -207,6 +220,24 @@ function SearchConfig(props) {
207
220
  var en = {
208
221
  title: "Web search",
209
222
  description: "Give agents access to information from the web to inform their analysis and decisions.",
223
+ protocol: "Search protocol",
224
+ codexAlpha: "Codex Alpha",
225
+ deepseekSearchApi: "DeepSeek Search API",
226
+ searchSettings: "Search service",
227
+ searchLimits: "Alpha search limits",
228
+ fetchSettings: "Web fetch",
229
+ credentials: "Credential management",
230
+ credentialTarget: "Selected credential reference",
231
+ deepseekEndpoint: "DeepSeek API base URL",
232
+ deepseekCredentialRef: "DeepSeek credential reference",
233
+ deepseekCount: "Search results (1\u201350)",
234
+ deepseekHint: "Uses the official DeepSeek Search API adapter. The API base URL defaults to https://api.deepseek.com/v1/tools; the adapter appends /search. Alpha timeout and response limits do not apply to this adapter.",
235
+ fetchAllowedCidrs: "Additional allowed CIDR ranges (one per line)",
236
+ fetchTimeoutMs: "Fetch timeout (ms)",
237
+ fetchMaxResponseBytes: "Fetch maximum response bytes",
238
+ fetchMaxContentChars: "Fetch maximum content characters",
239
+ cidrHint: "These ranges explicitly allow web_fetch to reach otherwise blocked network addresses. An empty list adds no exceptions. Enter one IPv4 or IPv6 CIDR per line; other URL, DNS, redirect and transport safety checks still apply. The shortcut only updates this draft; save configuration to apply it.",
240
+ cidrPreset: "Add 198.18.0.0/15 and 192.168.96.0/20 to draft",
210
241
  authMode: "Authentication",
211
242
  openai: "Reuse OpenAI provider (default)",
212
243
  credential: "Independent credential",
@@ -240,11 +271,29 @@ var en = {
240
271
  keySaved: "Credential updated. Environment or other source layers may still supply a value after removal.",
241
272
  keyFailed: "Credential operation failed. Check the credential provider and retry.",
242
273
  invalidRef: "Use an environment-style reference name (letters, digits, underscore; not starting with a digit).",
243
- invalid: "Use positive integer limits. Reuse requires a provider route; independent authentication requires a valid credential reference and a full HTTP(S) endpoint without credentials or a fragment."
274
+ invalid: "Use positive integer limits and 1\u201350 DeepSeek results. Reuse requires a provider route; independent authentication and DeepSeek require a valid credential reference and a full HTTP(S) URL without credentials or a fragment."
244
275
  };
245
276
  var zh = {
246
277
  title: "\u8054\u7F51\u641C\u7D22",
247
278
  description: "\u5C06\u4E92\u8054\u7F51\u4F5C\u4E3A\u667A\u80FD\u4F53\u7684\u4FE1\u606F\u6765\u6E90\uFF0C\u4E3A\u5206\u6790\u4E0E\u5224\u65AD\u8865\u5145\u5916\u90E8\u4F9D\u636E\u3002",
279
+ protocol: "\u641C\u7D22\u534F\u8BAE",
280
+ codexAlpha: "Codex Alpha",
281
+ deepseekSearchApi: "DeepSeek Search API",
282
+ searchSettings: "\u641C\u7D22\u670D\u52A1",
283
+ searchLimits: "Alpha \u641C\u7D22\u9650\u5236",
284
+ fetchSettings: "\u7F51\u9875\u6293\u53D6",
285
+ credentials: "\u51ED\u636E\u7BA1\u7406",
286
+ credentialTarget: "\u5F53\u524D\u51ED\u636E\u5F15\u7528",
287
+ deepseekEndpoint: "DeepSeek API \u57FA\u7840\u5730\u5740",
288
+ deepseekCredentialRef: "DeepSeek \u51ED\u636E\u5F15\u7528\u540D",
289
+ deepseekCount: "\u641C\u7D22\u7ED3\u679C\u6570\u91CF\uFF081\u201350\uFF09",
290
+ deepseekHint: "\u4F7F\u7528\u5B98\u65B9 DeepSeek Search API \u9002\u914D\u5668\u3002API \u57FA\u7840\u5730\u5740\u9ED8\u8BA4\u4E3A https://api.deepseek.com/v1/tools\uFF0C\u9002\u914D\u5668\u81EA\u52A8\u8FFD\u52A0 /search\u3002Alpha \u8D85\u65F6\u4E0E\u54CD\u5E94\u9650\u5236\u4E0D\u9002\u7528\u4E8E\u6B64\u9002\u914D\u5668\u3002",
291
+ fetchAllowedCidrs: "\u989D\u5916\u653E\u884C CIDR \u7F51\u6BB5\uFF08\u6BCF\u884C\u4E00\u4E2A\uFF09",
292
+ fetchTimeoutMs: "\u6293\u53D6\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF09",
293
+ fetchMaxResponseBytes: "\u6293\u53D6\u54CD\u5E94\u5B57\u8282\u4E0A\u9650",
294
+ fetchMaxContentChars: "\u6293\u53D6\u5185\u5BB9\u5B57\u7B26\u4E0A\u9650",
295
+ cidrHint: "\u8FD9\u4E9B\u7F51\u6BB5\u660E\u786E\u5141\u8BB8 web_fetch \u8BBF\u95EE\u539F\u672C\u53D7\u963B\u7684\u7F51\u7EDC\u5730\u5740\u3002\u7A7A\u5217\u8868\u4E0D\u589E\u52A0\u653E\u884C\u4F8B\u5916\u3002\u6BCF\u884C\u586B\u5199\u4E00\u4E2A IPv4 \u6216 IPv6 CIDR\uFF1B\u5176\u4ED6 URL\u3001DNS\u3001\u91CD\u5B9A\u5411\u548C\u4F20\u8F93\u5B89\u5168\u68C0\u67E5\u4ECD\u7136\u9002\u7528\u3002\u5FEB\u6377\u6309\u94AE\u4EC5\u4FEE\u6539\u8349\u7A3F\uFF0C\u4FDD\u5B58\u914D\u7F6E\u540E\u751F\u6548\u3002",
296
+ cidrPreset: "\u5C06 198.18.0.0/15 \u548C 192.168.96.0/20 \u52A0\u5165\u8349\u7A3F",
248
297
  authMode: "\u8BA4\u8BC1\u65B9\u5F0F",
249
298
  openai: "\u590D\u7528 OpenAI Provider\uFF08\u9ED8\u8BA4\uFF09",
250
299
  credential: "\u72EC\u7ACB\u51ED\u636E",
@@ -278,11 +327,11 @@ var zh = {
278
327
  keySaved: "\u51ED\u636E\u5DF2\u66F4\u65B0\uFF1B\u79FB\u9664\u540E\u73AF\u5883\u53D8\u91CF\u6216\u5176\u4ED6\u6765\u6E90\u4ECD\u53EF\u80FD\u63D0\u4F9B\u51ED\u636E\u3002",
279
328
  keyFailed: "\u51ED\u636E\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u51ED\u636E\u63D0\u4F9B\u65B9\u540E\u91CD\u8BD5\u3002",
280
329
  invalidRef: "\u8BF7\u8F93\u5165\u73AF\u5883\u53D8\u91CF\u683C\u5F0F\u7684\u5F15\u7528\u540D\uFF08\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\uFF0C\u4E0D\u80FD\u4EE5\u6570\u5B57\u5F00\u5934\uFF09\u3002",
281
- invalid: "\u8BF7\u586B\u5199\u6B63\u6574\u6570\u4E0A\u9650\u3002\u590D\u7528\u6A21\u5F0F\u9700\u8981 Provider \u8DEF\u7531\uFF1B\u72EC\u7ACB\u8BA4\u8BC1\u9700\u8981\u6709\u6548\u51ED\u636E\u5F15\u7528\u540D\u4E0E\u5B8C\u6574 HTTP(S) \u7AEF\u70B9\uFF0CURL \u4E0D\u5F97\u5305\u542B\u7528\u6237\u540D\u3001\u5BC6\u7801\u6216\u7247\u6BB5\u3002"
330
+ invalid: "\u8BF7\u586B\u5199\u6B63\u6574\u6570\u4E0A\u9650\uFF0CDeepSeek \u7ED3\u679C\u6570\u91CF\u987B\u4E3A 1\u201350\u3002\u590D\u7528\u6A21\u5F0F\u9700\u8981 Provider \u8DEF\u7531\uFF1B\u72EC\u7ACB\u8BA4\u8BC1\u548C DeepSeek \u9700\u8981\u6709\u6548\u51ED\u636E\u5F15\u7528\u540D\u4E0E\u5B8C\u6574 HTTP(S) \u5730\u5740\uFF0CURL \u4E0D\u5F97\u5305\u542B\u7528\u6237\u540D\u3001\u5BC6\u7801\u6216\u7247\u6BB5\u3002"
282
331
  };
283
332
 
284
333
  // src/client/style.css
285
- var style_default = ".dsh-search-config { display: flex; flex-direction: column; gap: 16px; color: var(--dsw-alias-label-primary); }\n.dsh-search-config fieldset { border: 0; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 16px; }\n.dsh-search-config label { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }\n.dsh-search-config input, .dsh-search-config select { box-sizing: border-box; width: 100%; min-height: 38px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: inherit; font: inherit; }\n.dsh-search-config button { min-height: 36px; padding: 8px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-button-elevated-fill); color: inherit; cursor: pointer; font: inherit; }\n.dsh-search-config :disabled { opacity: .55; cursor: not-allowed; }\n.dsh-search-config :focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; }\n.dsh-search-hint { margin: 0; color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.6; }\n.dsh-search-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }\n.dsh-search-secret { border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 20px; }\n.dsh-search-secret .dsh-search-hint { margin-top: 8px; }\n";
334
+ var style_default = ".dsh-search-config { display: flex; flex-direction: column; gap: 16px; color: var(--dsw-alias-label-primary); }\n.dsh-search-config fieldset { border: 0; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 16px; }\n.dsh-search-config label { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }\n.dsh-search-config input, .dsh-search-config select, .dsh-search-config textarea { box-sizing: border-box; width: 100%; min-height: 38px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: inherit; font: inherit; }\n.dsh-search-config button { min-height: 36px; padding: 8px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-button-elevated-fill); color: inherit; cursor: pointer; font: inherit; }\n.dsh-search-config :disabled { opacity: .55; cursor: not-allowed; }\n.dsh-search-config :focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; }\n.dsh-search-hint { margin: 0; color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.6; }\n.dsh-search-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }\n.dsh-search-secret { border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 20px; }\n.dsh-search-secret .dsh-search-hint { margin-top: 8px; }\n.dsh-search-config fieldset { margin-bottom: 16px; }\n.dsh-search-config legend { padding: 16px 0; font-weight: 600; }\n.dsh-search-config textarea { resize: vertical; }\n.dsh-search-wide { grid-column: 1 / -1; }\n.dsh-search-secret h3 { margin: 0 0 12px; font-size: 15px; }\n";
286
335
 
287
336
  // src/client/index.ts
288
337
  var inject = ["slots", "locale", "remote", "remote.credentials", "configForms"];
@@ -319,7 +368,11 @@ function apply(ctx) {
319
368
  const form = ctx.configForms.get("dsh-search-plugin");
320
369
  const refresh = () => {
321
370
  const value = form.getSnapshot().value;
322
- if (value) describeCredential(typeof value.credentialRef === "string" ? value.credentialRef : "DSH_CODEX_API_KEY");
371
+ if (value) {
372
+ const deepseek = value.protocol === "deepseek-search-api";
373
+ const ref = deepseek ? value.deepseekCredentialRef : value.credentialRef;
374
+ describeCredential(typeof ref === "string" ? ref : deepseek ? "DEEPSEEK_API_KEY" : "DSH_CODEX_API_KEY");
375
+ }
323
376
  };
324
377
  ctx.effect(() => form.subscribe(refresh));
325
378
  refresh();
package/lib/connection.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
2
2
  import { WebError } from '@deepseek-ai/dsh-web';
3
- import { CodexSearchProvider } from './provider.js';
3
+ import { searchProtocols } from './protocols.js';
4
4
  function fail(message) {
5
5
  throw new WebError(`Web search: ${message}`, 'CODEX_CONFIGURATION_ERROR');
6
6
  }
@@ -79,8 +79,8 @@ export class ConfiguredSearchProvider {
79
79
  }
80
80
  async search(request, signal) {
81
81
  signal?.throwIfAborted();
82
- const resolved = await this.resolve();
82
+ const provider = await searchProtocols[this.config.protocol.get()](this.ctx, this.config, () => this.resolve());
83
83
  signal?.throwIfAborted();
84
- return new CodexSearchProvider(resolved).search(request, signal);
84
+ return provider.search(request, signal);
85
85
  }
86
86
  }
package/lib/fetch.js ADDED
@@ -0,0 +1,133 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+ import ipaddr from 'ipaddr.js';
4
+ import { validateCidr } from './cidr.js';
5
+ import { WebError } from '@deepseek-ai/dsh-web';
6
+ import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http';
7
+ const bareHost = (host) => host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
8
+ function parseCidr(rule) {
9
+ const [address, prefixText] = validateCidr(rule).trim().split('/');
10
+ const bytes = ipaddr.parse(address).toByteArray();
11
+ const prefix = Number(prefixText);
12
+ return { bytes, prefix };
13
+ }
14
+ /** Validate explicit IPv4/IPv6 network CIDRs; no implicit private ranges are added. */
15
+ export function validateCidrs(cidrs) {
16
+ try {
17
+ cidrs.forEach(parseCidr);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function matches(bytes, cidr) {
25
+ return bytes.length === cidr.bytes.length && bytes.every((byte, i) => {
26
+ const bits = Math.max(0, Math.min(8, cidr.prefix - i * 8));
27
+ return (byte >>> (8 - bits)) === (cidr.bytes[i] >>> (8 - bits));
28
+ });
29
+ }
30
+ function isEmbeddedV4(bytes) {
31
+ return bytes.length === 16 && bytes.slice(0, 10).every(b => b === 0)
32
+ && ((bytes[10] === 255 && bytes[11] === 255) || (bytes[10] === 0 && bytes[11] === 0));
33
+ }
34
+ function allows(address, cidrs) {
35
+ let ip = ipaddr.parse(address);
36
+ const bytes = ip.toByteArray();
37
+ if (isEmbeddedV4(bytes) && ip.range() !== 'loopback' && ip.range() !== 'unspecified') {
38
+ ip = ipaddr.parse(bytes.slice(12).join('.'));
39
+ }
40
+ return ip.range() === 'unicast' || cidrs.some(cidr => matches(ip.toByteArray(), cidr));
41
+ }
42
+ function blocked() {
43
+ throw new WebError('Destination is outside the configured CIDR allowlist', 'WEB_BLOCKED_URL');
44
+ }
45
+ /** Stop waiting for an OS DNS lookup when the official transport deadline expires. */
46
+ function lookupAnswers(host, signal, resolve) {
47
+ signal.throwIfAborted();
48
+ return new Promise((accept, reject) => {
49
+ const abort = () => reject(signal.reason);
50
+ signal.addEventListener('abort', abort, { once: true });
51
+ resolve(host, { all: true, order: 'verbatim' }).then(answers => {
52
+ if (answers.some(a => (a.family !== 4 && a.family !== 6) || isIP(a.address) !== a.family)) {
53
+ reject(new WebError('DNS returned an invalid IP address', 'WEB_PROVIDER_ERROR'));
54
+ }
55
+ else {
56
+ accept(answers);
57
+ }
58
+ }, reject).finally(() => signal.removeEventListener('abort', abort));
59
+ });
60
+ }
61
+ const NAT64_LENGTHS = [32, 40, 48, 56, 64, 96];
62
+ function embeddedV4(bytes, length) {
63
+ if (length === 96)
64
+ return bytes.slice(12).join('.');
65
+ if (bytes[8] !== 0)
66
+ return undefined;
67
+ const start = length / 8;
68
+ return [...bytes.slice(start, 8), ...bytes.slice(9, 9 + 4 - (8 - start))].join('.');
69
+ }
70
+ /** Validate the entire DNS answer set once; the official transport pins these addresses. */
71
+ export async function resolveAllowedAddresses(hostname, signal, allowedCidrs, resolve = lookup) {
72
+ signal.throwIfAborted();
73
+ const cidrs = allowedCidrs.map(parseCidr);
74
+ const host = bareHost(hostname);
75
+ const family = isIP(host);
76
+ const answers = family ? [{ address: host, family: family }] : await lookupAnswers(host, signal, resolve);
77
+ if (!answers.length)
78
+ throw new WebError('DNS returned no addresses', 'WEB_PROVIDER_ERROR');
79
+ for (const answer of answers)
80
+ if (!allows(answer.address, cidrs))
81
+ blocked();
82
+ // RFC 7050: even a public-looking DNS64 prefix can embed a private IPv4 target.
83
+ if (answers.some(a => a.family === 6)) {
84
+ const discovery = await lookupAnswers('ipv4only.arpa', signal, resolve).catch((error) => {
85
+ // No DNS64 answer is normal on IPv4-only resolvers. Other failures stay visible.
86
+ if (!signal.aborted && (error.code === 'ENODATA' || error.code === 'ENOTFOUND'))
87
+ return [];
88
+ throw error;
89
+ });
90
+ for (const candidate of discovery.filter(a => a.family === 6)) {
91
+ const bytes = ipaddr.parse(candidate.address).toByteArray();
92
+ for (const length of NAT64_LENGTHS) {
93
+ if (!['192.0.0.170', '192.0.0.171'].includes(embeddedV4(bytes, length) ?? ''))
94
+ continue;
95
+ for (const answer of answers.filter(a => a.family === 6)) {
96
+ const target = ipaddr.parse(answer.address).toByteArray();
97
+ if (!bytes.slice(0, length / 8).every((b, i) => b === target[i]))
98
+ continue;
99
+ const translated = embeddedV4(target, length);
100
+ if (translated !== undefined && !allows(translated, cidrs))
101
+ blocked();
102
+ }
103
+ }
104
+ }
105
+ }
106
+ signal.throwIfAborted();
107
+ return answers;
108
+ }
109
+ /** Current settings are snapshotted once for each request, including redirect hops. */
110
+ export class ConfiguredFetchProvider {
111
+ read;
112
+ id = 'search-http';
113
+ constructor(read) {
114
+ this.read = read;
115
+ }
116
+ available() { return true; }
117
+ async fetch(request, signal) {
118
+ if (signal?.aborted)
119
+ throw new WebError('web fetch aborted', 'WEB_ABORTED');
120
+ const current = this.read();
121
+ const settings = { ...current, allowedCidrs: [...current.allowedCidrs] };
122
+ const cidrs = settings.allowedCidrs.map(parseCidr);
123
+ const url = URL.parse(request.url);
124
+ // The official proxy path skips resolution for public literals. Check compatible
125
+ // IPv6 too, which its classifier otherwise treats as public (e.g. ::7f00:1).
126
+ if (url && ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password) {
127
+ const host = bareHost(url.hostname);
128
+ if (isIP(host) && !allows(host, cidrs))
129
+ blocked();
130
+ }
131
+ return new HttpFetchProvider({ ...settings, maxBodyChars: settings.maxContentChars }, (host, abort) => resolveAllowedAddresses(host, abort, settings.allowedCidrs)).fetch(request, signal);
132
+ }
133
+ }
package/lib/index.js CHANGED
@@ -1,8 +1,18 @@
1
1
  import Schema from '@deepseek-ai/schemastery';
2
2
  import { ConfiguredSearchProvider } from './connection.js';
3
+ import { ConfiguredFetchProvider } from './fetch.js';
4
+ import { validateCidr } from './cidr.js';
3
5
  export const name = 'dsh-search-plugin';
4
6
  export const inject = ['web'];
5
7
  export const Config = Schema.object({
8
+ protocol: Schema.union(['codex-alpha', 'deepseek-search-api']).default('codex-alpha').volatile(),
9
+ deepseekEndpoint: Schema.string().pattern(/^https?:\/\/[^\s]+$/).default('https://api.deepseek.com/v1/tools').volatile(),
10
+ deepseekCredentialRef: Schema.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/).default('DEEPSEEK_API_KEY').volatile(),
11
+ deepseekCount: Schema.number().step(1).min(1).max(50).default(10).volatile(),
12
+ fetchAllowedCidrs: Schema.array(Schema.transform(Schema.string(), validateCidr, true)).default([]).volatile(),
13
+ fetchTimeoutMs: Schema.number().step(1).min(1).default(30000).volatile(),
14
+ fetchMaxResponseBytes: Schema.number().step(1).min(1).default(5000000).volatile(),
15
+ fetchMaxContentChars: Schema.number().step(1).min(1).default(100000).volatile(),
6
16
  authMode: Schema.union(['openai', 'credential']).default('openai').volatile(),
7
17
  openaiProvider: Schema.string().min(1).default('openai').volatile(),
8
18
  credentialRef: Schema.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/).default('DSH_CODEX_API_KEY').volatile(),
@@ -15,6 +25,11 @@ export const Config = Schema.object({
15
25
  /** Register the provider; Cordis owns its registration and optional settings policy. */
16
26
  export function apply(ctx, config) {
17
27
  ctx.web.registerSearchProvider(new ConfiguredSearchProvider(ctx, config));
28
+ ctx.web.registerFetchProvider(new ConfiguredFetchProvider(() => ({
29
+ allowedCidrs: [...config.fetchAllowedCidrs.get()], timeoutMs: config.fetchTimeoutMs.get(),
30
+ maxResponseBytes: config.fetchMaxResponseBytes.get(), maxContentChars: config.fetchMaxContentChars.get(),
31
+ maxRedirects: 5, userAgent: 'dsh-search-plugin',
32
+ })));
18
33
  ctx.inject(['settings'], child => {
19
34
  child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
20
35
  });
@@ -0,0 +1,15 @@
1
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
2
+ import { DeepSeekSearchApiProvider } from '@deepseek-ai/dsh-web-deepseek-api';
3
+ import { CodexSearchProvider } from './provider.js';
4
+ export const searchProtocols = {
5
+ 'codex-alpha': async (_ctx, _config, alpha) => new CodexSearchProvider(await alpha()),
6
+ 'deepseek-search-api': async (ctx, config) => {
7
+ const baseURL = config.deepseekEndpoint.get();
8
+ const count = config.deepseekCount.get();
9
+ const ref = credentialRef(config.deepseekCredentialRef.get());
10
+ return new DeepSeekSearchApiProvider(() => ({
11
+ baseURL, count, apiKeyEnv: ref,
12
+ resolveApiKey: async () => (await ctx.get('credentials')?.resolve(ref))?.value,
13
+ }));
14
+ },
15
+ };
@@ -0,0 +1,2 @@
1
+ /** Schemastery serializes this function for browser forms. Keep it closure-free. */
2
+ export declare function validateCidr(rule: string): string;
@@ -1,7 +1,7 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import { type WebSearchProvider, type WebSearchRequest, type WebSearchResult } from '@deepseek-ai/dsh-web';
3
3
  import type { Config } from './index.js';
4
- import { type AlphaSearchConfig } from './provider.js';
4
+ import type { AlphaSearchConfig } from './provider.js';
5
5
  /** Resolve configuration and credentials for each search, without retaining secrets between operations. */
6
6
  export declare class ConfiguredSearchProvider implements WebSearchProvider {
7
7
  private readonly ctx;
@@ -0,0 +1,23 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { type WebFetchProvider, type WebFetchRequest } from '@deepseek-ai/dsh-web';
3
+ import { type HttpFetchResolver } from '@deepseek-ai/dsh-web-fetch-http';
4
+ export interface FetchSettings {
5
+ allowedCidrs: readonly string[];
6
+ timeoutMs: number;
7
+ maxResponseBytes: number;
8
+ maxContentChars: number;
9
+ maxRedirects: number;
10
+ userAgent: string;
11
+ }
12
+ /** Validate explicit IPv4/IPv6 network CIDRs; no implicit private ranges are added. */
13
+ export declare function validateCidrs(cidrs: readonly string[]): boolean;
14
+ /** Validate the entire DNS answer set once; the official transport pins these addresses. */
15
+ export declare function resolveAllowedAddresses(hostname: string, signal: AbortSignal, allowedCidrs: readonly string[], resolve?: typeof lookup): ReturnType<HttpFetchResolver>;
16
+ /** Current settings are snapshotted once for each request, including redirect hops. */
17
+ export declare class ConfiguredFetchProvider implements WebFetchProvider {
18
+ private readonly read;
19
+ readonly id = "search-http";
20
+ constructor(read: () => FetchSettings);
21
+ available(): boolean;
22
+ fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<import("@deepseek-ai/dsh-web").WebFetchResult>;
23
+ }
@@ -4,6 +4,14 @@ export declare const name = "dsh-search-plugin";
4
4
  export declare const inject: string[];
5
5
  /** Live search settings. Credentials are resolved by reference for each operation. */
6
6
  export interface Config {
7
+ protocol: Volatile<'codex-alpha' | 'deepseek-search-api'>;
8
+ deepseekEndpoint: Volatile<string>;
9
+ deepseekCredentialRef: Volatile<string>;
10
+ deepseekCount: Volatile<number>;
11
+ fetchAllowedCidrs: Volatile<string[]>;
12
+ fetchTimeoutMs: Volatile<number>;
13
+ fetchMaxResponseBytes: Volatile<number>;
14
+ fetchMaxContentChars: Volatile<number>;
7
15
  authMode: Volatile<'openai' | 'credential'>;
8
16
  openaiProvider: Volatile<string>;
9
17
  credentialRef: Volatile<string>;
@@ -14,6 +22,14 @@ export interface Config {
14
22
  maxAnswerChars: Volatile<number>;
15
23
  }
16
24
  export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
25
+ protocol: Schema<"codex-alpha" | "deepseek-search-api", "codex-alpha" | "deepseek-search-api", "volatile-defined">;
26
+ deepseekEndpoint: Schema<string, string, "volatile-defined">;
27
+ deepseekCredentialRef: Schema<string, string, "volatile-defined">;
28
+ deepseekCount: Schema<number, number, "volatile-defined">;
29
+ fetchAllowedCidrs: Schema<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
30
+ fetchTimeoutMs: Schema<number, number, "volatile-defined">;
31
+ fetchMaxResponseBytes: Schema<number, number, "volatile-defined">;
32
+ fetchMaxContentChars: Schema<number, number, "volatile-defined">;
17
33
  authMode: Schema<"openai" | "credential", "openai" | "credential", "volatile-defined">;
18
34
  openaiProvider: Schema<string, string, "volatile-defined">;
19
35
  credentialRef: Schema<string, string, "volatile-defined">;
@@ -23,6 +39,14 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
23
39
  maxResponseBytes: Schema<number, number, "volatile-defined">;
24
40
  maxAnswerChars: Schema<number, number, "volatile-defined">;
25
41
  }>>, Schemastery.ObjectT<NoInfer<{
42
+ protocol: Schema<"codex-alpha" | "deepseek-search-api", "codex-alpha" | "deepseek-search-api", "volatile-defined">;
43
+ deepseekEndpoint: Schema<string, string, "volatile-defined">;
44
+ deepseekCredentialRef: Schema<string, string, "volatile-defined">;
45
+ deepseekCount: Schema<number, number, "volatile-defined">;
46
+ fetchAllowedCidrs: Schema<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
47
+ fetchTimeoutMs: Schema<number, number, "volatile-defined">;
48
+ fetchMaxResponseBytes: Schema<number, number, "volatile-defined">;
49
+ fetchMaxContentChars: Schema<number, number, "volatile-defined">;
26
50
  authMode: Schema<"openai" | "credential", "openai" | "credential", "volatile-defined">;
27
51
  openaiProvider: Schema<string, string, "volatile-defined">;
28
52
  credentialRef: Schema<string, string, "volatile-defined">;
@@ -0,0 +1,9 @@
1
+ /** Protocol factories are the extension point; each captures one operation's settings. */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import type { WebSearchProvider } from '@deepseek-ai/dsh-web';
4
+ import type { Config } from './index.js';
5
+ import { type AlphaSearchConfig } from './provider.js';
6
+ export type SearchProtocol = 'codex-alpha' | 'deepseek-search-api';
7
+ type Factory = (ctx: Context, config: Config, alpha: () => Promise<AlphaSearchConfig>) => Promise<WebSearchProvider>;
8
+ export declare const searchProtocols: Record<SearchProtocol, Factory>;
9
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-search-plugin",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/urzz/dsh-search-plugin.git"
@@ -16,10 +16,12 @@
16
16
  },
17
17
  "./client": "./lib/client.js",
18
18
  "./package.json": "./package.json",
19
- "./locale/*.json": "./locale/*.json"
19
+ "./locale/*.json": "./locale/*.json",
20
+ "./icon": "./icon.svg"
20
21
  },
21
22
  "files": [
22
23
  "lib",
24
+ "icon.svg",
23
25
  "locale/*.json",
24
26
  "cordis.patch.yml",
25
27
  "README.md"
@@ -50,12 +52,15 @@
50
52
  "prepack": "pnpm build"
51
53
  },
52
54
  "dependencies": {
53
- "@deepseek-ai/schemastery": "~3.18.4"
55
+ "@deepseek-ai/dsh-web-deepseek-api": "0.2.1-rc.4",
56
+ "@deepseek-ai/schemastery": "~3.18.4",
57
+ "ipaddr.js": "^2.5.0"
54
58
  },
55
59
  "peerDependencies": {
56
60
  "@deepseek-ai/cordis": "^4.0.0",
57
61
  "@deepseek-ai/dsh-credentials": "0.2.1-rc.4",
58
- "@deepseek-ai/dsh-web": "0.2.1-rc.4"
62
+ "@deepseek-ai/dsh-web": "0.2.1-rc.4",
63
+ "@deepseek-ai/dsh-web-fetch-http": "0.2.1-rc.4"
59
64
  },
60
65
  "devDependencies": {
61
66
  "@deepseek-ai/cordis": "^4.0.0",