dsh-crwu-workbench 0.0.13 → 0.0.14
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/CHANGELOG.md +166 -0
- package/README.en.md +33 -4
- package/README.md +34 -5
- package/common/skills/crwu-dws/SKILL.md +4 -4
- package/common/skills/crwu-h3yun-login/SKILL.md +3 -3
- package/common/skills/crwu-h3yun-query/SKILL.md +5 -5
- package/lib/client.js +193 -18
- package/lib/index.js +1040 -583
- package/package.json +17 -9
- package/scripts/lib/cli-entry.mjs +112 -0
- package/scripts/prepare.mjs +56 -16
- package/skills/crwu/crwu-audit/references/14-orchestration-workflow.md +15 -0
- package/skills/crwu/crwu-audit/scripts/README.md +2 -2
- package/skills/crwu/crwu-dev-audit-optimize/references/03-AI/344/272/272/345/267/245/345/267/256/350/267/235/345/210/206/346/236/220/346/265/201/347/250/213.md +1 -1
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/01-kb-source-discovery.md +1 -1
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/02-child-skill-contract.md +1 -1
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/04-registry-and-mapping-update.md +1 -1
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/05-validation-and-delivery.md +2 -2
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/06-live-routing-reconciliation.md +1 -1
- package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/README.md +2 -2
package/lib/index.js
CHANGED
|
@@ -229,7 +229,7 @@ const PLUGIN_INJECT = [
|
|
|
229
229
|
* 还会让打包器的 JSON 插件成为隐式依赖。代价是升版本时要同时改这里 ——
|
|
230
230
|
* `tests/unit/host-package.test.mjs` 有一条断言盯着它必须等于 `package.json` 的 version。
|
|
231
231
|
*/
|
|
232
|
-
const PLUGIN_VERSION = "0.0.
|
|
232
|
+
const PLUGIN_VERSION = "0.0.14";
|
|
233
233
|
/**
|
|
234
234
|
* `ping` / `boot` 应答里的版本指纹,形如 `pkg-0.0.5`。
|
|
235
235
|
*
|
|
@@ -538,20 +538,15 @@ function workspaceView(state) {
|
|
|
538
538
|
//#endregion
|
|
539
539
|
//#region src/host/environment/manifest.ts
|
|
540
540
|
/**
|
|
541
|
-
*
|
|
541
|
+
* 环境域的取值收窄。
|
|
542
542
|
*
|
|
543
543
|
* 2026-09-25 之前这里还住着「远程清单拉取与归一」:一份放在只读 OSS 上的 JSON 可以远程改写
|
|
544
544
|
* 员工机器上认哪些二进制、装到哪儿。整条只读依赖下掉后它已删除 —— 现在清单只有内置一份
|
|
545
545
|
* (`manifest-default.ts`),外部输入只剩 YAML(由 `config/yaml.ts` 校验)。
|
|
546
546
|
*
|
|
547
|
-
*
|
|
547
|
+
* 原来的 `quoteArg`(POSIX-only 引用)已经删除:平台引用只有一个出口,
|
|
548
|
+
* 就是 `platform/shell.ts` 的 `shellQuote` —— 留着第二个引用函数就等于留着第二条绕过 Windows 的路。
|
|
548
549
|
*/
|
|
549
|
-
/** 单引号安全引用;需要时包一层 shell 引用,避免清单地址里的特殊字符被解释。 */
|
|
550
|
-
function quoteArg(value) {
|
|
551
|
-
const raw = String(value);
|
|
552
|
-
if (raw.length > 0 && /^[A-Za-z0-9_@%+=:,./-]+$/.test(raw)) return raw;
|
|
553
|
-
return `'${raw.split("'").join("'\\''")}'`;
|
|
554
|
-
}
|
|
555
550
|
function asRecord$1(value) {
|
|
556
551
|
return value !== null && typeof value === "object" ? value : {};
|
|
557
552
|
}
|
|
@@ -927,10 +922,165 @@ const AUDIT_BINARIES = [
|
|
|
927
922
|
const TOOL_TEXT_MAX = 8e3;
|
|
928
923
|
/** 知识库材料的落地目录名(在案例目录内)。 */
|
|
929
924
|
const CASE_KNOWLEDGE_DIR = "knowledge";
|
|
925
|
+
/** 知识库下载清单的文件名(落在 `knowledge/` 下,与下载正文同级)。 */
|
|
926
|
+
const KB_MANIFEST_FILE = ".crwu-manifest.json";
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/shared/utils/local-path.ts
|
|
929
|
+
/**
|
|
930
|
+
* **本地展示路径**的纯函数适配器(不引用 `node:path`)。
|
|
931
|
+
*
|
|
932
|
+
* ## 为什么需要它
|
|
933
|
+
*
|
|
934
|
+
* Host 与 Client 都要算「案例目录」「文件名」这类**展示路径**,而共享层不能引入 Node 模块
|
|
935
|
+
* (Client 产物是要进浏览器的)。于是历史实现各处都用 `/` 硬拼,在 Windows 上产生三类错误:
|
|
936
|
+
*
|
|
937
|
+
* 1. **拼接**:`C:\Work` + `/` + `S1` 得到 `C:\Work/S1`,进 shell(PowerShell 的 `-Path`)
|
|
938
|
+
* 与进提示词(模型照着写命令)都是混用分隔符;
|
|
939
|
+
* 2. **取末段**:`'C:\\Work\\S1'.split('/')` 得到整串,界面上的案例名变成一整条路径;
|
|
940
|
+
* 3. **裁剪**:`replace(/\/+$/, '')` 对 `C:\Work\` 无效;对 `\\server\share\` 稍有不慎就裁成
|
|
941
|
+
* 无法解析的 `\\server`。
|
|
942
|
+
*
|
|
943
|
+
* ## 边界(改这个文件前先读)
|
|
944
|
+
*
|
|
945
|
+
* - 这里处理的是**本地路径**:盘符、UNC、POSIX 根。**OSS object key 永远用 `/`**,
|
|
946
|
+
* 不属于这里(见 `host/oss/ops.ts` 里 `objectKey` 的命名)。
|
|
947
|
+
* - **安全性判断不要用这里的函数**:`..`、符号链接、Windows 盘符大小写都会让字符串比较失效,
|
|
948
|
+
* 父目录/子目录判定一律走 `ctx.fs.contains`(后端 realpath 之后比较)。
|
|
949
|
+
* 这里只负责「拼一条给人看、给命令用的路径」。
|
|
950
|
+
* - `joinLocalPath` 对**不受信任的片段**是拒绝而不是清洗:绝对路径、`..` 片段都抛错。
|
|
951
|
+
* 静默清洗会让调用方以为拿到了一条安全路径(最危险的形态)。
|
|
952
|
+
*/
|
|
953
|
+
/**
|
|
954
|
+
* 盘符**风格**:`C:\` / `C:/`,也接受裸 `C:`。
|
|
955
|
+
*
|
|
956
|
+
* `C:` 是「盘符相对路径」(这个盘上的当前目录),它**不是**绝对路径 —— 风格与绝对必须分开判,
|
|
957
|
+
* 见 `isAbsoluteLocalPath`。
|
|
958
|
+
*/
|
|
959
|
+
const DRIVE_STYLE = /^[A-Za-z]:([\\/]|$)/;
|
|
960
|
+
/** 盘符**绝对**:盘符后必须有分隔符(`C:\` / `C:/`)。裸 `C:` 不算。 */
|
|
961
|
+
const DRIVE_ROOTED = /^[A-Za-z]:[\\/]/;
|
|
962
|
+
/** UNC / 双分隔符开头:`\\server\share` 或 `//server/share`。 */
|
|
963
|
+
const UNC = /^[\\/]{2}[^\\/]/;
|
|
964
|
+
/** 这个字符串看起来是 Windows 风格路径(盘符或 UNC)。**不表示它是绝对路径。** */
|
|
965
|
+
function isWindowsStylePath(value) {
|
|
966
|
+
const raw = text(value);
|
|
967
|
+
return DRIVE_STYLE.test(raw) || UNC.test(raw);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* 是否是绝对本地路径(POSIX `/…`、Windows 盘符根、UNC)。
|
|
971
|
+
*
|
|
972
|
+
* **裸 `C:` 不是绝对路径**(2026-09-28 复查修):早先这里复用了「Windows 风格」的判断,
|
|
973
|
+
* 于是 `requireCaseDir('C:')` 会把盘符相对路径当绝对路径放行 —— 案例目录随后随进程 cwd 漂移,
|
|
974
|
+
* 产物落点不可预测。`C:` 与 `C:\` 是完全不同的位置,两条正则不能共用。
|
|
975
|
+
*/
|
|
976
|
+
function isAbsoluteLocalPath(value) {
|
|
977
|
+
const raw = text(value);
|
|
978
|
+
return raw.startsWith("/") || DRIVE_ROOTED.test(raw) || UNC.test(raw);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* 去掉尾部路径分隔符,但**保留根**:
|
|
982
|
+
*
|
|
983
|
+
* - `'/work/'` → `'/work'`;`'/'` → `'/'`;
|
|
984
|
+
* - `'C:\\Work\\'` → `'C:\\Work'`;`'C:\\'` → `'C:\\'`(裁成 `'C:'` 会变成「盘符相对路径」);
|
|
985
|
+
* - `'\\\\server\\share\\'` → `'\\\\server\\share'`;
|
|
986
|
+
* `'\\\\server\\'` → `'\\\\server\\'`(裁掉尾部分隔符就只剩服务器名,不是一个可解析的路径)。
|
|
987
|
+
*/
|
|
988
|
+
function trimTrailingSeparators(value) {
|
|
989
|
+
const raw = text(value);
|
|
990
|
+
if (raw === "") return "";
|
|
991
|
+
if (/^\/+$/.test(raw)) return "/";
|
|
992
|
+
if (/^[A-Za-z]:[\\/]$/.test(raw)) return raw;
|
|
993
|
+
if (/^[\\/]{2}[^\\/]+[\\/]$/.test(raw)) return raw;
|
|
994
|
+
let end = raw.length;
|
|
995
|
+
while (end > 0 && (raw[end - 1] === "/" || raw[end - 1] === "\\")) end -= 1;
|
|
996
|
+
return end === 0 ? raw : raw.slice(0, end);
|
|
997
|
+
}
|
|
998
|
+
/** 该路径风格用哪个分隔符;非 Windows 风格一律 `/`。 */
|
|
999
|
+
function localSeparator(value) {
|
|
1000
|
+
return isWindowsStylePath(value) ? "\\" : "/";
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* `child` 是不是 `parent` 本身或它的后代(**仅供展示层的匹配**)。
|
|
1004
|
+
*
|
|
1005
|
+
* 为什么需要:`WorkspaceEntry` 之间可能互相嵌套(一个工作空间是另一个的子目录),
|
|
1006
|
+
* 界面要选**最长**的那个;历史实现写的是 `child.startsWith(parent + '/')`,
|
|
1007
|
+
* 在 Windows 上既漏(分隔符是 `\`)又不区分大小写(Windows 路径不区分)。
|
|
1008
|
+
*
|
|
1009
|
+
* **这不是安全边界**:`..`、符号链接、8.3 短名都会让它判断错 ——
|
|
1010
|
+
* 任何「能不能读写这个目录」的判断都必须走 `ctx.fs.contains`(后端 realpath 之后比较)。
|
|
1011
|
+
*/
|
|
1012
|
+
function isLocalPathUnder(child, parent) {
|
|
1013
|
+
const base = trimTrailingSeparators(parent);
|
|
1014
|
+
if (base === "") return false;
|
|
1015
|
+
const target = trimTrailingSeparators(child);
|
|
1016
|
+
if (target === "") return false;
|
|
1017
|
+
const fold = isWindowsStylePath(base) ? (value) => value.toLowerCase() : (value) => value;
|
|
1018
|
+
const left = fold(target);
|
|
1019
|
+
const right = fold(base);
|
|
1020
|
+
if (left === right) return true;
|
|
1021
|
+
if (/[\\/]$/.test(base)) return left.startsWith(right);
|
|
1022
|
+
return left.startsWith(`${right}/`) || left.startsWith(`${right}\\`);
|
|
1023
|
+
}
|
|
1024
|
+
/** 一个片段是不是 `..`(只看整段,不看 `a..b` 这种文件名)。 */
|
|
1025
|
+
function isParentSegment(segment) {
|
|
1026
|
+
return segment.split(/[\\/]+/).some((part) => part === "..");
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* 拼接一条本地路径:分隔符随 `root` 的风格。
|
|
1030
|
+
*
|
|
1031
|
+
* - `joinLocalPath('C:\\Work', 'a', 'b')` → `'C:\\Work\\a\\b'`
|
|
1032
|
+
* - `joinLocalPath('/work', 'a', 'b')` → `'/work/a/b'`
|
|
1033
|
+
* - `joinLocalPath('/work/', 'a')` → `'/work/a'`(根上的尾分隔符不产生双斜杠)
|
|
1034
|
+
*
|
|
1035
|
+
* **拒绝**(抛 `RangeError`)而不是清洗:
|
|
1036
|
+
* - 片段是绝对路径(`/x`、`C:\x`、`\\server\x`)—— 那会静默替换掉整条路径;
|
|
1037
|
+
* - 片段含 `..` 整段 —— 那会静默越出调用方以为的目录。
|
|
1038
|
+
*
|
|
1039
|
+
* 空片段(`''`)跳过;片段内部的另一种分隔符会被规范成目标分隔符。
|
|
1040
|
+
*
|
|
1041
|
+
* 裸盘符根(`'C:'`)会被规范成 `'C:\\'` —— 「往 `C:` 后面接一段」在 Windows 上的实际含义
|
|
1042
|
+
* 就是该盘的根,而不是「这个盘上的当前目录」;要判「是不是绝对路径」用 `isAbsoluteLocalPath`,
|
|
1043
|
+
* 那一条对裸 `C:` 明确返回 `false`。
|
|
1044
|
+
*/
|
|
1045
|
+
function joinLocalPath(root, ...segments) {
|
|
1046
|
+
const base = trimTrailingSeparators(root);
|
|
1047
|
+
const sep = localSeparator(base);
|
|
1048
|
+
const parts = [];
|
|
1049
|
+
if (base !== "") parts.push(base);
|
|
1050
|
+
for (const segment of segments) {
|
|
1051
|
+
const raw = text(segment);
|
|
1052
|
+
if (raw === "") continue;
|
|
1053
|
+
if (isAbsoluteLocalPath(raw)) throw new RangeError(`joinLocalPath 拒绝绝对路径片段:${raw}`);
|
|
1054
|
+
if (isParentSegment(raw)) throw new RangeError(`joinLocalPath 拒绝含 .. 的片段:${raw}`);
|
|
1055
|
+
const cleaned = raw.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
|
|
1056
|
+
if (cleaned === "") continue;
|
|
1057
|
+
parts.push(sep === "\\" ? cleaned.replace(/\//g, "\\") : cleaned.replace(/\\/g, "/"));
|
|
1058
|
+
}
|
|
1059
|
+
if (parts.length === 0) return "";
|
|
1060
|
+
if (parts.length === 1) return parts[0];
|
|
1061
|
+
const [head, ...tail] = parts;
|
|
1062
|
+
const joint = head !== void 0 && /[\\/]$/.test(head) ? "" : sep;
|
|
1063
|
+
return `${head ?? ""}${joint}${tail.join(sep)}`;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* 取路径最后一段(目录名 / 文件名)。
|
|
1067
|
+
*
|
|
1068
|
+
* 两种分隔符、盘符根、UNC 根都能正确处理:`'C:\\Work\\S1\\'` → `'S1'`,
|
|
1069
|
+
* `'\\\\server\\share'` → `'share'`,`'/'` → `'/'`,`'C:\\'` → `'C:\\'`。
|
|
1070
|
+
*/
|
|
1071
|
+
function basenameLocalPath(value) {
|
|
1072
|
+
const trimmed = trimTrailingSeparators(value);
|
|
1073
|
+
if (trimmed === "") return "";
|
|
1074
|
+
if (/^[\\/]+$/.test(trimmed)) return trimmed;
|
|
1075
|
+
if (/^[A-Za-z]:$/.test(trimmed)) return trimmed;
|
|
1076
|
+
if (/^[A-Za-z]:[\\/]$/.test(trimmed)) return trimmed;
|
|
1077
|
+
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
|
|
1078
|
+
return parts.length > 0 ? parts[parts.length - 1] ?? trimmed : trimmed;
|
|
1079
|
+
}
|
|
930
1080
|
//#endregion
|
|
931
1081
|
//#region src/shared/utils/case-dir.ts
|
|
932
1082
|
/**
|
|
933
|
-
*
|
|
1083
|
+
* 一条报告的**案例目录**:`<工作空间>/<流水号>`(Windows 上是 `<工作空间>\<流水号>`)。
|
|
934
1084
|
*
|
|
935
1085
|
* 这是 Host 与 Client 都依赖的唯一约定,所以只在这里定义一次:
|
|
936
1086
|
* - Host 的审核提示词拿它告诉审核子代理「本案例目录必须是 …」(`host/audit/prompt.ts`);
|
|
@@ -941,15 +1091,22 @@ const CASE_KNOWLEDGE_DIR = "knowledge";
|
|
|
941
1091
|
* 猜成了 `<工作空间>/cases/<流水号>`:既扫了用户的磁盘,又把材料下到了错的地方。
|
|
942
1092
|
* 两处各写一份拼接逻辑就是这种漂移的温床,所以宁可多一个共享模块。
|
|
943
1093
|
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
1094
|
+
* **拼接交给 `joinLocalPath`**(2026-09-28 Windows 适配):分隔符随工作空间的风格走 ——
|
|
1095
|
+
* 在 Windows 上硬拼 `/` 会得到 `C:\Work/S1` 这种混用形式,而这条路径既进提示词
|
|
1096
|
+
* (模型照着写 PowerShell 命令)又进 `ctx.shell` 的 `workdir`。
|
|
1097
|
+
*
|
|
1098
|
+
* 任一侧为空、或流水号不是安全的一段(绝对路径 / 含 `..`)时返回空串,
|
|
1099
|
+
* 调用方据此**不写这一段**(宁可不给,也不给一个半截或越界的路径)。
|
|
947
1100
|
*/
|
|
948
1101
|
function caseDirOf(workspacePath, seqNo) {
|
|
949
|
-
const workspace = text(workspacePath)
|
|
1102
|
+
const workspace = text(workspacePath);
|
|
950
1103
|
const seq = text(seqNo).trim();
|
|
951
1104
|
if (workspace === "" || seq === "") return "";
|
|
952
|
-
|
|
1105
|
+
try {
|
|
1106
|
+
return joinLocalPath(workspace, seq);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
return "";
|
|
1109
|
+
}
|
|
953
1110
|
}
|
|
954
1111
|
//#endregion
|
|
955
1112
|
//#region src/host/audit/prompt.ts
|
|
@@ -1537,421 +1694,120 @@ function isWindowsPlatform(platform) {
|
|
|
1537
1694
|
return platform.startsWith("win32");
|
|
1538
1695
|
}
|
|
1539
1696
|
//#endregion
|
|
1540
|
-
//#region src/host/
|
|
1697
|
+
//#region src/host/platform/shell.ts
|
|
1698
|
+
/** 一段文本是否可以不加引号直接放进 POSIX 命令(保守白名单,含 `$` 就不行)。 */
|
|
1699
|
+
const POSIX_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
1541
1700
|
/**
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
* 为什么必须集中一处:`ossutil` 的报错里会带请求 URL(含 `Signature` / `OSSAccessKeyId`)、
|
|
1545
|
-
* 甚至完整的临时凭据与 `x-oss-security-token`。这类文本一旦进会话记录或界面,
|
|
1546
|
-
* 就等于把凭据写进了可回放的日志。上传路径(`tools/oss.ts`)与环境探测
|
|
1547
|
-
* (`environment/probe.ts`)都要用同一份判据 —— 两套正则必然有一天只改了一处。
|
|
1548
|
-
*
|
|
1549
|
-
* 顺序有讲究:**先按字段名抹值、再抹整条查询串、最后兜底抹 AK 形态**。
|
|
1550
|
-
* 反过来会把 URL 整条打成 `?<redacted>`,连"是哪个 bucket"都看不出来。
|
|
1701
|
+
* NUL 无法在命令串里表达(两个平台都一样):原样带进去会截断命令、把后面的内容变成新命令。
|
|
1702
|
+
* 所以这里是**明确拒绝**,而不是「尽量拼一个」。
|
|
1551
1703
|
*/
|
|
1552
|
-
function
|
|
1553
|
-
|
|
1704
|
+
function assertNoNul(value, what) {
|
|
1705
|
+
if (value.includes("\0")) throw new Error(`${what} 含 NUL 字符,无法安全放进 shell 命令`);
|
|
1706
|
+
return value;
|
|
1707
|
+
}
|
|
1708
|
+
/** 该平台键走哪条方言。`win32-x64` / `win32-arm64` 都是 PowerShell。 */
|
|
1709
|
+
function shellDialect(platform) {
|
|
1710
|
+
return isWindowsPlatform(text(platform)) ? "powershell" : "posix";
|
|
1554
1711
|
}
|
|
1555
|
-
//#endregion
|
|
1556
|
-
//#region src/host/environment/probe.ts
|
|
1557
1712
|
/**
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
* POSIX 上就是 `bash -c`:能不加引号就不加(`quoteArg`),需要时用单引号 + `'\''`。
|
|
1713
|
+
* 把一个**参数**安全地引用成字面量。
|
|
1561
1714
|
*
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1565
|
-
* 而单引号是纯字面量。cmd 那套 `"…"` + `""` 转义在这里既没必要也不安全。
|
|
1715
|
+
* - PowerShell:一律单引号 + 内部单引号翻倍。**不用双引号** —— 双引号会插值 `$` / 反引号,
|
|
1716
|
+
* 路径或 URL 里的 `$` 会被当变量展开。
|
|
1717
|
+
* - POSIX:能不加引号就不加(可读性),否则单引号 + `'\''`。
|
|
1566
1718
|
*
|
|
1567
|
-
*
|
|
1568
|
-
* `shellInvoke()` 负责;`shellQuote` 只管「一个参数」。
|
|
1719
|
+
* 换行、制表、回车都**安全表示**(单引号内部是字面量,不会变成语句分隔符);只有 NUL 抛错。
|
|
1569
1720
|
*/
|
|
1570
|
-
function shellQuote
|
|
1571
|
-
|
|
1572
|
-
return `'${
|
|
1721
|
+
function shellQuote(value, platform) {
|
|
1722
|
+
const raw = assertNoNul(text(value), "参数");
|
|
1723
|
+
if (shellDialect(platform) === "powershell") return `'${raw.replace(/'/g, "''")}'`;
|
|
1724
|
+
if (raw !== "" && POSIX_SAFE.test(raw)) return raw;
|
|
1725
|
+
return `'${raw.replace(/'/g, "'\\''")}'`;
|
|
1573
1726
|
}
|
|
1574
1727
|
/**
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
* 为什么不能直接 `${shellQuote(exe, platform)} 参数…`(2026-09-28 修,员工在 Windows 上实测报错):
|
|
1578
|
-
* DSH 在 Windows 挂的执行器是 `@deepseek-ai/dsh-pwsh-local`,它把整条命令作为**一个 argv 元素**
|
|
1579
|
-
* 交给 `pwsh -NoLogo -NoProfile -NonInteractive -Command <整串>`(该包自己的 README:*the command
|
|
1580
|
-
* string is passed as ONE argv element to `-Command`; PowerShell itself parses the text*)。
|
|
1581
|
-
* 也就是说插件拼的是 **PowerShell 脚本**,不是 `cmd.exe` 的批处理行。
|
|
1582
|
-
*
|
|
1583
|
-
* 后果很硬:**以引号开头的 token 在 PowerShell 里是字符串表达式,不是命令调用**。
|
|
1584
|
-
*
|
|
1585
|
-
* ```text
|
|
1586
|
-
* PS> "C:\…\crwu.exe" "h3yun" "session" "status"
|
|
1587
|
-
* 表达式或语句中包含意外的标记"h3yun"。
|
|
1588
|
-
* ```
|
|
1728
|
+
* 拼一条「可执行文件 + 参数」的命令(**命令位置**)。
|
|
1589
1729
|
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
* `& 'C:\…\crwu.exe' 'h3yun' 'session' 'status'`。
|
|
1593
|
-
*
|
|
1594
|
-
* POSIX 上**绝不能**加 `&`:`bash -c` 里它是后台作业,会把前台命令变成异步执行。
|
|
1595
|
-
*
|
|
1596
|
-
* 所有「第一条 token 是可执行文件」的调用点都必须走这里,而不是自己 join ——
|
|
1597
|
-
* `tests/unit/host-shell-fs.test.mjs` 里有静态守卫钉着这一点。
|
|
1730
|
+
* Windows 上第一个 token 必须是 `& <引用后的路径>`:`&` 才是「把这段字符串当命令执行」。
|
|
1731
|
+
* POSIX 上**绝不能**加 `&` —— 在 `bash -c` 里它是后台作业,会把前台命令变成异步执行。
|
|
1598
1732
|
*/
|
|
1599
1733
|
function shellInvoke(executable, args, platform) {
|
|
1600
|
-
const parts = [executable, ...args].map((item) => shellQuote
|
|
1601
|
-
return
|
|
1734
|
+
const parts = [executable, ...args].map((item) => shellQuote(item, platform));
|
|
1735
|
+
return shellDialect(platform) === "powershell" ? `& ${parts.join(" ")}` : parts.join(" ");
|
|
1736
|
+
}
|
|
1737
|
+
/** 幂等建目录(`mkdir -p` 的等价物)。 */
|
|
1738
|
+
function mkdirCommand(path, platform) {
|
|
1739
|
+
return shellDialect(platform) === "powershell" ? `New-Item -ItemType Directory -Force -Path ${shellQuote(path, platform)} | Out-Null` : `mkdir -p ${shellQuote(path, platform)}`;
|
|
1602
1740
|
}
|
|
1603
1741
|
/**
|
|
1604
|
-
*
|
|
1742
|
+
* 幂等删除一个普通文件。
|
|
1605
1743
|
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1744
|
+
* 「目标本来不存在」是幂等成功,其余失败(权限、占用、父目录错误)必须让退出码非 0:
|
|
1745
|
+
* PowerShell 分支用 `-ErrorAction Stop` 让错误终止,并用 `if (Test-Path …)` 把「没有这个文件」
|
|
1746
|
+
* 挡在删除之前 —— **不用** `-ErrorAction SilentlyContinue`,那会把真实失败也吞成成功。
|
|
1609
1747
|
*/
|
|
1610
|
-
function
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
manifestPath: packageRoot === "" ? "" : join(packageRoot, "bin", "manifest.json")
|
|
1615
|
-
};
|
|
1616
|
-
}
|
|
1617
|
-
/** 读包内 `bin/manifest.json` 里某个平台的声明;读不到就返回空表(**不**当成「没有组件」)。 */
|
|
1618
|
-
function readBinManifest(raw, platform) {
|
|
1619
|
-
const tools = /* @__PURE__ */ new Map();
|
|
1620
|
-
let parsed;
|
|
1621
|
-
try {
|
|
1622
|
-
parsed = JSON.parse(text(raw));
|
|
1623
|
-
} catch (error) {
|
|
1624
|
-
return {
|
|
1625
|
-
found: false,
|
|
1626
|
-
tools
|
|
1627
|
-
};
|
|
1628
|
-
}
|
|
1629
|
-
const doc = parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
1630
|
-
const platforms = Array.isArray(doc.platforms) ? doc.platforms : [];
|
|
1631
|
-
for (const entry of platforms) {
|
|
1632
|
-
const row = entry !== null && typeof entry === "object" ? entry : {};
|
|
1633
|
-
if (text(row.platform) !== platform) continue;
|
|
1634
|
-
for (const item of Array.isArray(row.tools) ? row.tools : []) {
|
|
1635
|
-
const tool = item !== null && typeof item === "object" ? item : {};
|
|
1636
|
-
const file = text(tool.file);
|
|
1637
|
-
if (file === "") continue;
|
|
1638
|
-
tools.set(text(tool.tool) || file, {
|
|
1639
|
-
file,
|
|
1640
|
-
size: typeof tool.size === "number" && Number.isFinite(tool.size) ? tool.size : 0,
|
|
1641
|
-
sha256: text(tool.sha256),
|
|
1642
|
-
sourceVersion: text(tool.sourceVersion)
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
return {
|
|
1646
|
-
found: true,
|
|
1647
|
-
tools
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
return {
|
|
1651
|
-
found: false,
|
|
1652
|
-
tools
|
|
1653
|
-
};
|
|
1748
|
+
function removeFileCommand(path, platform) {
|
|
1749
|
+
if (shellDialect(platform) !== "powershell") return `rm -f -- ${shellQuote(path, platform)}`;
|
|
1750
|
+
const quoted = shellQuote(path, platform);
|
|
1751
|
+
return `if (Test-Path -LiteralPath ${quoted}) { Remove-Item -LiteralPath ${quoted} -Force -ErrorAction Stop }`;
|
|
1654
1752
|
}
|
|
1655
|
-
/**
|
|
1656
|
-
function
|
|
1657
|
-
return
|
|
1753
|
+
/** 用系统默认程序打开一个文件或 URL。 */
|
|
1754
|
+
function openExternalCommand(target, platform) {
|
|
1755
|
+
if (shellDialect(platform) === "powershell") return `Start-Process -FilePath ${shellQuote(target, platform)}`;
|
|
1756
|
+
if (text(platform).startsWith("darwin")) return `open ${shellQuote(target, platform)}`;
|
|
1757
|
+
return `xdg-open ${shellQuote(target, platform)}`;
|
|
1658
1758
|
}
|
|
1659
1759
|
/**
|
|
1660
|
-
*
|
|
1760
|
+
* 把 stdin 写进系统剪贴板的命令。
|
|
1661
1761
|
*
|
|
1662
|
-
*
|
|
1663
|
-
* 1. 当前平台有随包目录(否则「平台不受支持」);
|
|
1664
|
-
* 2. 文件真的在 `bin/<平台>/` 里;
|
|
1665
|
-
* 3. 字节数与包内 `bin/manifest.json` 一致(运行残留 / 安装不完整都会在这里露出来)。
|
|
1762
|
+
* 内容**只走 stdin**、永远不作为参数(提示词里的引号、反引号、`$` 拼进命令行会被解释)。
|
|
1666
1763
|
*/
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
let manifestFound = false;
|
|
1672
|
-
const declared = /* @__PURE__ */ new Map();
|
|
1673
|
-
if (fs !== void 0 && manifestPath !== "") try {
|
|
1674
|
-
const parsed = readBinManifest(await fs.readText(await resolveTarget(ctx, manifestPath)), platform);
|
|
1675
|
-
manifestFound = parsed.found;
|
|
1676
|
-
for (const [name, tool] of parsed.tools) declared.set(name, tool);
|
|
1677
|
-
} catch (error) {
|
|
1678
|
-
manifestFound = false;
|
|
1679
|
-
}
|
|
1680
|
-
const tools = [];
|
|
1681
|
-
for (const spec of manifest.packaged) tools.push(await checkPackagedTool(ctx, spec, platform, {
|
|
1682
|
-
supported,
|
|
1683
|
-
manifestFound,
|
|
1684
|
-
manifestPath,
|
|
1685
|
-
declared
|
|
1686
|
-
}));
|
|
1687
|
-
const failed = tools.filter((tool) => !tool.ok);
|
|
1688
|
-
const ok = failed.length === 0;
|
|
1689
|
-
return {
|
|
1690
|
-
ok,
|
|
1691
|
-
supported,
|
|
1692
|
-
platform,
|
|
1693
|
-
packageRoot,
|
|
1694
|
-
manifestPath,
|
|
1695
|
-
manifestFound,
|
|
1696
|
-
tools,
|
|
1697
|
-
note: ok ? `插件内置组件 ${String(tools.length)}/${String(tools.length)} 完整:字节数与包内 bin/manifest.json 一致(sha256 见明细,不在自检里重算)。` : failed[0]?.reason ?? packageGapMessage(platform, "插件内置组件不可用")
|
|
1698
|
-
};
|
|
1699
|
-
}
|
|
1700
|
-
/** 单个组件:存在性 + 字节数;理由必须能分辨「平台不受支持」「缺文件」「清单读不到」「字节数不一致」。 */
|
|
1701
|
-
async function checkPackagedTool(ctx, spec, platform, facts) {
|
|
1702
|
-
const file = binaryFileName(spec.name, platform);
|
|
1703
|
-
const entry = facts.declared.get(spec.name);
|
|
1704
|
-
const base = {
|
|
1705
|
-
name: spec.name,
|
|
1706
|
-
label: spec.label,
|
|
1707
|
-
file,
|
|
1708
|
-
present: false,
|
|
1709
|
-
sizeBytes: 0,
|
|
1710
|
-
manifestSizeBytes: entry?.size ?? 0,
|
|
1711
|
-
sha256: entry?.sha256 ?? "",
|
|
1712
|
-
expectedVersion: spec.expectedVersion,
|
|
1713
|
-
note: spec.note,
|
|
1714
|
-
ok: false,
|
|
1715
|
-
reason: ""
|
|
1716
|
-
};
|
|
1717
|
-
if (!facts.supported) return {
|
|
1718
|
-
...base,
|
|
1719
|
-
reason: packageGapMessage(platform, "当前平台没有随包发布的组件目录")
|
|
1720
|
-
};
|
|
1721
|
-
const bundled = bundledBinaryPath(platform, spec.name);
|
|
1722
|
-
if (bundled === "") return {
|
|
1723
|
-
...base,
|
|
1724
|
-
reason: packageGapMessage(platform, "当前平台没有随包发布的组件目录")
|
|
1725
|
-
};
|
|
1726
|
-
const fs = fileSystem(ctx);
|
|
1727
|
-
if (fs === void 0) return {
|
|
1728
|
-
...base,
|
|
1729
|
-
reason: `无法核对插件包完整性:Host 文件服务不可用(包内路径 ${bundled})`
|
|
1730
|
-
};
|
|
1731
|
-
let info;
|
|
1732
|
-
try {
|
|
1733
|
-
info = await fs.stat(await resolveTarget(ctx, bundled));
|
|
1734
|
-
} catch (error) {
|
|
1735
|
-
info = void 0;
|
|
1736
|
-
}
|
|
1737
|
-
if (info?.type !== "file") return {
|
|
1738
|
-
...base,
|
|
1739
|
-
reason: `插件包不完整:包内缺少 ${bundled}`
|
|
1740
|
-
};
|
|
1741
|
-
const sizeBytes = typeof info.size === "number" && Number.isFinite(info.size) ? info.size : 0;
|
|
1742
|
-
const present = {
|
|
1743
|
-
...base,
|
|
1744
|
-
present: true,
|
|
1745
|
-
sizeBytes
|
|
1746
|
-
};
|
|
1747
|
-
if (!facts.manifestFound) return {
|
|
1748
|
-
...present,
|
|
1749
|
-
reason: `插件包不完整:读不到包内清单 ${facts.manifestPath},无法核对 ${file} 的字节数`
|
|
1750
|
-
};
|
|
1751
|
-
if (present.manifestSizeBytes === 0) return {
|
|
1752
|
-
...present,
|
|
1753
|
-
reason: `插件包不完整:包内清单里没有 ${spec.name} 的字节数,无法核对`
|
|
1754
|
-
};
|
|
1755
|
-
if (sizeBytes !== present.manifestSizeBytes) return {
|
|
1756
|
-
...present,
|
|
1757
|
-
reason: `插件包不完整:包内 ${file} 的字节数与随包清单不一致(磁盘 ${String(sizeBytes)} / 清单 ${String(present.manifestSizeBytes)},可能是安装不完整或运行残留)`
|
|
1758
|
-
};
|
|
1759
|
-
return {
|
|
1760
|
-
...present,
|
|
1761
|
-
ok: true,
|
|
1762
|
-
reason: ""
|
|
1763
|
-
};
|
|
1764
|
-
}
|
|
1765
|
-
/** 定位失败时给用户的那句话。文案里不许出现安装指引:这个二进制随插件发布。 */
|
|
1766
|
-
function ossutilMissingMessage(lookup) {
|
|
1767
|
-
return lookup.error === "" ? packageGapMessage("", "没有找到随包发布的 ossutil") : lookup.error;
|
|
1764
|
+
function clipboardCommand(platform) {
|
|
1765
|
+
if (text(platform).startsWith("darwin")) return "pbcopy";
|
|
1766
|
+
if (shellDialect(platform) === "powershell") return "clip";
|
|
1767
|
+
return "xclip -selection clipboard 2>/dev/null || xsel -b";
|
|
1768
1768
|
}
|
|
1769
1769
|
/**
|
|
1770
|
-
*
|
|
1770
|
+
* 探测执行世界主目录的命令。
|
|
1771
1771
|
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
* `oss` 参数保留是为了不改调用方签名:清单里的 `ossutil` 命令名已经不再参与解析。
|
|
1772
|
+
* Windows 只用 PowerShell 自己的环境变量:`python3` / `printf` 在 Windows 上都不保证存在,
|
|
1773
|
+
* 拿它们当探针会把「插件包装配」变成「系统装没装 Python」。POSIX 侧同理不依赖 `python3`。
|
|
1775
1774
|
*/
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
if (bundled === "") return {
|
|
1779
|
-
path: "",
|
|
1780
|
-
error: packageGapMessage(platform, "当前平台没有随包发布的 ossutil")
|
|
1781
|
-
};
|
|
1782
|
-
const fs = fileSystem(ctx);
|
|
1783
|
-
if (fs === void 0) return {
|
|
1784
|
-
path: "",
|
|
1785
|
-
error: `无法核对插件包完整性:Host 文件服务不可用(包内路径 ${bundled})`
|
|
1786
|
-
};
|
|
1787
|
-
try {
|
|
1788
|
-
if ((await fs.stat(await resolveTarget(ctx, bundled)))?.type === "file") return {
|
|
1789
|
-
path: bundled,
|
|
1790
|
-
error: ""
|
|
1791
|
-
};
|
|
1792
|
-
} catch (error) {}
|
|
1793
|
-
return {
|
|
1794
|
-
path: "",
|
|
1795
|
-
error: packageGapMessage(platform, `包内缺少 ${bundled}`)
|
|
1796
|
-
};
|
|
1775
|
+
function homeProbeCommand(platform) {
|
|
1776
|
+
return shellDialect(platform) === "powershell" ? "Write-Output $env:USERPROFILE" : "printf %s \"$HOME\"";
|
|
1797
1777
|
}
|
|
1798
1778
|
/**
|
|
1799
|
-
*
|
|
1779
|
+
* 把普通文件权限收紧到「仅属主可读写」。
|
|
1800
1780
|
*
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1781
|
+
* Windows 没有 POSIX 权限位、也没有 `chmod` 命令,返回空串表示**这一项不适用**(凭据文件在
|
|
1782
|
+
* 用户配置目录内,由用户 ACL 保护)。调用方据此上报「继承当前账户 ACL」而不是伪造一次成功。
|
|
1803
1783
|
*/
|
|
1804
|
-
function
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
return {
|
|
1808
|
-
bucket: oss.bucket,
|
|
1809
|
-
prefix: path,
|
|
1810
|
-
target: `oss://${oss.bucket}/${path}`
|
|
1811
|
-
};
|
|
1784
|
+
function privateFileCommand(path, platform) {
|
|
1785
|
+
if (shellDialect(platform) === "powershell") return "";
|
|
1786
|
+
return `chmod 600 ${shellQuote(path, platform)}`;
|
|
1812
1787
|
}
|
|
1813
1788
|
/**
|
|
1814
|
-
*
|
|
1789
|
+
* 读取一个文件的 POSIX 权限模式(八进制,例如 `600`)。
|
|
1815
1790
|
*
|
|
1816
|
-
*
|
|
1817
|
-
*
|
|
1818
|
-
*
|
|
1819
|
-
* 不是"一定列到对象");
|
|
1820
|
-
* - 只用只读 `ls`:**不**为了验证去上传、删除或创建对象;
|
|
1821
|
-
* - 只认包内绝对路径(`resolveOssutil`),不回退 PATH、不要员工装 ossutil;
|
|
1822
|
-
* - 所有 stdout / stderr 在返回或写日志之前过 `sanitizeOssError`(AK / STS / Signature / 签名 URL)。
|
|
1791
|
+
* **为什么需要它**:`chmod` 在个别文件系统(网络盘、被容器/虚拟化层挡住的挂载点)上会
|
|
1792
|
+
* **静默无效** —— 退出码 0 但模式没变。只信退出码就把「命令跑过了」说成「权限已验证」,
|
|
1793
|
+
* 而 0600 正是凭据文件的安全边界。所以收紧之后要回读一次。
|
|
1823
1794
|
*
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
1826
|
-
*/
|
|
1827
|
-
async function probeOss(ctx, oss, platform, options = {}) {
|
|
1828
|
-
const { target, prefix } = ossProbeTarget(oss);
|
|
1829
|
-
const base = {
|
|
1830
|
-
id: "oss",
|
|
1831
|
-
label: "阿里云 OSS(AK 权限)",
|
|
1832
|
-
required: true,
|
|
1833
|
-
ok: false,
|
|
1834
|
-
state: "",
|
|
1835
|
-
detail: "",
|
|
1836
|
-
errorKind: "",
|
|
1837
|
-
target
|
|
1838
|
-
};
|
|
1839
|
-
if (!oss.enabled) return {
|
|
1840
|
-
...base,
|
|
1841
|
-
ok: false,
|
|
1842
|
-
state: "未启用",
|
|
1843
|
-
errorKind: "config",
|
|
1844
|
-
detail: "清单 oss.enabled 为 false"
|
|
1845
|
-
};
|
|
1846
|
-
if (oss.bucket === "") return {
|
|
1847
|
-
...base,
|
|
1848
|
-
ok: false,
|
|
1849
|
-
state: "缺 bucket",
|
|
1850
|
-
errorKind: "config",
|
|
1851
|
-
detail: "清单 oss.bucket 为空(由管理员在部署配置里设置)"
|
|
1852
|
-
};
|
|
1853
|
-
const lookup = await resolveOssutil(ctx, oss, platform, options);
|
|
1854
|
-
if (lookup.path === "") return {
|
|
1855
|
-
...base,
|
|
1856
|
-
ok: false,
|
|
1857
|
-
state: "插件包不完整 / 平台不受支持",
|
|
1858
|
-
errorKind: "infrastructure",
|
|
1859
|
-
detail: lookup.error
|
|
1860
|
-
};
|
|
1861
|
-
const ossutil = lookup.path;
|
|
1862
|
-
const run = await runShell(ctx, buildOssProbeCommand(oss, ossutil, platform), {
|
|
1863
|
-
timeoutMs: 6e4,
|
|
1864
|
-
...options.workdir === void 0 ? {} : { workdir: options.workdir }
|
|
1865
|
-
});
|
|
1866
|
-
if (shellUnavailable(run)) return {
|
|
1867
|
-
...base,
|
|
1868
|
-
ok: false,
|
|
1869
|
-
state: "无法探测",
|
|
1870
|
-
errorKind: "infrastructure",
|
|
1871
|
-
detail: sanitizeOssError(text(run.error) || text(run.stderr) || "探测命令没有执行")
|
|
1872
|
-
};
|
|
1873
|
-
const verdict = classifyOssFailure(run.ok, sanitizeOssError(text(run.stderr) || text(run.stdout) || text(run.error)));
|
|
1874
|
-
if (run.ok) return {
|
|
1875
|
-
...base,
|
|
1876
|
-
ok: true,
|
|
1877
|
-
state: "AK 正常",
|
|
1878
|
-
errorKind: "",
|
|
1879
|
-
detail: `已验证可访问 ${target}${oss.endpoint === "" ? "" : ` @ ${oss.endpoint}`}(只读列举,空目录也算通过)`
|
|
1880
|
-
};
|
|
1881
|
-
return {
|
|
1882
|
-
...base,
|
|
1883
|
-
ok: false,
|
|
1884
|
-
state: verdict.state,
|
|
1885
|
-
errorKind: verdict.kind,
|
|
1886
|
-
detail: verdict.detail
|
|
1887
|
-
};
|
|
1888
|
-
}
|
|
1889
|
-
/**
|
|
1890
|
-
* 探测命令:**只读 `ls` + 业务前缀 + `--limited-num 1`**。
|
|
1891
|
-
*
|
|
1892
|
-
* `oss.probeCommand` 仍被尊重(部署方可以换成等价的只读命令),但 `{prefix}` 占位符与默认分支
|
|
1893
|
-
* 都指向业务前缀 —— 旧默认(列桶根)证明不了"能写交付件"。跑之前不检查、跑之后才看错误码,
|
|
1894
|
-
* 所以它必须自身只读。
|
|
1795
|
+
* GNU 与 BSD 的 `stat` 参数不同,这个分歧只在这里出现一次:
|
|
1796
|
+
* `stat -c %a`(GNU/Linux)与 `stat -f %Lp`(BSD/macOS)。Windows 没有 POSIX 模式位,返回空串。
|
|
1895
1797
|
*/
|
|
1896
|
-
function
|
|
1897
|
-
|
|
1898
|
-
const
|
|
1899
|
-
|
|
1900
|
-
return shellInvoke(ossutil, [
|
|
1901
|
-
"ls",
|
|
1902
|
-
target,
|
|
1903
|
-
...oss.endpoint === "" ? [] : ["--endpoint", oss.endpoint],
|
|
1904
|
-
"--limited-num",
|
|
1905
|
-
"1",
|
|
1906
|
-
...oss.extraArgs
|
|
1907
|
-
], platform);
|
|
1798
|
+
function readFileModeCommand(path, platform) {
|
|
1799
|
+
if (shellDialect(platform) === "powershell") return "";
|
|
1800
|
+
const quoted = shellQuote(path, platform);
|
|
1801
|
+
return text(platform).startsWith("darwin") ? `stat -f %Lp ${quoted}` : `stat -c %a ${quoted}`;
|
|
1908
1802
|
}
|
|
1909
1803
|
/**
|
|
1910
|
-
*
|
|
1804
|
+
* 该平台用哪种机制保护凭据文件 —— 与 `privateFileCommand` **同一判据**。
|
|
1911
1805
|
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
* → 员工重填 AK;
|
|
1915
|
-
* - `permission`:`AccessDenied` / `Forbidden` / `no permission`
|
|
1916
|
-
* → 凭据有效但没有这个 Bucket/Prefix 的权限,找管理员开权限;
|
|
1917
|
-
* - `config`:`NoSuchBucket` / `InvalidBucketName` / `Endpoint` 解析失败 / `unknown endpoint`
|
|
1918
|
-
* → 部署配置错了,找管理员;
|
|
1919
|
-
* - `infrastructure`:上面都不匹配(网络、DNS、超时、TLS、上游 5xx)
|
|
1920
|
-
* → 稍后重试 / 找管理员排查连通性,**不是** AK 的问题。
|
|
1806
|
+
* 拆出来是因为调用方要把「谁在负责」写进结构化的权限结论(`CredentialPermission.mechanism`),
|
|
1807
|
+
* 而不是只看命令字符串空不空。
|
|
1921
1808
|
*/
|
|
1922
|
-
function
|
|
1923
|
-
|
|
1924
|
-
if (ok) return {
|
|
1925
|
-
state: "AK 正常",
|
|
1926
|
-
kind: "",
|
|
1927
|
-
detail
|
|
1928
|
-
};
|
|
1929
|
-
const lower = raw.toLowerCase();
|
|
1930
|
-
if (/invalidaccesskeyid|signaturedoesnotmatch|invalidsaccesskeyid|invalidsecuritytoken|accesskeyid is disabled|accesskeyidisdisabled|invalidaccesskeyid\.notfound/i.test(raw)) return {
|
|
1931
|
-
state: "AccessKey 无效",
|
|
1932
|
-
kind: "credential",
|
|
1933
|
-
detail: detail || "OSS 认为这份 AccessKey 无效或已禁用"
|
|
1934
|
-
};
|
|
1935
|
-
if (/accessdenied|forbidden|no permission|denied/i.test(raw)) return {
|
|
1936
|
-
state: "AccessKey 没有目标权限",
|
|
1937
|
-
kind: "permission",
|
|
1938
|
-
detail: detail || "凭据有效,但没有该 Bucket / 前缀的访问权限"
|
|
1939
|
-
};
|
|
1940
|
-
if (/nosuchbucket|invalidbucketname|unknown endpoint|invalidendpoint|no such host.*oss|endpoint/i.test(lower)) return {
|
|
1941
|
-
state: "Bucket 或 Endpoint 配置有误",
|
|
1942
|
-
kind: "config",
|
|
1943
|
-
detail: detail || "Bucket 或 Endpoint 配置有误(由管理员在部署配置里设置)"
|
|
1944
|
-
};
|
|
1945
|
-
return {
|
|
1946
|
-
state: "连接 OSS 失败",
|
|
1947
|
-
kind: "infrastructure",
|
|
1948
|
-
detail: detail || "无法连接 OSS(网络 / 超时 / 服务不可达)"
|
|
1949
|
-
};
|
|
1950
|
-
}
|
|
1951
|
-
/** 探测输出的截断 + 脱敏(**唯一**出口,见 `oss/sanitize.ts`)。 */
|
|
1952
|
-
function clampOssText(raw) {
|
|
1953
|
-
const clean = sanitizeOssError(raw).trim();
|
|
1954
|
-
return clean.length <= 400 ? clean : `${clean.slice(0, 400)}…`;
|
|
1809
|
+
function privateFileMechanism(platform) {
|
|
1810
|
+
return shellDialect(platform) === "powershell" ? "windows-acl" : "posix-0600";
|
|
1955
1811
|
}
|
|
1956
1812
|
//#endregion
|
|
1957
1813
|
//#region src/host/platform/command.ts
|
|
@@ -2142,63 +1998,102 @@ function maskKey(value) {
|
|
|
2142
1998
|
* 为什么不是 `node:fs`:插件跑在 Host 进程里,直接用 Node 文件 API 会绕过 DSH 的
|
|
2143
1999
|
* 文件服务与沙箱策略。「删掉上一轮同名产物」这种动作必须走与其它命令同一条路径。
|
|
2144
2000
|
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2001
|
+
* 三条口径(2026-09-28 收紧,含一次复查修正):
|
|
2002
|
+
* 1. 命令串由 `platform/shell.ts` 按平台生成 —— 这里不再出现 `cmd /c`、`mkdir`、`rm` 之类字面量;
|
|
2003
|
+
* 2. **失败必须传播**:只把「目标已经是我们要的状态」视为成功。旧形态在 Windows 上把任意
|
|
2004
|
+
* mkdir 失败都当成功,故障会推迟到「下一步写文件才炸」,归因到完全无关的地方;
|
|
2005
|
+
* 3. **后置条件要回读**,而且回读本身有**三种结果**(2026-09-28 复查修):
|
|
2006
|
+
* 存在 / 不存在 / **查不出来**。DSH `fs.stat` 的契约是「不存在返回 `undefined`,
|
|
2007
|
+
* 真故障(权限、解析、基础设施)抛异常」——把异常也折叠成「不存在」,
|
|
2008
|
+
* 就会在 `Access denied` 时回报「删除成功」,或把一次读不到的目录当成已建好。
|
|
2009
|
+
* 所以「查不出来」一律按基础设施失败如实上报,不猜。
|
|
2147
2010
|
*/
|
|
2148
2011
|
async function removeFileIfExists(ctx, path, options) {
|
|
2149
|
-
const
|
|
2150
|
-
if (
|
|
2012
|
+
const probe = await probePath(ctx, path);
|
|
2013
|
+
if (probe.kind === "error") return {
|
|
2151
2014
|
ok: false,
|
|
2152
|
-
error:
|
|
2015
|
+
error: `无法确认目标状态:${probe.error}(${path})`.slice(0, 300)
|
|
2153
2016
|
};
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
};
|
|
2164
|
-
} catch (error) {
|
|
2165
|
-
return {
|
|
2166
|
-
ok: true,
|
|
2167
|
-
error: ""
|
|
2168
|
-
};
|
|
2169
|
-
}
|
|
2170
|
-
const quote = (value) => shellQuote$1(value, options.platform);
|
|
2171
|
-
const run = await runShell(ctx, isWindowsPlatform(options.platform) ? `cmd /c del /f /q ${quote(path)}` : `rm -f -- ${quote(path)}`, {
|
|
2017
|
+
if (probe.kind === "absent") return {
|
|
2018
|
+
ok: true,
|
|
2019
|
+
error: ""
|
|
2020
|
+
};
|
|
2021
|
+
if (probe.type !== "file") return {
|
|
2022
|
+
ok: false,
|
|
2023
|
+
error: `目标不是普通文件,拒绝删除:${path}`
|
|
2024
|
+
};
|
|
2025
|
+
const run = await runShell(ctx, removeFileCommand(path, options.platform), {
|
|
2172
2026
|
workdir: options.workdir,
|
|
2173
2027
|
timeoutMs: 2e4,
|
|
2174
2028
|
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
2175
2029
|
});
|
|
2030
|
+
if (!run.ok) return {
|
|
2031
|
+
ok: false,
|
|
2032
|
+
error: (text(run.stderr) || text(run.error) || "删除旧产物失败").slice(0, 300)
|
|
2033
|
+
};
|
|
2034
|
+
const after = await probePath(ctx, path);
|
|
2035
|
+
if (after.kind === "error") return {
|
|
2036
|
+
ok: false,
|
|
2037
|
+
error: `删除命令返回成功,但无法确认目标是否已删除:${after.error}`.slice(0, 300)
|
|
2038
|
+
};
|
|
2039
|
+
if (after.kind === "present") return {
|
|
2040
|
+
ok: false,
|
|
2041
|
+
error: `删除命令返回成功,但文件仍然存在:${path}`
|
|
2042
|
+
};
|
|
2176
2043
|
return {
|
|
2177
|
-
ok:
|
|
2178
|
-
error:
|
|
2044
|
+
ok: true,
|
|
2045
|
+
error: ""
|
|
2179
2046
|
};
|
|
2180
2047
|
}
|
|
2181
|
-
/** 确保目录存在(`mkdir -p`)。 */
|
|
2048
|
+
/** 确保目录存在(`mkdir -p` / `New-Item -ItemType Directory -Force`)。 */
|
|
2182
2049
|
async function ensureDirectory$1(ctx, path, options) {
|
|
2183
|
-
const
|
|
2184
|
-
const run = await runShell(ctx, isWindowsPlatform(options.platform) ? `cmd /c mkdir ${quote(path)}` : `mkdir -p ${quote(path)}`, {
|
|
2050
|
+
const run = await runShell(ctx, mkdirCommand(path, options.platform), {
|
|
2185
2051
|
workdir: options.workdir,
|
|
2186
2052
|
timeoutMs: 2e4,
|
|
2187
2053
|
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
2188
2054
|
});
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2055
|
+
const after = await probePath(ctx, path);
|
|
2056
|
+
if (after.kind === "error") return {
|
|
2057
|
+
ok: false,
|
|
2058
|
+
error: `无法确认目录是否已创建:${after.error}(${path})`.slice(0, 300)
|
|
2192
2059
|
};
|
|
2193
|
-
if (
|
|
2060
|
+
if (after.kind === "present" && after.type === "directory") return {
|
|
2194
2061
|
ok: true,
|
|
2195
2062
|
error: ""
|
|
2196
2063
|
};
|
|
2064
|
+
if (!run.ok) return {
|
|
2065
|
+
ok: false,
|
|
2066
|
+
error: (text(run.stderr) || text(run.error) || "创建目录失败").slice(0, 300)
|
|
2067
|
+
};
|
|
2068
|
+
if (after.kind === "present") return {
|
|
2069
|
+
ok: false,
|
|
2070
|
+
error: `创建目录命令返回成功,但目标不是目录:${path}`
|
|
2071
|
+
};
|
|
2197
2072
|
return {
|
|
2198
2073
|
ok: false,
|
|
2199
|
-
error:
|
|
2074
|
+
error: `创建目录命令返回成功,但目录不存在:${path}`
|
|
2200
2075
|
};
|
|
2201
2076
|
}
|
|
2077
|
+
async function probePath(ctx, path) {
|
|
2078
|
+
const fs = fileSystem(ctx);
|
|
2079
|
+
if (fs === void 0) return {
|
|
2080
|
+
kind: "error",
|
|
2081
|
+
error: "Host 文件服务不可用"
|
|
2082
|
+
};
|
|
2083
|
+
try {
|
|
2084
|
+
const info = await fs.stat(await resolveTarget(ctx, path));
|
|
2085
|
+
if (info === void 0) return { kind: "absent" };
|
|
2086
|
+
return {
|
|
2087
|
+
kind: "present",
|
|
2088
|
+
type: text(info.type)
|
|
2089
|
+
};
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
return {
|
|
2092
|
+
kind: "error",
|
|
2093
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2202
2097
|
//#endregion
|
|
2203
2098
|
//#region src/shared/utils/json.ts
|
|
2204
2099
|
/**
|
|
@@ -2314,7 +2209,7 @@ function renderJson(value) {
|
|
|
2314
2209
|
async function requireCaseDir(ctx, raw) {
|
|
2315
2210
|
const value = text(raw).trim();
|
|
2316
2211
|
if (value === "") return { ...failure$1("input", "缺少案例目录(caseDir 必须是绝对路径)") };
|
|
2317
|
-
if (!
|
|
2212
|
+
if (!isAbsoluteLocalPath(value)) return { ...failure$1("input", `案例目录必须是绝对路径:${value}`) };
|
|
2318
2213
|
if (value.split(/[\\/]/).includes("..")) return { ...failure$1("input", `案例目录不能包含 .. 片段:${value}`) };
|
|
2319
2214
|
const fs = fileSystem(ctx);
|
|
2320
2215
|
if (fs === void 0) return { ...failure$1("infrastructure", "Host 文件服务不可用") };
|
|
@@ -2341,7 +2236,7 @@ async function requireInsideCase(ctx, casePath, relative) {
|
|
|
2341
2236
|
if (name.split("/").includes("..")) return { ...failure$1("input", `相对路径不能包含 ..:${name}`) };
|
|
2342
2237
|
const fs = fileSystem(ctx);
|
|
2343
2238
|
if (fs === void 0) return { ...failure$1("infrastructure", "Host 文件服务不可用") };
|
|
2344
|
-
const full =
|
|
2239
|
+
const full = joinLocalPath(casePath, name);
|
|
2345
2240
|
try {
|
|
2346
2241
|
const root = await resolveTarget(ctx, casePath);
|
|
2347
2242
|
const target = await fs.resolve(name, { cwd: text(root.displayPath) });
|
|
@@ -2792,7 +2687,7 @@ function bootstrapTools(deps) {
|
|
|
2792
2687
|
...failure$1("cli", `files list 没有返回附件数组:${clampText(filesRun.stdout, 400)}`),
|
|
2793
2688
|
...empty
|
|
2794
2689
|
};
|
|
2795
|
-
const snapshotDir =
|
|
2690
|
+
const snapshotDir = joinLocalPath(caseCheck.path, SNAPSHOT_DIR);
|
|
2796
2691
|
const made = await ensureDirectory$1(ctx, snapshotDir, {
|
|
2797
2692
|
workdir: caseCheck.path,
|
|
2798
2693
|
platform,
|
|
@@ -2819,9 +2714,9 @@ function bootstrapTools(deps) {
|
|
|
2819
2714
|
fieldCount: Object.keys(record).length,
|
|
2820
2715
|
attachmentCount: attachments.length,
|
|
2821
2716
|
snapshotDir,
|
|
2822
|
-
snapshotPath:
|
|
2823
|
-
attachmentsPath:
|
|
2824
|
-
metadataPath:
|
|
2717
|
+
snapshotPath: joinLocalPath(snapshotDir, SNAPSHOT_RECORD_FILE),
|
|
2718
|
+
attachmentsPath: joinLocalPath(snapshotDir, SNAPSHOT_ATTACHMENTS_FILE),
|
|
2719
|
+
metadataPath: joinLocalPath(snapshotDir, SNAPSHOT_METADATA_FILE),
|
|
2825
2720
|
routingFacts: routingFactsOf(record, seqNo, form.name),
|
|
2826
2721
|
reused: false
|
|
2827
2722
|
};
|
|
@@ -3884,7 +3779,7 @@ function dingtalkTools(deps) {
|
|
|
3884
3779
|
"drive",
|
|
3885
3780
|
"+upload",
|
|
3886
3781
|
"--file",
|
|
3887
|
-
requested
|
|
3782
|
+
basenameLocalPath(requested),
|
|
3888
3783
|
"--file-name",
|
|
3889
3784
|
plan.plan.remoteName,
|
|
3890
3785
|
"--folder",
|
|
@@ -4050,7 +3945,7 @@ function dingtalkTools(deps) {
|
|
|
4050
3945
|
};
|
|
4051
3946
|
const caseDir = caseCheck.path;
|
|
4052
3947
|
const send = async () => {
|
|
4053
|
-
const statePath =
|
|
3948
|
+
const statePath = joinLocalPath(caseDir, NOTIFY_STATE_FILE);
|
|
4054
3949
|
const existing = await readTextIfExists(ctx, statePath);
|
|
4055
3950
|
if (existing !== "") {
|
|
4056
3951
|
const parsed = jsonObject(existing);
|
|
@@ -4787,6 +4682,112 @@ function h3yunTools(deps) {
|
|
|
4787
4682
|
];
|
|
4788
4683
|
}
|
|
4789
4684
|
//#endregion
|
|
4685
|
+
//#region src/host/platform/credential-permission.ts
|
|
4686
|
+
/**
|
|
4687
|
+
* 凭据文件的**权限收紧与如实报告**(协议 17)。
|
|
4688
|
+
*
|
|
4689
|
+
* ## 为什么单独成一个模块
|
|
4690
|
+
*
|
|
4691
|
+
* iFinD API-Key 与 OSS AccessKey 落在同一个形态的文件里(用户配置目录下的私有文件),
|
|
4692
|
+
* 收紧方式与「失败怎么说话」必须**只有一份判据**。旧形态两处各写一遍
|
|
4693
|
+
* `if (!isWindowsPlatform(...)) { chmod } else { chmodOk = true }` —— 于是 Windows 上
|
|
4694
|
+
* 两处都报「成功」,而实际什么都没做。
|
|
4695
|
+
*
|
|
4696
|
+
* ## 三种结局(见 `shared/types.ts` 的 `CredentialPermission`)
|
|
4697
|
+
*
|
|
4698
|
+
* - `verified`:POSIX 上跑了收紧命令**并回读了模式位**,两者都确认是 `0600`(`posix-0600`);
|
|
4699
|
+
* - `inherited`:该平台没有 POSIX 权限位这件事(Windows),不执行任何命令,
|
|
4700
|
+
* 如实说明权限由**当前账户 ACL** 负责 —— 不伪装成成功,也不谎报失败;
|
|
4701
|
+
* - `failed`:试图收紧但失败,或**回读对不上**(chmod 报错 / 沙箱拒绝 / 命令没跑起来 /
|
|
4702
|
+
* 文件系统静默忽略 chmod),带原因。
|
|
4703
|
+
*
|
|
4704
|
+
* `verified` 的含义是「回读确认」,所以**必须有第二步**(2026-09-28 复查修):
|
|
4705
|
+
* 只信 `chmod` 的退出码就把「命令跑过了」说成「权限已验证」,而有些文件系统会静默忽略 chmod。
|
|
4706
|
+
* 回读命令由 `platform/shell.ts` 的 `readFileModeCommand()` 生成(GNU / BSD 的参数差异只在那里出现一次)。
|
|
4707
|
+
*
|
|
4708
|
+
* 本轮**不**调用 `icacls` 重写 ACL:企业域策略下的权限继承不是插件该擅自改的东西,
|
|
4709
|
+
* 真要「收紧 ACL」得另做设计与域环境测试(见设计说明 §3.5)。
|
|
4710
|
+
*/
|
|
4711
|
+
/** 期望的模式位(八进制字符串)。 */
|
|
4712
|
+
const EXPECTED_MODE = "600";
|
|
4713
|
+
async function enforceCredentialPermission(ctx, path, platform, options) {
|
|
4714
|
+
const mechanism = privateFileMechanism(platform);
|
|
4715
|
+
if (mechanism === "windows-acl") return {
|
|
4716
|
+
status: "inherited",
|
|
4717
|
+
mechanism,
|
|
4718
|
+
message: "使用当前 Windows 账户 ACL;POSIX 0600 不适用"
|
|
4719
|
+
};
|
|
4720
|
+
const command = privateFileCommand(path, platform);
|
|
4721
|
+
if (command === "") return {
|
|
4722
|
+
status: "failed",
|
|
4723
|
+
mechanism,
|
|
4724
|
+
message: `无法为该平台生成权限收紧命令(platform=${platform || "未知"})`
|
|
4725
|
+
};
|
|
4726
|
+
const shellOptions = {
|
|
4727
|
+
workdir: options.workdir,
|
|
4728
|
+
timeoutMs: 15e3,
|
|
4729
|
+
...options.escalate === true ? { escalate: true } : {}
|
|
4730
|
+
};
|
|
4731
|
+
const run = await runShell(ctx, command, shellOptions);
|
|
4732
|
+
if (!run.ok) return {
|
|
4733
|
+
status: "failed",
|
|
4734
|
+
mechanism,
|
|
4735
|
+
message: permissionFailureMessage(run)
|
|
4736
|
+
};
|
|
4737
|
+
const readCommand = readFileModeCommand(path, platform);
|
|
4738
|
+
if (readCommand === "") return {
|
|
4739
|
+
status: "failed",
|
|
4740
|
+
mechanism,
|
|
4741
|
+
message: "无法为该平台生成权限回读命令(平台事实自相矛盾)"
|
|
4742
|
+
};
|
|
4743
|
+
const read = await runShell(ctx, readCommand, shellOptions);
|
|
4744
|
+
if (!read.ok) return {
|
|
4745
|
+
status: "failed",
|
|
4746
|
+
mechanism,
|
|
4747
|
+
message: `权限回读失败:${permissionFailureMessage(read)}`
|
|
4748
|
+
};
|
|
4749
|
+
const mode = parseFileMode(read.stdout);
|
|
4750
|
+
if (mode === "") return {
|
|
4751
|
+
status: "failed",
|
|
4752
|
+
mechanism,
|
|
4753
|
+
message: `权限回读没有给出可解析的模式位:${text(read.stdout).trim().slice(0, 80)}`
|
|
4754
|
+
};
|
|
4755
|
+
if (mode !== EXPECTED_MODE) return {
|
|
4756
|
+
status: "failed",
|
|
4757
|
+
mechanism,
|
|
4758
|
+
message: `权限收紧命令返回成功,但回读到的模式是 ${mode}(期望 ${EXPECTED_MODE})—— 该文件系统可能忽略了 chmod`
|
|
4759
|
+
};
|
|
4760
|
+
return {
|
|
4761
|
+
status: "verified",
|
|
4762
|
+
mechanism,
|
|
4763
|
+
message: ""
|
|
4764
|
+
};
|
|
4765
|
+
}
|
|
4766
|
+
/**
|
|
4767
|
+
* 从 `stat` 的输出里取出八进制模式位。
|
|
4768
|
+
*
|
|
4769
|
+
* GNU `stat -c %a` 给 `600`(也可能带前导 `0`);BSD `stat -f %Lp` 给 `600`;
|
|
4770
|
+
* 都按「最后一个连续八进制串」取,避免把别的输出混进来。取不到返回空串。
|
|
4771
|
+
*/
|
|
4772
|
+
function parseFileMode(raw) {
|
|
4773
|
+
const match = /(?:^|\s)([0-7]{3,4})\s*$/.exec(text(raw).trim());
|
|
4774
|
+
if (match === null) return "";
|
|
4775
|
+
const digits = match[1] ?? "";
|
|
4776
|
+
return digits.length === 4 && digits.startsWith("0") ? digits.slice(1) : digits;
|
|
4777
|
+
}
|
|
4778
|
+
/** 失败原因:命令没跑起来(沙箱/审批)与跑完报错要能分辨。 */
|
|
4779
|
+
function permissionFailureMessage(run) {
|
|
4780
|
+
return ((text(run.stderr) || text(run.error) || text(run.stdout)).trim() || "权限收紧命令没有成功").slice(0, 200);
|
|
4781
|
+
}
|
|
4782
|
+
/** Host 凭据服务(`credentials`)托管时的权限结论:不由我们管,也不宣称已验证。 */
|
|
4783
|
+
function hostStorePermission() {
|
|
4784
|
+
return {
|
|
4785
|
+
status: "inherited",
|
|
4786
|
+
mechanism: "host-store",
|
|
4787
|
+
message: "凭据由 DSH 凭据服务保管,文件权限不适用"
|
|
4788
|
+
};
|
|
4789
|
+
}
|
|
4790
|
+
//#endregion
|
|
4790
4791
|
//#region src/host/ifind/store.ts
|
|
4791
4792
|
/**
|
|
4792
4793
|
* iFinD **API-Key** 的**插件自有凭据存储**。
|
|
@@ -4821,16 +4822,20 @@ const IFIND_CREDENTIAL_FILE = "ifind-credential.json";
|
|
|
4821
4822
|
const IFIND_CREDENTIAL_FIELD = "auth_token";
|
|
4822
4823
|
/** 视为占位符的取值(上游文档里就是这么写的)。 */
|
|
4823
4824
|
const IFIND_PLACEHOLDER = "your ifind-mcp key";
|
|
4824
|
-
/**
|
|
4825
|
+
/**
|
|
4826
|
+
* 凭据文件的绝对路径。
|
|
4827
|
+
*
|
|
4828
|
+
* 分隔符随主目录风格走(`joinLocalPath`):这条路径交给 fs 与 shell 两条链路,
|
|
4829
|
+
* 混用分隔符在 Windows 上既进不了 `New-Item -Path`,也会被别的程序当转义。
|
|
4830
|
+
*/
|
|
4825
4831
|
function ifindCredentialPath(home) {
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
const sep = base.includes("\\") ? "\\" : "/";
|
|
4829
|
-
return `${base}${sep}${PLUGIN_STATE_DIR.replace(/\//g, sep)}${sep}${IFIND_CREDENTIAL_FILE}`;
|
|
4832
|
+
if (text(home) === "") return `~/${PLUGIN_STATE_DIR}/${IFIND_CREDENTIAL_FILE}`;
|
|
4833
|
+
return joinLocalPath(home, PLUGIN_STATE_DIR, IFIND_CREDENTIAL_FILE);
|
|
4830
4834
|
}
|
|
4831
|
-
/** 状态目录的绝对路径(`
|
|
4835
|
+
/** 状态目录的绝对路径(`mkdir` / 权限收紧用)。 */
|
|
4832
4836
|
function ifindStateDir(home) {
|
|
4833
|
-
|
|
4837
|
+
if (text(home) === "") return `~/${PLUGIN_STATE_DIR}`;
|
|
4838
|
+
return joinLocalPath(home, PLUGIN_STATE_DIR);
|
|
4834
4839
|
}
|
|
4835
4840
|
/**
|
|
4836
4841
|
* 校验一份 API-Key。**五种坏值都必须能分辨**,因为界面要给的处置不同:
|
|
@@ -5018,15 +5023,23 @@ async function readIfindSecret(ctx, home) {
|
|
|
5018
5023
|
async function ifindCredentialView(ctx, home) {
|
|
5019
5024
|
return (await readIfindSecret(ctx, home)).view;
|
|
5020
5025
|
}
|
|
5021
|
-
/**
|
|
5022
|
-
|
|
5026
|
+
/**
|
|
5027
|
+
* 写盘结果里的失败形状(集中一处,避免每个分支各写一遍)。
|
|
5028
|
+
*
|
|
5029
|
+
* `mechanism` 由**注入的平台事实**推出(`privateFileMechanism` 是唯一判据,不看 `process.platform`);
|
|
5030
|
+
* 写盘都没成功时它只影响文案口径,`status` 恒为 `failed`。
|
|
5031
|
+
*/
|
|
5032
|
+
function writeFailure(kind, error, path, platform) {
|
|
5023
5033
|
return {
|
|
5024
5034
|
ok: false,
|
|
5025
5035
|
errorKind: kind,
|
|
5026
5036
|
error,
|
|
5027
5037
|
dirReady: false,
|
|
5028
|
-
|
|
5029
|
-
|
|
5038
|
+
permission: {
|
|
5039
|
+
status: "failed",
|
|
5040
|
+
mechanism: privateFileMechanism(platform),
|
|
5041
|
+
message: error
|
|
5042
|
+
},
|
|
5030
5043
|
mode: "file",
|
|
5031
5044
|
view: {
|
|
5032
5045
|
path,
|
|
@@ -5048,8 +5061,9 @@ function writeFailure(kind, error, path) {
|
|
|
5048
5061
|
*/
|
|
5049
5062
|
async function writeIfindSecret(ctx, home, rawSecret, options = {}) {
|
|
5050
5063
|
const path = ifindCredentialPath(home);
|
|
5064
|
+
const platform = text(options.platform);
|
|
5051
5065
|
const verdict = checkIfindSecret(rawSecret, options.placeholder ?? "your ifind-mcp key");
|
|
5052
|
-
if (!verdict.ok) return writeFailure("input", verdict.reason, path);
|
|
5066
|
+
if (!verdict.ok) return writeFailure("input", verdict.reason, path, platform);
|
|
5053
5067
|
const resolved = resolveIfindStore(ctx);
|
|
5054
5068
|
if (resolved.kind === "host" && resolved.store !== void 0) try {
|
|
5055
5069
|
await resolved.store.set(IFIND_CREDENTIAL_KEY, verdict.value);
|
|
@@ -5058,8 +5072,7 @@ async function writeIfindSecret(ctx, home, rawSecret, options = {}) {
|
|
|
5058
5072
|
errorKind: "",
|
|
5059
5073
|
error: "",
|
|
5060
5074
|
dirReady: true,
|
|
5061
|
-
|
|
5062
|
-
chmodError: "",
|
|
5075
|
+
permission: hostStorePermission(),
|
|
5063
5076
|
mode: "host",
|
|
5064
5077
|
view: {
|
|
5065
5078
|
path: `credentials:${IFIND_CREDENTIAL_KEY}`,
|
|
@@ -5070,20 +5083,20 @@ async function writeIfindSecret(ctx, home, rawSecret, options = {}) {
|
|
|
5070
5083
|
}
|
|
5071
5084
|
};
|
|
5072
5085
|
} catch (error) {
|
|
5073
|
-
return writeFailure("infrastructure", `写入 DSH 凭据存储失败:${error instanceof Error ? error.message : String(error)}`, path);
|
|
5086
|
+
return writeFailure("infrastructure", `写入 DSH 凭据存储失败:${error instanceof Error ? error.message : String(error)}`, path, platform);
|
|
5074
5087
|
}
|
|
5075
|
-
if (home === "") return writeFailure("infrastructure", "主目录未知,无法保存同花顺 iFinD 凭据", path);
|
|
5088
|
+
if (home === "") return writeFailure("infrastructure", "主目录未知,无法保存同花顺 iFinD 凭据", path, platform);
|
|
5076
5089
|
const fs = fileSystem(ctx);
|
|
5077
|
-
if (fs === void 0) return writeFailure("infrastructure", "Host 文件服务不可用,无法保存同花顺 iFinD 凭据", path);
|
|
5078
|
-
|
|
5090
|
+
if (fs === void 0) return writeFailure("infrastructure", "Host 文件服务不可用,无法保存同花顺 iFinD 凭据", path, platform);
|
|
5091
|
+
if (platform === "") return writeFailure("infrastructure", "未知平台,拒绝在没有平台事实的情况下写凭据", path, platform);
|
|
5079
5092
|
const dir = ifindStateDir(home);
|
|
5080
|
-
const
|
|
5093
|
+
const mkdirRun = await runShell(ctx, mkdirCommand(dir, platform), {
|
|
5081
5094
|
workdir: home,
|
|
5082
5095
|
timeoutMs: 15e3,
|
|
5083
5096
|
escalate: true
|
|
5084
5097
|
});
|
|
5085
|
-
const dirReady =
|
|
5086
|
-
if (!dirReady) return writeFailure("infrastructure", `创建插件状态目录失败:${text(
|
|
5098
|
+
const dirReady = mkdirRun.ok;
|
|
5099
|
+
if (!dirReady) return writeFailure("infrastructure", `创建插件状态目录失败:${text(mkdirRun.stderr) || text(mkdirRun.error) || dir}`.slice(0, 300), path, platform);
|
|
5087
5100
|
const body = `${JSON.stringify({ [IFIND_CREDENTIAL_FIELD]: verdict.value }, null, 2)}\n`;
|
|
5088
5101
|
try {
|
|
5089
5102
|
await fs.writeText(await resolveTarget(ctx, path), body, void 0, void 0, {
|
|
@@ -5091,26 +5104,17 @@ async function writeIfindSecret(ctx, home, rawSecret, options = {}) {
|
|
|
5091
5104
|
workspaceRoot: home
|
|
5092
5105
|
});
|
|
5093
5106
|
} catch (error) {
|
|
5094
|
-
return writeFailure("infrastructure", `写入 ${path} 失败:${error instanceof Error ? error.message : String(error)}`, path);
|
|
5095
|
-
}
|
|
5096
|
-
let chmodOk = true;
|
|
5097
|
-
let chmodError = "";
|
|
5098
|
-
if (!isWindowsPlatform(platform)) {
|
|
5099
|
-
const chmod = await runShell(ctx, `chmod 600 ${shellQuote(path, platform)}`, {
|
|
5100
|
-
workdir: home,
|
|
5101
|
-
timeoutMs: 15e3,
|
|
5102
|
-
escalate: true
|
|
5103
|
-
});
|
|
5104
|
-
chmodOk = chmod.ok;
|
|
5105
|
-
chmodError = chmod.ok ? "" : (text(chmod.stderr) || text(chmod.error) || "权限设置命令没有跑起来").slice(0, 200);
|
|
5107
|
+
return writeFailure("infrastructure", `写入 ${path} 失败:${error instanceof Error ? error.message : String(error)}`, path, platform);
|
|
5106
5108
|
}
|
|
5107
5109
|
return {
|
|
5108
5110
|
ok: true,
|
|
5109
5111
|
errorKind: "",
|
|
5110
5112
|
error: "",
|
|
5111
5113
|
dirReady,
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
+
permission: await enforceCredentialPermission(ctx, path, platform, {
|
|
5115
|
+
workdir: home,
|
|
5116
|
+
escalate: true
|
|
5117
|
+
}),
|
|
5114
5118
|
mode: "file",
|
|
5115
5119
|
view: {
|
|
5116
5120
|
path,
|
|
@@ -5146,8 +5150,14 @@ async function clearIfindSecret(ctx, home, options = {}) {
|
|
|
5146
5150
|
error: "主目录未知,无法清除同花顺 iFinD 凭据",
|
|
5147
5151
|
mode: "file"
|
|
5148
5152
|
};
|
|
5149
|
-
const platform = text(options.platform)
|
|
5150
|
-
|
|
5153
|
+
const platform = text(options.platform);
|
|
5154
|
+
if (platform === "") return {
|
|
5155
|
+
ok: false,
|
|
5156
|
+
errorKind: "infrastructure",
|
|
5157
|
+
error: "未知平台,拒绝在没有平台事实的情况下清除凭据",
|
|
5158
|
+
mode: "file"
|
|
5159
|
+
};
|
|
5160
|
+
const result = await runShell(ctx, removeFileCommand(ifindCredentialPath(home), platform), {
|
|
5151
5161
|
workdir: home,
|
|
5152
5162
|
timeoutMs: 15e3,
|
|
5153
5163
|
escalate: true
|
|
@@ -5165,19 +5175,6 @@ async function clearIfindSecret(ctx, home, options = {}) {
|
|
|
5165
5175
|
mode: "file"
|
|
5166
5176
|
};
|
|
5167
5177
|
}
|
|
5168
|
-
/**
|
|
5169
|
-
* 平台引用方式。
|
|
5170
|
-
*
|
|
5171
|
-
* 与 `environment/probe.ts` 的 `shellQuote` 同一判据(POSIX 单引号 / Windows 也是单引号 ——
|
|
5172
|
-
* Windows 上 DSH 跑的是 PowerShell,单引号才是纯字面量,双引号会插值),
|
|
5173
|
-
* 这里各写一份是因为 `probe.ts` 已经反向依赖本模块(`checkIfindSecret` 由它复用),
|
|
5174
|
-
* 再互相 import 会成环。两处都只有两行,且都有单测钉着 Windows 分支。
|
|
5175
|
-
*/
|
|
5176
|
-
function shellQuote(value, platform) {
|
|
5177
|
-
const raw = text(value);
|
|
5178
|
-
if (!isWindowsPlatform(text(platform))) return `'${raw.replace(/'/g, "'\\''")}'`;
|
|
5179
|
-
return `'${raw.replace(/'/g, "''")}'`;
|
|
5180
|
-
}
|
|
5181
5178
|
//#endregion
|
|
5182
5179
|
//#region src/host/ifind/mcp.ts
|
|
5183
5180
|
/**
|
|
@@ -6734,7 +6731,7 @@ function knowledgeTools(deps) {
|
|
|
6734
6731
|
const platform = await deps.world.platform();
|
|
6735
6732
|
const trusted = credentialsTrusted(deps);
|
|
6736
6733
|
const caseDir = caseCheck.path;
|
|
6737
|
-
const knowledgeDir =
|
|
6734
|
+
const knowledgeDir = joinLocalPath(caseDir, CASE_KNOWLEDGE_DIR);
|
|
6738
6735
|
const base = {
|
|
6739
6736
|
...empty,
|
|
6740
6737
|
caseDir,
|
|
@@ -6838,7 +6835,7 @@ function knowledgeTools(deps) {
|
|
|
6838
6835
|
});
|
|
6839
6836
|
continue;
|
|
6840
6837
|
}
|
|
6841
|
-
const local =
|
|
6838
|
+
const local = joinLocalPath(knowledgeDir, relative);
|
|
6842
6839
|
const cleared = await removeFileIfExists(ctx, local, {
|
|
6843
6840
|
workdir: caseDir,
|
|
6844
6841
|
platform,
|
|
@@ -6918,7 +6915,7 @@ function knowledgeTools(deps) {
|
|
|
6918
6915
|
});
|
|
6919
6916
|
}
|
|
6920
6917
|
}
|
|
6921
|
-
const manifestPath =
|
|
6918
|
+
const manifestPath = joinLocalPath(knowledgeDir, KB_MANIFEST_FILE);
|
|
6922
6919
|
const written = await writeManifest(ctx, manifestPath, {
|
|
6923
6920
|
schema: "crwu.kb-materialize.manifest.v1",
|
|
6924
6921
|
spaceName,
|
|
@@ -6990,6 +6987,22 @@ async function writeManifest(ctx, path, payload) {
|
|
|
6990
6987
|
}
|
|
6991
6988
|
}
|
|
6992
6989
|
//#endregion
|
|
6990
|
+
//#region src/host/oss/sanitize.ts
|
|
6991
|
+
/**
|
|
6992
|
+
* OSS 输出脱敏:**唯一**一处把 ossutil 的 stdout / stderr 变成可以给人看(或进日志)的文本。
|
|
6993
|
+
*
|
|
6994
|
+
* 为什么必须集中一处:`ossutil` 的报错里会带请求 URL(含 `Signature` / `OSSAccessKeyId`)、
|
|
6995
|
+
* 甚至完整的临时凭据与 `x-oss-security-token`。这类文本一旦进会话记录或界面,
|
|
6996
|
+
* 就等于把凭据写进了可回放的日志。上传路径(`tools/oss.ts`)与环境探测
|
|
6997
|
+
* (`environment/probe.ts`)都要用同一份判据 —— 两套正则必然有一天只改了一处。
|
|
6998
|
+
*
|
|
6999
|
+
* 顺序有讲究:**先按字段名抹值、再抹整条查询串、最后兜底抹 AK 形态**。
|
|
7000
|
+
* 反过来会把 URL 整条打成 `?<redacted>`,连"是哪个 bucket"都看不出来。
|
|
7001
|
+
*/
|
|
7002
|
+
function sanitizeOssError(value) {
|
|
7003
|
+
return text(value).replace(/(Signature|OSSAccessKeyId|security-token|AccessKeyId|AccessKeySecret|STS\w*Token)=[^&\s"']+/gi, "$1=<redacted>").replace(/(https?:\/\/[^\s"']*?)\?[^\s"']*/gi, "$1?<redacted>").replace(/\bLTAI[A-Za-z0-9]{8,}\b/g, "<redacted-access-key>").slice(0, 600);
|
|
7004
|
+
}
|
|
7005
|
+
//#endregion
|
|
6993
7006
|
//#region src/host/oss/parse.ts
|
|
6994
7007
|
/**
|
|
6995
7008
|
* OSS 对象路径与命令输出的解析。
|
|
@@ -7321,13 +7334,13 @@ function ossTools(deps) {
|
|
|
7321
7334
|
});
|
|
7322
7335
|
continue;
|
|
7323
7336
|
}
|
|
7324
|
-
const
|
|
7337
|
+
const objectKey = `${prefix}/${basenameLocalPath(relative)}`;
|
|
7325
7338
|
const argv = [
|
|
7326
7339
|
ossutil,
|
|
7327
7340
|
"cp",
|
|
7328
7341
|
"-f",
|
|
7329
7342
|
local,
|
|
7330
|
-
`oss://${oss.bucket}/${
|
|
7343
|
+
`oss://${oss.bucket}/${objectKey}`
|
|
7331
7344
|
];
|
|
7332
7345
|
if (oss.endpoint !== "") argv.push("--endpoint", oss.endpoint);
|
|
7333
7346
|
for (const extra of oss.extraArgs) argv.push(extra);
|
|
@@ -7340,7 +7353,7 @@ function ossTools(deps) {
|
|
|
7340
7353
|
results.push({
|
|
7341
7354
|
kind,
|
|
7342
7355
|
name: relative,
|
|
7343
|
-
key,
|
|
7356
|
+
key: objectKey,
|
|
7344
7357
|
ok: false,
|
|
7345
7358
|
sizeBytes: 0,
|
|
7346
7359
|
exitCode: run.exitCode,
|
|
@@ -7348,11 +7361,11 @@ function ossTools(deps) {
|
|
|
7348
7361
|
});
|
|
7349
7362
|
continue;
|
|
7350
7363
|
}
|
|
7351
|
-
const verify = await verifyObject(ctx, platform, oss.bucket, prefix,
|
|
7364
|
+
const verify = await verifyObject(ctx, platform, oss.bucket, prefix, objectKey, size, caseDir, ossutil, exec.signal);
|
|
7352
7365
|
results.push({
|
|
7353
7366
|
kind,
|
|
7354
7367
|
name: relative,
|
|
7355
|
-
key,
|
|
7368
|
+
key: objectKey,
|
|
7356
7369
|
ok: verify.ok,
|
|
7357
7370
|
sizeBytes: verify.sizeBytes,
|
|
7358
7371
|
exitCode: run.exitCode,
|
|
@@ -7624,8 +7637,19 @@ async function bootstrapInputSnapshot(ctx, agent, args) {
|
|
|
7624
7637
|
*/
|
|
7625
7638
|
/** 根会话的标题前缀;后面会补时间戳,便于区分分叉出来的多棵树。 */
|
|
7626
7639
|
const AUDIT_ROOT_TITLE = "审核子代理根节点";
|
|
7627
|
-
/**
|
|
7628
|
-
|
|
7640
|
+
/**
|
|
7641
|
+
* 预检指令:一次调用同时证明「模型能回」「shell 能用、cwd 正确」「CRWU Tool 链路与 policy pipeline 通」。
|
|
7642
|
+
*
|
|
7643
|
+
* **必须按平台生成**(2026-09-28):旧文案写死了「用 bash 执行 `pwd`」,而 DSH 在 Windows 上挂的是
|
|
7644
|
+
* PowerShell(`@deepseek-ai/dsh-pwsh-local`,整串命令交给 `pwsh -Command`)。后果是宿主侧环境自检
|
|
7645
|
+
* 全绿、Agent 预检却直接失败,错误里还出现一个这台机器上根本不存在的 `bash` —— 让人往
|
|
7646
|
+
* 「装 Git Bash」的方向排查。这里给出的命令一律是**当前平台 shell 的真实写法**。
|
|
7647
|
+
*/
|
|
7648
|
+
function auditRootProbe(platform) {
|
|
7649
|
+
const cwd = shellDialect(platform) === "powershell" ? "`Get-Location`" : "`pwd`";
|
|
7650
|
+
const shellName = shellDialect(platform) === "powershell" ? "PowerShell" : "POSIX shell";
|
|
7651
|
+
return "hello,请依次做两件事:① 用**当前平台的 shell 工具**执行 " + cwd + ` 输出当前目录(这个会话的 shell 是 ${shellName},不要再去找别的 shell);② 调用 \`crwu_audit_capabilities\`(无参数)。然后回复当前目录,以及该工具返回的 \`ok\`、\`platform\` 与 \`binPlatform\`。`;
|
|
7652
|
+
}
|
|
7629
7653
|
/** 根标题:`审核子代理根节点 · 09-20 21:30`。 */
|
|
7630
7654
|
function auditRootTitle(now) {
|
|
7631
7655
|
const pad = (value) => String(value).padStart(2, "0");
|
|
@@ -7642,13 +7666,13 @@ function mintSessionId() {
|
|
|
7642
7666
|
* 不走 `@deepseek-ai/dsh-llm` 的 `createUserMessage`:那会把 dsh-llm 变成我们的一条运行时依赖,
|
|
7643
7667
|
* 而这里只需要「一条带 id 与 role 的用户消息」这个形状(字段与它产出的完全一致)。
|
|
7644
7668
|
*/
|
|
7645
|
-
function probeMessage(sessionSeed) {
|
|
7669
|
+
function probeMessage(sessionSeed, platform) {
|
|
7646
7670
|
return {
|
|
7647
7671
|
id: `msg-${sessionSeed}`,
|
|
7648
7672
|
role: "user",
|
|
7649
7673
|
content: [{
|
|
7650
7674
|
type: "text",
|
|
7651
|
-
text:
|
|
7675
|
+
text: auditRootProbe(platform)
|
|
7652
7676
|
}],
|
|
7653
7677
|
source: { kind: "user" }
|
|
7654
7678
|
};
|
|
@@ -7698,10 +7722,11 @@ function auditRootUsability(ctx, state, workspacePath) {
|
|
|
7698
7722
|
/**
|
|
7699
7723
|
* 目录不存在就建(`workspaceRegistry.create` 要求目录已存在)。
|
|
7700
7724
|
*
|
|
7701
|
-
*
|
|
7725
|
+
* 命令由 `platform/shell.ts` 按平台生成:POSIX 是 `mkdir -p`,Windows 是 PowerShell 的
|
|
7726
|
+
* `New-Item -ItemType Directory -Force`(`cmd` 的 `mkdir` 既不认 `-p`,引号解析也是第二套规则)。
|
|
7702
7727
|
*/
|
|
7703
7728
|
async function ensureDirectory(ctx, path, workspace, platform) {
|
|
7704
|
-
const result = await runShell(ctx,
|
|
7729
|
+
const result = await runShell(ctx, mkdirCommand(path, platform), {
|
|
7705
7730
|
timeoutMs: 2e4,
|
|
7706
7731
|
workdir: workspace
|
|
7707
7732
|
});
|
|
@@ -7722,6 +7747,7 @@ async function ensureAuditRoot(deps, options = {}) {
|
|
|
7722
7747
|
notes: []
|
|
7723
7748
|
});
|
|
7724
7749
|
if (workspacePath === "") return fail("尚未选定工作空间:审核根会话需要一个明确的工作空间。");
|
|
7750
|
+
const platform = await deps.world.platform();
|
|
7725
7751
|
const usable = auditRootUsability(ctx, state, workspacePath);
|
|
7726
7752
|
if (usable.ok) return {
|
|
7727
7753
|
ok: true,
|
|
@@ -7736,7 +7762,7 @@ async function ensureAuditRoot(deps, options = {}) {
|
|
|
7736
7762
|
if (agents === void 0 || typeof agents.create !== "function") return fail("Host agents 服务不可用,无法创建审核根会话");
|
|
7737
7763
|
let workspace = await resolveWorkspaceEntity(ctx, workspacePath);
|
|
7738
7764
|
if (workspace === void 0) {
|
|
7739
|
-
const mkdirError = await ensureDirectory(ctx, workspacePath, await deps.world.workdir(),
|
|
7765
|
+
const mkdirError = await ensureDirectory(ctx, workspacePath, await deps.world.workdir(), platform);
|
|
7740
7766
|
if (mkdirError !== "") return fail(mkdirError);
|
|
7741
7767
|
workspace = await createWorkspace(ctx, workspacePath, state.workspaceTitle || workspacePath);
|
|
7742
7768
|
if (workspace === void 0) return fail(`工作空间登记失败:${workspacePath}`);
|
|
@@ -7769,7 +7795,7 @@ async function ensureAuditRoot(deps, options = {}) {
|
|
|
7769
7795
|
}
|
|
7770
7796
|
const title = auditRootTitle(/* @__PURE__ */ new Date());
|
|
7771
7797
|
if (!renameSession(ctx, sessionId, title)) notes.push("根会话改名失败(不影响审核,只是标题不好认)");
|
|
7772
|
-
const probeError = await preflight(ctx, created.agent, sessionId);
|
|
7798
|
+
const probeError = await preflight(ctx, created.agent, sessionId, platform);
|
|
7773
7799
|
if (probeError !== "") return fail(`审核根会话预检未通过:${probeError}`);
|
|
7774
7800
|
const missing = auditToolsVisible(ctx, created.agent);
|
|
7775
7801
|
if (missing.length > 0) return fail(`审核所需的 CRWU Tool 对该 Agent 不可见:${missing.join("、")}。请确认部署已装配 @deepseek-ai/dsh-tools(base bundle 的 tools 行)。`);
|
|
@@ -7789,10 +7815,10 @@ async function ensureAuditRoot(deps, options = {}) {
|
|
|
7789
7815
|
};
|
|
7790
7816
|
}
|
|
7791
7817
|
/** 一次 hello 预检;返回空串表示通过。 */
|
|
7792
|
-
async function preflight(ctx, agent, sessionSeed, timeoutMs = 9e4) {
|
|
7818
|
+
async function preflight(ctx, agent, sessionSeed, platform, timeoutMs = 9e4) {
|
|
7793
7819
|
if (typeof agent.followup !== "function" || typeof agent.whenIdle !== "function") return "这个部署的 Agent 不支持 followup/whenIdle,无法确认会话可驱动";
|
|
7794
7820
|
try {
|
|
7795
|
-
agent.followup(probeMessage(sessionSeed));
|
|
7821
|
+
agent.followup(probeMessage(sessionSeed, platform));
|
|
7796
7822
|
} catch (error) {
|
|
7797
7823
|
return `发送预检消息失败:${error instanceof Error ? error.message : String(error)}`;
|
|
7798
7824
|
}
|
|
@@ -7883,11 +7909,10 @@ function auditRootView(ctx, state) {
|
|
|
7883
7909
|
}
|
|
7884
7910
|
//#endregion
|
|
7885
7911
|
//#region src/host/audit/case.ts
|
|
7886
|
-
/**
|
|
7912
|
+
/** 目录名取路径最后一段;Windows 的 `\` 与 POSIX 的 `/`、结尾斜杠都不影响。 */
|
|
7887
7913
|
function caseNameOf(displayPath) {
|
|
7888
|
-
const
|
|
7889
|
-
|
|
7890
|
-
return parts.length > 0 ? parts[parts.length - 1] ?? trimmed : trimmed;
|
|
7914
|
+
const name = basenameLocalPath(displayPath);
|
|
7915
|
+
return name === "" ? text(displayPath) : name;
|
|
7891
7916
|
}
|
|
7892
7917
|
async function inspectCase(ctx, dirTarget) {
|
|
7893
7918
|
const fs = fileSystem(ctx);
|
|
@@ -7956,7 +7981,7 @@ async function inspectCase(ctx, dirTarget) {
|
|
|
7956
7981
|
error: ""
|
|
7957
7982
|
};
|
|
7958
7983
|
if (resultFile !== "") try {
|
|
7959
|
-
const target = await resolveTarget(ctx,
|
|
7984
|
+
const target = await resolveTarget(ctx, joinLocalPath(item.path, resultFile));
|
|
7960
7985
|
item.audit = JSON.parse(await fs.readText(target));
|
|
7961
7986
|
} catch (error) {
|
|
7962
7987
|
item.error = error instanceof Error ? error.message : String(error);
|
|
@@ -7969,8 +7994,8 @@ function rootCandidates(context, record) {
|
|
|
7969
7994
|
const paths = [];
|
|
7970
7995
|
if (record.casePath !== "") paths.push(record.casePath);
|
|
7971
7996
|
if (context.caseRoot !== "") {
|
|
7972
|
-
if (record.seqNo !== "") paths.push(
|
|
7973
|
-
if (record.key !== "") paths.push(
|
|
7997
|
+
if (record.seqNo !== "") paths.push(joinLocalPath(context.caseRoot, record.seqNo));
|
|
7998
|
+
if (record.key !== "") paths.push(joinLocalPath(context.caseRoot, record.key));
|
|
7974
7999
|
}
|
|
7975
8000
|
return paths;
|
|
7976
8001
|
}
|
|
@@ -8479,9 +8504,14 @@ async function auditStatus(deps, args) {
|
|
|
8479
8504
|
}
|
|
8480
8505
|
//#endregion
|
|
8481
8506
|
//#region src/host/workspace/resolve.ts
|
|
8482
|
-
/**
|
|
8507
|
+
/**
|
|
8508
|
+
* 去掉结尾分隔符;空值保持空。
|
|
8509
|
+
*
|
|
8510
|
+
* 用共享的 `trimTrailingSeparators` 而不是 `replace(/[\\/]+$/, '')`:
|
|
8511
|
+
* 后者会把 Windows 的 `C:\` 裁成 `C:`(盘符相对路径)—— 那是另一个目录。
|
|
8512
|
+
*/
|
|
8483
8513
|
function trimSlash(value) {
|
|
8484
|
-
return
|
|
8514
|
+
return trimTrailingSeparators(value);
|
|
8485
8515
|
}
|
|
8486
8516
|
/**
|
|
8487
8517
|
* 落盘里记的来源,**原样恢复**。
|
|
@@ -8630,7 +8660,7 @@ function sessionWorkspaceInfo(ctx, state) {
|
|
|
8630
8660
|
let best = null;
|
|
8631
8661
|
for (const entry of registryList(ctx)) {
|
|
8632
8662
|
if (entry.path === "") continue;
|
|
8633
|
-
if (out.sessionCwd
|
|
8663
|
+
if (isLocalPathUnder(out.sessionCwd, entry.path)) {
|
|
8634
8664
|
if (best === null || entry.path.length > best.path.length) best = entry;
|
|
8635
8665
|
}
|
|
8636
8666
|
}
|
|
@@ -8657,7 +8687,7 @@ async function adoptWorkspace(deps) {
|
|
|
8657
8687
|
}
|
|
8658
8688
|
/** 选定案例根目录(手动)。 */
|
|
8659
8689
|
async function pickWorkspace(deps, args) {
|
|
8660
|
-
const path = String(args.path ?? "").trim()
|
|
8690
|
+
const path = trimTrailingSeparators(String(args.path ?? "").trim());
|
|
8661
8691
|
let persistError = "";
|
|
8662
8692
|
if (path !== "") {
|
|
8663
8693
|
const title = String(args.title ?? "") || path;
|
|
@@ -8711,33 +8741,462 @@ async function autoWorkspace(deps) {
|
|
|
8711
8741
|
};
|
|
8712
8742
|
}
|
|
8713
8743
|
//#endregion
|
|
8714
|
-
//#region src/host/
|
|
8744
|
+
//#region src/host/environment/probe.ts
|
|
8715
8745
|
/**
|
|
8716
|
-
*
|
|
8746
|
+
* 包内路径:`<包根>` 与 `<包根>/bin/manifest.json`。
|
|
8717
8747
|
*
|
|
8718
|
-
*
|
|
8719
|
-
*
|
|
8748
|
+
* 为什么不用相对层数硬拼:这份代码在源码形态(`src/host/environment/`,距包根 3 层)与构建产物
|
|
8749
|
+
* (`lib/`,距包根 1 层)里都跑,由 `packageRootFrom` 按 `package.json` 定位才是唯一可靠的做法。
|
|
8750
|
+
* 导出它是为了让测试与实现比对**同一条**路径,而不是各写一份。
|
|
8720
8751
|
*/
|
|
8721
|
-
|
|
8722
|
-
|
|
8752
|
+
function packageIntegrityPaths() {
|
|
8753
|
+
const packageRoot = packageRootFrom(dirname(fileURLToPath(import.meta.url)));
|
|
8754
|
+
return {
|
|
8755
|
+
packageRoot,
|
|
8756
|
+
manifestPath: packageRoot === "" ? "" : join(packageRoot, "bin", "manifest.json")
|
|
8757
|
+
};
|
|
8758
|
+
}
|
|
8759
|
+
/** 读包内 `bin/manifest.json` 里某个平台的声明;读不到就返回空表(**不**当成「没有组件」)。 */
|
|
8760
|
+
function readBinManifest(raw, platform) {
|
|
8761
|
+
const tools = /* @__PURE__ */ new Map();
|
|
8762
|
+
let parsed;
|
|
8763
|
+
try {
|
|
8764
|
+
parsed = JSON.parse(text(raw));
|
|
8765
|
+
} catch (error) {
|
|
8766
|
+
return {
|
|
8767
|
+
found: false,
|
|
8768
|
+
tools
|
|
8769
|
+
};
|
|
8770
|
+
}
|
|
8771
|
+
const doc = parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
8772
|
+
const platforms = Array.isArray(doc.platforms) ? doc.platforms : [];
|
|
8773
|
+
for (const entry of platforms) {
|
|
8774
|
+
const row = entry !== null && typeof entry === "object" ? entry : {};
|
|
8775
|
+
if (text(row.platform) !== platform) continue;
|
|
8776
|
+
for (const item of Array.isArray(row.tools) ? row.tools : []) {
|
|
8777
|
+
const tool = item !== null && typeof item === "object" ? item : {};
|
|
8778
|
+
const file = text(tool.file);
|
|
8779
|
+
if (file === "") continue;
|
|
8780
|
+
tools.set(text(tool.tool) || file, {
|
|
8781
|
+
file,
|
|
8782
|
+
size: typeof tool.size === "number" && Number.isFinite(tool.size) ? tool.size : 0,
|
|
8783
|
+
sha256: text(tool.sha256),
|
|
8784
|
+
sourceVersion: text(tool.sourceVersion)
|
|
8785
|
+
});
|
|
8786
|
+
}
|
|
8787
|
+
return {
|
|
8788
|
+
found: true,
|
|
8789
|
+
tools
|
|
8790
|
+
};
|
|
8791
|
+
}
|
|
8792
|
+
return {
|
|
8793
|
+
found: false,
|
|
8794
|
+
tools
|
|
8795
|
+
};
|
|
8796
|
+
}
|
|
8797
|
+
/** 「插件包不完整 / 平台不受支持」那一句:三件组件共用,措辞里不许出现「安装命令」。 */
|
|
8798
|
+
function packageGapMessage(platform, detail) {
|
|
8799
|
+
return `插件包不完整 / 平台不受支持:${detail}。请重新安装中瑞世联工作台插件,或联系管理员确认插件包是否完整;这些组件由插件按包内绝对路径使用(当前平台 ${platform || "未知"}),不从 PATH 上查找,也不由员工单独配置。`;
|
|
8800
|
+
}
|
|
8801
|
+
/**
|
|
8802
|
+
* 检查随包组件:只读文件事实,**一次 shell 都不跑**。
|
|
8803
|
+
*
|
|
8804
|
+
* 判据(三条都来自包内,互相独立):
|
|
8805
|
+
* 1. 当前平台有随包目录(否则「平台不受支持」);
|
|
8806
|
+
* 2. 文件真的在 `bin/<平台>/` 里;
|
|
8807
|
+
* 3. 字节数与包内 `bin/manifest.json` 一致(运行残留 / 安装不完整都会在这里露出来)。
|
|
8808
|
+
*/
|
|
8809
|
+
async function probePackageIntegrity(ctx, manifest, platform, options = {}) {
|
|
8810
|
+
const { packageRoot, manifestPath } = packageIntegrityPaths();
|
|
8811
|
+
const supported = binPlatformDir(platform) !== "";
|
|
8812
|
+
const fs = fileSystem(ctx);
|
|
8813
|
+
let manifestFound = false;
|
|
8814
|
+
const declared = /* @__PURE__ */ new Map();
|
|
8815
|
+
if (fs !== void 0 && manifestPath !== "") try {
|
|
8816
|
+
const parsed = readBinManifest(await fs.readText(await resolveTarget(ctx, manifestPath)), platform);
|
|
8817
|
+
manifestFound = parsed.found;
|
|
8818
|
+
for (const [name, tool] of parsed.tools) declared.set(name, tool);
|
|
8819
|
+
} catch (error) {
|
|
8820
|
+
manifestFound = false;
|
|
8821
|
+
}
|
|
8822
|
+
const tools = [];
|
|
8823
|
+
for (const spec of manifest.packaged) tools.push(await checkPackagedTool(ctx, spec, platform, {
|
|
8824
|
+
supported,
|
|
8825
|
+
manifestFound,
|
|
8826
|
+
manifestPath,
|
|
8827
|
+
declared
|
|
8828
|
+
}));
|
|
8829
|
+
const failed = tools.filter((tool) => !tool.ok);
|
|
8830
|
+
const ok = failed.length === 0;
|
|
8831
|
+
return {
|
|
8832
|
+
ok,
|
|
8833
|
+
supported,
|
|
8834
|
+
platform,
|
|
8835
|
+
packageRoot,
|
|
8836
|
+
manifestPath,
|
|
8837
|
+
manifestFound,
|
|
8838
|
+
tools,
|
|
8839
|
+
note: ok ? `插件内置组件 ${String(tools.length)}/${String(tools.length)} 完整:字节数与包内 bin/manifest.json 一致(sha256 见明细,不在自检里重算)。` : failed[0]?.reason ?? packageGapMessage(platform, "插件内置组件不可用")
|
|
8840
|
+
};
|
|
8841
|
+
}
|
|
8842
|
+
/** 单个组件:存在性 + 字节数;理由必须能分辨「平台不受支持」「缺文件」「清单读不到」「字节数不一致」。 */
|
|
8843
|
+
async function checkPackagedTool(ctx, spec, platform, facts) {
|
|
8844
|
+
const file = binaryFileName(spec.name, platform);
|
|
8845
|
+
const entry = facts.declared.get(spec.name);
|
|
8846
|
+
const base = {
|
|
8847
|
+
name: spec.name,
|
|
8848
|
+
label: spec.label,
|
|
8849
|
+
file,
|
|
8850
|
+
present: false,
|
|
8851
|
+
sizeBytes: 0,
|
|
8852
|
+
manifestSizeBytes: entry?.size ?? 0,
|
|
8853
|
+
sha256: entry?.sha256 ?? "",
|
|
8854
|
+
expectedVersion: spec.expectedVersion,
|
|
8855
|
+
note: spec.note,
|
|
8856
|
+
ok: false,
|
|
8857
|
+
reason: ""
|
|
8858
|
+
};
|
|
8859
|
+
if (!facts.supported) return {
|
|
8860
|
+
...base,
|
|
8861
|
+
reason: packageGapMessage(platform, "当前平台没有随包发布的组件目录")
|
|
8862
|
+
};
|
|
8863
|
+
const bundled = bundledBinaryPath(platform, spec.name);
|
|
8864
|
+
if (bundled === "") return {
|
|
8865
|
+
...base,
|
|
8866
|
+
reason: packageGapMessage(platform, "当前平台没有随包发布的组件目录")
|
|
8867
|
+
};
|
|
8868
|
+
const fs = fileSystem(ctx);
|
|
8869
|
+
if (fs === void 0) return {
|
|
8870
|
+
...base,
|
|
8871
|
+
reason: `无法核对插件包完整性:Host 文件服务不可用(包内路径 ${bundled})`
|
|
8872
|
+
};
|
|
8873
|
+
let info;
|
|
8874
|
+
try {
|
|
8875
|
+
info = await fs.stat(await resolveTarget(ctx, bundled));
|
|
8876
|
+
} catch (error) {
|
|
8877
|
+
info = void 0;
|
|
8878
|
+
}
|
|
8879
|
+
if (info?.type !== "file") return {
|
|
8880
|
+
...base,
|
|
8881
|
+
reason: `插件包不完整:包内缺少 ${bundled}`
|
|
8882
|
+
};
|
|
8883
|
+
const sizeBytes = typeof info.size === "number" && Number.isFinite(info.size) ? info.size : 0;
|
|
8884
|
+
const present = {
|
|
8885
|
+
...base,
|
|
8886
|
+
present: true,
|
|
8887
|
+
sizeBytes
|
|
8888
|
+
};
|
|
8889
|
+
if (!facts.manifestFound) return {
|
|
8890
|
+
...present,
|
|
8891
|
+
reason: `插件包不完整:读不到包内清单 ${facts.manifestPath},无法核对 ${file} 的字节数`
|
|
8892
|
+
};
|
|
8893
|
+
if (present.manifestSizeBytes === 0) return {
|
|
8894
|
+
...present,
|
|
8895
|
+
reason: `插件包不完整:包内清单里没有 ${spec.name} 的字节数,无法核对`
|
|
8896
|
+
};
|
|
8897
|
+
if (sizeBytes !== present.manifestSizeBytes) return {
|
|
8898
|
+
...present,
|
|
8899
|
+
reason: `插件包不完整:包内 ${file} 的字节数与随包清单不一致(磁盘 ${String(sizeBytes)} / 清单 ${String(present.manifestSizeBytes)},可能是安装不完整或运行残留)`
|
|
8900
|
+
};
|
|
8901
|
+
return {
|
|
8902
|
+
...present,
|
|
8903
|
+
ok: true,
|
|
8904
|
+
reason: ""
|
|
8905
|
+
};
|
|
8906
|
+
}
|
|
8907
|
+
/** 定位失败时给用户的那句话。文案里不许出现安装指引:这个二进制随插件发布。 */
|
|
8908
|
+
function ossutilMissingMessage(lookup) {
|
|
8909
|
+
return lookup.error === "" ? packageGapMessage("", "没有找到随包发布的 ossutil") : lookup.error;
|
|
8910
|
+
}
|
|
8911
|
+
/**
|
|
8912
|
+
* 解析包内 ossutil 的绝对路径。
|
|
8913
|
+
*
|
|
8914
|
+
* **没有 PATH 回退**(2026-09-25 口径):回退会让模型在某台机器上看到 `command not found`,
|
|
8915
|
+
* 下一步自然就是 `which` / `command -v` / 去搜可执行文件 —— 正是本次改造要消灭的行为。
|
|
8916
|
+
* `oss` 参数保留是为了不改调用方签名:清单里的 `ossutil` 命令名已经不再参与解析。
|
|
8917
|
+
*/
|
|
8918
|
+
async function resolveOssutil(ctx, oss, platform, options = {}) {
|
|
8919
|
+
const bundled = bundledBinaryPath(platform, "ossutil");
|
|
8920
|
+
if (bundled === "") return {
|
|
8921
|
+
path: "",
|
|
8922
|
+
error: packageGapMessage(platform, "当前平台没有随包发布的 ossutil")
|
|
8923
|
+
};
|
|
8924
|
+
const fs = fileSystem(ctx);
|
|
8925
|
+
if (fs === void 0) return {
|
|
8926
|
+
path: "",
|
|
8927
|
+
error: `无法核对插件包完整性:Host 文件服务不可用(包内路径 ${bundled})`
|
|
8928
|
+
};
|
|
8929
|
+
try {
|
|
8930
|
+
if ((await fs.stat(await resolveTarget(ctx, bundled)))?.type === "file") return {
|
|
8931
|
+
path: bundled,
|
|
8932
|
+
error: ""
|
|
8933
|
+
};
|
|
8934
|
+
} catch (error) {}
|
|
8935
|
+
return {
|
|
8936
|
+
path: "",
|
|
8937
|
+
error: packageGapMessage(platform, `包内缺少 ${bundled}`)
|
|
8938
|
+
};
|
|
8939
|
+
}
|
|
8940
|
+
/**
|
|
8941
|
+
* 一次真实 OSS 只读验证的请求形状(**完全由受信配置推出,模型与员工都不能提交**)。
|
|
8942
|
+
*
|
|
8943
|
+
* 导出它有两个目的:① 单测可以对命令字符串逐字断言;② 开发者诊断里能显示"验的是哪个目标",
|
|
8944
|
+
* 而目标里**不含任何凭据**。
|
|
8945
|
+
*/
|
|
8946
|
+
function ossProbeTarget(oss) {
|
|
8947
|
+
const prefix = oss.prefix.replace(/^\/+|\/+$/g, "");
|
|
8948
|
+
const path = prefix === "" ? "" : `${prefix}/`;
|
|
8949
|
+
return {
|
|
8950
|
+
bucket: oss.bucket,
|
|
8951
|
+
prefix: path,
|
|
8952
|
+
target: `oss://${oss.bucket}/${path}`
|
|
8953
|
+
};
|
|
8954
|
+
}
|
|
8955
|
+
/**
|
|
8956
|
+
* 端到端唯一的 OSS 真实验证:**用包内 ossutil 对配置好的 bucket + 业务前缀做一次只读列举**。
|
|
8957
|
+
*
|
|
8958
|
+
* 判据(2026-09-26 收紧):
|
|
8959
|
+
* - 必须打**业务前缀**(`oss://<bucket>/<prefix>/`)而不是桶根:只验桶根证明不了"能写交付件";
|
|
8960
|
+
* - `--limited-num 1`:只要一次请求,空目录也算成功(判据是"请求成功且凭据有权访问该目标",
|
|
8961
|
+
* 不是"一定列到对象");
|
|
8962
|
+
* - 只用只读 `ls`:**不**为了验证去上传、删除或创建对象;
|
|
8963
|
+
* - 只认包内绝对路径(`resolveOssutil`),不回退 PATH、不要员工装 ossutil;
|
|
8964
|
+
* - 所有 stdout / stderr 在返回或写日志之前过 `sanitizeOssError`(AK / STS / Signature / 签名 URL)。
|
|
8965
|
+
*
|
|
8966
|
+
* 归因顺序是刻意的:**先看命令有没有跑起来**(沙箱 / 审批 / 包内缺文件),
|
|
8967
|
+
* 再看 HTTP / 服务端错误码。把"命令根本没跑起来"说成"AK 无效"会把员工指去换一份好密钥。
|
|
8968
|
+
*/
|
|
8969
|
+
async function probeOss(ctx, oss, platform, options = {}) {
|
|
8970
|
+
const { target } = ossProbeTarget(oss);
|
|
8971
|
+
const base = {
|
|
8972
|
+
id: "oss",
|
|
8973
|
+
label: "阿里云 OSS(AK 权限)",
|
|
8974
|
+
required: true,
|
|
8975
|
+
ok: false,
|
|
8976
|
+
state: "",
|
|
8977
|
+
detail: "",
|
|
8978
|
+
errorKind: "",
|
|
8979
|
+
target
|
|
8980
|
+
};
|
|
8981
|
+
if (!oss.enabled) return {
|
|
8982
|
+
...base,
|
|
8983
|
+
ok: false,
|
|
8984
|
+
state: "未启用",
|
|
8985
|
+
errorKind: "config",
|
|
8986
|
+
detail: "清单 oss.enabled 为 false"
|
|
8987
|
+
};
|
|
8988
|
+
if (oss.bucket === "") return {
|
|
8989
|
+
...base,
|
|
8990
|
+
ok: false,
|
|
8991
|
+
state: "缺 bucket",
|
|
8992
|
+
errorKind: "config",
|
|
8993
|
+
detail: "清单 oss.bucket 为空(由管理员在部署配置里设置)"
|
|
8994
|
+
};
|
|
8995
|
+
const lookup = await resolveOssutil(ctx, oss, platform, options);
|
|
8996
|
+
if (lookup.path === "") return {
|
|
8997
|
+
...base,
|
|
8998
|
+
ok: false,
|
|
8999
|
+
state: "插件包不完整 / 平台不受支持",
|
|
9000
|
+
errorKind: "infrastructure",
|
|
9001
|
+
detail: lookup.error
|
|
9002
|
+
};
|
|
9003
|
+
const ossutil = lookup.path;
|
|
9004
|
+
const built = buildOssProbeCommand(oss, ossutil, platform);
|
|
9005
|
+
if (!built.ok) return {
|
|
9006
|
+
...base,
|
|
9007
|
+
ok: false,
|
|
9008
|
+
state: "探测配置有误",
|
|
9009
|
+
errorKind: "config",
|
|
9010
|
+
detail: built.error
|
|
9011
|
+
};
|
|
9012
|
+
const run = await runShell(ctx, built.command, {
|
|
9013
|
+
timeoutMs: 6e4,
|
|
9014
|
+
...options.workdir === void 0 ? {} : { workdir: options.workdir }
|
|
9015
|
+
});
|
|
9016
|
+
if (shellUnavailable(run)) return {
|
|
9017
|
+
...base,
|
|
9018
|
+
ok: false,
|
|
9019
|
+
state: "无法探测",
|
|
9020
|
+
errorKind: "infrastructure",
|
|
9021
|
+
detail: sanitizeOssError(text(run.error) || text(run.stderr) || "探测命令没有执行")
|
|
9022
|
+
};
|
|
9023
|
+
const verdict = classifyOssFailure(run.ok, sanitizeOssError(text(run.stderr) || text(run.stdout) || text(run.error)));
|
|
9024
|
+
if (run.ok) return {
|
|
9025
|
+
...base,
|
|
9026
|
+
ok: true,
|
|
9027
|
+
state: "AK 正常",
|
|
9028
|
+
errorKind: "",
|
|
9029
|
+
detail: `已验证可访问 ${target}${oss.endpoint === "" ? "" : ` @ ${oss.endpoint}`}(只读列举,空目录也算通过)`
|
|
9030
|
+
};
|
|
9031
|
+
return {
|
|
9032
|
+
...base,
|
|
9033
|
+
ok: false,
|
|
9034
|
+
state: verdict.state,
|
|
9035
|
+
errorKind: verdict.kind,
|
|
9036
|
+
detail: verdict.detail
|
|
9037
|
+
};
|
|
9038
|
+
}
|
|
9039
|
+
/** 默认探测:**只读 `ls` + 业务前缀 + `--limited-num 1`**,结构化形状。 */
|
|
9040
|
+
function ossProbeSpec(oss, ossutil) {
|
|
9041
|
+
const { target } = ossProbeTarget(oss);
|
|
9042
|
+
return {
|
|
9043
|
+
executable: ossutil,
|
|
9044
|
+
args: [
|
|
9045
|
+
"ls",
|
|
9046
|
+
target,
|
|
9047
|
+
...oss.endpoint === "" ? [] : ["--endpoint", oss.endpoint],
|
|
9048
|
+
"--limited-num",
|
|
9049
|
+
"1",
|
|
9050
|
+
...oss.extraArgs
|
|
9051
|
+
]
|
|
9052
|
+
};
|
|
9053
|
+
}
|
|
9054
|
+
/** 旧字符串模板允许的占位符;出现别的 `{…}` 一律拒绝。 */
|
|
9055
|
+
const OSS_PROBE_PLACEHOLDERS = [
|
|
9056
|
+
"ossutil",
|
|
9057
|
+
"bucket",
|
|
9058
|
+
"prefix",
|
|
9059
|
+
"target",
|
|
9060
|
+
"endpoint"
|
|
9061
|
+
];
|
|
9062
|
+
/**
|
|
9063
|
+
* 生成探测命令。
|
|
9064
|
+
*
|
|
9065
|
+
* **默认路径是结构化的**(`ossProbeSpec` + `shellInvoke`):部署配置里不需要、也不应该出现
|
|
9066
|
+
* 「一段宿主 shell 脚本」。`oss.probeCommand` 作为 **deprecated 兼容路径**保留,但不再是裸替换:
|
|
9067
|
+
*
|
|
9068
|
+
* - 只认 `{ossutil}` / `{bucket}` / `{prefix}` / `{target}` / `{endpoint}` 五个占位符,别的一律拒绝;
|
|
9069
|
+
* - 每个占位符的值**逐个按平台引用**(`shellQuote`)—— 旧实现是把动态值裸拼进去,
|
|
9070
|
+
* 于是 endpoint 里一个空格或 `$` 就能改变命令结构;
|
|
9071
|
+
* - `{ossutil}` 是**命令位置**,替换成 `shellInvoke(ossutil, [], platform)`(Windows 上带 `&`);
|
|
9072
|
+
* - 拒绝换行、拒绝替换后仍残留的 `{` / `}`、拒绝空可执行文件。
|
|
9073
|
+
*
|
|
9074
|
+
* 部署配置是管理员写的,但这仍然是外部输入:它进 shell 前必须过同一套边界。
|
|
9075
|
+
*/
|
|
9076
|
+
function buildOssProbeCommand(oss, ossutil, platform) {
|
|
9077
|
+
if (ossutil === "") return {
|
|
9078
|
+
ok: false,
|
|
9079
|
+
error: "探测命令缺少可执行文件(ossutil 解析失败)"
|
|
9080
|
+
};
|
|
9081
|
+
if (oss.probeCommand === "") {
|
|
9082
|
+
const spec = ossProbeSpec(oss, ossutil);
|
|
9083
|
+
return {
|
|
9084
|
+
ok: true,
|
|
9085
|
+
command: shellInvoke(spec.executable, spec.args, platform),
|
|
9086
|
+
deprecated: false
|
|
9087
|
+
};
|
|
9088
|
+
}
|
|
9089
|
+
const template = oss.probeCommand;
|
|
9090
|
+
if (template.trim() === "") return {
|
|
9091
|
+
ok: false,
|
|
9092
|
+
error: "oss.probeCommand 是空白字符串,请留空使用内置只读探测"
|
|
9093
|
+
};
|
|
9094
|
+
if (/[\r\n]/.test(template)) return {
|
|
9095
|
+
ok: false,
|
|
9096
|
+
error: "oss.probeCommand 不能包含换行"
|
|
9097
|
+
};
|
|
9098
|
+
const { bucket, prefix, target } = ossProbeTarget(oss);
|
|
9099
|
+
const values = {
|
|
9100
|
+
ossutil,
|
|
9101
|
+
bucket,
|
|
9102
|
+
prefix,
|
|
9103
|
+
target,
|
|
9104
|
+
endpoint: oss.endpoint
|
|
9105
|
+
};
|
|
9106
|
+
const unknown = [];
|
|
9107
|
+
const command = template.replace(/\{([A-Za-z0-9_]+)\}/g, (whole, name) => {
|
|
9108
|
+
if (!Object.hasOwn(values, name)) {
|
|
9109
|
+
unknown.push(whole);
|
|
9110
|
+
return whole;
|
|
9111
|
+
}
|
|
9112
|
+
return name === "ossutil" ? shellInvoke(values[name], [], platform) : shellQuote(values[name], platform);
|
|
9113
|
+
});
|
|
9114
|
+
if (unknown.length > 0) return {
|
|
9115
|
+
ok: false,
|
|
9116
|
+
error: `oss.probeCommand 含未知占位符:${[...new Set(unknown)].join(" ")}(只认 ${OSS_PROBE_PLACEHOLDERS.join(" / ")})`
|
|
9117
|
+
};
|
|
9118
|
+
if (command.includes("{") || command.includes("}")) return {
|
|
9119
|
+
ok: false,
|
|
9120
|
+
error: "oss.probeCommand 含无法识别的花括号,请只使用 {ossutil} {bucket} {prefix} {target} {endpoint}"
|
|
9121
|
+
};
|
|
9122
|
+
return {
|
|
9123
|
+
ok: true,
|
|
9124
|
+
command,
|
|
9125
|
+
deprecated: true
|
|
9126
|
+
};
|
|
9127
|
+
}
|
|
9128
|
+
/**
|
|
9129
|
+
* OSS 失败 → 人话归因。
|
|
9130
|
+
*
|
|
9131
|
+
* 四类分开(用户口径:错误要"按凭据、权限、配置、网络分别给人话提示"):
|
|
9132
|
+
* - `credential`:`InvalidAccessKeyId` / `SignatureDoesNotMatch` / `InvalidSecurityToken` / `AccessKeyId is disabled`
|
|
9133
|
+
* → 员工重填 AK;
|
|
9134
|
+
* - `permission`:`AccessDenied` / `Forbidden` / `no permission`
|
|
9135
|
+
* → 凭据有效但没有这个 Bucket/Prefix 的权限,找管理员开权限;
|
|
9136
|
+
* - `config`:`NoSuchBucket` / `InvalidBucketName` / `Endpoint` 解析失败 / `unknown endpoint`
|
|
9137
|
+
* → 部署配置错了,找管理员;
|
|
9138
|
+
* - `infrastructure`:上面都不匹配(网络、DNS、超时、TLS、上游 5xx)
|
|
9139
|
+
* → 稍后重试 / 找管理员排查连通性,**不是** AK 的问题。
|
|
9140
|
+
*/
|
|
9141
|
+
function classifyOssFailure(ok, raw) {
|
|
9142
|
+
const detail = clampOssText(raw);
|
|
9143
|
+
if (ok) return {
|
|
9144
|
+
state: "AK 正常",
|
|
9145
|
+
kind: "",
|
|
9146
|
+
detail
|
|
9147
|
+
};
|
|
9148
|
+
const lower = raw.toLowerCase();
|
|
9149
|
+
if (/invalidaccesskeyid|signaturedoesnotmatch|invalidsaccesskeyid|invalidsecuritytoken|accesskeyid is disabled|accesskeyidisdisabled|invalidaccesskeyid\.notfound/i.test(raw)) return {
|
|
9150
|
+
state: "AccessKey 无效",
|
|
9151
|
+
kind: "credential",
|
|
9152
|
+
detail: detail || "OSS 认为这份 AccessKey 无效或已禁用"
|
|
9153
|
+
};
|
|
9154
|
+
if (/accessdenied|forbidden|no permission|denied/i.test(raw)) return {
|
|
9155
|
+
state: "AccessKey 没有目标权限",
|
|
9156
|
+
kind: "permission",
|
|
9157
|
+
detail: detail || "凭据有效,但没有该 Bucket / 前缀的访问权限"
|
|
9158
|
+
};
|
|
9159
|
+
if (/nosuchbucket|invalidbucketname|unknown endpoint|invalidendpoint|no such host.*oss|endpoint/i.test(lower)) return {
|
|
9160
|
+
state: "Bucket 或 Endpoint 配置有误",
|
|
9161
|
+
kind: "config",
|
|
9162
|
+
detail: detail || "Bucket 或 Endpoint 配置有误(由管理员在部署配置里设置)"
|
|
9163
|
+
};
|
|
9164
|
+
return {
|
|
9165
|
+
state: "连接 OSS 失败",
|
|
9166
|
+
kind: "infrastructure",
|
|
9167
|
+
detail: detail || "无法连接 OSS(网络 / 超时 / 服务不可达)"
|
|
9168
|
+
};
|
|
9169
|
+
}
|
|
9170
|
+
/** 探测输出的截断 + 脱敏(**唯一**出口,见 `oss/sanitize.ts`)。 */
|
|
9171
|
+
function clampOssText(raw) {
|
|
9172
|
+
const clean = sanitizeOssError(raw).trim();
|
|
9173
|
+
return clean.length <= 400 ? clean : `${clean.slice(0, 400)}…`;
|
|
9174
|
+
}
|
|
9175
|
+
//#endregion
|
|
9176
|
+
//#region src/host/platform/home.ts
|
|
9177
|
+
/**
|
|
9178
|
+
* 用户主目录。
|
|
9179
|
+
*
|
|
9180
|
+
* 包形态优先用 Host 的 `os.homedir()`:插件就跑在 DSH 进程里,不必再探一次。只有当它与执行
|
|
9181
|
+
* 世界不一致(例如 shell 走 SSH/容器,本地主目录不是目标机器的主目录)时,才退回 shell 探测。
|
|
9182
|
+
*
|
|
9183
|
+
* **探测命令按平台只选一条**(2026-09-28 收紧):旧实现把三条探测串成一条链
|
|
9184
|
+
* (`python3` → `printf` → `$env:USERPROFILE`)在 Windows 上先跑 `python3` —— 系统没装 Python 时
|
|
9185
|
+
* 每个探测都会先在 PowerShell 里报一次 command-not-found,日志噪音大、还让人误以为缺依赖。
|
|
9186
|
+
* 现在 Windows 只问 PowerShell 自己的环境变量,POSIX 只问 `$HOME`。
|
|
9187
|
+
*/
|
|
9188
|
+
async function detectHome(ctx, options = {}) {
|
|
9189
|
+
let local = "";
|
|
8723
9190
|
try {
|
|
8724
9191
|
local = text(homedir()).trim();
|
|
8725
9192
|
} catch (error) {
|
|
8726
9193
|
local = "";
|
|
8727
9194
|
}
|
|
8728
9195
|
if (local !== "") return local;
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
]) {
|
|
8734
|
-
const line = await commandLine(ctx, probe, {
|
|
8735
|
-
timeoutMs: 15e3,
|
|
8736
|
-
...workdir === void 0 ? {} : { workdir }
|
|
8737
|
-
});
|
|
8738
|
-
if (line !== "") return line;
|
|
8739
|
-
}
|
|
8740
|
-
return "";
|
|
9196
|
+
return await commandLine(ctx, homeProbeCommand(text(options.platform)), {
|
|
9197
|
+
timeoutMs: 15e3,
|
|
9198
|
+
...options.workdir === void 0 ? {} : { workdir: options.workdir }
|
|
9199
|
+
});
|
|
8741
9200
|
}
|
|
8742
9201
|
//#endregion
|
|
8743
9202
|
//#region src/host/oss/cred.ts
|
|
@@ -8747,10 +9206,14 @@ async function detectHome(ctx, workdir) {
|
|
|
8747
9206
|
* 两条硬约束(`AGENTS.md` §4.3):AccessKey/STS **绝不回显** —— 读只回脱敏与「有没有」;
|
|
8748
9207
|
* 写完之后也不回读内容,只回路径与是否成功。文件按 600 权限落盘。
|
|
8749
9208
|
*/
|
|
8750
|
-
/**
|
|
9209
|
+
/**
|
|
9210
|
+
* ossutil 配置文件的路径:跟着主目录走,不写死。
|
|
9211
|
+
*
|
|
9212
|
+
* 分隔符随主目录风格(Windows 上是 `\`):这条路径既进提示词与界面,也进 `chmod` / 读取命令。
|
|
9213
|
+
*/
|
|
8751
9214
|
function ossConfigPath(home) {
|
|
8752
9215
|
if (home === "") return "~/.ossutilconfig";
|
|
8753
|
-
return
|
|
9216
|
+
return joinLocalPath(home, ".ossutilconfig");
|
|
8754
9217
|
}
|
|
8755
9218
|
/** 解析 ossutil 的 INI 风格配置:`key=value`,忽略注释与段名,键小写。 */
|
|
8756
9219
|
function parseOssConfig(content) {
|
|
@@ -9121,8 +9584,7 @@ async function ossLink(deps, args) {
|
|
|
9121
9584
|
if (url === "") return failed(`签名命令没有输出 URL:${text(run.stdout).slice(0, 200)}`);
|
|
9122
9585
|
}
|
|
9123
9586
|
if (url.startsWith("http://")) url = `https://${url.slice(7)}`;
|
|
9124
|
-
const
|
|
9125
|
-
const opened = await runShell(deps.ctx, command, {
|
|
9587
|
+
const opened = await runShell(deps.ctx, openExternalCommand(url, deps.platform), {
|
|
9126
9588
|
workdir: await shellWorkdir(deps),
|
|
9127
9589
|
timeoutMs: 3e4,
|
|
9128
9590
|
escalate: true
|
|
@@ -9158,14 +9620,14 @@ async function uploadArtifacts(deps, item, projectId, ossutil, oss) {
|
|
|
9158
9620
|
const prefix = `${oss.prefix}/${projectId === "" ? item.name : projectId}`;
|
|
9159
9621
|
const results = [];
|
|
9160
9622
|
for (const file of files) {
|
|
9161
|
-
const
|
|
9162
|
-
const
|
|
9623
|
+
const localPath = joinLocalPath(item.path, file.name);
|
|
9624
|
+
const objectKey = `${prefix}/${file.name}`;
|
|
9163
9625
|
const argv = [
|
|
9164
9626
|
ossutil,
|
|
9165
9627
|
"cp",
|
|
9166
9628
|
"-f",
|
|
9167
|
-
|
|
9168
|
-
`oss://${oss.bucket}/${
|
|
9629
|
+
localPath,
|
|
9630
|
+
`oss://${oss.bucket}/${objectKey}`
|
|
9169
9631
|
];
|
|
9170
9632
|
if (oss.endpoint !== "") argv.push("--endpoint", oss.endpoint);
|
|
9171
9633
|
for (const extra of oss.extraArgs) argv.push(extra);
|
|
@@ -9176,9 +9638,9 @@ async function uploadArtifacts(deps, item, projectId, ossutil, oss) {
|
|
|
9176
9638
|
results.push({
|
|
9177
9639
|
kind: file.kind,
|
|
9178
9640
|
name: file.name,
|
|
9179
|
-
key,
|
|
9641
|
+
key: objectKey,
|
|
9180
9642
|
ok: run.ok,
|
|
9181
|
-
publicUrl: joinUrl(oss.publicBaseUrl,
|
|
9643
|
+
publicUrl: joinUrl(oss.publicBaseUrl, objectKey),
|
|
9182
9644
|
error: run.ok ? "" : (text(run.stderr) || text(run.error) || "上传失败").slice(0, 400)
|
|
9183
9645
|
});
|
|
9184
9646
|
}
|
|
@@ -9261,8 +9723,7 @@ async function ossCredSave(deps, args) {
|
|
|
9261
9723
|
error,
|
|
9262
9724
|
path: "",
|
|
9263
9725
|
operation: "",
|
|
9264
|
-
|
|
9265
|
-
chmodError: "",
|
|
9726
|
+
permission: failedPermission(error, deps.platform),
|
|
9266
9727
|
probe: null,
|
|
9267
9728
|
cred: null
|
|
9268
9729
|
});
|
|
@@ -9285,13 +9746,12 @@ async function ossCredSave(deps, args) {
|
|
|
9285
9746
|
error: probe.ok ? "" : probe.detail || probe.state || "OSS 验证未通过",
|
|
9286
9747
|
path: written.path,
|
|
9287
9748
|
operation: written.operation,
|
|
9288
|
-
|
|
9289
|
-
chmodError: written.chmodError,
|
|
9749
|
+
permission: written.permission,
|
|
9290
9750
|
probe,
|
|
9291
9751
|
cred: await readOssCred(deps.ctx, deps.home)
|
|
9292
9752
|
};
|
|
9293
9753
|
}
|
|
9294
|
-
/** 写 `~/.ossutilconfig`,并把权限收紧到 600
|
|
9754
|
+
/** 写 `~/.ossutilconfig`,并把权限收紧到 600(Windows 上如实报「继承账户 ACL」)。 */
|
|
9295
9755
|
async function writeOssCred(deps, input) {
|
|
9296
9756
|
const fs = fileSystem(deps.ctx);
|
|
9297
9757
|
if (fs === void 0) return {
|
|
@@ -9299,49 +9759,45 @@ async function writeOssCred(deps, input) {
|
|
|
9299
9759
|
error: "Host 文件服务不可用",
|
|
9300
9760
|
path: "",
|
|
9301
9761
|
operation: "",
|
|
9302
|
-
|
|
9303
|
-
chmodError: ""
|
|
9762
|
+
permission: failedPermission("Host 文件服务不可用", deps.platform)
|
|
9304
9763
|
};
|
|
9305
|
-
const path =
|
|
9764
|
+
const path = ossConfigPath(deps.home);
|
|
9306
9765
|
const built = buildConfigContent(input);
|
|
9307
9766
|
if (!built.ok) return {
|
|
9308
9767
|
ok: false,
|
|
9309
9768
|
error: built.error,
|
|
9310
9769
|
path: "",
|
|
9311
9770
|
operation: "",
|
|
9312
|
-
|
|
9313
|
-
chmodError: ""
|
|
9771
|
+
permission: failedPermission(built.error, deps.platform)
|
|
9314
9772
|
};
|
|
9315
9773
|
let operation = "";
|
|
9316
9774
|
try {
|
|
9317
9775
|
operation = text((await fs.writeText(await resolveTarget(deps.ctx, path), built.content))?.operation);
|
|
9318
9776
|
} catch (error) {
|
|
9777
|
+
const message = `写入 ${path} 失败:${error instanceof Error ? error.message : String(error)}`;
|
|
9319
9778
|
return {
|
|
9320
9779
|
ok: false,
|
|
9321
|
-
error:
|
|
9780
|
+
error: message,
|
|
9322
9781
|
path: "",
|
|
9323
9782
|
operation: "",
|
|
9324
|
-
|
|
9325
|
-
chmodError: ""
|
|
9783
|
+
permission: failedPermission(message, deps.platform)
|
|
9326
9784
|
};
|
|
9327
9785
|
}
|
|
9328
|
-
|
|
9329
|
-
let chmodError = "";
|
|
9330
|
-
if (!isWindowsPlatform(deps.platform)) {
|
|
9331
|
-
const chmod = await runShell(deps.ctx, `chmod 600 ${shellQuote$1(path, deps.platform)}`, {
|
|
9332
|
-
workdir: await shellWorkdir(deps),
|
|
9333
|
-
timeoutMs: 15e3
|
|
9334
|
-
});
|
|
9335
|
-
chmodOk = chmod.ok;
|
|
9336
|
-
chmodError = chmod.ok ? "" : (text(chmod.stderr) || text(chmod.error) || "权限设置失败").slice(0, 200);
|
|
9337
|
-
}
|
|
9786
|
+
const permission = await enforceCredentialPermission(deps.ctx, path, deps.platform, { workdir: await shellWorkdir(deps) });
|
|
9338
9787
|
return {
|
|
9339
9788
|
ok: true,
|
|
9340
9789
|
error: "",
|
|
9341
9790
|
path,
|
|
9342
9791
|
operation,
|
|
9343
|
-
|
|
9344
|
-
|
|
9792
|
+
permission
|
|
9793
|
+
};
|
|
9794
|
+
}
|
|
9795
|
+
/** 写盘都没走到权限那一步时的失败信封(mechanism 与平台一致,status 恒为 failed)。 */
|
|
9796
|
+
function failedPermission(message, platform) {
|
|
9797
|
+
return {
|
|
9798
|
+
status: "failed",
|
|
9799
|
+
mechanism: privateFileMechanism(platform),
|
|
9800
|
+
message
|
|
9345
9801
|
};
|
|
9346
9802
|
}
|
|
9347
9803
|
/** 生成 ossutil 配置内容(`[Credentials]` 段)。 */
|
|
@@ -9481,7 +9937,7 @@ function parseH3yunFiles(payload) {
|
|
|
9481
9937
|
return out;
|
|
9482
9938
|
}
|
|
9483
9939
|
function joinPath(base, name) {
|
|
9484
|
-
return
|
|
9940
|
+
return joinLocalPath(base, name);
|
|
9485
9941
|
}
|
|
9486
9942
|
/** 递归列目录(只名字与大小,深度与数量都设上限)。 */
|
|
9487
9943
|
async function collectLocal(fs, target, base, relative, depth, out) {
|
|
@@ -9568,15 +10024,20 @@ async function reportFiles(deps, args = {}) {
|
|
|
9568
10024
|
const localDir = workspacePath === "" ? "" : joinPath(workspacePath, seqNo);
|
|
9569
10025
|
let local = [];
|
|
9570
10026
|
let localExists = false;
|
|
9571
|
-
if (localDir !== ""
|
|
10027
|
+
if (localDir !== "") {
|
|
9572
10028
|
const fs = fileSystem(deps.ctx);
|
|
9573
10029
|
if (fs !== void 0) {
|
|
9574
10030
|
const target = await resolveTarget(deps.ctx, localDir);
|
|
9575
|
-
|
|
10031
|
+
const root = await resolveTarget(deps.ctx, workspacePath).catch((error) => {
|
|
9576
10032
|
return null;
|
|
9577
|
-
})
|
|
9578
|
-
|
|
9579
|
-
await
|
|
10033
|
+
});
|
|
10034
|
+
if (root !== null && fs.contains(root, target) === true) {
|
|
10035
|
+
if (await fs.listDir(target).catch((error) => {
|
|
10036
|
+
return null;
|
|
10037
|
+
}) !== null) {
|
|
10038
|
+
localExists = true;
|
|
10039
|
+
await collectLocal(fs, target, localDir, "", 1, local);
|
|
10040
|
+
}
|
|
9580
10041
|
}
|
|
9581
10042
|
}
|
|
9582
10043
|
}
|
|
@@ -9673,8 +10134,7 @@ async function clipboard(deps, args) {
|
|
|
9673
10134
|
ok: false,
|
|
9674
10135
|
error: "没有内容"
|
|
9675
10136
|
};
|
|
9676
|
-
const
|
|
9677
|
-
const run = await runShell(deps.ctx, command, {
|
|
10137
|
+
const run = await runShell(deps.ctx, clipboardCommand(deps.platform), {
|
|
9678
10138
|
workdir: await deps.workdir(),
|
|
9679
10139
|
timeoutMs: 2e4,
|
|
9680
10140
|
escalate: true,
|
|
@@ -9708,8 +10168,7 @@ async function openPath(deps, args) {
|
|
|
9708
10168
|
} catch (error) {
|
|
9709
10169
|
return failed(`路径解析失败:${error instanceof Error ? error.message : String(error)}`);
|
|
9710
10170
|
}
|
|
9711
|
-
const
|
|
9712
|
-
const run = await runShell(deps.ctx, command, {
|
|
10171
|
+
const run = await runShell(deps.ctx, openExternalCommand(path, deps.platform), {
|
|
9713
10172
|
workdir: await deps.workdir(),
|
|
9714
10173
|
timeoutMs: 3e4,
|
|
9715
10174
|
escalate: true
|
|
@@ -10676,8 +11135,7 @@ async function ifindCredentialSave(deps, args) {
|
|
|
10676
11135
|
error,
|
|
10677
11136
|
errorKind,
|
|
10678
11137
|
view: written.view,
|
|
10679
|
-
|
|
10680
|
-
chmodError: written.chmodError,
|
|
11138
|
+
permission: written.permission,
|
|
10681
11139
|
mode: written.mode,
|
|
10682
11140
|
probe: {
|
|
10683
11141
|
ok: false,
|
|
@@ -10697,14 +11155,13 @@ async function ifindCredentialSave(deps, args) {
|
|
|
10697
11155
|
const probe = await probeIfind(deps.transport ?? defaultIfindTransport(), await readSecretFor(deps), { ...deps.timeoutMs === void 0 ? {} : { timeoutMs: deps.timeoutMs } });
|
|
10698
11156
|
return {
|
|
10699
11157
|
ok: true,
|
|
10700
|
-
error: written.
|
|
11158
|
+
error: written.permission.status === "failed" ? `凭据已保存,但权限没有收紧到 0600:${written.permission.message}` : "",
|
|
10701
11159
|
errorKind: "",
|
|
10702
11160
|
view: {
|
|
10703
11161
|
...written.view,
|
|
10704
11162
|
state: probe.state
|
|
10705
11163
|
},
|
|
10706
|
-
|
|
10707
|
-
chmodError: written.chmodError,
|
|
11164
|
+
permission: written.permission,
|
|
10708
11165
|
mode: written.mode,
|
|
10709
11166
|
probe: probeViewOf(probe)
|
|
10710
11167
|
};
|
|
@@ -11077,7 +11534,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
11077
11534
|
buildKind: HOST_BUILD_KIND,
|
|
11078
11535
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11079
11536
|
builtAt: HOST_BUILD_STAMP,
|
|
11080
|
-
protocol:
|
|
11537
|
+
protocol: 17
|
|
11081
11538
|
}),
|
|
11082
11539
|
boot: async () => {
|
|
11083
11540
|
await ensureRegistry(ctx, await world.home(), state);
|
|
@@ -11089,7 +11546,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
11089
11546
|
});
|
|
11090
11547
|
return {
|
|
11091
11548
|
ok: true,
|
|
11092
|
-
protocol:
|
|
11549
|
+
protocol: 17,
|
|
11093
11550
|
rev: PLUGIN_REV,
|
|
11094
11551
|
version: PLUGIN_VERSION,
|
|
11095
11552
|
buildKind: HOST_BUILD_KIND,
|
|
@@ -12637,7 +13094,7 @@ function createWorldFacts(ctx) {
|
|
|
12637
13094
|
return platform;
|
|
12638
13095
|
},
|
|
12639
13096
|
async home() {
|
|
12640
|
-
if (home === null) home = await detectHome(ctx);
|
|
13097
|
+
if (home === null) home = await detectHome(ctx, { platform: await this.platform() });
|
|
12641
13098
|
return home;
|
|
12642
13099
|
},
|
|
12643
13100
|
async workdir() {
|