dsh-crwu-workbench 0.0.15 → 0.0.17

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 CHANGED
@@ -7,6 +7,109 @@
7
7
  `cordis_define` + `cordis_run` 装配,版本号用 DSH 的 `pkg-N`);它已在本仓收尾时删除
8
8
  (见 `0.0.1` 一节),下面 `legacy · pkg-43` 及更早的记录是它的历史。
9
9
 
10
+ ## package · 0.0.17 · 2026-09-29 · fix · 登录失败归因分两支 + 登录临时目录由插件指定
11
+
12
+ 员工在 Windows 上点「扫码登录氚云 / 钉钉登录」持续失败。开发者诊断给出的结构化事实是
13
+ `req = res = ran = danger-full-access`、`denied=false`、`runnerFailed=false`、`started=true` ——
14
+ **DSH 没有拦这次调用**;原文却是
15
+
16
+ ```
17
+ create fallback browser profile directory: mkdir C:\Users\51019\AppData\Local\crwu: Access is denied.
18
+ (system TEMP failed: mkdir C:\Users\51019\AppData\Local\Temp\crwu-scan-556823159: Access is denied.)
19
+ ```
20
+
21
+ 两条路径都在 `AppData\Local` 下、都被拒,而同一时刻 `dws auth status` 是 ok(`~/.dws` 可写)。
22
+ 本版据此做两件事,都只动插件侧:
23
+
24
+ 1. **登录的临时目录改由插件指定**(`src/host/system/auth-scratch.ts`):先建
25
+ `<home>/.dsh/crwu-workbench/auth-tmp`,再把 `TMPDIR`/`TMP`/`TEMP` 指过去
26
+ (POSIX `mkdir -p … && TMPDIR=… <cmd>`;PowerShell `New-Item -Force …; $env:TMP=…; & <cmd>`)。
27
+ 依据是员工机器上的事实:系统 TEMP 与用户缓存目录都被拒,而插件状态目录(`<home>/.dsh/…`)可写。
28
+ **不动 `LOCALAPPDATA`**(Chromium 自己的组件目录仍走系统默认);主目录未知时原样返回命令,不伪造。
29
+ `runCrwu` 增加可选 `scratchDir`(只有会就地起浏览器的那条命令用)。
30
+ 2. **归因分两支,不许合成一句**(`LoginAdvice.policyBlocked`):结构化事实说降级 / 拒绝 / 实际受限
31
+ → 「被文件策略挡在工作区之外」,动作是切「完全权限」;**事实干净却照样 `Access is denied`
32
+ → 「被这台机器拒绝(不是 DSH 的文件策略)」**,明说"切权限不会有用",并给退路
33
+ (氚云 `crwu h3yun session bind --token`、钉钉先试「设备码登录(无浏览器时)」)。
34
+ 上一版把两支合成一句"请切完全权限" —— 员工按提示切了,而日志证明根本不是策略问题(误导)。
35
+ 3. 面板「账号连接」的前置说明同步改口径,并回到"设备码登录(**无浏览器时**)"
36
+ (它不是"沙箱挡浏览器"的退路:同样要抢 `<HOME>/.dws` 的锁)。
37
+
38
+ **已证伪**:把两支合成策略支 → 5 条变红;把 scratch 的 env 前缀去掉(只建目录不指过去)→ 2 条变红。
39
+
40
+ ### ① 登录失败归因:两支分开,不许合成一句
41
+
42
+ **症状**:员工第一次用(Windows 与 macOS 都报)时,点「扫码登录氚云」「钉钉登录」浏览器起不来;
43
+ 面板给的却是 CLI 的原话 —— 氚云 `mkdir C:\…\Temp\crwu-scan-1799287186: Access is denied.`
44
+ (看着像 crwu 坏了)、钉钉 `acquiring file lock: …\.dws\.data.lock: Access is denied.`
45
+ (看着像 dws 自己的 bug)。
46
+
47
+ **根因(两条入口同一条边界)**:登录**必须写工作区之外的路径** ——
48
+
49
+ | 入口 | 必须写的东西 |
50
+ | --- | --- |
51
+ | `crwu h3yun session login` | `$TMPDIR`/`%TEMP%` 下的临时浏览器 profile(`crwu-scan-*`),再经 CDP 读会话 |
52
+ | `dws auth login`(含 `--device`) | `<HOME>/.dws/.data.lock`(拿登录态之前先抢文件锁) |
53
+ | 两者收尾 | 操作系统凭据存储(钥匙串 / Credential Manager) |
54
+
55
+ 宿主按完全访问跑、操作系统的 ACL/受限令牌仍然拒绝时报的就是上面那两句;受限沙箱下浏览器即使
56
+ 起来了也会在几毫秒内 renderer 崩溃(CDP 只回 `close 1006` / `Target crashed`)。
57
+
58
+ - **新增纯函数判据** `src/host/system/login-failure.ts`:`loginFailureAdvice()` / `describeLoginAdvice()`。
59
+ 顺序是「结构化事实优先,文本最后」:① `runnerFailed` → ② 提权被降级(`requested !== resolved`)
60
+ → ③ `denied === true` 或实际跑在受限模式 → ④ **事实干净但原文点名了登录必须写的工作区外目标
61
+ 且带拒绝字样**(Windows 实测就是这一形态)。
62
+ - **第 ④ 条是 `sandboxDenialNote()` 的唯一例外**,判据本身变强了:目标不是"任意路径",而是登录
63
+ 流程必须写的那几个已知目标。**目标词与拒绝字样必须同时命中** —— 只命中一个不归因(否则
64
+ `secret not found in keyring` 这种"真没条目 / 钥匙串被锁"会被说成"去切完全权限")。
65
+ - **`dwsLogin` 以前完全不做归因**(只有 `runDws` 做),现在两条登录都返回 `sandboxBlocked` + `advice`,
66
+ 并把「在输入框下方的访问模式里选「完全权限」再点一次」写进 `error`,**原始报错保留**在末尾。
67
+ - **设备码不再是"退路"**:它同样要抢 `~/.dws` 的锁,所以按钮文案改回「设备码登录(无浏览器时)」,
68
+ 归因文案里明说"在这个模式下也走不通"。
69
+ - **界面**:「账号连接」页在登录按钮下方**先说前置条件**(`envLoginSandboxHint`);
70
+ 被归因时不再追加 stdout 尾巴(否则那句「正在打开浏览器窗口…」会把真正的动作挤掉)。
71
+
72
+ **已证伪**:把第 ④ 条整段关掉 → 7 条变红;把「目标词」那一半去掉(只认拒绝字样)→
73
+ 误报用例变红。`cp` 还原后转绿。
74
+
75
+ ### ② 登录的临时目录由插件指定(不再依赖系统 TEMP)
76
+
77
+ 员工在 Windows 上用 0.0.15/0.0.16 再报一次,原文是**两条路径都被拒**:
78
+
79
+ ```
80
+ create fallback browser profile directory: mkdir C:\Users\51019\AppData\Local\crwu: Access is denied.
81
+ (system TEMP failed: mkdir C:\Users\51019\AppData\Local\Temp\crwu-scan-556823159: Access is denied.)
82
+ ```
83
+
84
+ 而同一时刻开发者诊断里 `h3yun.session.login` 是
85
+ `req = res = ran = danger-full-access`、`denied=false`、`runnerFailed=false`、`started=true` ——
86
+ **DSH 没有拦它**,`%TEMP%` 与用户缓存目录却都被这台机器拒绝。两处修正:
87
+
88
+ 1. **临时目录改由插件指定**(`src/host/system/auth-scratch.ts`):登录命令先建
89
+ `<home>/.dsh/crwu-workbench/auth-tmp`,再把 `TMPDIR`/`TMP`/`TEMP` 指过去
90
+ (POSIX `mkdir -p … && TMPDIR=… <cmd>`;PowerShell `New-Item -Force …; $env:TMP=…; …; & <cmd>`)。
91
+ 依据是**这台机器自己的事实**:系统 TEMP 与缓存目录都被拒,而插件状态目录(`<home>/.dsh/…`)
92
+ 是它一直在写的地方。**不动 `LOCALAPPDATA`**(Chromium 自己的组件目录仍走系统默认);
93
+ 主目录未知时**原样返回命令**,不伪造一个没建出来的目录。
94
+ `runCrwu` 增加可选的 `scratchDir`(只有会就地起浏览器的那条命令用)。
95
+ 2. **归因分两支,不许合成一句**:`LoginAdvice.policyBlocked`。
96
+ - `true`(结构化事实:降级 / 拒绝 / 实际受限 / runner 挂)→「被文件策略挡在工作区之外」,
97
+ 动作是切「完全权限」;
98
+ - `false`(事实干净、原文点名登录必须写的目标且带拒绝字样)→「**被这台机器拒绝**(不是 DSH 的
99
+ 文件策略)」,明确写「切权限不会有用」,并给退路:氚云用 `crwu h3yun session bind --token`、
100
+ 钉钉先试「设备码登录(无浏览器时)」。
101
+
102
+ 上一版把两支合成了一句"请切完全权限"——员工按提示切了,而日志证明根本不是策略问题(**误导**)。
103
+
104
+ **已证伪**:把两支合成策略支 → 5 条变红;把 scratch 的 env 前缀去掉(只建目录不指过去)→ 2 条变红。
105
+
106
+ ## package · 0.0.16 · 2026-09-29 · fix · Windows 氚云扫码临时目录回退
107
+
108
+ - 随包 `crwu.exe` 的 `h3yun session login` 在系统 `%TEMP%` 被 Windows 安全策略拒绝时,
109
+ 自动回退到 `%LOCALAPPDATA%\crwu\scan-tmp\crwu-scan-*`;登录结束仍清理临时 profile。
110
+ - 回退只针对权限错误,且不改变 OS 凭据存储边界;若用户缓存目录也被拒绝,保留两段错误供
111
+ Defender/企业安全软件排查。
112
+
10
113
  ## package · 0.0.15 · 2026-09-28
