@jayyuen66/dsh-plugin-shared 0.1.0 → 0.1.2

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/dist/trust.js CHANGED
@@ -1,122 +1 @@
1
- import { sendJson } from "./http.js";
2
- import os from "node:os";
3
- //#region lib/trust.ts
4
- /** 回环主机名的两个字面量(IPv6 在 WHATWG URL 里**带方括号**)。 */
5
- const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "[::1]"]);
6
- /** 唯一允许通过的 `sec-fetch-site` 取值(缺失=非浏览器,走第 1 条分歧的本地面)。 */
7
- const SAME_SITE_ALLOW = /* @__PURE__ */ new Set(["same-origin", "none"]);
8
- /** 站点级拒绝的响应文案(沿用 `lib/http.ts` 既有那句,统一口径时就少改一处断言)。 */
9
- const CROSS_ORIGIN_TEXT = "cross-origin request rejected";
10
- /** Host 权威不是本机可信地址时的响应文案。 */
11
- const UNTRUSTED_HOST_TEXT = "untrusted host authority";
12
- /** Origin 那条腿的结论(三形共用:沙箱 `null` 源、Origin 解析失败、权威不相等)。 */
13
- const BAD_ORIGIN_VERDICT = "bad-origin";
14
- /** 两形之外没有第三形(Node 的 IncomingHttpHeaders 值是 `string | string[]`),故不做 typeof 自卫。 */
15
- function singleHeader(req, name) {
16
- const value = req.headers?.[name];
17
- if (value === void 0) return null;
18
- if (Array.isArray(value)) return value.length === 1 ? value.join("") : null;
19
- return value;
20
- }
21
- /** Host 头的可接受字形。WHATWG 解析会**静默吃掉** tab/CR/LF 并丢掉 `user@` 前缀,
22
- * 于是 `local\thost:8787` 与 `evil.com@localhost:8787` 都能被折成"回环权威"(本机实测
23
- * `new URL("http://" + "local\\thost:8787").hostname === "localhost"`)。浏览器发不出这些,
24
- * 但这一腿是本仓唯一挡得住 DNS 重绑定的判据 ⇒ 先按字形拒,再交给解析。 */
25
- const HOST_GLYPHS_RE = /^[A-Za-z0-9._\-[\]:%]+$/u;
26
- /** WHATWG 解析一个 authority(`host:port`);失败返回 null。IPv6 必须已带方括号。 */
27
- function authorityOf(authority) {
28
- if (!HOST_GLYPHS_RE.test(authority)) return null;
29
- try {
30
- return new URL(`http://${authority}`);
31
- } catch {
32
- return null;
33
- }
34
- }
35
- /** 回环判定:`localhost` / `[::1]` / 127/8 四段纯数字(逐条对齐官方 loopback-hostname.ts)。 */
36
- function isLoopbackHostname(hostname) {
37
- const lowered = hostname.toLowerCase();
38
- if (LOOPBACK_HOSTNAMES.has(lowered)) return true;
39
- const parts = lowered.split(".");
40
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/u.test(part) && Number(part) <= 255);
41
- }
42
- /** `os.networkInterfaces()` 的地址面:IPv6 加方括号(与 `URL.hostname` 同形),IPv4 原样。 */
43
- function interfaceAddresses() {
44
- const listed = [];
45
- for (const infos of Object.values(os.networkInterfaces())) for (const { family, address } of infos ?? []) listed.push(family === "IPv6" ? `[${address}]` : address);
46
- return listed;
47
- }
48
- /** Host 权威是不是本机可信地址:回环恒过;非回环只在**声明了非回环服务面**且该址确属本机网卡时过。 */
49
- function isAcceptedAuthority(hostname, opts) {
50
- if (isLoopbackHostname(hostname)) return true;
51
- if (opts.servingNonLoopback !== true) return false;
52
- return (opts.localInterfaceAddresses ?? interfaceAddresses()).includes(hostname);
53
- }
54
- /** `sec-fetch-site` 白名单(缺失等于非浏览器面,交 Host/Origin 两条腿判)。 */
55
- function siteIsSameOrigin(req) {
56
- const site = singleHeader(req, "sec-fetch-site");
57
- return site === null || SAME_SITE_ALLOW.has(site);
58
- }
59
- /**
60
- * 对端是不是回环。拿不到 socket(手搓请求的测试构造点、非 node:http 的适配层)时返回 null,
61
- * 表示"这一维未知",交调用侧按只看头部的旧口径处理。
62
- */
63
- function peerLoopback(req) {
64
- const raw = req.socket?.remoteAddress;
65
- if (typeof raw !== "string" || raw === "") return null;
66
- const address = raw.replaceAll(/^\[|\]$/gu, "").replace(/^::ffff:/iu, "");
67
- return isLoopbackHostname(address) || address === "::1";
68
- }
69
- /**
70
- * Host 腿:返回解析出的权威、`null`(没带 Host 且对端不是可判的非回环),或 `"rejected"`。
71
- *
72
- * 缺 Host 只在**非回环对端**时拒——浏览器走不到这条路(HTTP/1.1 强制 Host、HTTP/2 强制
73
- * `:authority`,且 Host/Origin/Sec-Fetch-* 由同一个 URL 上下文导出),能走到的只有本地裸 socket
74
- * 与 Node 客户端(Node 确实收无 Host 的请求)。
75
- */
76
- function authorityOfFrom(req, opts) {
77
- const rawHost = req.headers?.host;
78
- if (Array.isArray(rawHost) && rawHost.length !== 1) return "rejected";
79
- const host = singleHeader(req, "host");
80
- if (host === null) return peerLoopback(req) === false ? "rejected" : null;
81
- const parsed = authorityOf(host);
82
- if (parsed === null || !isAcceptedAuthority(parsed.hostname, opts)) return "rejected";
83
- return parsed;
84
- }
85
- /**
86
- * 一次判据:Host 权威 → sec-fetch-site(白名单)→ Origin 逐字比对。
87
- * 纯函数、不写响应;写响应的样板在 `guardTrust`。
88
- */
89
- function requestTrust(req, opts = {}) {
90
- const hostUrl = authorityOfFrom(req, opts);
91
- if (hostUrl === "rejected") return "untrusted-host";
92
- if (!siteIsSameOrigin(req)) return "cross-site";
93
- const origin = singleHeader(req, "origin");
94
- if (origin === null) return "trusted";
95
- if (origin === "null" || hostUrl === null) return BAD_ORIGIN_VERDICT;
96
- const originUrl = authorityOf(origin.replace(/^[a-z][a-z0-9+.-]*:\/\//iu, ""));
97
- if (originUrl === null) return BAD_ORIGIN_VERDICT;
98
- return originUrl.host === hostUrl.host ? "trusted" : BAD_ORIGIN_VERDICT;
99
- }
100
- /** 结论对应的响应文案(cross-site 与 bad-origin 同一句,两者都是"不是本站的浏览器请求")。 */
101
- function trustRejectionText(verdict) {
102
- return verdict === "untrusted-host" ? UNTRUSTED_HOST_TEXT : CROSS_ORIGIN_TEXT;
103
- }
104
- /**
105
- * handler 体的**第一条语句**该放的东西:非 trusted 即写一次 403 JSON 并返回 false。
106
- * 返回 false 时调用方必须立即 return,不得继续读 body 或产生副作用。
107
- */
108
- function guardTrust(req, res, opts = {}) {
109
- const verdict = requestTrust(req, opts);
110
- if (verdict === "trusted") return true;
111
- if (res.headersSent || res.writableEnded) {
112
- console.error("[shared/trust] rejecting after headers were sent:", verdict);
113
- return false;
114
- }
115
- sendJson(res, 403, {
116
- ok: false,
117
- error: trustRejectionText(verdict)
118
- });
119
- return false;
120
- }
121
- //#endregion
122
- export { guardTrust, requestTrust, trustRejectionText };
1
+ import{CROSS_ORIGIN_TEXT as e,sendJson as t}from"./http.js";import n from"node:os";const r=/* @__PURE__ */ new Set([`localhost`,`[::1]`]),i=/* @__PURE__ */ new Set([`same-origin`,`none`]),a=`bad-origin`;function o(e,t){let n=e.headers?.[t];return n===void 0?null:Array.isArray(n)?n.length===1?n.join(``):null:n}const s=/^[A-Za-z0-9._\-[\]:%]+$/u;function c(e){if(!s.test(e))return null;try{return new URL(`http://${e}`)}catch{return null}}function l(e){let t=e.toLowerCase();if(r.has(t))return!0;let n=t.split(`.`);return n.length===4&&n[0]===`127`&&n.every(e=>/^\d{1,3}$/u.test(e)&&Number(e)<=255)}function u(){let e=[];for(let t of Object.values(n.networkInterfaces()))for(let{family:n,address:r}of t??[])e.push(n===`IPv6`?`[${r}]`:r);return e}function d(e,t){return l(e)?!0:t.servingNonLoopback===!0&&(t.localInterfaceAddresses??u()).includes(e)}function f(e){let t=o(e,`sec-fetch-site`);return t===null||i.has(t)}function p(e){let t=e.socket?.remoteAddress;if(typeof t!=`string`||t===``)return null;let n=t.replaceAll(/^\[|\]$/gu,``).replace(/^::ffff:/iu,``);return l(n)||n===`::1`}function m(e,t){let n=e.headers?.host;if(Array.isArray(n)&&n.length!==1)return`rejected`;let r=o(e,`host`);if(r===null)return p(e)===!1?`rejected`:null;let i=c(r);return i===null||!d(i.hostname,t)?`rejected`:i}function h(e,t={}){let n=m(e,t);if(n===`rejected`)return`untrusted-host`;if(!f(e))return`cross-site`;let r=o(e,`origin`);if(r===null)return`trusted`;if(r===`null`||n===null)return a;let i=c(r.replace(/^[a-z][a-z0-9+.-]*:\/\//iu,``));return i===null?a:i.host===n.host?`trusted`:a}function g(t){return t===`untrusted-host`?`untrusted host authority`:e}function _(e,n,r={}){let i=h(e,r);return i===`trusted`?!0:n.headersSent||n.writableEnded?(console.error(`[shared/trust] rejecting after headers were sent:`,i),!1):(t(n,403,{ok:!1,error:g(i)}),!1)}export{_ as guardTrust,h as requestTrust,g as trustRejectionText};
@@ -35,16 +35,35 @@ export interface OffJustification {
35
35
  */
36
36
  export declare const OFF_JUSTIFICATIONS: Record<string, OffJustification>;
37
37
  /**
38
- * 旧 `.oxlintrc.json` 里继承来、此次**取消豁免**且确实零代价的规则。
38
+ * 基线里**不设豁免**的规则清单:这些规则必须保持开启,谁要关掉就得先给出依据。
39
39
  *
40
- * 这里原来记着三条。"零代价"那句话是用 `oxlint --deny=<rule>` 量的,而 `--deny` 盖不住
41
- * override 层的 off(本文件头注释里有这条实测口径)——重测之后:`eslint/no-continue` 在
42
- * `scripts/` 有 53 条、`eslint/no-await-in-loop` 有 2 条,于是这两条按文体收窄回
43
- * `SCRIPTS_OVERRIDES`(TS 侧仍 0 条、仍生效),从这份"已作废"名单里撤掉。留在这儿的
44
- * 才是真正确认过零代价的。
40
+ * 判据是「按文体分别核命中数」。`oxlint --deny=<rule>` 量的是整体,而 `--deny` 盖不住
41
+ * override 层的 off(这条口径见本文件头),所以一条规则可以同时是「TS 侧零命中、保持开启」
42
+ * 与「`scripts/` 的无类型 .mjs 里有命中、按文体收窄」——`eslint/no-continue` 与
43
+ * `eslint/no-await-in-loop` 正是这一类,它们的收窄在 SCRIPTS_OVERRIDES 里,不在这里。
45
44
  */
46
45
  export declare const RETIRED_OFFS: string[];
47
46
  export declare const READONLY_SYNC_ALLOWLIST: string[];
47
+ /**
48
+ * 组装 `jsPlugins`:本包解析到的 sonarjs 入口在前,调用方自带的在后并去重。
49
+ *
50
+ * 为什么要有这一层而不是直接 `[resolveSonarjsEntry(), ...(jsPlugins ?? [])]`:
51
+ * 那写法无条件先求值,于是「调用方自带入口」这条官方逃生口在缺 sonarjs 时**用不了**——
52
+ * 抛错发生在调用方的入口被用上之前,报错信息给的补救办法照做必然再报同一条。
53
+ * 分支口径:
54
+ * - 调用方带了入口 → 解析失败就跳过(它显然自备了),成功则去重后前置;
55
+ * - 调用方没带 → 解析失败照旧抛那条可操作的信息(缺包是真故障,不该被静默)。
56
+ *
57
+ * 导出且把解析器做成入参,是为了「解析失败」那一支在**本仓**可测:本仓 devDependencies
58
+ * 装着 sonarjs,真实的解析路径永远成功,那两个分支就只能靠注桩走到(与 `LauncherLocator`、
59
+ * `ProjectKeyOptions.realpath` 同一套可注入面的取舍)。生产侧的 consumer 是
60
+ * `definePluginConfig`,故这不是「只被测试养着的导出」。
61
+ *
62
+ * @param callerPlugins - 调用方经 `jsPlugins` 传入的入口;undefined / 空数组均可
63
+ * @param resolve - sonarjs 入口解析器(生产用真实实现,测试注桩)
64
+ * @returns 交给 oxlint 的 jsPlugins 数组
65
+ */
66
+ export declare function resolveJsPlugins(callerPlugins: NonNullable<OxlintConfig["jsPlugins"]> | undefined, resolve?: () => string): NonNullable<OxlintConfig["jsPlugins"]>;
48
67
  export declare const BASE_RULES: RuleTable;
49
68
  /**
50
69
  * 用例文件(含 `test/*.ts` 夹具与 helper)共享的豁免:规模类,以及「桩件要跨官方名义类型」
@@ -1,16 +1,9 @@
1
1
  import type { ViteUserConfig } from "vitest/config";
2
2
  /**
3
- * 类型名按实测取(四轮才钉准,过程记在这里免得下次再猜):
4
- * - `import type { UserConfig } from "vitest/config"` ⇒ TS2305,5.0.2 不导出这个名字;
5
- * - `import type { UserConfig as ViteUserConfig }` 同样 TS2305 —— 它在 `vitest/config` 里的
6
- * **导出名就已经是 `ViteUserConfig`**(源上是 `export { UserConfig as ViteUserConfig } from 'vite'`),
7
- * 别名要写在被导入的那一侧,不能自己再造一遍;
8
- * - `Parameters<typeof defineConfig>[0]` ⇒ 合出来是重载联合 `ViteUserConfigExport`(函数类型),
9
- * 取 `.test` 直接 TS2339,并级联两条 `no-unsafe-*`;
10
- * - `TestUserConfig` 是 **test 块本身**的类型,不是配置文件对象类型,
11
- * 拿它当返回值会在 `exactOptionalPropertyTypes: true` 下报 TS2375。
12
- * 正确的那个是 `ViteUserConfig`:vitest 用模块声明合并给它加了 `test?: VitestInlineConfig`,
13
- * 也正是 `defineConfig(config: UserConfig): UserConfig` 那条重载吃的形状。
3
+ * 配置文件的返回形状。vitest 用模块声明合并给 `ViteUserConfig` 加了 `test?: VitestInlineConfig`,
4
+ * 而 `defineConfig` 吃的正是这个形状。别的名义都不对:`UserConfig` 在 `vitest/config` 里
5
+ * 就叫 `ViteUserConfig` 导出(再写一次别名等于没引到),`Parameters<typeof defineConfig>[0]`
6
+ * 合出来是重载联合(函数类型,取 `.test` 直接落空),`TestUserConfig` 是 test 块本身的类型。
14
7
  */
15
8
  type PackageConfig = ViteUserConfig;
16
9
  /** 覆盖率面与 test 块里真正因包而异的那一项。 */
@@ -21,9 +14,9 @@ export interface PackageVitestOptions {
21
14
  test?: PackageConfig["test"];
22
15
  }
23
16
  /**
24
- * 公共面:13 份配置里逐字相同的那部分,从今往后才真的只有一处。
25
- * `testTimeout: 20_000` 是 12 个包的共同值(vitest 默认 5000),shared 原先没写、
26
- * 现在继承这个值只会放宽不会收紧,故不会把任何已过用例变成不稳定。
17
+ * 公共面:各份配置里逐字相同的那部分,从今往后只有一处。
18
+ * `testTimeout: 20_000` 是各包共同值(vitest 默认 5000);继承它只会放宽不会收紧,
19
+ * 故不会把任何已过用例变成不稳定。
27
20
  */
28
21
  export declare function definePackageConfig(options: PackageVitestOptions): PackageConfig;
29
22
  export {};
@@ -1,2 +1,9 @@
1
- /** `Error` 取 `message`,其余一律 `String(error)`(`null` → `"null"`,对象 → 其 `toString()`)。 */
1
+ /**
2
+ * 把捕获到的值渲染成一段人读文本。
3
+ * `Error` 取 message(空串时回退 name)并接上 cause 链;`AggregateError` 附上成员;
4
+ * 非 Error 但带可读 message 属性的取该属性,其余按 `String()` 兜底。
5
+ * 全程不抛异常:敌意输入降级为占位文本。
6
+ * @param error - 捕获到的值(catch 里的 `unknown`)。
7
+ * @returns 可直接进界面通知或日志的文本。
8
+ */
2
9
  export declare function errorText(error: unknown): string;
@@ -1,9 +1,11 @@
1
1
  import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
2
2
  /**
3
- * 下面的 header 读取 helper 不只被真 `IncomingMessage` 调用:`test/trust*.test.ts` 手搓合成
4
- * 请求、以及非 node:http 的适配层都会传只带部分字段的对象。Node 的类型把 `headers` /
5
- * `socket` 都标成必存在,照抄那个形状会让类型面比现实更乐观 —— 运行时不得不写 `?.` 自卫,
6
- * 而那条自卫又会被类型感知规则判成冗余。所以这里如实声明可缺字段的入参类型。
3
+ * header 读取 helper 接受的请求形状:真 `IncomingMessage` 或只带部分字段的合成片段。
4
+ *
5
+ * Node 把 `headers` / `socket` 都标成必存在,照抄那个形状会让类型面比现实更乐观——运行时
6
+ * 不得不写 `?.` 自卫,而那条自卫又会被类型感知规则判成冗余。所以这里如实声明可缺字段。
7
+ * 仅覆盖**读头**所需的面:要消费请求体的函数(`readBody` / `guardBody`)仍收
8
+ * `IncomingMessage`,因为 `PartialRequest` 不保证可异步迭代。
7
9
  */
8
10
  export interface PartialRequest {
9
11
  readonly headers?: IncomingHttpHeaders;
@@ -12,28 +14,36 @@ export interface PartialRequest {
12
14
  } | null;
13
15
  readonly url?: string | undefined;
14
16
  }
15
- /** helper 接受的请求:真 `IncomingMessage` 或合成片段。 */
17
+ /** 只读头的 helper 接受的请求:真 `IncomingMessage` 或合成片段。 */
16
18
  export type HttpRequest = IncomingMessage | PartialRequest;
17
19
  /** 写 JSON 响应(no-store:端点都是运行时状态,禁缓存防陈旧)。
18
20
  * 已发头/已结束则静默跳过:同一响应被写两次会抛 ERR_HTTP_HEADERS_SENT,
19
- * 而端点多在 async 回调里,抛错变成 unhandled rejection 拖垮共享的 web host。 */
21
+ * 而端点多在 async 回调里,抛错变成 unhandled rejection 拖垮共享的 web host。
22
+ *
23
+ * **序列化先于写头**:循环引用与 BigInt 会让 JSON.stringify 抛错。若把它排在 writeHead
24
+ * 之后,头已发出而 end() 永不执行——响应就此挂住,客户端一直等到超时,调用方也再也
25
+ * 改不了状态码。先算出 body 就把这一支变回一个普通的同步异常。 */
20
26
  export declare function sendJson(res: ServerResponse, status: number, payload: unknown): void;
21
27
  /**
22
28
  * CORS 同源纵深防御:浏览器请求带 sec-fetch-site,非 same-origin/none 即拒绝。
23
29
  * 空头(curl/CLI)不拦——浏览器侧伪造不了同源判定,CLI 是本地可信调用面。
24
30
  */
25
- export declare function isCrossOrigin(req: IncomingMessage): boolean;
31
+ export declare function isCrossOrigin(req: HttpRequest): boolean;
26
32
  /** 从 req.url 读 query 参数(URLSearchParams 语义:+ 解码空格、尊重 ; 分隔等)。 */
27
- export declare function queryParam(req: IncomingMessage, name: string): string | null;
33
+ export declare function queryParam(req: HttpRequest, name: string): string | null;
28
34
  /** CSRF 校验:header 值非空且等于下发 token(sec-fetch-site 只防浏览器,可伪造)。 */
29
- export declare function checkCsrf(req: IncomingMessage, token: string, headerName: string): boolean;
30
- /** readBody 的失败原因:`too-large` 超限(413),`aborted` 流读取中断/坏流(400)。 */
35
+ export declare function checkCsrf(req: HttpRequest, token: string, headerName: string): boolean;
36
+ /**
37
+ * readBody 的失败原因:`too-large` 超限(413),`aborted` 流读取中断/坏流(400),
38
+ * `bad-budget` 是**调用方传错了 maxBytes**(负数、`NaN`、`Infinity`)——它不是请求的错,
39
+ * 而是端点自己的限额配置坏了,故单列一档而不是伪装成 413。
40
+ */
31
41
  export type BodyRead = {
32
42
  readonly ok: true;
33
43
  readonly text: string;
34
44
  } | {
35
45
  readonly ok: false;
36
- readonly reason: "too-large" | "aborted";
46
+ readonly reason: "too-large" | "aborted" | "bad-budget";
37
47
  };
38
48
  /**
39
49
  * 读 POST body(**按 UTF-8 字节**计上限;先查 content-length 再收流)。
@@ -44,12 +54,23 @@ export type BodyRead = {
44
54
  * 2. 只按解码后字符数设限,恶意 body 可在触顶前已占满内存;content-length
45
55
  * 预检让明显超限的请求根本不开始累积。
46
56
  * 超限/坏流都立即停止读取(for-await 提前 return 会 release 迭代器并销毁流)。
57
+ *
58
+ * `maxBytes` 必须是有限非负数。负数、`NaN`、`Infinity` 一律拒读而不是"当作没有限额":
59
+ * 任何数与 `NaN` 比较都是 false,所以 `bytes > NaN` 永不成立——一条坏预算会退化成
60
+ * 完全不设限,把本模块存在的理由(防超长输出占满内存)整个绕掉。
61
+ * 上限为 `0` 是合法配置,语义是"只收空 body"。
47
62
  */
48
63
  export declare function readBody(req: IncomingMessage, maxBytes: number): Promise<BodyRead>;
64
+ /**
65
+ * 浏览器来源不合法时的统一文案。导出是因为 `trust.ts` 的浏览器腿要给同一句话:
66
+ * 两处各写一份字面量,改一边就会出现"同一拒绝、两种文案",而消费方会照文案做断言。
67
+ */
68
+ export declare const CROSS_ORIGIN_TEXT = "cross-origin request rejected";
49
69
  /**
50
70
  * 一次性校验组合(通用 POST 端点样板):跨域拒绝 → CSRF 拒绝 → 读 body →
51
- * 413(超限)/400(流中断)。全过返回 body 字符串。缺 CSRF 规范(如 GET 或
52
- * 自持 token 端点)时传 skipCsrf=true。
71
+ * 413(超限)/400(流中断)/500(限额配置坏了)。全过返回 body 字符串。
72
+ * 缺 CSRF 规范的端点(GET、或自持 token 的端点)**省略 `csrf` 字段**即可——本函数没有
73
+ * `skipCsrf` 参数,旧注释写过,那是在描述一个并不存在的开关。
53
74
  */
54
75
  export declare function guardBody(req: IncomingMessage, res: ServerResponse, opts: {
55
76
  maxBytes: number;
@@ -1,5 +1,10 @@
1
1
  import type { ShellProcess } from "@deepseek-ai/dsh-shell";
2
2
  import type { JobOutcome } from "@deepseek-ai/dsh-jobs";
3
+ /**
4
+ * 官方结局类型在这里再出一个具名出口。`jobOutcomeOf` 的返回类型就是它,消费方把它写进自己的
5
+ * 签名(生产者回调、测试断言)时若没有具名出口,只能把结构重述一遍——而重述正是本包要收敛掉的。
6
+ */
7
+ export type { JobOutcome } from "@deepseek-ai/dsh-jobs";
3
8
  /** 映射只用得到的三个成员(不是整个 ShellProcess:生产者句柄的其余面与本函数无关)。 */
4
9
  export type SettledProcess = Pick<ShellProcess, "status" | "exitCode" | "signal">;
5
10
  /**
@@ -1,8 +1,27 @@
1
1
  /**
2
2
  * 反复保留后半行段直到 UTF-8 字节数 ≤ `maxBytes`;行数 ≤2 时停手(单条超限行原样保留,
3
3
  * 防死循环也防清空文件)。未超限直接返回入参**同一引用**,调用方可据此跳过写盘。
4
+ *
5
+ * **配额必须是有限数**(这一档由调用方保证,本函数不代为归一):
6
+ * - `0` 与负数**照样收缩**到 ≤2 行为止——「0 = 永不截断」是某消费方在**调用前**
7
+ * `maxBytes > 0` 那道触发策略的承诺,不是本函数的语义(见它自己的 jsonl-fuse 头注)。
8
+ * 把 0 当成「不裁」会把这个承诺写成一个假象。
9
+ * - `NaN` 与 `+Infinity` 让 `字节数 > maxBytes` 恒假 ⇒ 循环一次都不进,整份原样返回,
10
+ * 即**静默关掉收缩**。现存两个消费方都不会把这种值传进来:一个用固定常量作默认实参,
11
+ 另一个在调用前就把非有限值与负数归一成 0。但那道归一在**消费方**,本包并不强制——
12
+ 新增消费方时没有任何东西会提醒它。
13
+ *
14
+ * 为什么不改成 http.readBody 那样的 bad-budget 拒读:那边拒读是因为不设限等于放开内存
15
+ * 上限(请求体是攻击面),这边不设限只是「这次不裁」。抛错会被两个消费方各自的 catch
16
+ * 接走(一个上抛、一个内部吞),等于把一条清晰的预算错误变成一次静默跳过,排查方向
17
+ * 反而更差。形状不同的同一种错,各按各的失败模型处理。
18
+ *
19
+ * 两个消费方的具体调用点与配额归一位置记在 test/jsonl.test.ts 的同名用例里——本文件头
20
+ * 不能出现兄弟包标识符:那些名字会随 JSDoc 一起被 tsc 抄进 dist/types/*.d.ts 发出去,
21
+ * 让装了这个包的消费方以为存在一个并不存在的依赖(test/publish-manifest.test.ts 会红)。
22
+ *
4
23
  * @param text 整份 JSONL 文本
5
- * @param maxBytes 字节上限
24
+ * @param maxBytes 字节上限;调用方须保证是有限数
6
25
  * @returns 收缩后的文本
7
26
  */
8
27
  export declare function shrinkJsonlTail(text: string, maxBytes: number): string;
@@ -1,19 +1,39 @@
1
- /** 支持的界面/模型侧文案语言。 */
2
- export type Locale = "zh" | "en";
1
+ import type { BuiltInLocaleId } from "@deepseek-ai/dsh-client-locale";
2
+ /**
3
+ * 支持的界面/模型侧文案语言。官方件是唯一定义源:`@deepseek-ai/dsh-client-locale` 的
4
+ * `BuiltInLocaleId` 由它自己的 `LOCALE_IDS` 取下标得出,宿主哪天添一门语言时本包跟着走,
5
+ * 不需要有人记得回来改这里的字面量联合。
6
+ */
7
+ export type Locale = BuiltInLocaleId;
3
8
  /** 缺省语言。设置项未设或值不合语法时落到这里。 */
4
9
  export declare const DEFAULT_LOCALE: Locale;
5
10
  /**
6
- * 官方 `@deepseek-ai/dsh-client-locale` 在 Host settings 文档里拥有的命名空间与字段名
7
- * (其 `lib/index.js:5-7`)。这里是**抄常量不是引依赖**:那是宿主内部实现,没进公开
8
- * 依赖面,而两侧必须同名才能读到同一份偏好。
11
+ * 官方 `@deepseek-ai/dsh-client-locale` 拥有的 settings 命名空间与字段名。那两枚常量官方
12
+ * 也外销(同包的根入口),这里**不引它而是抄字面量**:引它就得把 client 包变成 host 半的运行期
13
+ * 依赖,而这两个值是纯字符串常量、不参与任何 client 行为。两侧必须逐字同名才读得到同一份偏好,
14
+ * 所以这条耦合是本包 README 的登记项:官方那两枚常量一旦改名,本包必须同步改。
9
15
  */
10
16
  export declare const LOCALE_SETTINGS_NAMESPACE = "locale";
11
17
  export declare const LOCALE_PREFERENCE_FIELD = "preference";
12
- /** 一个包的全部消息键值表(键名即语义,值是人读文案)。 */
13
- export interface MessagesCatalog<Messages> {
14
- readonly zh: Messages;
15
- readonly en: Messages;
16
- }
18
+ /**
19
+ * 一个包的全部消息键值表(键名即语义,值是人读文案)。
20
+ *
21
+ * 键集绑 `Locale` 而不是手写 `{ zh; en }`:本文件的 `Locale` 已改成官方 `BuiltInLocaleId`
22
+ * 的别名,宿主哪天加一门语言,这两处必须**一起**响——只改 `Locale` 而把键集钉死成
23
+ * `{ zh; en }`,新增那门语言会静默取不到文案(`messagesFor` 的 else 分支落回 zh)。
24
+ * `Record` 与原 interface 在当前 `Locale`(恰为 `"zh"|"en"`)下结构等价,消费方字面量
25
+ * 无需改动;差别只在宿主扩宽那一刻,从「静默回落」变成「编译报错」。
26
+ *
27
+ * 值域收在 string 上,用**同构映射类型**而不是 `Record<string, string>`:实测(tsc)消费方
28
+ * 全部用 `interface Messages { readonly xxx: string }` 声明,而 interface **拿不到**隐式索引
29
+ * 签名——写成 `Messages extends Record<string, string>` 会让全部消费方 TS2322,那是外溢到
30
+ * 9 个包的破坏性变更。映射类型对 interface 与 type 都成立(test/type-guards.test.ts 钉住)。
31
+ * 收窄前实测到的缺口:`messagesFor({ zh: { g: 1 }, en: { g: "x" } }, "zh")` 零错误通过,即文件头
32
+ * 「值不是字符串都在 tsc 阶段就红」原本只在消费方自己声明 `Messages` 时才成立。
33
+ */
34
+ export type MessagesCatalog<Messages> = Record<Locale, {
35
+ readonly [Key in keyof Messages]: string;
36
+ }>;
17
37
  /**
18
38
  * 把设置里的原始值归一成受支持的 locale:按**主语言子标签**判定(`en-US`、`zh-Hans-CN`
19
39
  * 都落位),与 client 半「先完整标签、再主语言子标签」的规则同源;判不出来一律退
@@ -32,8 +52,15 @@ export declare function resolveLocale(raw: unknown): Locale;
32
52
  export declare function resolveLocalePreference(described: unknown): Locale;
33
53
  /**
34
54
  * 取某个语言下的消息表。
35
- * @param catalog - 两语齐全的消息表。
55
+ *
56
+ * 按 `locale` 直接索引而不是 `locale === "en" ? … : …` 的二元分派:后者的 else 分支
57
+ * 把「非 en 即 zh」这个假设写死在运行期,`Locale` 扩宽时它**照样编译通过**,新增那门
58
+ * 语言静默拿不到文案。与 `MessagesCatalog` 的 `Record<Locale, …>` 合起来,
59
+ * 扩宽会在 `catalog[locale]` 这一处落成 TS2322。
60
+ * @param catalog - 各语言齐全的消息表。
36
61
  * @param locale - 已归一的语言。
37
62
  * @returns 对应语言的消息表(引用返回,不做拷贝)。
38
63
  */
39
- export declare function messagesFor<Messages>(catalog: MessagesCatalog<Messages>, locale: Locale): Messages;
64
+ export declare function messagesFor<Messages>(catalog: MessagesCatalog<Messages>, locale: Locale): {
65
+ readonly [Key in keyof Messages]: string;
66
+ };
@@ -1,6 +1,11 @@
1
- /** 可注入面:realpath 是同步 fs 调用,测试替换它来观察软链归一与失败兜底两条臂。 */
1
+ /**
2
+ * 可注入面。`realpath` 是同步 fs 调用,`sep` 决定**整条**路径语义臂:分隔符字形与 `resolve`
3
+ * 用的那套解析都由它选。两者注入是为了让两条臂在任何 runner 上都可判定——`path.sep` 在模块
4
+ * 装载时就定死了,而只注入字形会让非 POSIX runner 上的 POSIX 用例拿到原生解析结果,键随之漂移。
5
+ */
2
6
  export interface ProjectKeyOptions {
3
7
  realpath?: (target: string) => string;
8
+ sep?: string;
4
9
  }
5
10
  /**
6
11
  * cwd → 项目桶键。非字符串 / trim 后为空 / 归一后落到根目录 → `"default"`
@@ -1,4 +1,23 @@
1
1
  /** 对象类型守卫:非 `null` 的 `object` 且非数组(`Record` 索引签名投影的基础)。 */
2
2
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
3
- /** 从 `unknown` 投影单个字段;非 `Record` → `undefined`。 */
3
+ /**
4
+ * 从 `unknown` 投影单个字段;非 `Record` → `undefined`。
5
+ *
6
+ * ⚠ **取值走原型链,这是承重的,不是疏忽**:`value[key]` 不做 `hasOwn` 判定,所以原型上的
7
+ * 方法照样取得到。消费方正靠这一条做**能力探测**——cordis 服务对象的方法挂在原型上、不是
8
+ * 自有键,形如 `typeof fieldOf(settings, "describe") === "function"`、
9
+ * `hasMethods(fieldOf(value, "timer"), ["timeout"])`、`typeof fieldOf(session,
10
+ * "snapshotEvents") === "function"`(三处消费包的调用点与行号记在 test/record.test.ts)。
11
+ * 改成 `Object.hasOwn` 会让上面几处**一律判否**,那些包随即认不出合法的 cordis 服务——
12
+ * 而这类误判的表现是「能力探测静默返回 false」,不是报错,极难往这里归因。
13
+ *
14
+ * 代价是明写在这里:键名取到的是原型成员时(`fieldOf({}, "__proto__")` 交回
15
+ * `Object.prototype`、`fieldOf({}, "constructor")` 交回 `Object`)。这是本函数的取舍,
16
+ * 不是让消费方猜的属性——现存调用点的键名全是字面量,没有一处来自数据。
17
+ * test/record.test.ts 两侧都钉住了:原型方法取得到(承重面),自有键优先于原型键。
18
+ *
19
+ * ⚠ 本段刻意不点名兄弟包:**声明上的 JSDoc 会被 tsc 抄进 dist/types/record.d.ts 一起
20
+ * 发出去**,包名会让装了这个包的消费方以为存在一个并不存在的依赖
21
+ *(test/publish-manifest.test.ts 的产物面判据会红)。要写具体调用点就写在 test/ 里。
22
+ */
4
23
  export declare function fieldOf(value: unknown, key: string): unknown;
@@ -1,77 +1,106 @@
1
1
  import type { SessionEvent } from "@deepseek-ai/dsh-session";
2
2
  export type { SessionEvent } from "@deepseek-ai/dsh-session";
3
3
  export type { PtcDispatchEventData, PtcDispatchStartEventData } from "@deepseek-ai/dsh-tools/types";
4
- /** 一条 tool/call 的记账记录。 */
4
+ /**
5
+ * 一条 tool/call 的记账记录。
6
+ *
7
+ * 字段一律 `readonly`:台账一旦入表就只作证据读,账目被就地改写会让证据链失去意义。
8
+ */
5
9
  export interface ToolCallRecord {
6
- /** 调用名(edit/write/str_replace_editor/read/grep/…)。 */
7
- name: string | undefined;
10
+ /**
11
+ * 调用名(edit/write/str_replace_editor/read/grep/…)——本模块的**分桶键**,必选。
12
+ *
13
+ * 官方把它声明成必选 `string`(`tool/call` 见 packages/core/session/src/types.ts:361,
14
+ * PtcDispatchStartEventData.name 见 packages/core/tools/src/types.ts:15),但运行期拦不住
15
+ * (依据见 usableName)。所以可空性在**扫描层**就处理掉了,不带到类型面:callRecordOf 遇到
16
+ * 缺可用 name 的调用类事件整条跳过,与 hasObjectData 同一口径。
17
+ *
18
+ * 为什么不把过滤留给消费方:下游分类全建立在 name 上——editPathOf 的 str_replace_editor
19
+ * 只读判定、门禁侧的 EDIT_TOOLS 与 refSearchTools 名单——而 editPathOf 的兜底分支会把任何
20
+ * 非 str_replace_editor 的调用一律判成 write。无名调用一旦进了台账,只要它的 arguments
21
+ * 里有 file_path 就算成写操作。现在是靠三个消费方各自记得先判一次才没出事——那是同一条
22
+ * 不变式的三份手抄,漏一份就出事。
23
+ *
24
+ * 与 callId 的差别是**为什么一个必选、一个可空**:callId 由 usableCallId 兜底,且它有
25
+ * nc:<seq> 兜底席位(消费方的 noCallId 桶专门收那种行),缺了不影响归类;name 一旦缺了
26
+ * 就没法分桶、没有兜底席位,只能在入口滤掉。
27
+ */
28
+ readonly name: string;
8
29
  /** callId:tool/result 串联键;缺失时由调用方按 seq 造 `nc:` 键。 */
9
- callId: string | undefined;
30
+ readonly callId: string | undefined;
10
31
  /** 解析后的参数对象(解析失败/非对象时为空对象,配合 badArguments 使用)。 */
11
- arguments: Record<string, unknown>;
32
+ readonly arguments: Record<string, unknown>;
12
33
  /**
13
34
  * arguments 原始值无法解析为对象(JSON 字符串非法、或非字符串非对象)。
14
- * 调用方据此保留各自语义:quality-gate 判 EDIT_SKIP、danger-guard 跳过记账——
15
- * 共享层只标记不决策,避免把两插件的"坏参是否记账"策略悄悄统一。
35
+ * 调用方据此保留各自语义——有的把坏参判成"这次编辑不记账",有的仍记账但路径留空。
36
+ * 共享层只标记不决策:把两档策略统一掉,等于在其中一侧悄悄改掉门禁的松紧。
16
37
  */
17
- badArguments: boolean;
38
+ readonly badArguments: boolean;
18
39
  /**
19
40
  * 在**入参数组**中的下标(归因/游标用),不是官方事件自带的 `SessionSeq`:消费方按
20
41
  * 窗口切片读取事件流,切片下标才是它们游标语义里的那个「序」。
21
42
  */
22
- seq: number;
43
+ readonly seq: number;
23
44
  }
24
- /** 一条 tool/result 的成败。 */
45
+ /** 一条 tool/result 的成败。同样只读。 */
25
46
  export interface ToolResultRecord {
26
- callId: string | undefined;
27
- isError: boolean;
28
- seq: number;
47
+ readonly callId: string | undefined;
48
+ readonly isError: boolean;
49
+ readonly seq: number;
29
50
  }
30
- /**
31
- * tool/result 的 isError 判定(官方 v4:成败位在 message 级、类型为 `?: boolean`;
32
- * 字段缺失按成功)。非 tool/result 事件一律按成功——PTC 子调度的成败位在 data 顶层,
33
- * 由 `resultRecordOf` 的对应分支处理,不从这儿走。
34
- */
35
- export declare function resultIsError(event: SessionEvent): boolean;
36
- /** 解析 tool/call 的 arguments:string JSON 文本或已解析对象;非法/缺失 → 空对象。 */
51
+ /** 解析 tool/call 的 arguments:string JSON 文本或已解析对象;非法/缺失 → 空对象。
52
+ *
53
+ * **这一对原语是公开面,不是残留。** 自建折叠单元的消费方(`toolEventRowsOf` 的使用者)
54
+ * 必须自己再读一遍 arguments 字段;若它们只能走 `callRecordOf` 的成片结果,就无法在
55
+ * "自己那一层"复现同一套判据——而本模块存在的理由正是让字段读取只有一份。 */
37
56
  export declare function parseToolArguments(raw: unknown): Record<string, unknown>;
38
57
  /**
39
58
  * arguments 是否「无法参与记账」:仅当字符串 JSON **语法非法**(JSON.parse 抛错)。
40
- * 合法 JSON 非对象("42")、undefined/数字/对象等一律不标——quality-gate 原实现
41
- * 对 JSON.parse 抛错判 EDIT_SKIP,但对"解析成功却非对象"仍记账(path=undefined),
42
- * 本标记必须精确复用那一个语义点,不能把"非对象"也划进坏参。
59
+ * 合法 JSON 非对象(`"42"`)、undefined/数字/对象等一律不标——既有消费方对 JSON.parse
60
+ * 抛错判"不记账",但对"解析成功却非对象"仍记账(路径留空)。本标记必须精确复用那一个
61
+ * 语义点,不能把"非对象"也划进坏参:多划一刀就等于替消费方改了门禁的松紧。
43
62
  */
44
63
  export declare function toolArgumentsBad(raw: unknown): boolean;
45
64
  /** 一条事件产出的记账行(call 与 result 互斥;非记账事件两位都是 undefined)。 */
46
65
  export interface ToolEventRows {
47
- call: ToolCallRecord | undefined;
48
- result: ToolResultRecord | undefined;
66
+ readonly call: ToolCallRecord | undefined;
67
+ readonly result: ToolResultRecord | undefined;
49
68
  }
50
69
  /**
51
70
  * **单条事件**的记账行(`scanToolEvents` 的一步,也是它唯一的实现处)。
52
71
  *
53
- * 之所以导出:danger-guard 把同一套台账折成 `sessionProjections` 投影单元(增量折叠)后,
54
- * 折叠与"注册表缺席时的全量扫描"必须逐字同源——两处各写一遍字段读取,就会在两行上给出
55
- * 不同的证据链(那是安全门禁的 fail-open)。折叠侧传 `event.seq`,扫描侧传数组下标,
56
- * 两者在官方 `seq = log.length` 的连号契约下同一个值。
72
+ * 之所以导出:有的消费方要把同一套台账折成投影单元(增量折叠),折叠与"注册表缺席时的全量
73
+ * 扫描"必须逐字同源——两处各写一遍字段读取,就会在两行上给出不同的证据链(那是安全门禁的
74
+ * fail-open)。折叠侧传 `event.seq`,扫描侧传数组下标,两者在官方 `seq = log.length` 的连号
75
+ * 契约下是同一个值。
57
76
  */
58
77
  export declare function toolEventRowsOf(event: SessionEvent, index: number): ToolEventRows;
59
78
  /**
60
79
  * 扫描事件流 → tool/call 与 tool/result 两张表。
61
80
  * 只收集形状可用的事件,其余静默跳过——调用方(门禁/证据链)以空表安全降级,不因零星
62
81
  * 坏事件抛错。数组空洞(类型面不可表示、重放数据里却可能出现)同样跳过。
63
- * seq 取事件在入参数组里的下标(与 danger-guard scanToolCalls 的归因语义一致)。
82
+ * seq 取事件在入参数组里的下标(与折叠侧按 seq 归因的语义同一个值)。
64
83
  */
65
84
  export declare function scanToolEvents(events: readonly SessionEvent[]): {
66
85
  calls: ToolCallRecord[];
67
86
  results: ToolResultRecord[];
68
87
  };
69
88
  /**
70
- * 由 tool/call 记录推导被编辑的文件路径;str_replace_editor 的 view 只读不算写。
71
- * @returns { kind: "write", path } 写操作;{ kind: "read-view", path } 只读视图;
72
- * { kind: "skip" } 无路径或参数形状不可用(调用方忽略)。
89
+ * {@link editPathOf} 的返回值。按 `kind` 判别联合。
90
+ *
91
+ * 只有两支:**`write` / `read-view`**。取不到路径时仍返回所属那一支、只把 `path` 置为
92
+ * `undefined`——判据是工具名与 `command`,与路径取不取得到无关;再多一枚 `skip` 只会逼
93
+ * 调用方写出永不执行的分支。路径有没有由 `path === undefined` 表达。
73
94
  */
74
- export declare function editPathOf(call: ToolCallRecord): {
75
- kind: "write" | "read-view" | "skip";
76
- path: string | undefined;
95
+ export type EditTarget = {
96
+ readonly kind: "write";
97
+ readonly path: string | undefined;
98
+ } | {
99
+ readonly kind: "read-view";
100
+ readonly path: string | undefined;
77
101
  };
102
+ /**
103
+ * 由 tool/call 记录推导被编辑的文件路径;str_replace_editor 的 view 只读不算写。
104
+ * @returns `write` = 写操作;`read-view` = 只读视图。取不到路径时 `path` 为 `undefined`。
105
+ */
106
+ export declare function editPathOf(call: ToolCallRecord): EditTarget;
@@ -1,4 +1,5 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
1
+ import type { ServerResponse } from "node:http";
2
+ import type { HttpRequest } from "./http.ts";
2
3
  /** 信任判据的结论。除 `trusted` 外都该被拒。 */
3
4
  export type TrustVerdict = "trusted" | "untrusted-host" | "cross-site" | "bad-origin";
4
5
  /** `requestTrust` / `guardTrust` 的可选入参。 */
@@ -12,11 +13,11 @@ export interface TrustOptions {
12
13
  * 一次判据:Host 权威 → sec-fetch-site(白名单)→ Origin 逐字比对。
13
14
  * 纯函数、不写响应;写响应的样板在 `guardTrust`。
14
15
  */
15
- export declare function requestTrust(req: IncomingMessage, opts?: TrustOptions): TrustVerdict;
16
+ export declare function requestTrust(req: HttpRequest, opts?: TrustOptions): TrustVerdict;
16
17
  /** 结论对应的响应文案(cross-site 与 bad-origin 同一句,两者都是"不是本站的浏览器请求")。 */
17
18
  export declare function trustRejectionText(verdict: TrustVerdict): string;
18
19
  /**
19
20
  * handler 体的**第一条语句**该放的东西:非 trusted 即写一次 403 JSON 并返回 false。
20
21
  * 返回 false 时调用方必须立即 return,不得继续读 body 或产生副作用。
21
22
  */
22
- export declare function guardTrust(req: IncomingMessage, res: ServerResponse, opts?: TrustOptions): boolean;
23
+ export declare function guardTrust(req: HttpRequest, res: ServerResponse, opts?: TrustOptions): boolean;