- 回滚/备份:空 bak 改名 .bad 跳过;升级刷新 bak 补上单数 marker 补丁;统一原子写 - 补丁命中:optional 命中不符记软失败;#42 插入型替换加探针防重复插入 - Windows:进程枚举改用 | 分隔 + UTF-8,修复中文路径换包不杀旧进程;换包失败拉起原版 - 安全:asar 解包防越界;红队路径校验 realpath 防空 symlink;更新仅 https;报告回放去换行注入 - 提示词钩子:停用后 rewriteFn 返回原装配文本并清理 assemble guard;缓存签名纳入红队/策略/override - 红队:listEngagements 默认不算统计;listVulns/报告支持 evidenceLimit - 子代理名额占位即时生效并回退;--apply 有失败项退出码 1 - 清理:删除 lib/mnemon-settings.js 及 13 处死代码/未使用 import;移除 test 目录与 test 脚本
383 lines
15 KiB
JavaScript
383 lines
15 KiB
JavaScript
/**
|
||
* 用户层 profile/cordis.patch.yml 的本体 insert(#154 / #159 / #191)。
|
||
*
|
||
* 包内 overlay 只改已入队行的 inject/config,不能再 insert 一次(Hub 已入队会
|
||
* already queued)。postinstall 和演练台子行都走这里:行不存在就写一条。
|
||
*
|
||
* #191:文件本身是一个完整文档时(宿主生成的空 flow 序列 `[]`),不能再追加,
|
||
* 追加会变成第二个文档,宿主启动解析失败。
|
||
*/
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { atomicWriteSync } from "./core.js";
|
||
|
||
export const PURGE_NAME = "dsh-purge";
|
||
|
||
export const INSERT_BLOCK = [
|
||
"- insert:",
|
||
" - id: dsh-purge",
|
||
" name: dsh-purge",
|
||
" inject: [systemPrompt, tools]",
|
||
" config:",
|
||
" enabled: true",
|
||
" stripMnemon: true",
|
||
" capabilityProtocol: true",
|
||
" hooksDenyBypass: true",
|
||
"",
|
||
].join("\n");
|
||
|
||
export function profileHasPurgeBodyInsert(text) {
|
||
const lines = String(text || "").split(/\r?\n/);
|
||
let insertIndent = -1;
|
||
for (const line of lines) {
|
||
if (/^-\s*insert:\s*$/.test(line) || /^\s+-\s*insert:\s*$/.test(line)) {
|
||
insertIndent = (line.match(/^[ \t]*/) || [""])[0].length;
|
||
continue;
|
||
}
|
||
if (insertIndent < 0) continue;
|
||
const indent = (line.match(/^[ \t]*/) || [""])[0].length;
|
||
const trimmed = line.trim();
|
||
if (trimmed && indent <= insertIndent && /^-\s/.test(trimmed)) {
|
||
insertIndent = -1;
|
||
if (/^-\s*insert:\s*$/.test(trimmed)) {
|
||
insertIndent = indent;
|
||
}
|
||
continue;
|
||
}
|
||
// 本体行:id: dsh-purge,或用户按 #159 临时绕过写的 dsh-purge-main / name: dsh-purge
|
||
if (/^-\s*id:\s*dsh-purge(-main)?\s*$/.test(trimmed)) return true;
|
||
if (/^-\s*name:\s*dsh-purge\s*$/.test(trimmed)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 判断 cordis.patch.yml 的数据部分形态(#191)。
|
||
*
|
||
* 宿主生成的 profile 层 patch 文件可能整体只有一个空 flow 序列 `[]`,
|
||
* 那是**一个完整 YAML 文档**。往文件尾追加 `- insert:` 会变成第二个文档,
|
||
* 宿主启动解析报 "end of the stream or a document separator is expected"。
|
||
*
|
||
* 只看最后一个文档的根节点类型(追加内容落在最后一个文档里):
|
||
* - "empty" 没有数据行(空文件 / 只有注释和空行)
|
||
* - "empty-flow" 数据部分恰好是空 flow 序列 `[]`(可带 `---` 前缀)
|
||
* - "block-seq" 正常块序列(顶层 `- ...`),沿用旧的追加行为
|
||
* - "flow-seq" 非空 flow 序列(宿主/用户内容,动不得)
|
||
* - "unknown" 其它形态(顶层映射等),认不准就不写
|
||
*/
|
||
export function classifyPatchBody(text) {
|
||
const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
|
||
const dataLines = [];
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
const trimmed = lines[i].trim();
|
||
if (!trimmed) continue; // 空行
|
||
if (trimmed.startsWith("#")) continue; // 注释行
|
||
dataLines.push({ index: i, trimmed });
|
||
}
|
||
// 按 `---` 切成多个文档,只看最后一个(追加内容会落在最后一个文档里)
|
||
const lastDoc = [];
|
||
for (const item of dataLines) {
|
||
if (item.trimmed === "---") {
|
||
lastDoc.length = 0;
|
||
continue;
|
||
}
|
||
lastDoc.push(item);
|
||
}
|
||
if (lastDoc.length === 0) return { kind: "empty", lines };
|
||
// 空 flow 序列:`[]` / `[ ]`,允许行尾注释
|
||
if (lastDoc.length === 1 && /^\[\s*\]\s*(#.*)?$/.test(lastDoc[0].trimmed)) {
|
||
return { kind: "empty-flow", lines, emptyFlowIndex: lastDoc[0].index };
|
||
}
|
||
if (lastDoc[0].trimmed.startsWith("[")) return { kind: "flow-seq", lines };
|
||
if (/^-(\s|$)/.test(lastDoc[0].trimmed)) {
|
||
// 块序列里混进顶层 flow 序列 → 本身就是坏 YAML,认不准就不动
|
||
if (lastDoc.slice(1).some((item) => item.trimmed.startsWith("["))) return { kind: "unknown", lines };
|
||
return { kind: "block-seq", lines };
|
||
}
|
||
return { kind: "unknown", lines };
|
||
}
|
||
|
||
export function ensureProfileBodyInsert(profileDir) {
|
||
const fp = path.join(profileDir, "cordis.patch.yml");
|
||
let text = "";
|
||
try { text = fs.readFileSync(fp, "utf8"); } catch { text = ""; }
|
||
if (profileHasPurgeBodyInsert(text)) {
|
||
return { ok: true, wrote: false, path: fp };
|
||
}
|
||
const shape = classifyPatchBody(text);
|
||
if (shape.kind === "empty-flow") {
|
||
// `[]` 本身是一个完整文档:把整行替换成 insert 块,绝不追加成第二个文档(#191)
|
||
const blockLines = INSERT_BLOCK.replace(/\n$/, "").split("\n");
|
||
const out = [
|
||
...shape.lines.slice(0, shape.emptyFlowIndex),
|
||
...blockLines,
|
||
...shape.lines.slice(shape.emptyFlowIndex + 1),
|
||
].join("\n");
|
||
atomicWriteSync(fp, `${out.endsWith("\n") ? out : `${out}\n`}`, "utf8");
|
||
return { ok: true, wrote: true, path: fp };
|
||
}
|
||
if (shape.kind === "flow-seq" || shape.kind === "unknown") {
|
||
// 宿主 / 用户自己的内容,宁可不动也别写坏(#191)
|
||
return { ok: false, wrote: false, path: fp };
|
||
}
|
||
// 空文件 / 只有注释 → 直接写 insert 块;正常块序列 → 沿用旧的追加行为
|
||
const prefix = text && !text.endsWith("\n") ? "\n" : "";
|
||
atomicWriteSync(fp, `${text}${prefix}${text ? "\n" : ""}${INSERT_BLOCK}`, "utf8");
|
||
return { ok: true, wrote: true, path: fp };
|
||
}
|
||
|
||
function readJson(fp) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(fp, "utf8"));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function profileOwnsPurge(dir) {
|
||
const pkg = readJson(path.join(dir, "package.json"));
|
||
const bundles = pkg?.dsh?.profile?.bundles;
|
||
if (Array.isArray(bundles) && bundles.includes(PURGE_NAME)) return true;
|
||
try {
|
||
return fs.existsSync(path.join(dir, "node_modules", PURGE_NAME, "package.json"));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export function listProfileDirs(dshHome) {
|
||
const root = path.join(dshHome || "", "profiles");
|
||
const out = [];
|
||
let names = [];
|
||
try { names = fs.readdirSync(root); } catch { return out; }
|
||
for (const name of names) {
|
||
const dir = path.join(root, name);
|
||
try {
|
||
if (fs.statSync(dir).isDirectory()) out.push(dir);
|
||
} catch { /* skip */ }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function ensureProfileBundles(profileDir) {
|
||
const fp = path.join(profileDir, "package.json");
|
||
const pkg = readJson(fp);
|
||
if (!pkg) return { ok: false, wrote: false, path: fp };
|
||
pkg.dsh = pkg.dsh && typeof pkg.dsh === "object" ? pkg.dsh : {};
|
||
pkg.dsh.profile = pkg.dsh.profile && typeof pkg.dsh.profile === "object" ? pkg.dsh.profile : {};
|
||
const bundles = Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : [];
|
||
if (bundles.includes(PURGE_NAME)) return { ok: true, wrote: false, path: fp };
|
||
pkg.dsh.profile.bundles = [...bundles, PURGE_NAME];
|
||
atomicWriteSync(fp, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||
return { ok: true, wrote: true, path: fp };
|
||
}
|
||
|
||
/**
|
||
* pnpm allowBuilds 期望键(#185)。
|
||
*
|
||
* 只写裸包名 `dsh-purge`:pnpm 的 allowBuilds 按包名匹配,tarball / git / registry
|
||
* 三种安装形态都放行。而 `dsh-purge@https://...` 这类键在 pnpm 11(DSH 内置
|
||
* 11.2.2)会被 semver 解析器当成版本联合,直接报
|
||
* `[ERR_PNPM_INVALID_VERSION_UNION] Invalid versions union` 让 install 崩掉。
|
||
*
|
||
* profileDir 参数只为兼容旧调用保留,实际不再需要(#185)。
|
||
*/
|
||
export function purgePnpmBuildSpecs(profileDir) {
|
||
return [PURGE_NAME];
|
||
}
|
||
|
||
function isPurgeAllowBuildKey(key) {
|
||
const name = String(key || "").replace(/^['"]|['"]$/g, "");
|
||
return name === PURGE_NAME || name.startsWith(`${PURGE_NAME}@`);
|
||
}
|
||
|
||
/** 导出仅供单测 round-trip(#180)。 */
|
||
export function parseAllowBuildLine(line) {
|
||
const text = String(line || "").replace(/\r$/, "");
|
||
// 引号包裹:键整段取出,URL 里的冒号不会切碎键
|
||
const quoted = text.match(/^[ \t]+(?:"([^"]+)"|'([^']+)')[ \t]*:[ \t]*(.*?)\s*$/);
|
||
if (quoted) return { key: quoted[1] !== undefined ? quoted[1] : quoted[2], value: String(quoted[3] || "").trim() };
|
||
// 未加引号的键:要求有缩进(顶层键不算),只在「冒号+空白(或行尾)」处切分,且取最后一次出现的位置——
|
||
// URL 里的 `:` 后面跟的是 `/`,不会断键;`dsh-purge@https: true`(畸形历史键)也能正常切成键+值(#180)
|
||
if (!/^[ \t]+/.test(text)) return null;
|
||
let split = -1;
|
||
for (let i = 0; i < text.length; i += 1) {
|
||
if (text[i] !== ":") continue;
|
||
if (i + 1 >= text.length || /[ \t]/.test(text[i + 1])) split = i;
|
||
}
|
||
if (split < 0) return null;
|
||
const key = text.slice(0, split).trim();
|
||
if (!key) return null;
|
||
return { key, value: text.slice(split + 1).trim() };
|
||
}
|
||
|
||
/** 导出仅供单测 round-trip(#180)。 */
|
||
export function formatAllowBuildLine(key) {
|
||
const name = String(key || "");
|
||
// 含 `://` 的 URL 键加引号,避免 YAML 把 URL 里的冒号当分隔符(#180)
|
||
if (name.includes("://")) return ` ${JSON.stringify(name)}: true`;
|
||
return ` ${name}: true`;
|
||
}
|
||
|
||
/**
|
||
* 重写前做最小校验:顶层键、allowBuilds 段内键都不重复,每个键写回格式都能被
|
||
* parseAllowBuildLine 无损还原。校验不过就不写盘,宁可不动用户的文件(#180)。
|
||
* 导出仅供单测(#180)。
|
||
*/
|
||
export function validateAllowBuildRewrite(text) {
|
||
const lines = String(text || "").split("\n");
|
||
let inAllow = false;
|
||
let allowIndent = -1;
|
||
const topKeys = new Set();
|
||
const allowKeys = new Set();
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
const trimmed = line.trim();
|
||
if (trimmed.startsWith("#")) continue; // 注释行原样保留,不参与校验
|
||
if (/^\S/.test(line)) {
|
||
inAllow = /^allowBuilds[ \t]*:/.test(line);
|
||
allowIndent = -1;
|
||
const m = line.match(/^(\S+?)[ \t]*:/);
|
||
if (!m) return false;
|
||
if (topKeys.has(m[1])) return false;
|
||
topKeys.add(m[1]);
|
||
continue;
|
||
}
|
||
if (!inAllow) continue;
|
||
const indent = (line.match(/^[ \t]*/) || [""])[0].length;
|
||
if (allowIndent < 0) allowIndent = indent;
|
||
if (indent < allowIndent) {
|
||
// 缩进回到 allowBuilds 的子键层级以上,说明段已结束
|
||
inAllow = false;
|
||
continue;
|
||
}
|
||
const parsed = parseAllowBuildLine(line);
|
||
if (!parsed || !parsed.key) return false;
|
||
const name = parsed.key;
|
||
if (allowKeys.has(name)) return false;
|
||
// 写回后的行必须能无损解析出同一个键
|
||
if (parseAllowBuildLine(formatAllowBuildLine(name))?.key !== name) return false;
|
||
allowKeys.add(name);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 把 dsh-purge 写成 true,避免装别的插件还报 ERR_PNPM_IGNORED_BUILDS(#168)。
|
||
*
|
||
* #185:只写裸包名。`dsh-purge@https://...` 这类键在 pnpm 11 会被当成版本联合,
|
||
* 直接触发 ERR_PNPM_INVALID_VERSION_UNION 让 install 崩掉。
|
||
* 自愈时所有 `dsh-purge@*` 历史键(URL 键、旧截断键 `dsh-purge@https`、
|
||
* `@1.1.81` 之类版本键)一律丢弃,只保留裸包名;非 purge 键原样保留。
|
||
*/
|
||
export function rewritePnpmAllowBuilds(raw, profileDir) {
|
||
const wanted = purgePnpmBuildSpecs(profileDir);
|
||
const wantedSet = new Set(wanted);
|
||
const text = String(raw || "").replace(/\r\n/g, "\n");
|
||
const wantedBlock = wanted.map(formatAllowBuildLine);
|
||
if (!text.trim()) {
|
||
return `allowBuilds:\n${wantedBlock.join("\n")}\n`;
|
||
}
|
||
const lines = text.split(/\r?\n/);
|
||
let start = -1;
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
if (/^allowBuilds[ \t]*:/.test(lines[i])) {
|
||
start = i;
|
||
break;
|
||
}
|
||
}
|
||
if (start < 0) {
|
||
const prefix = text.endsWith("\n") ? "" : "\n";
|
||
return `${text}${prefix}\nallowBuilds:\n${wantedBlock.join("\n")}\n`;
|
||
}
|
||
let end = lines.length;
|
||
const kept = [];
|
||
const seen = new Set();
|
||
for (let i = start + 1; i < lines.length; i += 1) {
|
||
const line = lines[i];
|
||
if (!line.trim()) {
|
||
end = i;
|
||
break;
|
||
}
|
||
if (/^\S/.test(line)) {
|
||
end = i;
|
||
break;
|
||
}
|
||
const trimmed = line.trim();
|
||
if (trimmed.startsWith("#")) {
|
||
kept.push(line);
|
||
continue;
|
||
}
|
||
const parsed = parseAllowBuildLine(line);
|
||
if (!parsed) {
|
||
kept.push(line);
|
||
continue;
|
||
}
|
||
if (isPurgeAllowBuildKey(parsed.key)) {
|
||
// 只保留精确等于期望键的行,且去重;其余历史 purge 键丢弃(#180 自愈)
|
||
if (!wantedSet.has(parsed.key) || seen.has(parsed.key)) continue;
|
||
kept.push(formatAllowBuildLine(parsed.key));
|
||
seen.add(parsed.key);
|
||
continue;
|
||
}
|
||
// 非 purge 键原样保留;同键重复(会让 pnpm 报 duplicated mapping key)只留第一份
|
||
if (seen.has(parsed.key)) continue;
|
||
seen.add(parsed.key);
|
||
kept.push(line);
|
||
}
|
||
for (let i = 0; i < wanted.length; i += 1) {
|
||
if (!seen.has(wanted[i])) {
|
||
kept.push(wantedBlock[i]);
|
||
seen.add(wanted[i]);
|
||
}
|
||
}
|
||
const next = [...lines.slice(0, start + 1), ...kept, ...lines.slice(end)].join("\n");
|
||
return next.endsWith("\n") ? next : `${next}\n`;
|
||
}
|
||
|
||
/** pnpm 11/12 默认拦 postinstall(ERR_PNPM_IGNORED_BUILDS)。写进 profile 的 allowBuilds,下次 add 能跑脚本。 */
|
||
export function ensurePnpmAllowBuild(profileDir) {
|
||
const fp = path.join(profileDir, "pnpm-workspace.yaml");
|
||
let text = "";
|
||
try { text = fs.readFileSync(fp, "utf8"); } catch { text = ""; }
|
||
const current = String(text || "").replace(/\r\n/g, "\n");
|
||
const next = rewritePnpmAllowBuilds(current, profileDir);
|
||
if (next === current) return { ok: true, wrote: false, path: fp };
|
||
// 写前校验不过就不写盘,宁可保持现状(#180)
|
||
if (!validateAllowBuildRewrite(next)) return { ok: false, wrote: false, path: fp, reason: "allowBuilds-invalid" };
|
||
atomicWriteSync(fp, next, "utf8");
|
||
return { ok: true, wrote: true, path: fp };
|
||
}
|
||
|
||
/** 已装进 bundles / node_modules 但缺 insert 的 profile,补上本体行。 */
|
||
export function ensureAllProfileBodyInserts(dshHome) {
|
||
return ensureAllProfileInstall(dshHome).map((item) => ({
|
||
dir: item.dir,
|
||
ok: item.ok,
|
||
wrote: Boolean(item.insert?.wrote),
|
||
path: item.insert?.path || path.join(item.dir || "", "cordis.patch.yml"),
|
||
error: item.error,
|
||
}));
|
||
}
|
||
|
||
/** bundles + 本体 insert + pnpm allowBuilds。postinstall 被跳过、升级覆盖 overlay 之后靠这条把按钮找回来。 */
|
||
export function ensureAllProfileInstall(dshHome) {
|
||
const results = [];
|
||
for (const dir of listProfileDirs(dshHome)) {
|
||
if (!profileOwnsPurge(dir)) continue;
|
||
try {
|
||
const bundles = ensureProfileBundles(dir);
|
||
const insert = ensureProfileBodyInsert(dir);
|
||
const pnpm = ensurePnpmAllowBuild(dir);
|
||
results.push({ dir, ok: true, bundles, insert, pnpm });
|
||
} catch (error) {
|
||
results.push({
|
||
dir,
|
||
ok: false,
|
||
error: error && error.message ? error.message : String(error),
|
||
});
|
||
}
|
||
}
|
||
return results;
|
||
}
|