11
114
 
12
115
  **协议 18:本机访问从「调用方声明提权」改成「具名操作 + 版本化授权收据」,
Binary file
package/bin/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "crwu.plugin-bin-manifest.v1",
3
- "generatedAt": "2026-09-28T01:55:47.678Z",
3
+ "generatedAt": "2026-09-29T07:54:23.331Z",
4
4
  "platforms": [
5
5
  {
6
6
  "platform": "darwin-arm64",
@@ -12,9 +12,9 @@
12
12
  "source": "repo-build",
13
13
  "sourceVersion": "0.0.1",
14
14
  "target": "darwin/arm64",
15
- "buildCommit": "d90d55efe18bd6ae01bd3102959ed7a62c4381c2",
15
+ "buildCommit": "6769a60cf2b4262793fc10492a7fa4ff2658fee7",
16
16
  "size": 7351330,
17
- "sha256": "864496c621bcfaddd23a433ce4dcd36b32a882f5281e2ebc223bbdca57c64816"
17
+ "sha256": "4a406f33795444b2f2ae36c62efb3fce4b040f94458d01b6815b51122525a192"
18
18
  },
19
19
  {
20
20
  "tool": "ossutil",
@@ -50,9 +50,9 @@
50
50
  "source": "repo-build",
51
51
  "sourceVersion": "0.0.1",
52
52
  "target": "windows/amd64",
53
- "buildCommit": "d90d55efe18bd6ae01bd3102959ed7a62c4381c2",
54
- "size": 8122880,
55
- "sha256": "ca0d055a2335bf0ddefaf3b448e59a3999ed328e88c868e47873e73b29fdadb3"
53
+ "buildCommit": "6769a60cf2b4262793fc10492a7fa4ff2658fee7",
54
+ "size": 8124928,
55
+ "sha256": "8142ef5bd35eddcdc43bcf0c3180a626494efc08a5517043724e97c965ecfa58"
56
56
  },
57
57
  {
58
58
  "tool": "ossutil",
Binary file
package/lib/client.js CHANGED
@@ -2519,6 +2519,12 @@ body[data-ds-dark-theme] .crwu-audit-root {
2519
2519
  envOssReasonNoSecret: "凭据文件里没有 AccessKey Secret",
2520
2520
  envOssOssutilMissing: "插件内置的 ossutil 不可用:这不是凭据问题,是插件包不完整 —— 需要重新安装插件",
2521
2521
  envLoginH3yun: "扫码登录氚云",
2522
+ /**
2523
+ * 登录按钮的**前置条件**(2026-09-29 员工实测):两条登录命令都要写工作区之外的路径 ——
2524
+ * 临时浏览器 profile、`<HOME>/.dws` 的登录态、操作系统凭据存储。文件策略不给时一条都走不通,
2525
+ * 而这一点在前端看不出来(表现只是"浏览器没起来")。说在点之前,别让员工靠试错发现。
2526
+ */
2527
+ envLoginSandboxHint: "登录要在工作区之外起临时浏览器 / 更新登录态文件。报「拒绝访问」时看面板那句话:说「被文件策略挡下」就把访问模式切到「完全权限」再点一次;说「被这台机器拒绝」就是本机安全软件或临时目录权限的问题,切权限没有用。",
2522
2528
  envDetailsTitle: "排查详情(维护者看)",
2523
2529
  envDetailsTools: "插件内置组件明细",
2524
2530
  envDetailsRuntime: "DSH 脚本运行时明细",
@@ -3103,7 +3109,7 @@ body[data-ds-dark-theme] .crwu-audit-root {
3103
3109
  runCardSummary: "中瑞世联工作台:环境自检 → 报告审核(单条)→ 自动上云 → 直接开云端审核意见。",
3104
3110
  relogin: "氚云登录",
3105
3111
  dwsLogin: "钉钉登录",
3106
- dwsLoginDevice: "设备码登录(浏览器打不开时)",
3112
+ dwsLoginDevice: "设备码登录(无浏览器时)",
3107
3113
  stopAudit: "停止这条审核",
3108
3114
  stopPhaseRequested: "正在请求停止审核…",
3109
3115
  stopPhaseAborting: "正在取消子会话…",
@@ -6094,6 +6100,10 @@ body[data-ds-dark-theme] .crwu-audit-root {
6094
6100
  })]
6095
6101
  })
