dsh-plugin-manager-companion 0.1.0
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/LICENSE +21 -0
- package/README.en.md +144 -0
- package/README.md +142 -0
- package/cordis.patch.yml +9 -0
- package/dist/about.d.ts +77 -0
- package/dist/about.js +179 -0
- package/dist/cli.d.ts +226 -0
- package/dist/cli.js +856 -0
- package/dist/client/AboutPage.d.ts +75 -0
- package/dist/client/ConsolePage.d.ts +79 -0
- package/dist/client/KindsPage.d.ts +21 -0
- package/dist/client/MarketplacePage.d.ts +36 -0
- package/dist/client/OfficialSlots.d.ts +35 -0
- package/dist/client/UpgradeRow.d.ts +108 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/locales.d.ts +475 -0
- package/dist/client/pmSelect.d.ts +38 -0
- package/dist/client/shared.d.ts +928 -0
- package/dist/client/upgradeView.d.ts +278 -0
- package/dist/client/wire.d.ts +401 -0
- package/dist/client.js +9194 -0
- package/dist/diagnostics.d.ts +332 -0
- package/dist/diagnostics.js +2631 -0
- package/dist/envManager.d.ts +1047 -0
- package/dist/envManager.js +3214 -0
- package/dist/fix.d.ts +60 -0
- package/dist/fix.js +168 -0
- package/dist/guard.d.ts +133 -0
- package/dist/guard.js +232 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +1150 -0
- package/dist/installSession.d.ts +111 -0
- package/dist/installSession.js +150 -0
- package/dist/kinds.d.ts +464 -0
- package/dist/kinds.js +1029 -0
- package/dist/marketView.d.ts +261 -0
- package/dist/marketView.js +406 -0
- package/dist/marketplace.d.ts +248 -0
- package/dist/marketplace.js +500 -0
- package/dist/match.d.ts +67 -0
- package/dist/match.js +203 -0
- package/dist/net.d.ts +108 -0
- package/dist/net.js +163 -0
- package/dist/official.d.ts +145 -0
- package/dist/official.js +205 -0
- package/dist/paths.d.ts +108 -0
- package/dist/paths.js +236 -0
- package/dist/presets.d.ts +299 -0
- package/dist/presets.js +578 -0
- package/dist/qualityGate.d.ts +66 -0
- package/dist/qualityGate.js +247 -0
- package/dist/rank.d.ts +88 -0
- package/dist/rank.js +164 -0
- package/dist/registry.d.ts +295 -0
- package/dist/registry.js +686 -0
- package/dist/rest.d.ts +122 -0
- package/dist/rest.js +219 -0
- package/dist/scan.d.ts +134 -0
- package/dist/scan.js +396 -0
- package/dist/settings.d.ts +447 -0
- package/dist/settings.js +263 -0
- package/dist/tags.d.ts +119 -0
- package/dist/tags.js +166 -0
- package/dist/tools.d.ts +131 -0
- package/dist/tools.js +377 -0
- package/dist/types.d.ts +651 -0
- package/dist/types.js +13 -0
- package/dist/upgrade.d.ts +428 -0
- package/dist/upgrade.js +1100 -0
- package/dist/upgradeView.d.ts +313 -0
- package/dist/upgradeView.js +273 -0
- package/docs/images/readme/01-console-health.png +0 -0
- package/docs/images/readme/02-console-envs.png +0 -0
- package/docs/images/readme/03-marketplace.png +0 -0
- package/docs/images/readme/04-official-plugin-page.png +0 -0
- package/package.json +104 -0
package/dist/rest.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自有能力的传输层:REST 原语(信任围栏、请求体读取、响应信封、job 注册表)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:A 类·重写(旧仓库把围栏、信封、job 全塞在 index.ts 里,2358 行;
|
|
5
|
+
* 这里把它们抽成可独立测试的原语)。
|
|
6
|
+
* 官方复用:ctx.webServer.register(官方公开路由 API,不经 typert 生成器)。
|
|
7
|
+
* 前提检查:见 docs/DESIGN.md 第 11 节——官方 Remote 的能力集合是编译期固定的,
|
|
8
|
+
* 第三方自带 Remote 需 typert 生成器,而其 npm 可用版本远落后于运行时。
|
|
9
|
+
* 所以自有能力走 REST,官方能力仍走官方 Remote(客户端直连)。
|
|
10
|
+
*
|
|
11
|
+
* 安全模型(这层是 host 的唯一入口,必须自己扛):
|
|
12
|
+
* 1. 只接受 POST —— 浏览器同源 GET 会被 <img>/<script> 等载具触发,
|
|
13
|
+
* 而 POST + JSON 需要预检,天然收紧。
|
|
14
|
+
* 2. content-type 必须是 JSON —— 挡掉简单表单跨站提交。
|
|
15
|
+
* 3. Host 必须是回环或显式白名单 —— 挡 DNS-rebinding(攻击者域名解析到 127.0.0.1)。
|
|
16
|
+
* 4. Origin 存在时必须同源 —— 挡 CSRF。无 Origin 的非浏览器载体按本地语义放行,
|
|
17
|
+
* 但此时必须同时满足"无 Origin 且非 cross-site"。
|
|
18
|
+
*/
|
|
19
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
20
|
+
/** 自有 REST 的路由前缀。避开旧仓库的 /api2/plugin-manager(两包可能短期共存)。 */
|
|
21
|
+
export declare const ROUTE_PREFIX = "/api2/companion";
|
|
22
|
+
/** 默认请求体上限(字节):普通操作。 */
|
|
23
|
+
export declare const BODY_LIMIT_DEFAULT: number;
|
|
24
|
+
/** 备份导入的请求体上限(字节)。 */
|
|
25
|
+
export declare const BODY_LIMIT_BACKUP: number;
|
|
26
|
+
/**
|
|
27
|
+
* 按 op 名给出请求体上限。
|
|
28
|
+
*
|
|
29
|
+
* 分级而不是统一放宽:备份导入确实可能很大,但普通操作没有理由接受兆级 body,
|
|
30
|
+
* 统一放宽等于把所有 op 的暴露面都放大。
|
|
31
|
+
*
|
|
32
|
+
* @param op - 操作名。
|
|
33
|
+
* @returns 该 op 允许的最大请求体字节数。
|
|
34
|
+
*/
|
|
35
|
+
export declare function bodyLimitFor(op: string): number;
|
|
36
|
+
/** 成功信封。 */
|
|
37
|
+
export interface OkEnvelope<T> {
|
|
38
|
+
readonly ok: true;
|
|
39
|
+
readonly value: T;
|
|
40
|
+
}
|
|
41
|
+
/** 失败信封。code 是稳定机器码,message 面向用户。 */
|
|
42
|
+
export interface ErrEnvelope {
|
|
43
|
+
readonly ok: false;
|
|
44
|
+
readonly error: {
|
|
45
|
+
readonly code: string;
|
|
46
|
+
readonly message: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** 任意响应信封。 */
|
|
50
|
+
export type Envelope<T> = OkEnvelope<T> | ErrEnvelope;
|
|
51
|
+
/**
|
|
52
|
+
* 信任围栏:判断一个请求是否来自可信的同源浏览器上下文。
|
|
53
|
+
*
|
|
54
|
+
* 与旧实现的差别:旧版把"Host 回环判定 + Origin 同源"混在一个布尔里,且对
|
|
55
|
+
* 非 HTTP 载体(无 Host 头)的处理是在补丁里加的。这里把规则写成可穷举测试的纯函数,
|
|
56
|
+
* 并显式区分"有 Host"与"无 Host(非 HTTP 载体)"两种语义。
|
|
57
|
+
*
|
|
58
|
+
* @param req - 传入请求(只读其 headers)。
|
|
59
|
+
* @param options - 可信 Host 白名单(不含端口)与是否允许非 HTTP 载体。
|
|
60
|
+
* @returns 允许与否,以及拒绝时的机器码。
|
|
61
|
+
*/
|
|
62
|
+
export declare function isTrustedRequest(req: Pick<IncomingMessage, "headers" | "method">, options?: {
|
|
63
|
+
readonly trustedHosts?: readonly string[];
|
|
64
|
+
readonly allowNonHttpCarrier?: boolean;
|
|
65
|
+
}): {
|
|
66
|
+
readonly ok: true;
|
|
67
|
+
} | {
|
|
68
|
+
readonly ok: false;
|
|
69
|
+
readonly code: string;
|
|
70
|
+
readonly message: string;
|
|
71
|
+
};
|
|
72
|
+
/** 判定请求是否是一个可接受的 JSON POST。 */
|
|
73
|
+
export declare function isJsonPost(req: Pick<IncomingMessage, "headers" | "method">): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* 读取并解析 JSON 请求体,带硬上限。
|
|
76
|
+
*
|
|
77
|
+
* 上限在**读取过程中**生效(超出即停止累积并拒绝),而不是先读完再判断长度——
|
|
78
|
+
* 后者对超大 body 毫无保护。
|
|
79
|
+
*
|
|
80
|
+
* @param req - 传入请求。
|
|
81
|
+
* @param limit - 允许的最大字节数。
|
|
82
|
+
* @returns 解析结果;失败时给出机器码。
|
|
83
|
+
*/
|
|
84
|
+
export declare function readJsonBody<T = Record<string, unknown>>(req: AsyncIterable<Buffer | string>, limit: number): Promise<{
|
|
85
|
+
ok: true;
|
|
86
|
+
value: T;
|
|
87
|
+
} | {
|
|
88
|
+
ok: false;
|
|
89
|
+
code: string;
|
|
90
|
+
message: string;
|
|
91
|
+
}>;
|
|
92
|
+
/** 写一个 JSON 响应(含状态码)。 */
|
|
93
|
+
export declare function sendJson(res: ServerResponse, status: number, payload: unknown): void;
|
|
94
|
+
/** job 结果保留时长(毫秒)。与旧仓库一致的 30 分钟。 */
|
|
95
|
+
export declare const JOB_TTL_MS: number;
|
|
96
|
+
/** 同时在途的 job 上限。超出即背压拒绝,避免堆叠点击打爆进程。 */
|
|
97
|
+
export declare const JOB_MAX_PENDING = 4;
|
|
98
|
+
/** job 注册表(进程内)。 */
|
|
99
|
+
export declare class JobRegistry {
|
|
100
|
+
private readonly jobs;
|
|
101
|
+
private seq;
|
|
102
|
+
/**
|
|
103
|
+
* 启动一个 job。
|
|
104
|
+
* @param task - 要执行的工作。
|
|
105
|
+
* @returns job id。
|
|
106
|
+
* @throws {Error} 在途数量达到上限时(调用方应回 429)。
|
|
107
|
+
*/
|
|
108
|
+
start(task: () => Promise<unknown>): string;
|
|
109
|
+
/**
|
|
110
|
+
* 读一个 job 的状态。
|
|
111
|
+
* @param id - job id。
|
|
112
|
+
* @returns 状态;id 不存在(或已过期)时 `missing: true`。
|
|
113
|
+
*/
|
|
114
|
+
status(id: string): {
|
|
115
|
+
done: boolean;
|
|
116
|
+
result?: unknown;
|
|
117
|
+
error?: string;
|
|
118
|
+
missing?: true;
|
|
119
|
+
};
|
|
120
|
+
/** 清掉超过 TTL 的记录。 */
|
|
121
|
+
private prune;
|
|
122
|
+
}
|
package/dist/rest.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自有能力的传输层:REST 原语(信任围栏、请求体读取、响应信封、job 注册表)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:A 类·重写(旧仓库把围栏、信封、job 全塞在 index.ts 里,2358 行;
|
|
5
|
+
* 这里把它们抽成可独立测试的原语)。
|
|
6
|
+
* 官方复用:ctx.webServer.register(官方公开路由 API,不经 typert 生成器)。
|
|
7
|
+
* 前提检查:见 docs/DESIGN.md 第 11 节——官方 Remote 的能力集合是编译期固定的,
|
|
8
|
+
* 第三方自带 Remote 需 typert 生成器,而其 npm 可用版本远落后于运行时。
|
|
9
|
+
* 所以自有能力走 REST,官方能力仍走官方 Remote(客户端直连)。
|
|
10
|
+
*
|
|
11
|
+
* 安全模型(这层是 host 的唯一入口,必须自己扛):
|
|
12
|
+
* 1. 只接受 POST —— 浏览器同源 GET 会被 <img>/<script> 等载具触发,
|
|
13
|
+
* 而 POST + JSON 需要预检,天然收紧。
|
|
14
|
+
* 2. content-type 必须是 JSON —— 挡掉简单表单跨站提交。
|
|
15
|
+
* 3. Host 必须是回环或显式白名单 —— 挡 DNS-rebinding(攻击者域名解析到 127.0.0.1)。
|
|
16
|
+
* 4. Origin 存在时必须同源 —— 挡 CSRF。无 Origin 的非浏览器载体按本地语义放行,
|
|
17
|
+
* 但此时必须同时满足"无 Origin 且非 cross-site"。
|
|
18
|
+
*/
|
|
19
|
+
/** 自有 REST 的路由前缀。避开旧仓库的 /api2/plugin-manager(两包可能短期共存)。 */
|
|
20
|
+
export const ROUTE_PREFIX = "/api2/companion";
|
|
21
|
+
/** 默认请求体上限(字节):普通操作。 */
|
|
22
|
+
export const BODY_LIMIT_DEFAULT = 1024 * 1024;
|
|
23
|
+
/** 备份导入的请求体上限(字节)。 */
|
|
24
|
+
export const BODY_LIMIT_BACKUP = 16 * 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* 按 op 名给出请求体上限。
|
|
27
|
+
*
|
|
28
|
+
* 分级而不是统一放宽:备份导入确实可能很大,但普通操作没有理由接受兆级 body,
|
|
29
|
+
* 统一放宽等于把所有 op 的暴露面都放大。
|
|
30
|
+
*
|
|
31
|
+
* @param op - 操作名。
|
|
32
|
+
* @returns 该 op 允许的最大请求体字节数。
|
|
33
|
+
*/
|
|
34
|
+
export function bodyLimitFor(op) {
|
|
35
|
+
return op === "backupRestore" ? BODY_LIMIT_BACKUP : BODY_LIMIT_DEFAULT;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 信任围栏:判断一个请求是否来自可信的同源浏览器上下文。
|
|
39
|
+
*
|
|
40
|
+
* 与旧实现的差别:旧版把"Host 回环判定 + Origin 同源"混在一个布尔里,且对
|
|
41
|
+
* 非 HTTP 载体(无 Host 头)的处理是在补丁里加的。这里把规则写成可穷举测试的纯函数,
|
|
42
|
+
* 并显式区分"有 Host"与"无 Host(非 HTTP 载体)"两种语义。
|
|
43
|
+
*
|
|
44
|
+
* @param req - 传入请求(只读其 headers)。
|
|
45
|
+
* @param options - 可信 Host 白名单(不含端口)与是否允许非 HTTP 载体。
|
|
46
|
+
* @returns 允许与否,以及拒绝时的机器码。
|
|
47
|
+
*/
|
|
48
|
+
export function isTrustedRequest(req, options = {}) {
|
|
49
|
+
const host = headerValue(req.headers["host"]);
|
|
50
|
+
const origin = headerValue(req.headers["origin"]);
|
|
51
|
+
// 非 HTTP 载体(第三方桌面壳 / app:// 自定义协议):不带 Host 头。
|
|
52
|
+
// 这类请求没有浏览器同源模型的保护,因此要求"无 Origin 且非 cross-site"。
|
|
53
|
+
if (host === undefined) {
|
|
54
|
+
if (options.allowNonHttpCarrier !== true) {
|
|
55
|
+
return { ok: false, code: "untrusted-host", message: "request carries no Host header" };
|
|
56
|
+
}
|
|
57
|
+
if (crossSite(req.headers["sec-fetch-site"]) !== false) {
|
|
58
|
+
return { ok: false, code: "cross-site", message: "cross-site request refused" };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true };
|
|
61
|
+
}
|
|
62
|
+
const hostname = stripPort(host);
|
|
63
|
+
if (!isLoopbackHost(hostname) && !(options.trustedHosts ?? []).includes(hostname)) {
|
|
64
|
+
return { ok: false, code: "untrusted-host", message: "host is not loopback or allowlisted" };
|
|
65
|
+
}
|
|
66
|
+
if (origin !== undefined) {
|
|
67
|
+
let originHost;
|
|
68
|
+
try {
|
|
69
|
+
originHost = new URL(origin).host;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { ok: false, code: "bad-origin", message: "Origin is not a valid URL" };
|
|
73
|
+
}
|
|
74
|
+
if (originHost !== host) {
|
|
75
|
+
return { ok: false, code: "cross-origin", message: "Origin does not match Host" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { ok: true };
|
|
79
|
+
}
|
|
80
|
+
/** 取首值:node 的 header 可能是数组。 */
|
|
81
|
+
function headerValue(raw) {
|
|
82
|
+
if (raw === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
return Array.isArray(raw) ? raw[0] : raw;
|
|
85
|
+
}
|
|
86
|
+
/** 去掉 Host 里的端口(IPv6 字面量的方括号保留处理)。 */
|
|
87
|
+
function stripPort(host) {
|
|
88
|
+
// IPv6 字面量形如 [::1]:3080 —— 先处理方括号形式,避免把 ::1 里的冒号当端口分隔符。
|
|
89
|
+
if (host.startsWith("[")) {
|
|
90
|
+
const close = host.indexOf("]");
|
|
91
|
+
return close === -1 ? host : host.slice(1, close);
|
|
92
|
+
}
|
|
93
|
+
const colon = host.lastIndexOf(":");
|
|
94
|
+
return colon === -1 ? host : host.slice(0, colon);
|
|
95
|
+
}
|
|
96
|
+
/** 回环判定:IPv4 回环段、IPv6 回环、localhost。 */
|
|
97
|
+
function isLoopbackHost(hostname) {
|
|
98
|
+
if (hostname === "localhost")
|
|
99
|
+
return true;
|
|
100
|
+
if (hostname === "::1" || hostname === "0:0:0:0:0:0:0:1")
|
|
101
|
+
return true;
|
|
102
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
|
|
103
|
+
if (v4 === null)
|
|
104
|
+
return false;
|
|
105
|
+
const first = Number(v4[1]);
|
|
106
|
+
// 严格说整个 127/8 都是回环;实践中 127.0.0.1 与 127.x.y.z 都要放行。
|
|
107
|
+
return first === 127;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* sec-fetch-site 判定:明确为 cross-site 时返回 true,其它(含缺失)返回 false。
|
|
111
|
+
*
|
|
112
|
+
* 缺失时判 false 是刻意的:非浏览器载体不带这个头,把它当 cross-site 会让
|
|
113
|
+
* 合法载体被拒(旧仓库 issue #11)。真正的防跨站由 Origin/Host 规则承担。
|
|
114
|
+
*/
|
|
115
|
+
function crossSite(raw) {
|
|
116
|
+
return headerValue(raw)?.toLowerCase() === "cross-site";
|
|
117
|
+
}
|
|
118
|
+
/** 判定请求是否是一个可接受的 JSON POST。 */
|
|
119
|
+
export function isJsonPost(req) {
|
|
120
|
+
if (req.method !== "POST")
|
|
121
|
+
return false;
|
|
122
|
+
const contentType = headerValue(req.headers["content-type"]) ?? "";
|
|
123
|
+
return contentType.split(";")[0].trim().toLowerCase() === "application/json";
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 读取并解析 JSON 请求体,带硬上限。
|
|
127
|
+
*
|
|
128
|
+
* 上限在**读取过程中**生效(超出即停止累积并拒绝),而不是先读完再判断长度——
|
|
129
|
+
* 后者对超大 body 毫无保护。
|
|
130
|
+
*
|
|
131
|
+
* @param req - 传入请求。
|
|
132
|
+
* @param limit - 允许的最大字节数。
|
|
133
|
+
* @returns 解析结果;失败时给出机器码。
|
|
134
|
+
*/
|
|
135
|
+
export async function readJsonBody(req, limit) {
|
|
136
|
+
const chunks = [];
|
|
137
|
+
let size = 0;
|
|
138
|
+
try {
|
|
139
|
+
for await (const chunk of req) {
|
|
140
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
141
|
+
size += buf.byteLength;
|
|
142
|
+
if (size > limit) {
|
|
143
|
+
return { ok: false, code: "body-too-large", message: `request body exceeds ${String(limit)} bytes` };
|
|
144
|
+
}
|
|
145
|
+
chunks.push(buf);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return { ok: false, code: "body-read-failed", message: error instanceof Error ? error.message : String(error) };
|
|
150
|
+
}
|
|
151
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
152
|
+
if (text === "")
|
|
153
|
+
return { ok: true, value: {} };
|
|
154
|
+
try {
|
|
155
|
+
return { ok: true, value: JSON.parse(text) };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return { ok: false, code: "bad-json", message: error instanceof Error ? error.message : String(error) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** 写一个 JSON 响应(含状态码)。 */
|
|
162
|
+
export function sendJson(res, status, payload) {
|
|
163
|
+
const body = JSON.stringify(payload);
|
|
164
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
165
|
+
res.end(body);
|
|
166
|
+
}
|
|
167
|
+
/** job 结果保留时长(毫秒)。与旧仓库一致的 30 分钟。 */
|
|
168
|
+
export const JOB_TTL_MS = 30 * 60 * 1000;
|
|
169
|
+
/** 同时在途的 job 上限。超出即背压拒绝,避免堆叠点击打爆进程。 */
|
|
170
|
+
export const JOB_MAX_PENDING = 4;
|
|
171
|
+
/** job 注册表(进程内)。 */
|
|
172
|
+
export class JobRegistry {
|
|
173
|
+
jobs = new Map();
|
|
174
|
+
seq = 0;
|
|
175
|
+
/**
|
|
176
|
+
* 启动一个 job。
|
|
177
|
+
* @param task - 要执行的工作。
|
|
178
|
+
* @returns job id。
|
|
179
|
+
* @throws {Error} 在途数量达到上限时(调用方应回 429)。
|
|
180
|
+
*/
|
|
181
|
+
start(task) {
|
|
182
|
+
this.prune();
|
|
183
|
+
const pending = [...this.jobs.values()].filter(job => !job.done).length;
|
|
184
|
+
if (pending >= JOB_MAX_PENDING) {
|
|
185
|
+
throw new Error(`too many operations in flight (${String(pending)}); wait for one to finish`);
|
|
186
|
+
}
|
|
187
|
+
this.seq += 1;
|
|
188
|
+
const id = `${Date.now().toString(36)}-${this.seq.toString(36)}`;
|
|
189
|
+
const record = { startedAt: Date.now(), done: false };
|
|
190
|
+
this.jobs.set(id, record);
|
|
191
|
+
// 结果与错误都落进 record:调用方只通过 job op 读,避免"谁先到"的竞态。
|
|
192
|
+
void task().then((value) => { record.result = value; record.done = true; }, (error) => { record.error = error instanceof Error ? error.message : String(error); record.done = true; });
|
|
193
|
+
return id;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 读一个 job 的状态。
|
|
197
|
+
* @param id - job id。
|
|
198
|
+
* @returns 状态;id 不存在(或已过期)时 `missing: true`。
|
|
199
|
+
*/
|
|
200
|
+
status(id) {
|
|
201
|
+
this.prune();
|
|
202
|
+
const record = this.jobs.get(id);
|
|
203
|
+
if (record === undefined)
|
|
204
|
+
return { done: true, missing: true };
|
|
205
|
+
return {
|
|
206
|
+
done: record.done,
|
|
207
|
+
...record.result === undefined ? {} : { result: record.result },
|
|
208
|
+
...record.error === undefined ? {} : { error: record.error },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** 清掉超过 TTL 的记录。 */
|
|
212
|
+
prune() {
|
|
213
|
+
const cutoff = Date.now() - JOB_TTL_MS;
|
|
214
|
+
for (const [id, record] of this.jobs) {
|
|
215
|
+
if (record.startedAt < cutoff)
|
|
216
|
+
this.jobs.delete(id);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安装期环境变量需求扫描与子进程环境过滤(给 git 源插件用)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:B 类·参考算法后重写(纯决策 + 有界只读扫描;旧 src/scan.ts 仅作意图参考,未复制代码)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/scan.ts(91 行,2 层/40 文件/8 变量上限、
|
|
6
|
+
* TOKEN|KEY|SECRET|PASSWORD|PASS|CREDENTIAL 六族敏感键、\b 词边界的坑)。
|
|
7
|
+
* 官方复用:无(官方没有安装期 env 扫描;官方 plugin-manager 用 scrubbedParentEnv()
|
|
8
|
+
* 清空 service 侧子进程环境,我们借鉴其"默认最小"的取向,但实现自有)。
|
|
9
|
+
* 前提检查:仍然成立——git 源插件的 pnpm 解析与生命周期脚本会继承子进程 env,
|
|
10
|
+
* 全量透传等于把宿主凭据交给未审核的第三方代码。旧实现的已知限制(只覆盖常见
|
|
11
|
+
* 敏感键形态)在这里被显式扩大:见 SENSITIVE_FAMILIES / SENSITIVE_PREFIXES;
|
|
12
|
+
* 未被覆盖的形态仍然放行,这一事实写在 buildFilteredEnv 的文档里,不假装完整。
|
|
13
|
+
*
|
|
14
|
+
* 三条安全不变量(写在代码里,不靠调用方自觉):
|
|
15
|
+
* 1. answers 只按**扫描白名单**注入——PATH/HOME/NODE_OPTIONS 这类键永远进不来
|
|
16
|
+
* (旧仓库审计点:任意键注入可劫持子进程执行)。
|
|
17
|
+
* 2. 子进程环境默认剔除敏感键形态;扫描出的键由调用方显式给定值时按值注入
|
|
18
|
+
* (用户显式提供即同意),未提供的宿主同名残留值仍被剔除。
|
|
19
|
+
* 3. 过滤是"剔除",不是"清空":宿主仍需要 PATH/HOME 才能跑 pnpm。这与官方
|
|
20
|
+
* service 侧的全清策略不同是有意的:CLI 的生命周期脚本依赖用户工具链
|
|
21
|
+
* (nvm 下的 node/pnpm 解析),清空会让安装直接失败。
|
|
22
|
+
*/
|
|
23
|
+
/** 扫描成本上限:目录层数、候选文件数、返回变量数、单文件字节数。 */
|
|
24
|
+
export interface ScanLimits {
|
|
25
|
+
/** 递归层数上限(根为第 0 层)。 */
|
|
26
|
+
readonly maxDepth: number;
|
|
27
|
+
/** 最多读取的候选文件数。 */
|
|
28
|
+
readonly maxFiles: number;
|
|
29
|
+
/** 最多返回的变量名数量。 */
|
|
30
|
+
readonly maxVariables: number;
|
|
31
|
+
/** 单个候选文件的读取上限(字节);超出即放弃该文件并记账。 */
|
|
32
|
+
readonly maxFileBytes: number;
|
|
33
|
+
}
|
|
34
|
+
/** 默认上限。旧仓库实测:2 层/40 文件足够覆盖 README + package.json + .env.example。 */
|
|
35
|
+
export declare const DEFAULT_SCAN_LIMITS: ScanLimits;
|
|
36
|
+
/**
|
|
37
|
+
* 敏感环境变量名判定。
|
|
38
|
+
*
|
|
39
|
+
* 边界用字母数字感知(不能用 \b:下划线属于 \w,GITHUB_TOKEN 中 TOKEN 前无边界)。
|
|
40
|
+
* 结果:GITHUB_TOKEN / OPENAI_API_KEY / DB_PASSWORD / XXX_PASS 命中,
|
|
41
|
+
* KEYBOARD_LAYOUT(KEY 后接 B)不误伤,AWS_SECRET_ACCESS_KEY 命中。
|
|
42
|
+
*
|
|
43
|
+
* @param name - 变量名;非字符串输入按字符串处理(调用方可能传任意 JSON)。
|
|
44
|
+
* @returns 是否属于敏感形态。
|
|
45
|
+
*/
|
|
46
|
+
export declare function isSensitiveEnvKey(name: string): boolean;
|
|
47
|
+
/** 扫描源:从哪个文件/字段发现了这个变量名。 */
|
|
48
|
+
export interface ScanSource {
|
|
49
|
+
/** 相对仓库根的路径;用于输出让用户能自己核实。 */
|
|
50
|
+
readonly file: string;
|
|
51
|
+
/** 发现位置(文件名 / package.json 字段)。 */
|
|
52
|
+
readonly via: string;
|
|
53
|
+
/** 该变量名是否被判为敏感形态。 */
|
|
54
|
+
readonly sensitive: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** 一次扫描的结果。 */
|
|
57
|
+
export interface ScanReport {
|
|
58
|
+
/** 需要的变量名(去重、按发现顺序、受 maxVariables 限制)。 */
|
|
59
|
+
readonly requirements: readonly string[];
|
|
60
|
+
/** 每个变量名的来源,与 requirements 同序。 */
|
|
61
|
+
readonly sources: readonly ScanSource[];
|
|
62
|
+
/** 层数/文件数/变量数触顶时为 true——超限如实标注,不静默截断。 */
|
|
63
|
+
readonly truncated: boolean;
|
|
64
|
+
/** 触顶的维度说明,直接面向用户展示。 */
|
|
65
|
+
readonly truncatedReasons: readonly string[];
|
|
66
|
+
/** 实际被读取的候选文件数。 */
|
|
67
|
+
readonly filesRead: number;
|
|
68
|
+
/** 实际遍历到的最深层数。 */
|
|
69
|
+
readonly depthReached: number;
|
|
70
|
+
/** 因超过单文件上限而被跳过的文件数。 */
|
|
71
|
+
readonly oversized: number;
|
|
72
|
+
}
|
|
73
|
+
/** 扫描选项。 */
|
|
74
|
+
export interface ScanOptions {
|
|
75
|
+
/** 覆盖默认上限(部分覆盖)。 */
|
|
76
|
+
readonly limits?: Partial<ScanLimits>;
|
|
77
|
+
/** 是否收录非敏感形态的变量名。默认 false(只收敏感形态,避免打扰用户)。 */
|
|
78
|
+
readonly includePlain?: boolean;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 扫描仓库目录,给出安装/构建阶段可能需要的环境变量名。
|
|
82
|
+
*
|
|
83
|
+
* 有界:层数、文件数、变量数、单文件大小全部设上限;触顶时 truncated 为 true
|
|
84
|
+
* 并在 truncatedReasons 里说明是哪个维度触顶——漏报一个必需变量会让用户在安装
|
|
85
|
+
* 中途才失败,所以超限必须如实标注而不是静默截断。
|
|
86
|
+
*
|
|
87
|
+
* 只读取,不执行:仓库里的 install.sh 永远不会被本模块或调用方自动执行。
|
|
88
|
+
*
|
|
89
|
+
* @param repoDir - 已克隆/已就绪的仓库根目录。
|
|
90
|
+
* @param options - 上限覆盖与是否收录非敏感形态。
|
|
91
|
+
* @returns 扫描报告。
|
|
92
|
+
*/
|
|
93
|
+
export declare function scanRequirements(repoDir: string, options?: ScanOptions): Promise<ScanReport>;
|
|
94
|
+
/** 取某个变量名在扫描报告里的来源。 */
|
|
95
|
+
export declare function sourceOf(report: ScanReport, name: string): ScanSource | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* 过滤子进程环境:剔除敏感键。
|
|
98
|
+
*
|
|
99
|
+
* 取值为 undefined 的键(Node 允许)保持 undefined,其余原样保留——这里是过滤器
|
|
100
|
+
* 而不是构造器,宿主工具链(PATH/HOME/NVM_DIR)必须继续可用,否则 nvm 用户的
|
|
101
|
+
* node/pnpm 解析会失败(旧仓库 issue 记录)。
|
|
102
|
+
*
|
|
103
|
+
* @param source - 基础环境;默认 process.env。
|
|
104
|
+
* @returns 剔除敏感键后的新对象(不改动传入对象)。
|
|
105
|
+
*/
|
|
106
|
+
export declare function buildFilteredEnv(source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
107
|
+
/**
|
|
108
|
+
* 构造注入给子进程的环境:先剔敏感键,再按白名单注入用户提供的值。
|
|
109
|
+
*
|
|
110
|
+
* 白名单来自扫描(session 或 CLI 本次扫描),因此 PATH/HOME/NODE_OPTIONS/
|
|
111
|
+
* __proto__ 这类键即使出现在 answers 里也不会被注入。
|
|
112
|
+
*
|
|
113
|
+
* 显式提供即同意:用户主动为某个**敏感**键提供值时该值会被注入——这个值只存在于
|
|
114
|
+
* 用户自己的机器上、由用户自己录入,安装链路没有"静默读取宿主凭据"的部分。
|
|
115
|
+
* 未提供时,宿主同名变量仍然被剔除。
|
|
116
|
+
*
|
|
117
|
+
* @param source - 基础环境(通常是 process.env)。
|
|
118
|
+
* @param answers - 用户提供的键值;键不在白名单内或值不是非空字符串即忽略。
|
|
119
|
+
* @param allowlist - 允许注入的键(扫描结果)。
|
|
120
|
+
* @returns 供子进程使用的环境对象。
|
|
121
|
+
*/
|
|
122
|
+
export declare function buildFilteredEnvWithAnswers(source: NodeJS.ProcessEnv, answers: Readonly<Record<string, string>> | undefined, allowlist: readonly string[]): NodeJS.ProcessEnv;
|
|
123
|
+
/** 给用户看的缺失变量清单(CLI 输出用;含每个变量的来源,便于用户自己核实)。 */
|
|
124
|
+
export declare function formatMissingRequirements(report: ScanReport, provided: Readonly<Record<string, string>>): string[];
|
|
125
|
+
/**
|
|
126
|
+
* 是否需要为某次安装做 env 扫描。
|
|
127
|
+
*
|
|
128
|
+
* 只对 git 源扫描:本地路径是用户自己的目录(想读环境变量早就读了),npm 包在
|
|
129
|
+
* 安装前没有可扫的仓库内容。git+ / github: / git@ / .git 结尾 / git URL 都算。
|
|
130
|
+
*
|
|
131
|
+
* @param spec - 安装源。
|
|
132
|
+
* @returns 是否为 git 源。
|
|
133
|
+
*/
|
|
134
|
+
export declare function isGitSource(spec: string): boolean;
|