dsh-rss 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/README.en.md CHANGED
@@ -1,12 +1,18 @@
1
1
  # dsh-rss
2
2
 
3
+ ![npm](https://img.shields.io/npm/v/dsh-rss) ![downloads](https://img.shields.io/npm/dm/dsh-rss) ![license](https://img.shields.io/github/license/STARDUSTLC666/dsh-rss) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-rss?style=social)
4
+
3
5
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
6
 
5
7
  DSH (DeepSeek Harness) plugin for RSS/Atom subscriptions: manage feeds, fetch and parse RSS 0.9x / 1.0 / 2.0 and Atom, with OPML bulk import/export, exposing seven model-facing tools.
6
8
 
7
9
  ## Compatibility
8
10
 
9
- Verified against `@deepseek-ai/dsh@0.1.1-rc.2` on 2026-08-26. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
11
+ Adapted to the tool execution contracts in `@deepseek-ai/dsh@0.1.3-alpha.1`. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
12
+
13
+ Fetches honor the Harness cancellation signal through DNS, response streaming, and cross-feed searches. Hostname preflight checks reject loopback, private, and link-local addresses by default, including redirect destinations. Set `allowPrivateNetwork: true` for trusted internal feeds.
14
+
15
+ `rss_opml_export.path` is relative to the calling session's workspace, with an existing parent directory. Absolute paths, traversal, and links outside the workspace are rejected; the result returns the absolute written path. File writes request Harness approval by default (`opmlWriteApproval: false` disables this gate). Omit `path` to return only OPML text.
10
16
 
11
17
  ## Installation
12
18
 
@@ -86,9 +92,9 @@ Most feeds are reachable directly; a few require a special proxy from your netwo
86
92
 
87
93
  ```bash
88
94
  pnpm install
89
- pnpm test # builds + 55 tests
95
+ pnpm test # build + offline tests with explicit fetch and DNS fixtures
90
96
  ```
91
97
 
92
98
  ## License
93
99
 
94
- MIT
100
+ MIT
package/README.md CHANGED
@@ -13,7 +13,11 @@ DSH(DeepSeek Harness)的 RSS/Atom 订阅工具插件:管理订阅源,抓
13
13
 
14
14
  ## 兼容性
15
15
 
16
- 在 `@deepseek-ai/dsh@0.1.1-rc.2` 上验证(2026-08-26)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
16
+ 按 `@deepseek-ai/dsh@0.1.3-alpha.1` 的工具执行接口适配。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
17
+
18
+ 抓取接收 Harness 的取消信号,取消 DNS、网络读取或跨源搜索时保留原始取消原因。默认预检域名解析结果并拒绝回环、私网和链路本地地址;每次重定向都重新校验。可信内网源可显式配置 `allowPrivateNetwork: true`。
19
+
20
+ `rss_opml_export.path` 必须相对本次调用的会话工作区,父目录须已存在;工具拒绝绝对路径、目录穿越和指向工作区外的链接,并返回实际写入文件的绝对路径。文件写入默认通过 Harness 审批门,`opmlWriteApproval: false` 可关闭该门;省略 `path` 时仅返回 OPML 文本。
17
21
 
18
22
  ## 安装
19
23
 
@@ -95,11 +99,11 @@ rss_opml_import { opml: "<?xml version=\"1.0\"?>..." }
95
99
 
96
100
  ```bash
97
101
  pnpm install
98
- pnpm test # 构建 + 63 个测试
102
+ pnpm test # 构建 + 离线测试(fetch 与 DNS 均使用夹具)
99
103
  ```
100
104
 
101
105
  发布前门禁:危险模式扫描、manifest 自检、`pnpm audit --prod`、全量测试,以及全新 profile 的真实启动冒烟测试。
102
106
 
103
107
  ## License
104
108
 
105
- MIT
109
+ MIT
package/lib/config.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface RssConfig {
11
11
  maxBodyBytes?: number;
12
12
  userAgent?: string;
13
13
  feedsYaml?: string;
14
+ allowPrivateNetwork?: boolean;
15
+ opmlWriteApproval?: boolean;
14
16
  }
15
17
  /** 解析后的配置:所有字段都有值。 */
16
18
  export interface ResolvedRssConfig {
@@ -19,6 +21,8 @@ export interface ResolvedRssConfig {
19
21
  maxBodyBytes: number;
20
22
  userAgent: string;
21
23
  feedsYaml: string;
24
+ allowPrivateNetwork: boolean;
25
+ opmlWriteApproval: boolean;
22
26
  }
23
27
  /**
24
28
  * 解析并校验配置。
package/lib/config.js CHANGED
@@ -6,9 +6,30 @@
6
6
  */
7
7
  const DEFAULT_TIMEOUT_MS = 15000;
8
8
  const DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024;
9
- const DEFAULT_USER_AGENT = 'dsh-rss/0.2.0 (DeepSeek Harness RSS plugin)';
10
- function optionalString(value) {
11
- return typeof value === 'string' ? value : undefined;
9
+ const DEFAULT_USER_AGENT = 'dsh-rss/0.3.1 (DeepSeek Harness RSS plugin)';
10
+ function configRecord(config) {
11
+ if (config === undefined || config === null)
12
+ return {};
13
+ if (typeof config !== 'object' || Array.isArray(config)) {
14
+ throw new Error('dsh-rss config 必须是对象。');
15
+ }
16
+ return config;
17
+ }
18
+ function optionalString(config, key) {
19
+ const value = config[key];
20
+ if (value === undefined)
21
+ return undefined;
22
+ if (typeof value !== 'string')
23
+ throw new Error(key + ' 必须是字符串。');
24
+ return value;
25
+ }
26
+ function optionalBoolean(config, key, fallback) {
27
+ const value = config[key];
28
+ if (value === undefined)
29
+ return fallback;
30
+ if (typeof value !== 'boolean')
31
+ throw new Error(key + ' 必须是 true 或 false。');
32
+ return value;
12
33
  }
13
34
  /**
14
35
  * 解析并校验配置。
@@ -16,26 +37,37 @@ function optionalString(value) {
16
37
  * @throws 配置值非法时抛出中文错误。
17
38
  */
18
39
  export function resolveConfig(config) {
19
- const cfg = config ?? {};
20
- const proxyUrl = (optionalString(cfg.proxyUrl) ?? '').trim();
21
- if (proxyUrl !== '' && !/^https?:\/\//i.test(proxyUrl)) {
22
- throw new Error('proxyUrl 必须是 http(s):// 开头的地址,例如 http://127.0.0.1:7890。');
40
+ const cfg = configRecord(config);
41
+ const proxyUrl = (optionalString(cfg, 'proxyUrl') ?? '').trim();
42
+ if (proxyUrl !== '') {
43
+ try {
44
+ const parsed = new URL(proxyUrl);
45
+ if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.hostname === '')
46
+ throw new Error();
47
+ }
48
+ catch {
49
+ throw new Error('proxyUrl 必须是有效的 http(s) 地址,例如 http://127.0.0.1:7890。');
50
+ }
23
51
  }
24
52
  let timeoutMs = DEFAULT_TIMEOUT_MS;
25
53
  if (cfg.timeoutMs !== undefined) {
26
- if (typeof cfg.timeoutMs !== 'number' || !Number.isFinite(cfg.timeoutMs) || cfg.timeoutMs <= 0) {
27
- throw new Error('timeoutMs 必须是大于 0 的数字(毫秒),例如 15000。');
54
+ if (typeof cfg.timeoutMs !== 'number' || !Number.isInteger(cfg.timeoutMs) || cfg.timeoutMs < 1000 || cfg.timeoutMs > 120000) {
55
+ throw new Error('timeoutMs 必须是 1000 到 120000 之间的整数(毫秒),例如 15000。');
28
56
  }
29
- timeoutMs = Math.min(120000, Math.max(1000, Math.round(cfg.timeoutMs)));
57
+ timeoutMs = cfg.timeoutMs;
30
58
  }
31
59
  let maxBodyBytes = DEFAULT_MAX_BODY_BYTES;
32
60
  if (cfg.maxBodyBytes !== undefined) {
33
- if (typeof cfg.maxBodyBytes !== 'number' || !Number.isFinite(cfg.maxBodyBytes) || cfg.maxBodyBytes <= 0) {
34
- throw new Error('maxBodyBytes 必须是大于 0 的数字(字节),例如 5242880。');
61
+ if (typeof cfg.maxBodyBytes !== 'number' || !Number.isInteger(cfg.maxBodyBytes) || cfg.maxBodyBytes < 1024 * 1024 || cfg.maxBodyBytes > 50 * 1024 * 1024) {
62
+ throw new Error('maxBodyBytes 必须是 1048576 到 52428800 之间的整数(字节),例如 5242880。');
35
63
  }
36
- maxBodyBytes = Math.min(50 * 1024 * 1024, Math.max(1024 * 1024, Math.round(cfg.maxBodyBytes)));
64
+ maxBodyBytes = cfg.maxBodyBytes;
37
65
  }
38
- const userAgent = optionalString(cfg.userAgent) ?? DEFAULT_USER_AGENT;
39
- const feedsYaml = optionalString(cfg.feedsYaml) ?? '';
40
- return { proxyUrl, timeoutMs, maxBodyBytes, userAgent, feedsYaml };
66
+ const userAgent = (optionalString(cfg, 'userAgent') ?? DEFAULT_USER_AGENT).trim();
67
+ if (userAgent === '')
68
+ throw new Error('userAgent 不能为空字符串。');
69
+ const feedsYaml = optionalString(cfg, 'feedsYaml') ?? '';
70
+ const allowPrivateNetwork = optionalBoolean(cfg, 'allowPrivateNetwork', false);
71
+ const opmlWriteApproval = optionalBoolean(cfg, 'opmlWriteApproval', true);
72
+ return { proxyUrl, timeoutMs, maxBodyBytes, userAgent, feedsYaml, allowPrivateNetwork, opmlWriteApproval };
41
73
  }
@@ -0,0 +1,11 @@
1
+ /** Harness 0.1.3 传给工具实现的最小执行上下文。 */
2
+ export interface RssToolExecution {
3
+ readonly signal: AbortSignal;
4
+ readonly agent?: {
5
+ readonly session: {
6
+ readonly header: {
7
+ readonly cwd?: string;
8
+ };
9
+ };
10
+ };
11
+ }
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.d.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * dsh-rss —— RSS/Atom 订阅工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):把七个面向模型的工具(rss_list / rss_add / rss_remove /
5
- * rss_fetch / rss_check / rss_opml_export / rss_opml_import)注册进宿主进程的工具注册表,并把订阅列表接入 settings
6
- * 命名空间(dsh-rss.feedsYaml)。配置缺失时插件照常加载,工具在 execute 时才抛出
7
- * 带中文指引的错误。
4
+ * 插件导出 apply(ctx, config):把九个面向模型的 RSS 工具注册进宿主进程的工具注册表,
5
+ * 并把订阅列表接入 settings 命名空间(dsh-rss.feedsYaml)。
8
6
  *
9
7
  * @module dsh-rss
10
8
  */
@@ -13,6 +11,20 @@ import { type RssSettingsScope, type RssToolDefinition } from './tools.js';
13
11
  /** cordis 服务注入:apply 里要用 ctx.settings 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
14
12
  export declare const name = "rss";
15
13
  export declare const inject: string[];
14
+ type RssPreToolDecision = {
15
+ kind: 'allow';
16
+ } | {
17
+ kind: 'deny';
18
+ reason: string;
19
+ } | {
20
+ kind: 'ask';
21
+ reason?: string;
22
+ };
23
+ interface RssPendingToolExecution {
24
+ readonly name: string;
25
+ readonly arguments: unknown;
26
+ }
27
+ type RssPreExecuteListener = (exec: RssPendingToolExecution, next: () => Promise<RssPreToolDecision>) => Promise<RssPreToolDecision>;
16
28
  /** 插件所需的最小 ctx 面(社区插件不依赖宿主内部类型)。 */
17
29
  export interface RssPluginContext {
18
30
  settings: {
@@ -24,10 +36,11 @@ export interface RssPluginContext {
24
36
  tools: {
25
37
  register(definition: RssToolDefinition): () => void;
26
38
  };
27
- on?(event: string, listener: () => void): () => void;
39
+ on(event: 'tools/pre-execute', listener: RssPreExecuteListener): () => void;
40
+ on(event: 'dispose', listener: () => void): () => void;
28
41
  }
29
42
  /**
30
- * 插件入口:解析配置、注册 settings 命名空间与五个 RSS 工具。
43
+ * 插件入口:严格解析配置、注册 settings 命名空间、九个 RSS 工具与 OPML 写入审批门。
31
44
  * @param ctx - 宿主上下文(至少含 settings.register 与 tools.register)。
32
45
  * @param config - 插件配置(可缺省)。
33
46
  */
package/lib/index.js CHANGED
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * dsh-rss —— RSS/Atom 订阅工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):把七个面向模型的工具(rss_list / rss_add / rss_remove /
5
- * rss_fetch / rss_check / rss_opml_export / rss_opml_import)注册进宿主进程的工具注册表,并把订阅列表接入 settings
6
- * 命名空间(dsh-rss.feedsYaml)。配置缺失时插件照常加载,工具在 execute 时才抛出
7
- * 带中文指引的错误。
4
+ * 插件导出 apply(ctx, config):把九个面向模型的 RSS 工具注册进宿主进程的工具注册表,
5
+ * 并把订阅列表接入 settings 命名空间(dsh-rss.feedsYaml)。
8
6
  *
9
7
  * @module dsh-rss
10
8
  */
@@ -15,19 +13,12 @@ import { buildRssTools } from './tools.js';
15
13
  export const name = 'rss';
16
14
  export const inject = ['settings', 'tools'];
17
15
  /**
18
- * 插件入口:解析配置、注册 settings 命名空间与五个 RSS 工具。
16
+ * 插件入口:严格解析配置、注册 settings 命名空间、九个 RSS 工具与 OPML 写入审批门。
19
17
  * @param ctx - 宿主上下文(至少含 settings.register 与 tools.register)。
20
18
  * @param config - 插件配置(可缺省)。
21
19
  */
22
20
  export function apply(ctx, config) {
23
- let cfg;
24
- try {
25
- cfg = resolveConfig(config);
26
- }
27
- catch (error) {
28
- console.warn('[dsh-rss] ' + (error instanceof Error ? error.message : String(error)));
29
- cfg = resolveConfig(null);
30
- }
21
+ const cfg = resolveConfig(config);
31
22
  const settingsScope = ctx.settings.register(RSS_SETTINGS_NAMESPACE, RssSettingsSchema, {
32
23
  base: { feedsYaml: cfg.feedsYaml },
33
24
  applies: 'live',
@@ -36,12 +27,24 @@ export function apply(ctx, config) {
36
27
  for (const definition of buildRssTools(cfg, settingsScope)) {
37
28
  disposers.push(ctx.tools.register(definition));
38
29
  }
39
- if (typeof ctx.on === 'function') {
40
- ctx.on('dispose', () => {
41
- for (const dispose of disposers)
42
- dispose();
30
+ if (cfg.opmlWriteApproval) {
31
+ ctx.on('tools/pre-execute', async (exec, next) => {
32
+ if (exec.name !== 'rss_opml_export')
33
+ return next();
34
+ const args = typeof exec.arguments === 'object' && exec.arguments !== null
35
+ ? exec.arguments
36
+ : {};
37
+ const path = typeof args.path === 'string' ? args.path.trim() : '';
38
+ if (path === '')
39
+ return next();
40
+ const safeLabel = path.replace(/[\r\n]/g, ' ').slice(0, 200);
41
+ return { kind: 'ask', reason: '将 RSS 订阅列表导出并写入当前会话工作区文件:' + safeLabel };
43
42
  });
44
43
  }
44
+ ctx.on('dispose', () => {
45
+ for (const dispose of disposers)
46
+ dispose();
47
+ });
45
48
  }
46
49
  export * from './config.js';
47
50
  export * from './feeds.js';
@@ -0,0 +1,20 @@
1
+ import type { ResolvedRssConfig } from './config.js';
2
+ import type { RssToolExecution } from './execution.js';
3
+ /** 可注入的 fetch 实现(测试用假 fetch 替换真网络)。 */
4
+ export type FetchLike = (url: string, init?: {
5
+ headers?: Record<string, string>;
6
+ redirect?: string;
7
+ signal?: AbortSignal;
8
+ }) => Promise<Response>;
9
+ /** 可注入的 DNS 查询(测试不依赖公网 DNS)。 */
10
+ export type DnsLookupLike = (hostname: string) => Promise<readonly {
11
+ address: string;
12
+ family: number;
13
+ }[]>;
14
+ export declare function assertHttpUrl(url: string): void;
15
+ /** 仅允许可公开路由的地址,避免回环、私网、链路本地和保留地址。 */
16
+ export declare function isBlockedNetworkAddress(address: string): boolean;
17
+ export declare function fetchFeedXml(url: string, cfg: ResolvedRssConfig, exec: RssToolExecution | undefined, fetchImpl?: FetchLike, lookupImpl?: DnsLookupLike): Promise<{
18
+ xml: string;
19
+ finalUrl: string;
20
+ }>;
package/lib/network.js ADDED
@@ -0,0 +1,232 @@
1
+ /** RSS 网络边界:地址校验、重定向、响应大小与调用取消。 */
2
+ import { lookup } from 'node:dns/promises';
3
+ import { isIP } from 'node:net';
4
+ import { createProxyFetch } from './proxy-fetch.js';
5
+ const MAX_REDIRECTS = 5;
6
+ const PROXY_HINT = '。若该订阅源需要特殊代理(梯子)才能访问,请在 cordis.patch.yml 里给 dsh-rss 配置 proxyUrl(如 http://127.0.0.1:7890)后重启。';
7
+ function parseHttpUrl(url) {
8
+ let parsed;
9
+ try {
10
+ parsed = new URL(url);
11
+ }
12
+ catch {
13
+ throw new Error('订阅源地址必须是 http(s):// 开头的完整地址,例如 https://example.com/feed.xml。');
14
+ }
15
+ if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.hostname === '') {
16
+ throw new Error('订阅源地址必须是 http(s):// 开头的完整地址,例如 https://example.com/feed.xml。');
17
+ }
18
+ if (parsed.username !== '' || parsed.password !== '') {
19
+ throw new Error('订阅源地址不能包含用户名或密码。');
20
+ }
21
+ return parsed;
22
+ }
23
+ export function assertHttpUrl(url) {
24
+ parseHttpUrl(url);
25
+ }
26
+ function ipv4Bytes(address) {
27
+ if (isIP(address) !== 4)
28
+ return null;
29
+ return address.split('.').map((part) => Number(part));
30
+ }
31
+ function ipv6Words(address) {
32
+ let input = address.toLowerCase().replace(/^\[|\]$/g, '').split('%', 1)[0];
33
+ if (isIP(input) !== 6)
34
+ return null;
35
+ if (input.includes('.')) {
36
+ const lastColon = input.lastIndexOf(':');
37
+ const v4 = ipv4Bytes(input.slice(lastColon + 1));
38
+ if (v4 === null)
39
+ return null;
40
+ input = input.slice(0, lastColon) + ':' + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16);
41
+ }
42
+ const halves = input.split('::');
43
+ if (halves.length > 2)
44
+ return null;
45
+ const left = halves[0] === '' ? [] : halves[0].split(':');
46
+ const right = halves.length === 1 || halves[1] === '' ? [] : halves[1].split(':');
47
+ const zeroCount = halves.length === 2 ? 8 - left.length - right.length : 0;
48
+ if (zeroCount < 0 || (halves.length === 1 && left.length !== 8))
49
+ return null;
50
+ const words = [...left, ...Array.from({ length: zeroCount }, () => '0'), ...right].map((word) => Number.parseInt(word, 16));
51
+ return words.length === 8 && words.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffff) ? words : null;
52
+ }
53
+ /** 仅允许可公开路由的地址,避免回环、私网、链路本地和保留地址。 */
54
+ export function isBlockedNetworkAddress(address) {
55
+ const v4 = ipv4Bytes(address);
56
+ if (v4 !== null) {
57
+ const [a, b] = v4;
58
+ return a === 0
59
+ || a === 10
60
+ || a === 127
61
+ || (a === 100 && b >= 64 && b <= 127)
62
+ || (a === 169 && b === 254)
63
+ || (a === 172 && b >= 16 && b <= 31)
64
+ || (a === 192 && b === 168)
65
+ || (a === 198 && (b === 18 || b === 19))
66
+ || a >= 224;
67
+ }
68
+ const v6 = ipv6Words(address);
69
+ if (v6 === null)
70
+ return true;
71
+ const isUnspecified = v6.every((word) => word === 0);
72
+ const isLoopback = v6.slice(0, 7).every((word) => word === 0) && v6[7] === 1;
73
+ const isUniqueLocal = (v6[0] & 0xfe00) === 0xfc00;
74
+ const isLinkLocal = (v6[0] & 0xffc0) === 0xfe80;
75
+ const isMulticast = (v6[0] & 0xff00) === 0xff00;
76
+ if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal || isMulticast)
77
+ return true;
78
+ const isMappedV4 = v6.slice(0, 5).every((word) => word === 0) && v6[5] === 0xffff;
79
+ const isCompatibleV4 = v6.slice(0, 6).every((word) => word === 0);
80
+ if (isMappedV4 || isCompatibleV4) {
81
+ const mapped = [v6[6] >> 8, v6[6] & 0xff, v6[7] >> 8, v6[7] & 0xff].join('.');
82
+ return isBlockedNetworkAddress(mapped);
83
+ }
84
+ return false;
85
+ }
86
+ function mergedSignal(timeoutMs, callerSignal) {
87
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
88
+ return callerSignal === undefined ? timeoutSignal : AbortSignal.any([callerSignal, timeoutSignal]);
89
+ }
90
+ async function lookupWithSignal(hostname, lookupImpl, signal) {
91
+ signal.throwIfAborted();
92
+ return await new Promise((resolveLookup, reject) => {
93
+ const onAbort = () => reject(signal.reason);
94
+ signal.addEventListener('abort', onAbort, { once: true });
95
+ void Promise.resolve().then(() => lookupImpl(hostname)).then((addresses) => {
96
+ signal.removeEventListener('abort', onAbort);
97
+ resolveLookup(addresses);
98
+ }, (error) => {
99
+ signal.removeEventListener('abort', onAbort);
100
+ reject(error);
101
+ });
102
+ });
103
+ }
104
+ async function assertPublicUrl(url, cfg, lookupImpl, signal) {
105
+ if (cfg.allowPrivateNetwork)
106
+ return;
107
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
108
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
109
+ throw new Error('出于安全原因,默认禁止访问 localhost。若确需访问可信内网源,请显式配置 allowPrivateNetwork: true。');
110
+ }
111
+ if (isIP(hostname) !== 0) {
112
+ if (isBlockedNetworkAddress(hostname)) {
113
+ throw new Error('出于安全原因,默认禁止访问回环、私网、链路本地或保留地址:' + hostname + '。若确需访问可信内网源,请显式配置 allowPrivateNetwork: true。');
114
+ }
115
+ return;
116
+ }
117
+ let addresses;
118
+ try {
119
+ addresses = await lookupWithSignal(hostname, lookupImpl, signal);
120
+ }
121
+ catch (error) {
122
+ signal.throwIfAborted();
123
+ throw new Error('无法安全解析订阅源域名 ' + hostname + ':' + (error instanceof Error ? error.message : String(error)));
124
+ }
125
+ if (addresses.length === 0)
126
+ throw new Error('无法安全解析订阅源域名 ' + hostname + ':DNS 未返回地址。');
127
+ const blocked = addresses.find((entry) => isBlockedNetworkAddress(entry.address));
128
+ if (blocked !== undefined) {
129
+ throw new Error('出于安全原因,域名 ' + hostname + ' 解析到了非公网地址 ' + blocked.address + ',已拒绝访问。若确需访问可信内网源,请显式配置 allowPrivateNetwork: true。');
130
+ }
131
+ }
132
+ /** 构造默认 fetch:配置了 proxyUrl 时走插件级代理。 */
133
+ function makeFetch(cfg) {
134
+ if (cfg.proxyUrl === '')
135
+ return globalThis.fetch;
136
+ return createProxyFetch(cfg.proxyUrl);
137
+ }
138
+ /**
139
+ * 抓取订阅源 XML:校验地址、限时、限制体积、剥 BOM。
140
+ */
141
+ async function readResponseText(response, maxBodyBytes, signal) {
142
+ signal.throwIfAborted();
143
+ const declaredLength = Number(response.headers.get('content-length'));
144
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
145
+ await response.body?.cancel('response body exceeds maxBodyBytes');
146
+ throw new Error('订阅源内容超过 ' + maxBodyBytes + ' 字节上限,已停止读取。');
147
+ }
148
+ if (response.body === null)
149
+ return '';
150
+ const reader = response.body.getReader();
151
+ const onAbort = () => { void reader.cancel(signal.reason).catch(() => { }); };
152
+ signal.addEventListener('abort', onAbort, { once: true });
153
+ const decoder = new TextDecoder('utf-8', { fatal: false });
154
+ let byteLength = 0;
155
+ let text = '';
156
+ try {
157
+ while (true) {
158
+ signal.throwIfAborted();
159
+ const chunk = await reader.read();
160
+ signal.throwIfAborted();
161
+ if (chunk.done)
162
+ break;
163
+ byteLength += chunk.value.byteLength;
164
+ if (byteLength > maxBodyBytes) {
165
+ await reader.cancel('response body exceeds maxBodyBytes');
166
+ throw new Error('订阅源内容超过 ' + maxBodyBytes + ' 字节上限,已停止读取。');
167
+ }
168
+ text += decoder.decode(chunk.value, { stream: true });
169
+ }
170
+ return text + decoder.decode();
171
+ }
172
+ finally {
173
+ signal.removeEventListener('abort', onAbort);
174
+ reader.releaseLock();
175
+ }
176
+ }
177
+ export async function fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl = async (hostname) => await lookup(hostname, { all: true, verbatim: true })) {
178
+ let currentUrl = parseHttpUrl(url);
179
+ const fetcher = fetchImpl ?? makeFetch(cfg);
180
+ const signal = mergedSignal(cfg.timeoutMs, exec?.signal);
181
+ let response;
182
+ try {
183
+ for (let redirectCount = 0;; redirectCount++) {
184
+ signal.throwIfAborted();
185
+ await assertPublicUrl(currentUrl, cfg, lookupImpl, signal);
186
+ response = await fetcher(currentUrl.href, {
187
+ headers: {
188
+ 'user-agent': cfg.userAgent,
189
+ accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
190
+ },
191
+ redirect: 'manual',
192
+ signal,
193
+ });
194
+ signal.throwIfAborted();
195
+ if (![301, 302, 303, 307, 308].includes(response.status))
196
+ break;
197
+ const location = response.headers.get('location');
198
+ if (location === null)
199
+ break;
200
+ await response.body?.cancel('following validated redirect');
201
+ if (redirectCount >= MAX_REDIRECTS)
202
+ throw new Error('抓取失败:重定向次数超过 ' + MAX_REDIRECTS + ' 次上限。');
203
+ currentUrl = parseHttpUrl(new URL(location, currentUrl).href);
204
+ }
205
+ }
206
+ catch (error) {
207
+ signal.throwIfAborted();
208
+ if (error instanceof Error && (error.message.includes('出于安全原因') || error.message.includes('无法安全解析') || error.message.includes('重定向次数超过')))
209
+ throw error;
210
+ throw new Error('抓取失败:' + (error instanceof Error ? error.message : String(error)) + PROXY_HINT);
211
+ }
212
+ if (response === undefined)
213
+ throw new Error('抓取失败:未收到服务器响应。');
214
+ if (!response.ok) {
215
+ await response.body?.cancel('unsuccessful feed response');
216
+ throw new Error('抓取失败:服务器返回 HTTP ' + response.status + '。');
217
+ }
218
+ const reportedUrl = response.url === '' ? currentUrl : parseHttpUrl(response.url);
219
+ await assertPublicUrl(reportedUrl, cfg, lookupImpl, signal);
220
+ let xml;
221
+ try {
222
+ xml = await readResponseText(response, cfg.maxBodyBytes, signal);
223
+ }
224
+ catch (error) {
225
+ signal.throwIfAborted();
226
+ if (error instanceof Error && error.message.includes('字节上限'))
227
+ throw error;
228
+ throw new Error('读取订阅源内容失败:' + (error instanceof Error ? error.message : String(error)));
229
+ }
230
+ xml = xml.replace(/^\uFEFF/, '');
231
+ return { xml, finalUrl: reportedUrl.href };
232
+ }
package/lib/tools.d.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  * @module dsh-rss/tools
6
6
  */
7
7
  import { type ResolvedRssConfig } from './config.js';
8
+ import type { RssToolExecution } from './execution.js';
9
+ import { type FetchLike, type DnsLookupLike } from './network.js';
10
+ export type { RssToolExecution } from './execution.js';
11
+ export { isBlockedNetworkAddress, type FetchLike, type DnsLookupLike } from './network.js';
8
12
  /** 模型可见的内容块。 */
9
13
  export interface ContentBlock {
10
14
  type: 'text';
@@ -23,7 +27,7 @@ export interface RssToolDefinition {
23
27
  schema: Record<string, unknown>;
24
28
  render(args: unknown, value: unknown): ContentBlock[];
25
29
  };
26
- execute(args: unknown, exec: unknown): Promise<unknown>;
30
+ execute(args: unknown, exec?: RssToolExecution): Promise<unknown>;
27
31
  timeoutMs?: number;
28
32
  }
29
33
  /** 工具所需的 settings scope 最小面。 */
@@ -31,12 +35,6 @@ export interface RssSettingsScope {
31
35
  get(): unknown;
32
36
  update(patch: Record<string, unknown>): Promise<void>;
33
37
  }
34
- /** 可注入的 fetch 实现(测试用假 fetch 替换真网络)。 */
35
- export type FetchLike = (url: string, init?: {
36
- headers?: Record<string, string>;
37
- redirect?: string;
38
- signal?: AbortSignal;
39
- }) => Promise<Response>;
40
38
  /** 单个订阅源的增量游标:已知条目 id 列表与更新时间。 */
41
39
  export interface FeedCursor {
42
40
  guids: string[];
@@ -49,9 +47,10 @@ export declare function advanceCursor(prev: FeedCursor | undefined, entries: unk
49
47
  /** 用游标过滤出未见过的条目(无游标时全部视为新)。 */
50
48
  export declare function filterNewEntries(cursor: FeedCursor | undefined, entries: unknown[]): unknown[];
51
49
  /**
52
- * 构建七个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
50
+ * 构建九个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
53
51
  * @param config - 已解析配置。
54
52
  * @param settingsScope - dsh-rss settings scope(get/update)。
55
53
  * @param fetchImpl - 可选注入的 fetch(测试用),缺省按 proxyUrl 构造。
54
+ * @param lookupImpl - 可选注入的 DNS 查询(测试用),缺省使用系统 DNS。
56
55
  */
57
- export declare function buildRssTools(config: ResolvedRssConfig, settingsScope: RssSettingsScope, fetchImpl?: FetchLike): RssToolDefinition[];
56
+ export declare function buildRssTools(config: ResolvedRssConfig, settingsScope: RssSettingsScope, fetchImpl?: FetchLike, lookupImpl?: DnsLookupLike): RssToolDefinition[];
package/lib/tools.js CHANGED
@@ -8,9 +8,10 @@ import { writeFile } from 'node:fs/promises';
8
8
  import { addFeed, findFeedsByName, parseFeedsYaml, removeFeed, serializeFeeds } from './feeds.js';
9
9
  import { buildOpml, importOpmlFeeds, parseOpml } from './opml.js';
10
10
  import { parseFeed } from './parser.js';
11
- import { createProxyFetch } from './proxy-fetch.js';
11
+ import { assertHttpUrl, fetchFeedXml } from './network.js';
12
+ import { resolveOpmlOutputPath } from './workspace.js';
13
+ export { isBlockedNetworkAddress } from './network.js';
12
14
  const TIMEOUT_MS = 30000;
13
- const PROXY_HINT = '。若该订阅源需要特殊代理(梯子)才能访问,请在 cordis.patch.yml 里给 dsh-rss 配置 proxyUrl(如 http://127.0.0.1:7890)后重启。';
14
15
  /**
15
16
  * 编译作者 DSL 为原始 JSON Schema(正是 defineTool 存为 definition.parameters 的值)。
16
17
  * 原生线路会原样下发该值,原始 DSL 会被模型 API 拒绝(schema must be a JSON Schema)。
@@ -56,54 +57,6 @@ function clampedInteger(args, key, fallback, min, max) {
56
57
  return fallback;
57
58
  return Math.min(max, Math.max(min, value));
58
59
  }
59
- function assertHttpUrl(url) {
60
- if (!/^https?:\/\//i.test(url)) {
61
- throw new Error('订阅源地址必须是 http(s):// 开头的完整地址,例如 https://example.com/feed.xml。');
62
- }
63
- }
64
- /** 构造默认 fetch:配置了 proxyUrl 时走插件级代理。 */
65
- function makeFetch(cfg) {
66
- if (cfg.proxyUrl === '')
67
- return globalThis.fetch;
68
- return createProxyFetch(cfg.proxyUrl);
69
- }
70
- /**
71
- * 抓取订阅源 XML:校验地址、限时、限制体积、剥 BOM。
72
- */
73
- async function fetchFeedXml(url, cfg, fetchImpl) {
74
- assertHttpUrl(url);
75
- const fetcher = fetchImpl ?? makeFetch(cfg);
76
- let response;
77
- try {
78
- response = await fetcher(url, {
79
- headers: {
80
- 'user-agent': cfg.userAgent,
81
- accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
82
- },
83
- redirect: 'follow',
84
- signal: AbortSignal.timeout(cfg.timeoutMs),
85
- });
86
- }
87
- catch (error) {
88
- throw new Error('抓取失败:' + (error instanceof Error ? error.message : String(error)) + PROXY_HINT);
89
- }
90
- if (!response.ok) {
91
- throw new Error('抓取失败:服务器返回 HTTP ' + response.status + '。');
92
- }
93
- let bytes;
94
- try {
95
- bytes = new Uint8Array(await response.arrayBuffer());
96
- }
97
- catch (error) {
98
- throw new Error('读取订阅源内容失败:' + (error instanceof Error ? error.message : String(error)));
99
- }
100
- if (bytes.length > cfg.maxBodyBytes) {
101
- throw new Error('订阅源内容超过 ' + cfg.maxBodyBytes + ' 字节上限,已停止读取。');
102
- }
103
- let xml = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
104
- xml = xml.replace(/^\uFEFF/, '');
105
- return { xml, finalUrl: response.url || url };
106
- }
107
60
  // ---------- 输出 JSON Schema ----------
108
61
  const feedItemSchema = {
109
62
  type: 'object',
@@ -444,12 +397,13 @@ function renderHealth(_args, value) {
444
397
  }
445
398
  // ---------- 工具构建 ----------
446
399
  /**
447
- * 构建七个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
400
+ * 构建九个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
448
401
  * @param config - 已解析配置。
449
402
  * @param settingsScope - dsh-rss settings scope(get/update)。
450
403
  * @param fetchImpl - 可选注入的 fetch(测试用),缺省按 proxyUrl 构造。
404
+ * @param lookupImpl - 可选注入的 DNS 查询(测试用),缺省使用系统 DNS。
451
405
  */
452
- export function buildRssTools(config, settingsScope, fetchImpl) {
406
+ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
453
407
  const cfg = config;
454
408
  const getFeeds = () => {
455
409
  const value = settingsScope.get();
@@ -472,7 +426,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
472
426
  schema: listSchema,
473
427
  render: renderList,
474
428
  },
475
- async execute(rawArgs) {
429
+ async execute() {
476
430
  const feeds = getFeeds();
477
431
  return { count: feeds.length, feeds };
478
432
  },
@@ -490,20 +444,20 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
490
444
  schema: addSchema,
491
445
  render: renderAdd,
492
446
  },
493
- async execute(rawArgs) {
447
+ async execute(rawArgs, exec) {
494
448
  const args = asRecord(rawArgs);
495
449
  const url = requiredString(args, 'url', '订阅源地址');
496
450
  assertHttpUrl(url);
497
451
  const name = optionalString(args, 'name') ?? '';
498
452
  const category = optionalString(args, 'category') ?? '';
499
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, fetchImpl);
453
+ const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
500
454
  const parsed = parseFeed(xml, finalUrl);
501
455
  const effectiveName = name !== '' ? name : parsed.feed.title;
502
456
  const { feeds, added, existed } = addFeed(getFeeds(), url, effectiveName, category);
503
457
  await persistFeeds(feeds);
504
458
  return { added, existed, count: feeds.length, feedTitle: parsed.feed.title, feedType: parsed.feed.feedType };
505
459
  },
506
- timeoutMs: TIMEOUT_MS,
460
+ timeoutMs: cfg.timeoutMs,
507
461
  };
508
462
  const rssRemove = {
509
463
  name: 'rss_remove',
@@ -549,7 +503,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
549
503
  schema: fetchSchema,
550
504
  render: renderFetch,
551
505
  },
552
- async execute(rawArgs) {
506
+ async execute(rawArgs, exec) {
553
507
  const args = asRecord(rawArgs);
554
508
  let url = optionalString(args, 'url');
555
509
  const name = optionalString(args, 'name');
@@ -569,7 +523,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
569
523
  assertHttpUrl(url);
570
524
  const limit = clampedInteger(args, 'limit', 20, 1, 100);
571
525
  const incremental = args.incremental === true;
572
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, fetchImpl);
526
+ const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
573
527
  const parsed = parseFeed(xml, finalUrl);
574
528
  if (!incremental) {
575
529
  const entries = parsed.entries.slice(0, limit);
@@ -582,7 +536,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
582
536
  const entries = fresh.slice(0, limit);
583
537
  return { url: finalUrl, feed: parsed.feed, entries, truncated: fresh.length > limit, incremental: true, newCount: fresh.length };
584
538
  },
585
- timeoutMs: TIMEOUT_MS,
539
+ timeoutMs: cfg.timeoutMs,
586
540
  };
587
541
  const rssCheck = {
588
542
  name: 'rss_check',
@@ -594,11 +548,11 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
594
548
  schema: checkSchema,
595
549
  render: renderCheck,
596
550
  },
597
- async execute(rawArgs) {
551
+ async execute(rawArgs, exec) {
598
552
  const args = asRecord(rawArgs);
599
553
  const url = requiredString(args, 'url', '订阅源地址');
600
554
  assertHttpUrl(url);
601
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, fetchImpl);
555
+ const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
602
556
  const parsed = parseFeed(xml, finalUrl);
603
557
  return {
604
558
  ok: true,
@@ -609,7 +563,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
609
563
  description: parsed.feed.description,
610
564
  };
611
565
  },
612
- timeoutMs: TIMEOUT_MS,
566
+ timeoutMs: cfg.timeoutMs,
613
567
  };
614
568
  const rssOpmlExport = {
615
569
  name: 'rss_opml_export',
@@ -621,15 +575,19 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
621
575
  schema: opmlExportSchema,
622
576
  render: renderOpmlExport,
623
577
  },
624
- async execute(rawArgs) {
578
+ async execute(rawArgs, exec) {
625
579
  const args = asRecord(rawArgs);
626
580
  const feeds = getFeeds();
627
581
  const opml = buildOpml(feeds);
628
582
  const target = optionalString(args, 'path');
583
+ let outputPath = '';
629
584
  if (target !== undefined) {
630
- await writeFile(target, opml, 'utf8');
585
+ exec?.signal.throwIfAborted();
586
+ outputPath = await resolveOpmlOutputPath(target, exec);
587
+ exec?.signal.throwIfAborted();
588
+ await writeFile(outputPath, opml, { encoding: 'utf8', signal: exec?.signal });
631
589
  }
632
- return { opml, path: target ?? '', feedCount: feeds.length };
590
+ return { opml, path: outputPath, feedCount: feeds.length };
633
591
  },
634
592
  timeoutMs: TIMEOUT_MS,
635
593
  };
@@ -674,7 +632,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
674
632
  schema: searchSchema,
675
633
  render: renderSearch,
676
634
  },
677
- async execute(rawArgs) {
635
+ async execute(rawArgs, exec) {
678
636
  const args = asRecord(rawArgs);
679
637
  const query = requiredString(args, 'query', '搜索关键词').toLowerCase();
680
638
  const limit = clampedInteger(args, 'limit', 20, 1, 50);
@@ -698,7 +656,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
698
656
  if (hits.length >= limit)
699
657
  break;
700
658
  try {
701
- const { xml, finalUrl } = await fetchFeedXml(feed.url, cfg, fetchImpl);
659
+ const { xml, finalUrl } = await fetchFeedXml(feed.url, cfg, exec, fetchImpl, lookupImpl);
702
660
  const parsed = parseFeed(xml, finalUrl);
703
661
  for (const entry of parsed.entries) {
704
662
  const rec = asRecord(entry);
@@ -713,6 +671,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
713
671
  }
714
672
  }
715
673
  catch (error) {
674
+ exec?.signal.throwIfAborted();
716
675
  failedFeeds.push({ url: feed.url, name: feed.name, error: error instanceof Error ? error.message : String(error) });
717
676
  }
718
677
  }
@@ -0,0 +1,6 @@
1
+ import type { RssToolExecution } from './execution.js';
2
+ /**
3
+ * 把模型给出的 OPML 相对路径约束在本次调用的 session cwd 内。
4
+ * 同时解析父目录的真实路径并拒绝目标符号链接,避免通过链接越界。
5
+ */
6
+ export declare function resolveOpmlOutputPath(requestedPath: string, exec: RssToolExecution | undefined): Promise<string>;
@@ -0,0 +1,42 @@
1
+ /** 将 OPML 文件输出约束在工具调用所属的会话工作区。 */
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep, win32 } from 'node:path';
4
+ function isWithin(root, target) {
5
+ const rel = relative(root, target);
6
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
7
+ }
8
+ /**
9
+ * 把模型给出的 OPML 相对路径约束在本次调用的 session cwd 内。
10
+ * 同时解析父目录的真实路径并拒绝目标符号链接,避免通过链接越界。
11
+ */
12
+ export async function resolveOpmlOutputPath(requestedPath, exec) {
13
+ if (isAbsolute(requestedPath) || win32.isAbsolute(requestedPath)) {
14
+ throw new Error('OPML 输出 path 必须是相对当前会话工作区的路径,不能使用绝对路径。');
15
+ }
16
+ const sessionCwd = exec?.agent?.session.header.cwd;
17
+ if (typeof sessionCwd !== 'string' || sessionCwd.trim() === '') {
18
+ throw new Error('无法确定本次调用的 session cwd,已拒绝写入 OPML 文件。');
19
+ }
20
+ const lexicalRoot = resolve(sessionCwd);
21
+ const lexicalTarget = resolve(lexicalRoot, requestedPath);
22
+ if (!isWithin(lexicalRoot, lexicalTarget)) {
23
+ throw new Error('OPML 输出 path 不能越过当前会话工作区。');
24
+ }
25
+ const canonicalRoot = await realpath(lexicalRoot);
26
+ const canonicalParent = await realpath(dirname(lexicalTarget));
27
+ const canonicalTarget = resolve(canonicalParent, basename(lexicalTarget));
28
+ if (!isWithin(canonicalRoot, canonicalTarget)) {
29
+ throw new Error('OPML 输出 path 通过符号链接越过了当前会话工作区。');
30
+ }
31
+ try {
32
+ const stat = await lstat(lexicalTarget);
33
+ if (stat.isSymbolicLink())
34
+ throw new Error('OPML 输出目标不能是符号链接。');
35
+ }
36
+ catch (error) {
37
+ const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
38
+ if (code !== 'ENOENT')
39
+ throw error;
40
+ }
41
+ return canonicalTarget;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rss",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DSH RSS 订阅工具插件:管理订阅源、OPML 导入导出、抓取解析 RSS/Atom 并保留正文(rss_list / rss_add / rss_remove / rss_fetch / rss_check / rss_opml_export / rss_opml_import)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",