6096
6102
  }),
6103
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6104
+ className: WORKBENCH_CLASSES.muted,
6105
+ children: zhCN.envLoginSandboxHint
6106
+ }),
6097
6107
  props.loginMessage === void 0 || props.loginMessage === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6098
6108
  className: WORKBENCH_CLASSES.itemFix,
6099
6109
  style: { whiteSpace: "pre-wrap" },
@@ -11161,6 +11171,10 @@ body[data-ds-dark-theme] .crwu-audit-root {
11161
11171
  * 这两条命令会开浏览器、等人扫码(宿主侧超时 5 分钟),所以:成功也要给一句话,
11162
11172
  * 失败必须带原因,CLI 打到 stdout 的 URL / 设备码 / 提示原样带出来 —— 浏览器打不开时,
11163
11173
  * 那串 URL 就是员工唯一能自己走下去的路。
11174
+ *
11175
+ * `sandboxBlocked` 是 **Host 的归因事实**(登录必须写工作区之外的路径):为真时
11176
+ * `error` 里已经是一句人话 + 下一步动作,这里不再追加 stdout 尾巴 —— 那只会是一句
11177
+ * 「正在打开浏览器窗口…」,把真正的动作挤到看不见。
11164
11178
  */
11165
11179
  const runLogin = react.useCallback(async (label, call) => {
11166
11180
  setLoginMessage(`${label}:正在等待浏览器授权…(最多 5 分钟)`);
@@ -11171,8 +11185,10 @@ body[data-ds-dark-theme] .crwu-audit-root {
11171
11185
  if (result.ok) lines.push(`${label}:命令已执行完成,请刷新查看结果。`);
11172
11186
  else if (result.timedOut === true) lines.push(`${label}:等待超时(5 分钟)。若浏览器没有自动打开,请重试或改用设备码登录。`);
11173
11187
  else lines.push(`${label}失败:${result.error || result.stderrTail || "未知原因"}`);
11174
- const detail = (result.stdoutTail ?? "").trim() || (result.stderrTail ?? "").trim();
11175
- if (detail !== "") lines.push(detail);
11188
+ if (result.sandboxBlocked !== true) {
11189
+ const detail = (result.stdoutTail ?? "").trim() || (result.stderrTail ?? "").trim();
11190
+ if (detail !== "") lines.push(detail);
11191
+ }
11176
11192
  setLoginMessage(lines.join("\n"));
11177
11193
  } catch (cause) {
11178
11194
  if (mounted.current) setLoginMessage(`${label}失败:${describe(cause)}`);
package/lib/index.js CHANGED
@@ -231,7 +231,7 @@ const PLUGIN_INJECT = [
231
231
  * 还会让打包器的 JSON 插件成为隐式依赖。代价是升版本时要同时改这里 ——
232
232
  * `tests/unit/host-package.test.mjs` 有一条断言盯着它必须等于 `package.json` 的 version。
233
233
  */
234
- const PLUGIN_VERSION = "0.0.15";
234
+ const PLUGIN_VERSION = "0.0.17";
235
235
  /**
236
236
  * `ping` / `boot` 应答里的版本指纹,形如 `pkg-0.0.5`。
237
237
  *
@@ -3037,6 +3037,64 @@ function chmodCommand(mode, path, platform) {
3037
3037
  return `chmod ${mode} ${shellQuote(path, platform)}`;
3038
3038
  }
3039
3039
  //#endregion
3040
+ //#region src/host/system/auth-scratch.ts
3041
+ /**
3042
+ * 登录命令的**私有 scratch 目录**(协议 19 · G 段,2026-09-29)。
3043
+ *
3044
+ * ## 为什么要由插件指定,而不是让 CLI 用系统 TEMP
3045
+ *
3046
+ * 扫码登录要就地起一个临时浏览器 profile,`crwu` 用 `os.MkdirTemp("", …)` 建它 ——
3047
+ * 也就是**系统 TEMP**。真实员工机器上实测(2026-09-29,Windows):
3048
+ *
3049
+ * ```
3050
+ * create fallback browser profile directory: mkdir C:\Users\51019\AppData\Local\crwu: Access is denied.
3051
+ * (system TEMP failed: mkdir C:\Users\51019\AppData\Local\Temp\crwu-scan-556823159: Access is denied.)
3052
+ * ```
3053
+ *
3054
+ * 两条路径都在 `AppData\Local` 下、都被拒,而**插件的日志说明 DSH 没有拦它**
3055
+ * (`requested = resolved = ran = danger-full-access`、`denied=false`)—— 拒它的是这台机器
3056
+ * 自己(安全软件的按二进制规则 / 低完整性令牌 / 该目录的 ACL)。同一条命令在
3057
+ * `%USERPROFILE%` 下(例如 `~/.dws`)却是通的。
3058
+ *
3059
+ * 所以插件不再把"我自己的 scratch 放哪"交给系统 TEMP:给它一个**插件自己长期在写、
3060
+ * 已经确认可写**的位置 —— `<home>/.dsh/crwu-workbench/auth-tmp`(同目录下已经有
3061
+ * `ifind-credential.json`),并在**同一条命令**里先把目录建出来、再把 `TMPDIR`/`TMP`/`TEMP`
3062
+ * 指过去。这样 CLI 与它拉起的浏览器 profile 都落在同一条已知可写的路径上。
3063
+ *
3064
+ * ## 三条约束
3065
+ *
3066
+ * 1. **不改 `LOCALAPPDATA`**:Chromium 自己的组件目录仍按系统默认走,改它只会让浏览器行为
3067
+ * 更难解释;这里只动"临时文件"那三个变量。
3068
+ * 2. **目录必须是插件状态目录下的固定叶子**(不接受任何调用方/模型输入):它与
3069
+ * `state/persist.ts` 推导的状态文件同根,越界无从谈起。
3070
+ * 3. **不伪造成功**:设不上(`home` 为空)就原样返回命令 —— 由 CLI 自己报它的 TEMP 错误,
3071
+ * 而不是我们假装指到了某个目录。
3072
+ */
3073
+ /** scratch 目录相对主目录的叶子路径(唯一推导点)。 */
3074
+ const AUTH_SCRATCH_LEAF = [
3075
+ ".dsh",
3076
+ "crwu-workbench",
3077
+ "auth-tmp"
3078
+ ];
3079
+ /** 登录命令的 scratch 目录;主目录未知时返回空串(调用方据此走原命令)。 */
3080
+ function authScratchDir(home) {
3081
+ if (home === "") return "";
3082
+ return joinLocalPath(home, ...AUTH_SCRATCH_LEAF);
3083
+ }
3084
+ /**
3085
+ * 把一条已经拼好的命令包成"先建 scratch 目录、再把临时目录指向它"。
3086
+ *
3087
+ * 两种方言各用自己那条 `mkdir`(`mkdir -p` / `New-Item -Force`),都来自
3088
+ * `platform/shell.ts` 的同一个生成器 —— 这里不再手写第三条。
3089
+ */
3090
+ function withAuthScratch(command, dir, platform) {
3091
+ if (dir === "") return command;
3092
+ const quoted = shellQuote(dir, platform);
3093
+ const ensure = mkdirCommand(dir, platform);
3094
+ if (shellDialect(platform) === "powershell") return `${ensure}; $env:TMP=${quoted}; $env:TEMP=${quoted}; $env:TMPDIR=${quoted}; ${command}`;
3095
+ return `${ensure} && TMPDIR=${quoted} TMP=${quoted} TEMP=${quoted} ${command}`;
3096
+ }
3097
+ //#endregion
3040
3098
  //#region src/host/platform/command.ts
3041
3099
  /**
3042
3100
  * 把「按名字调用的自带命令」解析成**包内绝对路径**;解析不到就原样回退那个名字。
@@ -3168,12 +3226,12 @@ async function runCrwu(ctx, argv, options) {
3168
3226
  const operation = crwuOperationOf(clean);
3169
3227
  if (operation === null) return failed(`该 crwu 子命令还没有登记本机访问操作,拒绝执行:${describeCrwuCommand(clean)}`);
3170
3228
  const platform = options.platform ?? "";
3171
- const resolved = await resolveBundledCommand(ctx, platform, "crwu");
3229
+ const command = withAuthScratch(shellInvoke(await resolveBundledCommand(ctx, platform, "crwu"), clean.slice(1), platform), options.scratchDir ?? "", platform);
3172
3230
  const result = await options.access.runShell({
3173
3231
  operation,
3174
3232
  source: options.source,
3175
3233
  ...options.workdir === void 0 ? {} : { workdir: options.workdir }
3176
- }, shellInvoke(resolved, clean.slice(1), platform), {
3234
+ }, command, {
3177
3235
  ...options.workdir === void 0 ? {} : { workdir: options.workdir },
3178
3236
  timeoutMs: options.timeoutMs ?? 6e4,
3179
3237
  ...options.stdoutMaxBytes === void 0 ? {} : { stdoutMaxBytes: options.stdoutMaxBytes },
@@ -12056,6 +12114,135 @@ function parseSessionOutput(stdout) {
12056
12114
  return sessionView(doc);
12057
12115
  }
12058
12116
  //#endregion
12117
+ //#region src/host/system/login-failure.ts
12118
+ /** 完全访问模式的字面量;只在**结构化事实**里比较。 */
12119
+ const FULL_ACCESS = "danger-full-access";
12120
+ /**
12121
+ * 这一条命令**必须**能写的工作区外目标(大小写不敏感的子串)。
12122
+ *
12123
+ * 刻意保守:只登记登录流程真正会碰的东西。多写一个词就会把别的失败误判成沙箱问题。
12124
+ */
12125
+ const LOGIN_TARGETS = {
12126
+ h3yun: ["crwu-scan-", "crwu-scan"],
12127
+ dws: [".dws", ".data.lock"]
12128
+ };
12129
+ /** 两个入口共有的目标:操作系统凭据存储。 */
12130
+ const CREDENTIAL_STORE_TARGETS = [
12131
+ "keyring",
12132
+ "credential store",
12133
+ "credential-store",
12134
+ "钥匙串",
12135
+ "凭据存储"
12136
+ ];
12137
+ /** 拒绝字样。命中它**且**命中目标词才算归因。 */
12138
+ const DENIED_MARKERS = [
12139
+ "access is denied",
12140
+ "access denied",
12141
+ "permission denied",
12142
+ "operation not permitted",
12143
+ "eacces",
12144
+ "eperm",
12145
+ "拒绝访问",
12146
+ "不允许",
12147
+ "被拒绝"
12148
+ ];
12149
+ const POLICY_ACTIONS = {
12150
+ h3yun: "在输入框下方的访问模式里选「完全权限」(或输入 /permission danger-full-access),回到「账号连接」再点一次「扫码登录氚云」。",
12151
+ dws: "在输入框下方的访问模式里选「完全权限」(或输入 /permission danger-full-access),回到「账号连接」再点一次「钉钉登录」。设备码登录同样要更新 <HOME>/.dws 的登录态,在这个模式下也走不通。"
12152
+ };
12153
+ /**
12154
+ * ② 策略允许、**这台机器**拒绝(2026-09-29 Windows 实测):
12155
+ * `requested = resolved = ran = danger-full-access`、`denied=false`,照样 `Access is denied`。
12156
+ * 这种时候叫人去切权限是**误导** —— 切了也没用。
12157
+ */
12158
+ const MACHINE_REASONS = {
12159
+ h3yun: "氚云扫码登录要就地建一个临时浏览器配置目录(crwu-scan-*),而这台机器不让这条命令创建它",
12160
+ dws: "钉钉登录要先更新 <HOME>/.dws 里的登录态,而这台机器不让这条命令写它"
12161
+ };
12162
+ const MACHINE_ACTIONS = {
12163
+ h3yun: "这不是工作区权限问题:DSH 这次是以完全访问跑的(请求与实际都是 danger-full-access),切权限不会有用。请先看这台机器的安全软件(企业管控 / 杀软)是否拦了这条命令,以及临时目录的权限;确实起不了浏览器时,改用本机终端里的 `crwu h3yun session bind --token <JWT>` 绑定会话(令牌不要发到对话里)。",
12164
+ dws: "这不是工作区权限问题:DSH 这次是以完全访问跑的(请求与实际都是 danger-full-access),切权限不会有用。请先看这台机器的安全软件是否拦了这条命令,以及 <HOME>/.dws 的权限;也可以先试「设备码登录(无浏览器时)」,它同样写 <HOME>/.dws,但不开浏览器。"
12165
+ };
12166
+ function textList(values) {
12167
+ return values.map((value) => text(value)).filter((value) => value !== "").join("\n").toLowerCase();
12168
+ }
12169
+ /** 命中拒绝字样,且原文点名了该入口**必须写**的工作区外目标。 */
12170
+ function deniedOutsideWorkspace(kind, facts) {
12171
+ const blob = textList([
12172
+ facts.stderr,
12173
+ facts.stdout,
12174
+ facts.error
12175
+ ]);
12176
+ if (blob === "") return false;
12177
+ if (!DENIED_MARKERS.some((marker) => blob.includes(marker))) return false;
12178
+ return [...LOGIN_TARGETS[kind], ...CREDENTIAL_STORE_TARGETS].some((target) => blob.includes(target));
12179
+ }
12180
+ /** 一条没有归因的结论(调用方据此原样回原始错误)。 */
12181
+ function none() {
12182
+ return {
12183
+ blocked: false,
12184
+ policyBlocked: false,
12185
+ reason: "",
12186
+ action: ""
12187
+ };
12188
+ }
12189
+ /**
12190
+ * 判据入口:给一次登录失败的事实,回答"是不是本机访问被挡在工作区之外"。
12191
+ *
12192
+ * 纯函数:没有 IO、没有时间、没有平台分支 —— 平台差异全部体现在传进来的事实里。
12193
+ */
12194
+ function loginFailureAdvice(kind, facts) {
12195
+ const sandbox = facts.sandbox;
12196
+ const requested = text(sandbox?.requested);
12197
+ const resolved = text(sandbox?.resolved);
12198
+ const ran = text(sandbox?.ran);
12199
+ const confined = (value) => value !== "" && value !== FULL_ACCESS;
12200
+ if (sandbox?.runnerFailed === true) return {
12201
+ blocked: true,
12202
+ policyBlocked: true,
12203
+ reason: "DSH 的沙箱 runner 在命令跑起来之前就失败了(不是命令本身的业务失败)",
12204
+ action: POLICY_ACTIONS[kind]
12205
+ };
12206
+ if (requested !== "" && resolved !== "" && requested !== resolved) return {
12207
+ blocked: true,
12208
+ policyBlocked: true,
12209
+ reason: `提权请求被降级:请求 ${requested},执行器实际按 ${resolved} 跑`,
12210
+ action: POLICY_ACTIONS[kind]
12211
+ };
12212
+ if (sandbox?.denied === true) return {
12213
+ blocked: true,
12214
+ policyBlocked: true,
12215
+ reason: `DSH 沙箱拒绝了这次操作(${ran || resolved || requested || "当前模式"})`,
12216
+ action: POLICY_ACTIONS[kind]
12217
+ };
12218
+ if (confined(resolved) || confined(ran)) return {
12219
+ blocked: true,
12220
+ policyBlocked: true,
12221
+ reason: `登录进程实际跑在 ${ran || resolved} 下,写不了工作区之外的登录目标`,
12222
+ action: POLICY_ACTIONS[kind]
12223
+ };
12224
+ if (deniedOutsideWorkspace(kind, facts)) return {
12225
+ blocked: true,
12226
+ policyBlocked: false,
12227
+ reason: MACHINE_REASONS[kind],
12228
+ action: MACHINE_ACTIONS[kind]
12229
+ };
12230
+ return none();
12231
+ }
12232
+ /**
12233
+ * 把归因拼成一句给员工看的话(界面直接显示 `error`)。
12234
+ *
12235
+ * `original` 必须是**命令自己的原话**(stderr / 脱离沙箱前的错误),不是别的归因函数
12236
+ * 已经加工过的句子 —— 否则同一句"沙箱挡住了"会出现两次,员工会以为是两个故障。
12237
+ */
12238
+ function describeLoginAdvice(kind, advice, original) {
12239
+ if (!advice.blocked) return text(original);
12240
+ const head = advice.policyBlocked ? `本机访问被文件策略挡在工作区之外:${advice.reason}。` : `本机访问被这台机器拒绝(不是 DSH 的文件策略):${advice.reason}。`;
12241
+ const body = text(original).trim();
12242
+ const evidence = body === "" ? "" : `(原始报错:${body.slice(0, 300)})`;
12243
+ return `${head}${advice.action}${evidence}`;
12244
+ }
12245
+ //#endregion
12059
12246
  //#region src/host/system/ops.ts
12060
12247
  /**
12061
12248
  * 把文本写进系统剪贴板。
@@ -12130,30 +12317,43 @@ function tails(run) {
12130
12317
  };
12131
12318
  }
12132
12319
  /** 钉钉登录。`--device` 走设备码流程(无浏览器时用)。 */
12133
- async function dwsLogin(deps, args) {
12320
+ async function dwsLogin(deps, args, home) {
12134
12321
  const argv = [
12135
12322
  await resolveBundledCommand(deps.ctx, deps.platform, "dws"),
12136
12323
  "auth",
12137
12324
  "login"
12138
12325
  ];
12139
12326
  if (args.device === true) argv.push("--device");
12327
+ const command = withAuthScratch(shellInvoke(argv[0] ?? "", argv.slice(1), deps.platform), authScratchDir(home), deps.platform);
12140
12328
  const run = await deps.access.runShell({
12141
12329
  operation: "dws.auth.login",
12142
12330
  source: "panel",
12143
12331
  workdir: await deps.workdir()
12144
- }, shellInvoke(argv[0] ?? "", argv.slice(1), deps.platform), {
12332
+ }, command, {
12145
12333
  timeoutMs: 3e5,
12146
12334
  summary: "dws auth login"
12147
12335
  });
12336
+ const advice = loginFailureAdvice("dws", {
12337
+ error: run.error,
12338
+ stdout: run.stdout,
12339
+ stderr: run.stderr,
12340
+ exitCode: run.exitCode,
12341
+ timedOut: run.timedOut,
12342
+ sandbox: run.sandbox
12343
+ });
12344
+ const raw = text(run.stderr).trim() || text(run.error).trim();
12345
+ const error = run.ok ? "" : advice.blocked ? describeLoginAdvice("dws", advice, raw) : describeFailure(run);
12148
12346
  return {
12149
12347
  ok: run.ok,
12150
- error: run.error,
12348
+ error,
12151
12349
  timedOut: run.timedOut,
12350
+ sandboxBlocked: advice.blocked,
12351
+ advice: advice.blocked ? advice.action : "",
12152
12352
  ...tails(run)
12153
12353
  };
12154
12354
  }
12155
12355
  /** 氚云重新登录(`crwu h3yun session login` 在提权白名单里)。 */
12156
- async function relogin(deps) {
12356
+ async function relogin(deps, home) {
12157
12357
  const run = await runCrwu(deps.ctx, [
12158
12358
  "crwu",
12159
12359
  "h3yun",
@@ -12164,12 +12364,24 @@ async function relogin(deps) {
12164
12364
  timeoutMs: 3e5,
12165
12365
  access: deps.access,
12166
12366
  source: "panel",
12167
- platform: deps.platform
12367
+ platform: deps.platform,
12368
+ scratchDir: authScratchDir(home)
12168
12369
  });
12370
+ const advice = loginFailureAdvice("h3yun", {
12371
+ error: run.error,
12372
+ stdout: run.stdout,
12373
+ stderr: run.stderr,
12374
+ exitCode: run.exitCode,
12375
+ timedOut: run.timedOut,
12376
+ sandbox: run.sandbox
12377
+ });
12378
+ const error = run.ok ? "" : advice.blocked ? describeLoginAdvice("h3yun", advice, text(run.stderr)) : run.error;
12169
12379
  return {
12170
12380
  ok: run.ok,
12171
- error: run.error,
12381
+ error,
12172
12382
  timedOut: run.timedOut,
12383
+ sandboxBlocked: advice.blocked,
12384
+ advice: advice.blocked ? advice.action : "",
12173
12385
  session: parseSessionOutput(run.stdout),
12174
12386
  ...tails(run)
12175
12387
  };
@@ -14783,7 +14995,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
14783
14995
  access: resolvers.access,
14784
14996
  platform: await world.platform(),
14785
14997
  workdir: () => world.workdir()
14786
- });
14998
+ }, await world.home());
14787
14999
  gate.invalidate();
14788
15000
  return result;
14789
15001
  },
@@ -14824,7 +15036,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
14824
15036
  access: resolvers.access,
14825
15037
  platform: await world.platform(),
14826
15038
  workdir: () => world.workdir()
14827
- }, args);
15039
+ }, args, await world.home());
14828
15040
  gate.invalidate();
14829
15041
  return result;
14830
15042
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crwu-workbench",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "中瑞世联工作台 / CRWU audit workbench for DeepSeek Harness: pick a pending audit report, dispatch one AI audit subagent, watch and stop/restart it, auto-upload deliverables to Aliyun OSS.",
5
5
  "keywords": [
6
6
  "deepseek-harness",