dsh-crwu-workbench 0.0.39 → 0.0.41
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 +171 -1
- package/README.en.md +18 -1
- package/README.md +20 -5
- package/bin/darwin-arm64/crwu +0 -0
- package/bin/manifest.json +5 -5
- package/bin/win32-x64/crwu.exe +0 -0
- package/lib/client.js +3576 -1072
- package/lib/index.js +1880 -315
- package/package.json +1 -1
- package/skills/crwu/crwu-audit/SKILL.md +5 -1
- package/skills/crwu/crwu-audit/references/11-html-delivery-spec.md +1 -1
- package/skills/crwu/crwu-audit/references/16-local-audit.md +123 -0
- package/skills/crwu/crwu-audit/references/99-maintenance.md +1 -0
- package/skills/crwu/crwu-audit/scripts/audit_delivery.py +18 -2
- package/skills/crwu/crwu-audit/scripts/audit_result.schema.json +8 -0
- package/skills/crwu/crwu-audit/scripts/test_audit_delivery.py +28 -0
- package/skills/crwu/crwu-audit/template/audit-report.html +3 -0
- package/skills/crwu/crwu-audit-public-general-standards/SKILL.md +2 -2
- package/skills/crwu/crwu-audit-public-general-standards/references/00-applicability.md +2 -2
- package/skills/crwu/crwu-audit-public-general-standards/references/01-kb-assembly.md +1 -1
- package/skills/crwu/crwu-audit-public-general-standards/references/03-execution-contract.md +3 -1
- package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/test_audit_skill_maintainer.py +21 -0
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { constants, existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import Schema from "@deepseek-ai/schemastery";
|
|
4
4
|
import { parse } from "yaml";
|
|
@@ -8,6 +8,7 @@ import { setSandboxMode } from "@deepseek-ai/dsh-sandbox-policy";
|
|
|
8
8
|
import { setApprovalPolicy } from "@deepseek-ai/dsh-user-approval";
|
|
9
9
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import { access, copyFile, lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
11
12
|
import { compare, gt, gte, prerelease, valid } from "semver";
|
|
12
13
|
//#region src/host/config/yaml.ts
|
|
13
14
|
function record(value, path) {
|
|
@@ -231,7 +232,7 @@ const PLUGIN_INJECT = [
|
|
|
231
232
|
* 还会让打包器的 JSON 插件成为隐式依赖。代价是升版本时要同时改这里 ——
|
|
232
233
|
* `tests/unit/host-package.test.mjs` 有一条断言盯着它必须等于 `package.json` 的 version。
|
|
233
234
|
*/
|
|
234
|
-
const PLUGIN_VERSION = "0.0.
|
|
235
|
+
const PLUGIN_VERSION = "0.0.41";
|
|
235
236
|
/**
|
|
236
237
|
* `ping` / `boot` 应答里的版本指纹,形如 `pkg-0.0.5`。
|
|
237
238
|
*
|
|
@@ -285,6 +286,15 @@ function registerRpcRoute(ctx, operations) {
|
|
|
285
286
|
kind: "exact",
|
|
286
287
|
path: WORKBENCH_ROUTE,
|
|
287
288
|
async handler(req, res) {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const abort = () => {
|
|
291
|
+
controller.abort();
|
|
292
|
+
};
|
|
293
|
+
const disconnect = () => {
|
|
294
|
+
if (!res.writableEnded) abort();
|
|
295
|
+
};
|
|
296
|
+
req.once?.("aborted", abort);
|
|
297
|
+
res.once?.("close", disconnect);
|
|
288
298
|
try {
|
|
289
299
|
if (req.method !== "POST") {
|
|
290
300
|
writeJson$1(res, 405, {
|
|
@@ -312,12 +322,16 @@ function registerRpcRoute(ctx, operations) {
|
|
|
312
322
|
});
|
|
313
323
|
return;
|
|
314
324
|
}
|
|
315
|
-
|
|
325
|
+
const result = await operation(body.args !== null && typeof body.args === "object" ? body.args : {}, { signal: controller.signal });
|
|
326
|
+
if (!controller.signal.aborted) writeJson$1(res, 200, result);
|
|
316
327
|
} catch (error) {
|
|
317
|
-
writeJson$1(res, 200, {
|
|
328
|
+
if (!controller.signal.aborted) writeJson$1(res, 200, {
|
|
318
329
|
ok: false,
|
|
319
330
|
error: error instanceof Error ? error.message : String(error)
|
|
320
331
|
});
|
|
332
|
+
} finally {
|
|
333
|
+
req.removeListener?.("aborted", abort);
|
|
334
|
+
res.removeListener?.("close", disconnect);
|
|
321
335
|
}
|
|
322
336
|
}
|
|
323
337
|
});
|
|
@@ -1539,7 +1553,8 @@ const TOOL_NAMES = {
|
|
|
1539
1553
|
ossPublish: "crwu_audit_oss_publish",
|
|
1540
1554
|
dingtalkArchive: "crwu_audit_dingtalk_archive",
|
|
1541
1555
|
dingtalkNotifySelf: "crwu_audit_dingtalk_notify_self",
|
|
1542
|
-
runPythonScript: "crwu_run_python_script"
|
|
1556
|
+
runPythonScript: "crwu_run_python_script",
|
|
1557
|
+
localAuditClaim: "crwu_audit_local_claim"
|
|
1543
1558
|
};
|
|
1544
1559
|
/** 自研审核链路随包发布的三个二进制。 */
|
|
1545
1560
|
const AUDIT_BINARIES = [
|
|
@@ -1661,7 +1676,7 @@ const NO_SANDBOX_FACTS = {
|
|
|
1661
1676
|
denied: false,
|
|
1662
1677
|
runnerFailed: false
|
|
1663
1678
|
};
|
|
1664
|
-
function failed$
|
|
1679
|
+
function failed$2(error, sandbox = NO_SANDBOX_FACTS) {
|
|
1665
1680
|
return {
|
|
1666
1681
|
ok: false,
|
|
1667
1682
|
error,
|
|
@@ -1693,9 +1708,9 @@ function failed$1(error, sandbox = NO_SANDBOX_FACTS) {
|
|
|
1693
1708
|
*/
|
|
1694
1709
|
async function runShell(ctx, command, options = {}) {
|
|
1695
1710
|
const shell = ctx.get("shell");
|
|
1696
|
-
if (shell === void 0) return failed$
|
|
1711
|
+
if (shell === void 0) return failed$2("Host shell 服务不可用");
|
|
1697
1712
|
const workRoot = options.workdir ?? "";
|
|
1698
|
-
if (options.escalate === true && workRoot === "") return failed$
|
|
1713
|
+
if (options.escalate === true && workRoot === "") return failed$2("未知会话工作区,无法申请无沙箱执行");
|
|
1699
1714
|
const declared = options.sandboxPolicy ?? (options.escalate === true ? {
|
|
1700
1715
|
mode: "danger-full-access",
|
|
1701
1716
|
workspaceRoot: workRoot
|
|
@@ -1714,7 +1729,7 @@ async function runShell(ctx, command, options = {}) {
|
|
|
1714
1729
|
...declared === void 0 ? {} : { sandboxPolicy: declared }
|
|
1715
1730
|
});
|
|
1716
1731
|
} catch (error) {
|
|
1717
|
-
return failed$
|
|
1732
|
+
return failed$2(`执行失败:${failureMessage(error)}`, {
|
|
1718
1733
|
...NO_SANDBOX_FACTS,
|
|
1719
1734
|
requested: requestedMode
|
|
1720
1735
|
});
|
|
@@ -1745,7 +1760,7 @@ async function runShell(ctx, command, options = {}) {
|
|
|
1745
1760
|
}
|
|
1746
1761
|
};
|
|
1747
1762
|
} catch (error) {
|
|
1748
|
-
return failed$
|
|
1763
|
+
return failed$2(`执行失败:${failureMessage(error)}`, baseSandbox);
|
|
1749
1764
|
}
|
|
1750
1765
|
}
|
|
1751
1766
|
/**
|
|
@@ -3074,7 +3089,7 @@ function classifyRun(run) {
|
|
|
3074
3089
|
return "cli";
|
|
3075
3090
|
}
|
|
3076
3091
|
/** 从任意一次运行结果里取一句最有信息量的失败描述。 */
|
|
3077
|
-
function reasonOf(run) {
|
|
3092
|
+
function reasonOf$1(run) {
|
|
3078
3093
|
const stderr = text(run.stderr).trim();
|
|
3079
3094
|
if (stderr !== "") return stderr.slice(0, 600);
|
|
3080
3095
|
const error = text(run.error).trim();
|
|
@@ -3171,10 +3186,11 @@ function samePathText(a, b) {
|
|
|
3171
3186
|
*/
|
|
3172
3187
|
function isAuditChild(state, childId, parentSessionId = "") {
|
|
3173
3188
|
if (childId === "") return false;
|
|
3189
|
+
if (parentSessionId !== "" && parentSessionId === state.auditRoot?.sessionId) return true;
|
|
3174
3190
|
for (const record of Object.values(state.audits ?? {})) {
|
|
3175
3191
|
if (record === void 0) continue;
|
|
3176
|
-
if (record.childId === childId) return true;
|
|
3177
|
-
if (
|
|
3192
|
+
if (record.childId === childId || record.replacedChildId === childId) return true;
|
|
3193
|
+
if (parentSessionId !== "" && record.parentSessionId === parentSessionId) return true;
|
|
3178
3194
|
}
|
|
3179
3195
|
return false;
|
|
3180
3196
|
}
|
|
@@ -3189,7 +3205,7 @@ function isAuditChild(state, childId, parentSessionId = "") {
|
|
|
3189
3205
|
* 退路问的是会话存储本身:**审核根的孩子里有没有这个调用者**。
|
|
3190
3206
|
* 两条都拿不到才算"问不到",由门禁 fail closed(不猜)。
|
|
3191
3207
|
*
|
|
3192
|
-
*
|
|
3208
|
+
* Query known audit roots so replaced children retain their managed identity.
|
|
3193
3209
|
*/
|
|
3194
3210
|
async function callerParentSessionId(ctx, state, exec) {
|
|
3195
3211
|
const { childId, parentSessionId } = callerIdentity(exec);
|
|
@@ -3197,13 +3213,10 @@ async function callerParentSessionId(ctx, state, exec) {
|
|
|
3197
3213
|
if (parentSessionId !== "") return parentSessionId;
|
|
3198
3214
|
const subagents = ctx.get("subagents");
|
|
3199
3215
|
if (subagents === void 0 || typeof subagents.listChildren !== "function") return "";
|
|
3200
|
-
const candidates = Object.values(state.audits ?? {}).
|
|
3201
|
-
|
|
3202
|
-
const parentId = candidates[0]?.parentSessionId ?? "";
|
|
3203
|
-
if (parentId === "") return "";
|
|
3204
|
-
try {
|
|
3216
|
+
const candidates = new Set([text(state.auditRoot?.sessionId), ...Object.values(state.audits ?? {}).map((record) => text(record?.parentSessionId))].filter((id) => id !== ""));
|
|
3217
|
+
for (const parentId of candidates) try {
|
|
3205
3218
|
const children = await subagents.listChildren(parentId);
|
|
3206
|
-
if (!Array.isArray(children))
|
|
3219
|
+
if (!Array.isArray(children)) continue;
|
|
3207
3220
|
for (const child of children) {
|
|
3208
3221
|
if (child === null || typeof child !== "object") continue;
|
|
3209
3222
|
const record = child;
|
|
@@ -4800,7 +4813,7 @@ function bootstrapTools(deps) {
|
|
|
4800
4813
|
objectId
|
|
4801
4814
|
], runOptions);
|
|
4802
4815
|
if (!recordRun.ok) return {
|
|
4803
|
-
...failure$1(recordRun.error !== "" ? "infrastructure" : "cli", reasonOf(recordRun)),
|
|
4816
|
+
...failure$1(recordRun.error !== "" ? "infrastructure" : "cli", reasonOf$1(recordRun)),
|
|
4804
4817
|
...empty
|
|
4805
4818
|
};
|
|
4806
4819
|
const record = recordOf$1(recordRun.stdout);
|
|
@@ -4837,7 +4850,7 @@ function bootstrapTools(deps) {
|
|
|
4837
4850
|
objectId
|
|
4838
4851
|
], runOptions);
|
|
4839
4852
|
if (!filesRun.ok) return {
|
|
4840
|
-
...failure$1(filesRun.error !== "" ? "infrastructure" : "cli", reasonOf(filesRun)),
|
|
4853
|
+
...failure$1(filesRun.error !== "" ? "infrastructure" : "cli", reasonOf$1(filesRun)),
|
|
4841
4854
|
...empty
|
|
4842
4855
|
};
|
|
4843
4856
|
const attachments = attachmentsOf(filesRun.stdout);
|
|
@@ -5366,6 +5379,215 @@ function nodeSize(item) {
|
|
|
5366
5379
|
}
|
|
5367
5380
|
return null;
|
|
5368
5381
|
}
|
|
5382
|
+
function createDiscussionScopeRegistry(options = {}) {
|
|
5383
|
+
const now = options.now ?? (() => Date.now());
|
|
5384
|
+
const ttlMs = options.ttlMs ?? 432e5;
|
|
5385
|
+
const limit = options.limit ?? 32;
|
|
5386
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
5387
|
+
/** 过期的一律先清掉:判据只有 `expiresAt` 与当前时刻。 */
|
|
5388
|
+
const sweep = () => {
|
|
5389
|
+
const at = now();
|
|
5390
|
+
for (const [id, scope] of scopes) if (scope.expiresAt <= at) scopes.delete(id);
|
|
5391
|
+
};
|
|
5392
|
+
/** 超出上限时丢**最久没用过**的(不是最早建的:人可能一直在用一条老会话)。 */
|
|
5393
|
+
const trim = () => {
|
|
5394
|
+
while (scopes.size > limit) {
|
|
5395
|
+
let oldestId = "";
|
|
5396
|
+
let oldestAt = Number.POSITIVE_INFINITY;
|
|
5397
|
+
for (const [id, scope] of scopes) if (scope.lastUsedAt < oldestAt) {
|
|
5398
|
+
oldestAt = scope.lastUsedAt;
|
|
5399
|
+
oldestId = id;
|
|
5400
|
+
}
|
|
5401
|
+
if (oldestId === "") return;
|
|
5402
|
+
scopes.delete(oldestId);
|
|
5403
|
+
}
|
|
5404
|
+
};
|
|
5405
|
+
return {
|
|
5406
|
+
register(input) {
|
|
5407
|
+
const at = now();
|
|
5408
|
+
const scope = {
|
|
5409
|
+
sessionId: text(input.sessionId),
|
|
5410
|
+
seqNo: text(input.seqNo),
|
|
5411
|
+
objectId: text(input.objectId),
|
|
5412
|
+
caseDir: text(input.caseDir),
|
|
5413
|
+
allowedAttachmentIds: [...input.allowedAttachmentIds].map((id) => text(id)).filter((id) => id !== ""),
|
|
5414
|
+
createdAt: at,
|
|
5415
|
+
lastUsedAt: at,
|
|
5416
|
+
expiresAt: at + ttlMs
|
|
5417
|
+
};
|
|
5418
|
+
scopes.set(scope.sessionId, scope);
|
|
5419
|
+
trim();
|
|
5420
|
+
return scope;
|
|
5421
|
+
},
|
|
5422
|
+
use(sessionId) {
|
|
5423
|
+
sweep();
|
|
5424
|
+
const id = text(sessionId);
|
|
5425
|
+
if (id === "") return void 0;
|
|
5426
|
+
const scope = scopes.get(id);
|
|
5427
|
+
if (scope === void 0) return void 0;
|
|
5428
|
+
const touched = {
|
|
5429
|
+
...scope,
|
|
5430
|
+
lastUsedAt: now()
|
|
5431
|
+
};
|
|
5432
|
+
scopes.set(id, touched);
|
|
5433
|
+
return touched;
|
|
5434
|
+
},
|
|
5435
|
+
peek(sessionId) {
|
|
5436
|
+
sweep();
|
|
5437
|
+
return scopes.get(text(sessionId));
|
|
5438
|
+
},
|
|
5439
|
+
drop(sessionId) {
|
|
5440
|
+
scopes.delete(text(sessionId));
|
|
5441
|
+
},
|
|
5442
|
+
list() {
|
|
5443
|
+
sweep();
|
|
5444
|
+
return [...scopes.values()];
|
|
5445
|
+
},
|
|
5446
|
+
clear() {
|
|
5447
|
+
scopes.clear();
|
|
5448
|
+
},
|
|
5449
|
+
size() {
|
|
5450
|
+
sweep();
|
|
5451
|
+
return scopes.size;
|
|
5452
|
+
}
|
|
5453
|
+
};
|
|
5454
|
+
}
|
|
5455
|
+
/**
|
|
5456
|
+
* 案例内 Tool 的**统一门禁**:认证身份 → 解析范围 → 校验模型提交的标识。
|
|
5457
|
+
*
|
|
5458
|
+
* 与 `requireAuditScope` 的关系:后者是"审核子会话"这一支的实现,这里在它之前再加一支
|
|
5459
|
+
* "已登记的讨论会话"。**讨论会话绝不复用审核 scope**(见文件头第 2 条)。
|
|
5460
|
+
*/
|
|
5461
|
+
async function requireMaterialScope(ctx, state, scopes, exec, args, options = {}) {
|
|
5462
|
+
const { childId } = callerIdentity(exec);
|
|
5463
|
+
if (childId === "") return { ...failure$1("policy", "无法确认调用者身份:案例内的操作只允许由本次审核的子会话或已登记的报告讨论会话发起") };
|
|
5464
|
+
if (auditScopeFor(state, childId) !== void 0) {
|
|
5465
|
+
const checked = await requireAuditScope(ctx, state, exec, args, options);
|
|
5466
|
+
if (!checked.ok) return checked;
|
|
5467
|
+
return {
|
|
5468
|
+
ok: true,
|
|
5469
|
+
material: {
|
|
5470
|
+
kind: "audit",
|
|
5471
|
+
scope: checked.scope
|
|
5472
|
+
}
|
|
5473
|
+
};
|
|
5474
|
+
}
|
|
5475
|
+
const scope = scopes.use(childId);
|
|
5476
|
+
if (scope === void 0) return { ...failure$1("policy", "调用者既不是进行中的审核子会话,也不是已登记的报告讨论会话:案例内的操作一律拒绝(报告讨论请在工作台点「与 DeepSeek 讨论报告」重新登记材料范围)") };
|
|
5477
|
+
const seqNo = text(args.seqNo).trim();
|
|
5478
|
+
if (seqNo !== "" && seqNo !== scope.seqNo) return { ...failure$1("input", "流水号与本次登记的材料范围不一致:拒绝操作") };
|
|
5479
|
+
const objectId = text(args.objectId).trim();
|
|
5480
|
+
if (objectId !== "" && objectId !== scope.objectId) return { ...failure$1("input", "记录标识与本次登记的材料范围不一致:拒绝操作") };
|
|
5481
|
+
const caseDir = text(args.caseDir).trim();
|
|
5482
|
+
if (caseDir === "") {
|
|
5483
|
+
if (options.requireCaseDir === false) return {
|
|
5484
|
+
ok: true,
|
|
5485
|
+
material: {
|
|
5486
|
+
kind: "discussion",
|
|
5487
|
+
scope
|
|
5488
|
+
}
|
|
5489
|
+
};
|
|
5490
|
+
return { ...failure$1("input", "缺少案例目录:案例内的操作必须带上本轮案例目录") };
|
|
5491
|
+
}
|
|
5492
|
+
if (fileSystem(ctx) === void 0) return { ...failure$1("infrastructure", "Host 文件服务不可用") };
|
|
5493
|
+
try {
|
|
5494
|
+
const given = await resolveTarget(ctx, caseDir);
|
|
5495
|
+
const expected = await resolveTarget(ctx, scope.caseDir);
|
|
5496
|
+
if (text(given.targetKey) === "" || given.targetKey !== expected.targetKey) return { ...failure$1("policy", "案例目录必须是本次登记的那一个(不许用工作空间根、兄弟案例或子目录)") };
|
|
5497
|
+
} catch (error) {
|
|
5498
|
+
return { ...failure$1("infrastructure", `案例目录解析失败:${error instanceof Error ? error.message : String(error)}`) };
|
|
5499
|
+
}
|
|
5500
|
+
return {
|
|
5501
|
+
ok: true,
|
|
5502
|
+
material: {
|
|
5503
|
+
kind: "discussion",
|
|
5504
|
+
scope
|
|
5505
|
+
}
|
|
5506
|
+
};
|
|
5507
|
+
}
|
|
5508
|
+
//#endregion
|
|
5509
|
+
//#region src/host/audit/case-access.ts
|
|
5510
|
+
/**
|
|
5511
|
+
* 本地审核的产物**不上传、不回传**(协议 28)。
|
|
5512
|
+
*
|
|
5513
|
+
* 产品口径是「审核结果只存在于这次对话里」:对话的固定提示词里已经写了不许调用这三条工具,
|
|
5514
|
+
* 但提示词是**请求**,不是门禁。真正的边界在这里 —— OSS 发布与钉钉归档 / 通知三条交付工具
|
|
5515
|
+
* 在本地审核的案例 scope 下一律拒绝,理由明确指向产品口径,而不是让模型以为"配置坏了"。
|
|
5516
|
+
*/
|
|
5517
|
+
function localDeliveryRefused(access) {
|
|
5518
|
+
if (access.kind !== "local") return null;
|
|
5519
|
+
return failure$1("policy", "本地审核的产物只留在本机临时案例目录里:不上传 OSS、也不做钉钉归档与通知。请把交付件留在案例目录并直接向我汇报,不要改道到任何远端。");
|
|
5520
|
+
}
|
|
5521
|
+
async function requireCaseAccess(ctx, state, discussions, exec, args, localAudit) {
|
|
5522
|
+
const { childId } = callerIdentity(exec);
|
|
5523
|
+
if (childId === "") return failure$1("policy", "无法确认调用者身份:案例操作需要会话上下文");
|
|
5524
|
+
const parentId = await callerParentSessionId(ctx, state, exec);
|
|
5525
|
+
if (isAuditChild(state, childId, parentId)) {
|
|
5526
|
+
const checked = await requireAuditScope(ctx, state, exec, args);
|
|
5527
|
+
if (!checked.ok) return checked;
|
|
5528
|
+
return {
|
|
5529
|
+
ok: true,
|
|
5530
|
+
kind: "audit",
|
|
5531
|
+
casePath: checked.casePath,
|
|
5532
|
+
allowedAttachmentIds: checked.scope.allowedAttachmentIds
|
|
5533
|
+
};
|
|
5534
|
+
}
|
|
5535
|
+
const local = localAudit?.scopeOf(childId);
|
|
5536
|
+
if (local !== void 0) {
|
|
5537
|
+
const caseDir = text(args.caseDir).trim();
|
|
5538
|
+
if (caseDir === "") return {
|
|
5539
|
+
ok: true,
|
|
5540
|
+
kind: "local",
|
|
5541
|
+
casePath: local.casePath
|
|
5542
|
+
};
|
|
5543
|
+
if (fileSystem(ctx) === void 0) return failure$1("infrastructure", "Host 文件服务不可用");
|
|
5544
|
+
try {
|
|
5545
|
+
const given = await resolveTarget(ctx, caseDir);
|
|
5546
|
+
const expected = await resolveTarget(ctx, local.casePath);
|
|
5547
|
+
if (text(given.targetKey) === "" || given.targetKey !== expected.targetKey) return failure$1("policy", "案例目录必须是**本次本地审核自己的**临时快照目录");
|
|
5548
|
+
} catch (error) {
|
|
5549
|
+
return failure$1("infrastructure", `案例目录解析失败:${error instanceof Error ? error.message : String(error)}`);
|
|
5550
|
+
}
|
|
5551
|
+
return {
|
|
5552
|
+
ok: true,
|
|
5553
|
+
kind: "local",
|
|
5554
|
+
casePath: local.casePath
|
|
5555
|
+
};
|
|
5556
|
+
}
|
|
5557
|
+
const header = exec.agent?.session?.header;
|
|
5558
|
+
if (Object.values(state.audits ?? {}).some((record) => record?.pending === true && record.childId === "") && parentId === "" && header === void 0) return failure$1("policy", "审核子会话正在建立,尚无法确认调用者的会话范围:请稍后重试");
|
|
5559
|
+
if (discussions.peek(childId) !== void 0) {
|
|
5560
|
+
const checked = await requireMaterialScope(ctx, state, discussions, exec, args);
|
|
5561
|
+
if (!checked.ok) return checked;
|
|
5562
|
+
const scope = checked.material.scope;
|
|
5563
|
+
return {
|
|
5564
|
+
ok: true,
|
|
5565
|
+
kind: checked.material.kind,
|
|
5566
|
+
casePath: checked.material.kind === "audit" ? checked.material.scope.casePath : checked.material.scope.caseDir,
|
|
5567
|
+
allowedAttachmentIds: scope.allowedAttachmentIds
|
|
5568
|
+
};
|
|
5569
|
+
}
|
|
5570
|
+
const root = allowedCaseRootOf(state);
|
|
5571
|
+
const checked = await requireCaseDir(ctx, args.caseDir, { allowedRoot: root });
|
|
5572
|
+
if (!checked.ok) return checked;
|
|
5573
|
+
const seqNo = text(args.seqNo).trim() || basenameLocalPath(checked.path);
|
|
5574
|
+
if (seqNo === "." || seqNo === ".." || /[\\/]/.test(seqNo)) return failure$1("input", "流水号必须是单段案例目录名,不能包含路径分隔符");
|
|
5575
|
+
const expectedPath = caseDirOf(root, seqNo);
|
|
5576
|
+
if (expectedPath === "") return failure$1("input", "流水号必须是有效的案例目录名");
|
|
5577
|
+
try {
|
|
5578
|
+
const given = await resolveTarget(ctx, checked.path);
|
|
5579
|
+
const expected = await resolveTarget(ctx, expectedPath);
|
|
5580
|
+
const workspace = await resolveTarget(ctx, root);
|
|
5581
|
+
if (text(given.targetKey) === "" || given.targetKey === workspace.targetKey || given.targetKey !== expected.targetKey) return failure$1("policy", "案例目录必须是当前工作空间下的 `<工作空间>/<流水号>`,不能使用工作空间根或嵌套目录");
|
|
5582
|
+
} catch (error) {
|
|
5583
|
+
return failure$1("infrastructure", `案例目录解析失败:${error instanceof Error ? error.message : String(error)}`);
|
|
5584
|
+
}
|
|
5585
|
+
return {
|
|
5586
|
+
ok: true,
|
|
5587
|
+
kind: "session",
|
|
5588
|
+
casePath: checked.path
|
|
5589
|
+
};
|
|
5590
|
+
}
|
|
5369
5591
|
//#endregion
|
|
5370
5592
|
//#region src/host/dws/run.ts
|
|
5371
5593
|
/** 允许出现在产品域之前的**全局**取值 flag;值必须紧随其后。 */
|
|
@@ -5767,14 +5989,19 @@ function dingtalkTools(deps) {
|
|
|
5767
5989
|
steps
|
|
5768
5990
|
};
|
|
5769
5991
|
const ctx = toolContext(deps.ctx, exec);
|
|
5770
|
-
const caseCheck = await
|
|
5992
|
+
const caseCheck = await requireCaseAccess(ctx, deps.state, deps.discussionScopes, exec, {
|
|
5771
5993
|
caseDir: args.caseDir,
|
|
5772
5994
|
seqNo: args.seqNo
|
|
5773
|
-
});
|
|
5995
|
+
}, deps.localAudit);
|
|
5774
5996
|
if (!caseCheck.ok) return {
|
|
5775
5997
|
...caseCheck,
|
|
5776
5998
|
...empty
|
|
5777
5999
|
};
|
|
6000
|
+
const localRefused = localDeliveryRefused(caseCheck);
|
|
6001
|
+
if (localRefused !== null) return {
|
|
6002
|
+
...localRefused,
|
|
6003
|
+
...empty
|
|
6004
|
+
};
|
|
5778
6005
|
const seqNo = text(args.seqNo).trim();
|
|
5779
6006
|
const requested = text(args.fileName).trim() || `审核结果.${seqNo}.json`;
|
|
5780
6007
|
const inside = await requireInsideCase(ctx, caseCheck.casePath, requested);
|
|
@@ -6153,14 +6380,19 @@ function dingtalkTools(deps) {
|
|
|
6153
6380
|
openDingId: "",
|
|
6154
6381
|
steps
|
|
6155
6382
|
};
|
|
6156
|
-
const caseCheck = await
|
|
6383
|
+
const caseCheck = await requireCaseAccess(ctx, deps.state, deps.discussionScopes, exec, {
|
|
6157
6384
|
caseDir: args.caseDir,
|
|
6158
6385
|
seqNo: args.seqNo
|
|
6159
|
-
});
|
|
6386
|
+
}, deps.localAudit);
|
|
6160
6387
|
if (!caseCheck.ok) return {
|
|
6161
6388
|
...caseCheck,
|
|
6162
6389
|
...empty
|
|
6163
6390
|
};
|
|
6391
|
+
const localRefused = localDeliveryRefused(caseCheck);
|
|
6392
|
+
if (localRefused !== null) return {
|
|
6393
|
+
...localRefused,
|
|
6394
|
+
...empty
|
|
6395
|
+
};
|
|
6164
6396
|
const seqNo = text(args.seqNo).trim();
|
|
6165
6397
|
if (seqNo === "") return {
|
|
6166
6398
|
...failure$1("input", "seqNo 不能为空"),
|
|
@@ -6481,140 +6713,10 @@ function locateMessage(payload, fileName) {
|
|
|
6481
6713
|
async function persistNotifyState(ctx, path, state, options) {
|
|
6482
6714
|
return (await writeCaseText(ctx, path, `${JSON.stringify(state, null, 2)}\n`, options)).ok;
|
|
6483
6715
|
}
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
const ttlMs = options.ttlMs ?? 432e5;
|
|
6487
|
-
const limit = options.limit ?? 32;
|
|
6488
|
-
const scopes = /* @__PURE__ */ new Map();
|
|
6489
|
-
/** 过期的一律先清掉:判据只有 `expiresAt` 与当前时刻。 */
|
|
6490
|
-
const sweep = () => {
|
|
6491
|
-
const at = now();
|
|
6492
|
-
for (const [id, scope] of scopes) if (scope.expiresAt <= at) scopes.delete(id);
|
|
6493
|
-
};
|
|
6494
|
-
/** 超出上限时丢**最久没用过**的(不是最早建的:人可能一直在用一条老会话)。 */
|
|
6495
|
-
const trim = () => {
|
|
6496
|
-
while (scopes.size > limit) {
|
|
6497
|
-
let oldestId = "";
|
|
6498
|
-
let oldestAt = Number.POSITIVE_INFINITY;
|
|
6499
|
-
for (const [id, scope] of scopes) if (scope.lastUsedAt < oldestAt) {
|
|
6500
|
-
oldestAt = scope.lastUsedAt;
|
|
6501
|
-
oldestId = id;
|
|
6502
|
-
}
|
|
6503
|
-
if (oldestId === "") return;
|
|
6504
|
-
scopes.delete(oldestId);
|
|
6505
|
-
}
|
|
6506
|
-
};
|
|
6507
|
-
return {
|
|
6508
|
-
register(input) {
|
|
6509
|
-
const at = now();
|
|
6510
|
-
const scope = {
|
|
6511
|
-
sessionId: text(input.sessionId),
|
|
6512
|
-
seqNo: text(input.seqNo),
|
|
6513
|
-
objectId: text(input.objectId),
|
|
6514
|
-
caseDir: text(input.caseDir),
|
|
6515
|
-
allowedAttachmentIds: [...input.allowedAttachmentIds].map((id) => text(id)).filter((id) => id !== ""),
|
|
6516
|
-
createdAt: at,
|
|
6517
|
-
lastUsedAt: at,
|
|
6518
|
-
expiresAt: at + ttlMs
|
|
6519
|
-
};
|
|
6520
|
-
scopes.set(scope.sessionId, scope);
|
|
6521
|
-
trim();
|
|
6522
|
-
return scope;
|
|
6523
|
-
},
|
|
6524
|
-
use(sessionId) {
|
|
6525
|
-
sweep();
|
|
6526
|
-
const id = text(sessionId);
|
|
6527
|
-
if (id === "") return void 0;
|
|
6528
|
-
const scope = scopes.get(id);
|
|
6529
|
-
if (scope === void 0) return void 0;
|
|
6530
|
-
const touched = {
|
|
6531
|
-
...scope,
|
|
6532
|
-
lastUsedAt: now()
|
|
6533
|
-
};
|
|
6534
|
-
scopes.set(id, touched);
|
|
6535
|
-
return touched;
|
|
6536
|
-
},
|
|
6537
|
-
peek(sessionId) {
|
|
6538
|
-
sweep();
|
|
6539
|
-
return scopes.get(text(sessionId));
|
|
6540
|
-
},
|
|
6541
|
-
drop(sessionId) {
|
|
6542
|
-
scopes.delete(text(sessionId));
|
|
6543
|
-
},
|
|
6544
|
-
list() {
|
|
6545
|
-
sweep();
|
|
6546
|
-
return [...scopes.values()];
|
|
6547
|
-
},
|
|
6548
|
-
clear() {
|
|
6549
|
-
scopes.clear();
|
|
6550
|
-
},
|
|
6551
|
-
size() {
|
|
6552
|
-
sweep();
|
|
6553
|
-
return scopes.size;
|
|
6554
|
-
}
|
|
6555
|
-
};
|
|
6556
|
-
}
|
|
6716
|
+
//#endregion
|
|
6717
|
+
//#region src/host/tools/h3yun.ts
|
|
6557
6718
|
/**
|
|
6558
|
-
*
|
|
6559
|
-
*
|
|
6560
|
-
* 与 `requireAuditScope` 的关系:后者是"审核子会话"这一支的实现,这里在它之前再加一支
|
|
6561
|
-
* "已登记的讨论会话"。**讨论会话绝不复用审核 scope**(见文件头第 2 条)。
|
|
6562
|
-
*/
|
|
6563
|
-
async function requireMaterialScope(ctx, state, scopes, exec, args, options = {}) {
|
|
6564
|
-
const { childId } = callerIdentity(exec);
|
|
6565
|
-
if (childId === "") return { ...failure$1("policy", "无法确认调用者身份:案例内的操作只允许由本次审核的子会话或已登记的报告讨论会话发起") };
|
|
6566
|
-
if (auditScopeFor(state, childId) !== void 0) {
|
|
6567
|
-
const checked = await requireAuditScope(ctx, state, exec, args, options);
|
|
6568
|
-
if (!checked.ok) return checked;
|
|
6569
|
-
return {
|
|
6570
|
-
ok: true,
|
|
6571
|
-
material: {
|
|
6572
|
-
kind: "audit",
|
|
6573
|
-
scope: checked.scope
|
|
6574
|
-
}
|
|
6575
|
-
};
|
|
6576
|
-
}
|
|
6577
|
-
const scope = scopes.use(childId);
|
|
6578
|
-
if (scope === void 0) return { ...failure$1("policy", "调用者既不是进行中的审核子会话,也不是已登记的报告讨论会话:案例内的操作一律拒绝(报告讨论请在工作台点「与 DeepSeek 讨论报告」重新登记材料范围)") };
|
|
6579
|
-
const seqNo = text(args.seqNo).trim();
|
|
6580
|
-
if (seqNo !== "" && seqNo !== scope.seqNo) return { ...failure$1("input", "流水号与本次登记的材料范围不一致:拒绝操作") };
|
|
6581
|
-
const objectId = text(args.objectId).trim();
|
|
6582
|
-
if (objectId !== "" && objectId !== scope.objectId) return { ...failure$1("input", "记录标识与本次登记的材料范围不一致:拒绝操作") };
|
|
6583
|
-
const caseDir = text(args.caseDir).trim();
|
|
6584
|
-
if (caseDir === "") {
|
|
6585
|
-
if (options.requireCaseDir === false) return {
|
|
6586
|
-
ok: true,
|
|
6587
|
-
material: {
|
|
6588
|
-
kind: "discussion",
|
|
6589
|
-
scope
|
|
6590
|
-
}
|
|
6591
|
-
};
|
|
6592
|
-
return { ...failure$1("input", "缺少案例目录:案例内的操作必须带上本轮案例目录") };
|
|
6593
|
-
}
|
|
6594
|
-
if (fileSystem(ctx) === void 0) return { ...failure$1("infrastructure", "Host 文件服务不可用") };
|
|
6595
|
-
try {
|
|
6596
|
-
const given = await resolveTarget(ctx, caseDir);
|
|
6597
|
-
const expected = await resolveTarget(ctx, scope.caseDir);
|
|
6598
|
-
if (text(given.targetKey) === "" || given.targetKey !== expected.targetKey) return { ...failure$1("policy", "案例目录必须是本次登记的那一个(不许用工作空间根、兄弟案例或子目录)") };
|
|
6599
|
-
} catch (error) {
|
|
6600
|
-
return { ...failure$1("infrastructure", `案例目录解析失败:${error instanceof Error ? error.message : String(error)}`) };
|
|
6601
|
-
}
|
|
6602
|
-
return {
|
|
6603
|
-
ok: true,
|
|
6604
|
-
material: {
|
|
6605
|
-
kind: "discussion",
|
|
6606
|
-
scope
|
|
6607
|
-
}
|
|
6608
|
-
};
|
|
6609
|
-
}
|
|
6610
|
-
/** 这次调用是不是**已登记**的报告讨论会话(给它开材料权限的那一条身份判据)。 */
|
|
6611
|
-
function isRegisteredDiscussion(scopes, sessionId) {
|
|
6612
|
-
return scopes.peek(sessionId) !== void 0;
|
|
6613
|
-
}
|
|
6614
|
-
//#endregion
|
|
6615
|
-
//#region src/host/tools/h3yun.ts
|
|
6616
|
-
/**
|
|
6617
|
-
* 氚云(H3Yun)业务 Tool。
|
|
6719
|
+
* 氚云(H3Yun)业务 Tool。
|
|
6618
6720
|
*
|
|
6619
6721
|
* 三个工具对应审核链路的三个动作,**都不是通用逃生口**:模型提交的只有业务标识
|
|
6620
6722
|
* (`objectId` / `fileId`)与案例目录,argv 由这里按固定模板拼装。
|
|
@@ -6746,10 +6848,17 @@ function h3yunTools(deps) {
|
|
|
6746
6848
|
...failure$1("policy", "审核子会话不能直接查询氚云记录:本轮记录与附件清单已在输入快照里"),
|
|
6747
6849
|
...base
|
|
6748
6850
|
};
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6851
|
+
const discussion = deps.discussionScopes.peek(callerIdentity(exec).childId);
|
|
6852
|
+
if (discussion !== void 0) {
|
|
6853
|
+
const checked = await requireMaterialScope(ctx, deps.state, deps.discussionScopes, exec, {
|
|
6854
|
+
caseDir: args.caseDir,
|
|
6855
|
+
objectId
|
|
6856
|
+
}, { requireCaseDir: false });
|
|
6857
|
+
if (!checked.ok) return {
|
|
6858
|
+
...checked,
|
|
6859
|
+
...base
|
|
6860
|
+
};
|
|
6861
|
+
}
|
|
6753
6862
|
const platform = await deps.world.platform();
|
|
6754
6863
|
const gap = await requireCrwu(ctx, platform);
|
|
6755
6864
|
if (gap !== null) return {
|
|
@@ -6762,7 +6871,7 @@ function h3yunTools(deps) {
|
|
|
6762
6871
|
...base
|
|
6763
6872
|
};
|
|
6764
6873
|
const requested = text(args.caseDir).trim();
|
|
6765
|
-
let workdir = await deps.world.workdir();
|
|
6874
|
+
let workdir = discussion?.caseDir || await deps.world.workdir();
|
|
6766
6875
|
if (requested !== "") {
|
|
6767
6876
|
const caseCheck = await requireCaseDir(ctx, requested, { allowedRoot: allowedCaseRootOf(deps.state) });
|
|
6768
6877
|
if (!caseCheck.ok) return {
|
|
@@ -6790,7 +6899,7 @@ function h3yunTools(deps) {
|
|
|
6790
6899
|
signal: exec.signal
|
|
6791
6900
|
});
|
|
6792
6901
|
if (!run.ok) return {
|
|
6793
|
-
...failure$1(run.error !== "" ? "infrastructure" : "cli", reasonOf(run)),
|
|
6902
|
+
...failure$1(run.error !== "" ? "infrastructure" : "cli", reasonOf$1(run)),
|
|
6794
6903
|
...base
|
|
6795
6904
|
};
|
|
6796
6905
|
const record = recordOf(run.stdout);
|
|
@@ -6885,11 +6994,18 @@ function h3yunTools(deps) {
|
|
|
6885
6994
|
count: 0,
|
|
6886
6995
|
files: []
|
|
6887
6996
|
};
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6997
|
+
const discussion = deps.discussionScopes.peek(callerIdentity(exec).childId);
|
|
6998
|
+
if (discussion !== void 0) {
|
|
6999
|
+
const checked = await requireMaterialScope(ctx, deps.state, deps.discussionScopes, exec, {
|
|
7000
|
+
caseDir: args.caseDir,
|
|
7001
|
+
objectId
|
|
7002
|
+
}, { requireCaseDir: false });
|
|
7003
|
+
if (!checked.ok) return {
|
|
7004
|
+
...checked,
|
|
7005
|
+
count: 0,
|
|
7006
|
+
files: []
|
|
7007
|
+
};
|
|
7008
|
+
}
|
|
6893
7009
|
const platform = await deps.world.platform();
|
|
6894
7010
|
const gap = await requireCrwu(ctx, platform);
|
|
6895
7011
|
if (gap !== null) return {
|
|
@@ -6904,7 +7020,7 @@ function h3yunTools(deps) {
|
|
|
6904
7020
|
files: []
|
|
6905
7021
|
};
|
|
6906
7022
|
const requested = text(args.caseDir).trim();
|
|
6907
|
-
let workdir = await deps.world.workdir();
|
|
7023
|
+
let workdir = discussion?.caseDir || await deps.world.workdir();
|
|
6908
7024
|
if (requested !== "") {
|
|
6909
7025
|
const caseCheck = await requireCaseDir(ctx, requested, { allowedRoot: allowedCaseRootOf(deps.state) });
|
|
6910
7026
|
if (!caseCheck.ok) return {
|
|
@@ -6933,7 +7049,7 @@ function h3yunTools(deps) {
|
|
|
6933
7049
|
signal: exec.signal
|
|
6934
7050
|
});
|
|
6935
7051
|
if (!run.ok) return {
|
|
6936
|
-
...failure$1(run.error !== "" ? "infrastructure" : "cli", reasonOf(run)),
|
|
7052
|
+
...failure$1(run.error !== "" ? "infrastructure" : "cli", reasonOf$1(run)),
|
|
6937
7053
|
count: 0,
|
|
6938
7054
|
files: []
|
|
6939
7055
|
};
|
|
@@ -6967,7 +7083,7 @@ function h3yunTools(deps) {
|
|
|
6967
7083
|
name: TOOL_NAMES.h3yunFileGet,
|
|
6968
7084
|
description: [
|
|
6969
7085
|
"按 fileId **单附件定向下载**氚云记录附件到**本轮案例目录**内的相对路径。",
|
|
6970
|
-
"
|
|
7086
|
+
"普通会话可按员工权限下载;托管审核与已登记讨论会话只接受本次材料清单里的 fileId。",
|
|
6971
7087
|
"目标必须落在给定案例目录之下(越界直接拒绝)。",
|
|
6972
7088
|
"本工具没有「整单下载」路径:一次只取一个附件,失败就是失败,不会退化成批量下载。"
|
|
6973
7089
|
].join(" "),
|
|
@@ -6975,7 +7091,7 @@ function h3yunTools(deps) {
|
|
|
6975
7091
|
fileId: {
|
|
6976
7092
|
type: "string",
|
|
6977
7093
|
required: true,
|
|
6978
|
-
description: "附件 fileId
|
|
7094
|
+
description: "附件 fileId(托管审核与已登记讨论会话须在本次材料清单内)"
|
|
6979
7095
|
},
|
|
6980
7096
|
caseDir: {
|
|
6981
7097
|
type: "string",
|
|
@@ -7013,22 +7129,22 @@ function h3yunTools(deps) {
|
|
|
7013
7129
|
async execute(args, exec) {
|
|
7014
7130
|
const ctx = toolContext(deps.ctx, exec);
|
|
7015
7131
|
const fileId = text(args.fileId).trim();
|
|
7016
|
-
const materialCheck = await
|
|
7132
|
+
const materialCheck = await requireCaseAccess(ctx, deps.state, deps.discussionScopes, exec, { caseDir: args.caseDir }, deps.localAudit);
|
|
7017
7133
|
if (!materialCheck.ok) return {
|
|
7018
7134
|
...materialCheck,
|
|
7019
7135
|
fileId,
|
|
7020
7136
|
path: "",
|
|
7021
7137
|
sizeBytes: 0
|
|
7022
7138
|
};
|
|
7023
|
-
const material = materialCheck
|
|
7024
|
-
const casePath = material.
|
|
7139
|
+
const material = materialCheck;
|
|
7140
|
+
const casePath = material.casePath;
|
|
7025
7141
|
if (fileId === "") return {
|
|
7026
7142
|
...failure$1("input", "fileId 不能为空"),
|
|
7027
7143
|
fileId,
|
|
7028
7144
|
path: "",
|
|
7029
7145
|
sizeBytes: 0
|
|
7030
7146
|
};
|
|
7031
|
-
if (!material.
|
|
7147
|
+
if (material.kind !== "session" && !material.allowedAttachmentIds?.includes(fileId)) return {
|
|
7032
7148
|
...failure$1("policy", material.kind === "audit" ? "这个附件不在本轮审核的输入快照里:只允许下载本次登记的附件" : "这个附件不在本次登记的材料范围里:只允许下载登记时取到的那批附件(请在工作台重新点「与 DeepSeek 讨论报告」刷新)"),
|
|
7033
7149
|
fileId,
|
|
7034
7150
|
path: "",
|
|
@@ -7077,7 +7193,7 @@ function h3yunTools(deps) {
|
|
|
7077
7193
|
signal: exec.signal
|
|
7078
7194
|
});
|
|
7079
7195
|
if (!run.ok) return {
|
|
7080
|
-
...failure$1(run.error !== "" ? "infrastructure" : "cli", reasonOf(run)),
|
|
7196
|
+
...failure$1(run.error !== "" ? "infrastructure" : "cli", reasonOf$1(run)),
|
|
7081
7197
|
fileId,
|
|
7082
7198
|
path: target.path,
|
|
7083
7199
|
sizeBytes: 0
|
|
@@ -9406,7 +9522,7 @@ function knowledgeTools(deps) {
|
|
|
9406
9522
|
...failure$1("input", "paths 不能为空"),
|
|
9407
9523
|
...empty
|
|
9408
9524
|
};
|
|
9409
|
-
const caseCheck = await
|
|
9525
|
+
const caseCheck = await requireCaseAccess(ctx, deps.state, deps.discussionScopes, exec, { caseDir: args.caseDir }, deps.localAudit);
|
|
9410
9526
|
if (!caseCheck.ok) return {
|
|
9411
9527
|
...caseCheck,
|
|
9412
9528
|
...empty
|
|
@@ -9670,6 +9786,160 @@ async function writeManifest(ctx, path, payload, options) {
|
|
|
9670
9786
|
};
|
|
9671
9787
|
}
|
|
9672
9788
|
//#endregion
|
|
9789
|
+
//#region src/host/tools/local-claim.ts
|
|
9790
|
+
function idle() {
|
|
9791
|
+
return {
|
|
9792
|
+
ok: false,
|
|
9793
|
+
errorKind: "",
|
|
9794
|
+
error: "",
|
|
9795
|
+
caseDir: "",
|
|
9796
|
+
mode: "local",
|
|
9797
|
+
fileCount: 0,
|
|
9798
|
+
skippedCount: 0,
|
|
9799
|
+
files: [],
|
|
9800
|
+
skipped: []
|
|
9801
|
+
};
|
|
9802
|
+
}
|
|
9803
|
+
function localClaimTools(deps) {
|
|
9804
|
+
return [defineTool({
|
|
9805
|
+
name: TOOL_NAMES.localAuditClaim,
|
|
9806
|
+
description: [
|
|
9807
|
+
"认领一次**本地审核**交接:提交 handoffId,取回本轮临时案例目录与材料清单,",
|
|
9808
|
+
"并把该案例目录绑定到当前对话。",
|
|
9809
|
+
"只由 Workbench「本地审核」生成的提示词调用一次;过期 / 已用 / 快照不存在时如实失败,",
|
|
9810
|
+
"不要自己去找用户本机的文件。"
|
|
9811
|
+
].join(" "),
|
|
9812
|
+
parameters: { handoffId: {
|
|
9813
|
+
type: "string",
|
|
9814
|
+
required: true,
|
|
9815
|
+
description: "本地审核提示词里的交接标识(形如 la-<时间戳>-<随机段>);由 Workbench 生成,不要自己编。"
|
|
9816
|
+
} },
|
|
9817
|
+
output: {
|
|
9818
|
+
schema: {
|
|
9819
|
+
type: "object",
|
|
9820
|
+
additionalProperties: false,
|
|
9821
|
+
properties: {
|
|
9822
|
+
ok: {
|
|
9823
|
+
required: true,
|
|
9824
|
+
type: "boolean"
|
|
9825
|
+
},
|
|
9826
|
+
errorKind: {
|
|
9827
|
+
required: true,
|
|
9828
|
+
type: "string"
|
|
9829
|
+
},
|
|
9830
|
+
error: {
|
|
9831
|
+
required: true,
|
|
9832
|
+
type: "string"
|
|
9833
|
+
},
|
|
9834
|
+
caseDir: {
|
|
9835
|
+
required: true,
|
|
9836
|
+
type: "string",
|
|
9837
|
+
description: "本轮案例目录(也是本次本地审核的工作目录与可写范围)"
|
|
9838
|
+
},
|
|
9839
|
+
mode: {
|
|
9840
|
+
required: true,
|
|
9841
|
+
type: "string",
|
|
9842
|
+
description: "固定为 local(本地审核)"
|
|
9843
|
+
},
|
|
9844
|
+
fileCount: {
|
|
9845
|
+
required: true,
|
|
9846
|
+
type: "integer"
|
|
9847
|
+
},
|
|
9848
|
+
skippedCount: {
|
|
9849
|
+
required: true,
|
|
9850
|
+
type: "integer"
|
|
9851
|
+
},
|
|
9852
|
+
files: {
|
|
9853
|
+
required: true,
|
|
9854
|
+
type: "array",
|
|
9855
|
+
items: {
|
|
9856
|
+
type: "object",
|
|
9857
|
+
additionalProperties: false,
|
|
9858
|
+
properties: {
|
|
9859
|
+
name: {
|
|
9860
|
+
required: true,
|
|
9861
|
+
type: "string"
|
|
9862
|
+
},
|
|
9863
|
+
relativePath: {
|
|
9864
|
+
required: true,
|
|
9865
|
+
type: "string"
|
|
9866
|
+
},
|
|
9867
|
+
sizeBytes: {
|
|
9868
|
+
required: true,
|
|
9869
|
+
type: "integer"
|
|
9870
|
+
}
|
|
9871
|
+
}
|
|
9872
|
+
}
|
|
9873
|
+
},
|
|
9874
|
+
skipped: {
|
|
9875
|
+
required: true,
|
|
9876
|
+
type: "array",
|
|
9877
|
+
items: {
|
|
9878
|
+
type: "object",
|
|
9879
|
+
additionalProperties: false,
|
|
9880
|
+
properties: {
|
|
9881
|
+
name: {
|
|
9882
|
+
required: true,
|
|
9883
|
+
type: "string"
|
|
9884
|
+
},
|
|
9885
|
+
relativePath: {
|
|
9886
|
+
required: true,
|
|
9887
|
+
type: "string"
|
|
9888
|
+
},
|
|
9889
|
+
reason: {
|
|
9890
|
+
required: true,
|
|
9891
|
+
type: "string"
|
|
9892
|
+
}
|
|
9893
|
+
}
|
|
9894
|
+
}
|
|
9895
|
+
}
|
|
9896
|
+
}
|
|
9897
|
+
},
|
|
9898
|
+
render: (_args, value) => renderJson(value)
|
|
9899
|
+
},
|
|
9900
|
+
isConcurrencySafe: () => false,
|
|
9901
|
+
async execute(args, exec) {
|
|
9902
|
+
const base = idle();
|
|
9903
|
+
const registry = deps.localAudit;
|
|
9904
|
+
const io = deps.localAuditIo;
|
|
9905
|
+
if (registry === void 0 || io === void 0) return {
|
|
9906
|
+
...base,
|
|
9907
|
+
...failure$1("capability-gap", "本地审核注册表没有装配:本插件实例不能认领本地审核交接")
|
|
9908
|
+
};
|
|
9909
|
+
const handoffId = text(args.handoffId).trim();
|
|
9910
|
+
if (handoffId === "") return {
|
|
9911
|
+
...base,
|
|
9912
|
+
...failure$1("input", "缺少 handoffId:请把 Workbench 提示词里的交接标识原样提交")
|
|
9913
|
+
};
|
|
9914
|
+
const { childId } = callerIdentity(exec);
|
|
9915
|
+
if (childId === "") return {
|
|
9916
|
+
...base,
|
|
9917
|
+
...failure$1("policy", "无法确认调用者的会话身份:本地审核必须绑定到具体对话")
|
|
9918
|
+
};
|
|
9919
|
+
const outcome = await registry.claim(handoffId, childId, io);
|
|
9920
|
+
return {
|
|
9921
|
+
...base,
|
|
9922
|
+
ok: outcome.ok,
|
|
9923
|
+
errorKind: outcome.errorKind,
|
|
9924
|
+
error: outcome.error,
|
|
9925
|
+
caseDir: outcome.casePath,
|
|
9926
|
+
fileCount: outcome.fileCount,
|
|
9927
|
+
skippedCount: outcome.skippedCount,
|
|
9928
|
+
files: outcome.files.map((file) => ({
|
|
9929
|
+
name: file.name,
|
|
9930
|
+
relativePath: file.relativePath,
|
|
9931
|
+
sizeBytes: file.sizeBytes
|
|
9932
|
+
})),
|
|
9933
|
+
skipped: outcome.skipped.map((item) => ({
|
|
9934
|
+
name: item.name,
|
|
9935
|
+
relativePath: item.relativePath,
|
|
9936
|
+
reason: item.reason
|
|
9937
|
+
}))
|
|
9938
|
+
};
|
|
9939
|
+
}
|
|
9940
|
+
})];
|
|
9941
|
+
}
|
|
9942
|
+
//#endregion
|
|
9673
9943
|
//#region src/host/oss/sanitize.ts
|
|
9674
9944
|
/**
|
|
9675
9945
|
* OSS 输出脱敏:**唯一**一处把 ossutil 的 stdout / stderr 变成可以给人看(或进日志)的文本。
|
|
@@ -9983,14 +10253,19 @@ function ossTools(deps) {
|
|
|
9983
10253
|
...failure$1("input", `流水号形状不对(不放进 OSS 路径):${seqNo === "" ? "(空)" : seqNo}`),
|
|
9984
10254
|
...empty
|
|
9985
10255
|
};
|
|
9986
|
-
const caseCheck = await
|
|
10256
|
+
const caseCheck = await requireCaseAccess(ctx, deps.state, deps.discussionScopes, exec, {
|
|
9987
10257
|
caseDir: args.caseDir,
|
|
9988
10258
|
seqNo: args.seqNo
|
|
9989
|
-
});
|
|
10259
|
+
}, deps.localAudit);
|
|
9990
10260
|
if (!caseCheck.ok) return {
|
|
9991
10261
|
...caseCheck,
|
|
9992
10262
|
...empty
|
|
9993
10263
|
};
|
|
10264
|
+
const localRefused = localDeliveryRefused(caseCheck);
|
|
10265
|
+
if (localRefused !== null) return {
|
|
10266
|
+
...localRefused,
|
|
10267
|
+
...empty
|
|
10268
|
+
};
|
|
9994
10269
|
const oss = normalizeOss(deps.state.manifest.oss, deps.state.manifest.oss);
|
|
9995
10270
|
if (!oss.enabled) return {
|
|
9996
10271
|
...failure$1("policy", "OSS 回传未启用(受信配置里 oss.enabled 不是 true)"),
|
|
@@ -10410,7 +10685,7 @@ function pythonScriptTools(deps) {
|
|
|
10410
10685
|
caseDir: {
|
|
10411
10686
|
required: true,
|
|
10412
10687
|
type: "string",
|
|
10413
|
-
description: "本轮案例目录(Host
|
|
10688
|
+
description: "本轮案例目录(Host 校验工作空间与案例范围)。"
|
|
10414
10689
|
},
|
|
10415
10690
|
script: {
|
|
10416
10691
|
required: true,
|
|
@@ -10522,7 +10797,7 @@ function pythonScriptTools(deps) {
|
|
|
10522
10797
|
async execute(args, exec) {
|
|
10523
10798
|
const ctx = toolContext(deps.ctx, exec);
|
|
10524
10799
|
const base = idleResult();
|
|
10525
|
-
const caseCheck = await
|
|
10800
|
+
const caseCheck = await requireCaseAccess(ctx, deps.state, deps.discussionScopes, exec, { caseDir: args.caseDir }, deps.localAudit);
|
|
10526
10801
|
if (!caseCheck.ok) return {
|
|
10527
10802
|
...base,
|
|
10528
10803
|
...caseCheck
|
|
@@ -10553,7 +10828,7 @@ function pythonScriptTools(deps) {
|
|
|
10553
10828
|
...base,
|
|
10554
10829
|
...failure$1("capability-gap", "DSH 自带 Python 解析器没有装配:本插件实例不能执行技能脚本")
|
|
10555
10830
|
};
|
|
10556
|
-
const runtime = await deps.python.check();
|
|
10831
|
+
const runtime = await deps.python.check({ agent: exec.agent });
|
|
10557
10832
|
if (!runtime.ok) return {
|
|
10558
10833
|
...base,
|
|
10559
10834
|
...failure$1("capability-gap", `DSH 自带 Python 不可用(${runtime.state}):${runtime.error}`),
|
|
@@ -10581,7 +10856,7 @@ function pythonScriptTools(deps) {
|
|
|
10581
10856
|
return {
|
|
10582
10857
|
ok: run.ok && errorKind === "",
|
|
10583
10858
|
errorKind,
|
|
10584
|
-
error: errorKind === "" ? "" : reasonOf(run),
|
|
10859
|
+
error: errorKind === "" ? "" : reasonOf$1(run),
|
|
10585
10860
|
scriptPath,
|
|
10586
10861
|
argv,
|
|
10587
10862
|
exitCode: run.exitCode,
|
|
@@ -10623,7 +10898,8 @@ function registerCrwuTools(ctx, deps) {
|
|
|
10623
10898
|
ifindTool(deps),
|
|
10624
10899
|
...ossTools(deps),
|
|
10625
10900
|
...dingtalkTools(deps),
|
|
10626
|
-
...pythonScriptTools(deps)
|
|
10901
|
+
...pythonScriptTools(deps),
|
|
10902
|
+
...localClaimTools(deps)
|
|
10627
10903
|
].map((definition) => registry.register(definition));
|
|
10628
10904
|
return () => {
|
|
10629
10905
|
for (const dispose of [...disposers].reverse()) try {
|
|
@@ -12344,7 +12620,7 @@ function emptyView(input = {}) {
|
|
|
12344
12620
|
expiresAt: 0
|
|
12345
12621
|
};
|
|
12346
12622
|
}
|
|
12347
|
-
function failed(view, errorKind, error) {
|
|
12623
|
+
function failed$1(view, errorKind, error) {
|
|
12348
12624
|
return {
|
|
12349
12625
|
...view,
|
|
12350
12626
|
ok: false,
|
|
@@ -12352,123 +12628,1075 @@ function failed(view, errorKind, error) {
|
|
|
12352
12628
|
error: error.slice(0, 600)
|
|
12353
12629
|
};
|
|
12354
12630
|
}
|
|
12355
|
-
/** 会话层级事实:只有子代理才有 `origin === 'subagent'` / `delegationDepth > 0`。 */
|
|
12356
|
-
function sessionShapeOf(ctx, sessionId) {
|
|
12357
|
-
const out = {
|
|
12358
|
-
found: false,
|
|
12359
|
-
subagent: false,
|
|
12360
|
-
depth: 0
|
|
12631
|
+
/** 会话层级事实:只有子代理才有 `origin === 'subagent'` / `delegationDepth > 0`。 */
|
|
12632
|
+
function sessionShapeOf(ctx, sessionId) {
|
|
12633
|
+
const out = {
|
|
12634
|
+
found: false,
|
|
12635
|
+
subagent: false,
|
|
12636
|
+
depth: 0
|
|
12637
|
+
};
|
|
12638
|
+
if (sessionId === "") return out;
|
|
12639
|
+
try {
|
|
12640
|
+
const sessions = ctx.get("sessions");
|
|
12641
|
+
const header = typeof sessions?.get === "function" ? sessions.get(sessionId)?.header : void 0;
|
|
12642
|
+
if (header === void 0) return out;
|
|
12643
|
+
out.found = true;
|
|
12644
|
+
out.subagent = text(header.origin) === "subagent";
|
|
12645
|
+
out.depth = typeof header.delegationDepth === "number" ? header.delegationDepth : 0;
|
|
12646
|
+
} catch (error) {}
|
|
12647
|
+
return out;
|
|
12648
|
+
}
|
|
12649
|
+
async function openDiscussionMaterial(deps, args) {
|
|
12650
|
+
const { ctx, state } = deps;
|
|
12651
|
+
const sessionId = text(args.sessionId).trim();
|
|
12652
|
+
const seqNo = text(args.seqNo).trim();
|
|
12653
|
+
const objectId = text(args.objectId).trim();
|
|
12654
|
+
const base = emptyView({
|
|
12655
|
+
sessionId,
|
|
12656
|
+
seqNo,
|
|
12657
|
+
objectId
|
|
12658
|
+
});
|
|
12659
|
+
if (sessionId === "") return failed$1(base, "input", "缺少 sessionId:材料范围必须绑定到一条具体会话");
|
|
12660
|
+
if (seqNo === "" || !isSafeSeqNo(seqNo)) return failed$1(base, "input", "流水号缺失或形状不合法");
|
|
12661
|
+
if (objectId === "") return failed$1(base, "input", "缺少 objectId:无法核对这条报告");
|
|
12662
|
+
const shape = sessionShapeOf(ctx, sessionId);
|
|
12663
|
+
if (shape.subagent || shape.depth > 0) return failed$1(base, "policy", "这条会话本身是子代理:报告讨论的材料范围只登记给顶层会话");
|
|
12664
|
+
const workspace = text(state.workspacePath) || text(state.caseRoot);
|
|
12665
|
+
if (workspace === "") return failed$1(base, "input", "尚未选定工作空间:请先在工作台第 ① 步选择一个已有目录");
|
|
12666
|
+
const caseDir = caseDirOf(workspace, seqNo);
|
|
12667
|
+
if (caseDir === "") return failed$1(base, "input", "案例目录无法确定:请确认流水号与工作空间");
|
|
12668
|
+
const platform = await deps.world.platform();
|
|
12669
|
+
const made = await ensureCaseDirectory(deps.access, caseDir, {
|
|
12670
|
+
workspace,
|
|
12671
|
+
probeWorkdir: await deps.world.workdir(),
|
|
12672
|
+
platform,
|
|
12673
|
+
source: "panel"
|
|
12674
|
+
});
|
|
12675
|
+
if (!made.ok) return failed$1(base, made.errorKind === "missing" ? "input" : "infrastructure", made.error);
|
|
12676
|
+
const form = await deps.form.ensure();
|
|
12677
|
+
if (!form.ok) return failed$1(base, form.error.startsWith("未在氚云定位到表单") ? "not-found" : "cli", form.error);
|
|
12678
|
+
const runOptions = {
|
|
12679
|
+
workdir: caseDir,
|
|
12680
|
+
timeoutMs: 9e4,
|
|
12681
|
+
access: deps.access,
|
|
12682
|
+
source: "panel",
|
|
12683
|
+
platform,
|
|
12684
|
+
stdoutMaxBytes: RECORDS_STDOUT_MAX
|
|
12685
|
+
};
|
|
12686
|
+
const recordRun = await runCrwu(ctx, [
|
|
12687
|
+
"crwu",
|
|
12688
|
+
"h3yun",
|
|
12689
|
+
"records",
|
|
12690
|
+
"get",
|
|
12691
|
+
"--schema",
|
|
12692
|
+
form.code,
|
|
12693
|
+
"--id",
|
|
12694
|
+
objectId
|
|
12695
|
+
], runOptions);
|
|
12696
|
+
if (!recordRun.ok) return failed$1(base, recordRun.error !== "" ? "infrastructure" : "cli", `核对报告身份失败:${text(recordRun.error) || text(recordRun.stderr) || "records get 未成功"}`);
|
|
12697
|
+
const record = jsonObject(recordRun.stdout);
|
|
12698
|
+
const data = record !== null && record.data !== null && typeof record.data === "object" && !Array.isArray(record.data) ? record.data : null;
|
|
12699
|
+
if (data === null) return failed$1(base, "cli", "记录接口没有返回可解析的记录对象");
|
|
12700
|
+
const recordId = text(pick(data, [
|
|
12701
|
+
"ObjectId",
|
|
12702
|
+
"objectId",
|
|
12703
|
+
"Id",
|
|
12704
|
+
"id"
|
|
12705
|
+
]));
|
|
12706
|
+
const recordSeq = text(pick(data, ["SeqNo", "seqNo"]));
|
|
12707
|
+
if (recordId === "" || recordSeq === "") return failed$1(base, "cli", "记录缺少 ObjectId / SeqNo:无法确认材料属于这条报告");
|
|
12708
|
+
if (recordId !== objectId) return failed$1(base, "input", "取回的记录不是请求的那一条(ObjectId 不一致)");
|
|
12709
|
+
if (recordSeq !== seqNo) return failed$1(base, "input", "记录的流水号与请求的不一致:拒绝登记材料范围");
|
|
12710
|
+
const filesRun = await runCrwu(ctx, [
|
|
12711
|
+
"crwu",
|
|
12712
|
+
"h3yun",
|
|
12713
|
+
"files",
|
|
12714
|
+
"list",
|
|
12715
|
+
"--schema",
|
|
12716
|
+
form.code,
|
|
12717
|
+
"--id",
|
|
12718
|
+
objectId
|
|
12719
|
+
], runOptions);
|
|
12720
|
+
if (!filesRun.ok) return failed$1(base, filesRun.error !== "" ? "infrastructure" : "cli", `读取附件清单失败:${text(filesRun.error) || text(filesRun.stderr) || "files list 未成功"}`);
|
|
12721
|
+
const rows = attachmentsOf(filesRun.stdout);
|
|
12722
|
+
if (rows === null) return failed$1(base, "cli", "附件接口没有返回附件数组");
|
|
12723
|
+
const registered = deps.scopes.register({
|
|
12724
|
+
sessionId,
|
|
12725
|
+
seqNo,
|
|
12726
|
+
objectId,
|
|
12727
|
+
caseDir,
|
|
12728
|
+
allowedAttachmentIds: rows.map((row) => row.fileId)
|
|
12729
|
+
});
|
|
12730
|
+
return {
|
|
12731
|
+
ok: true,
|
|
12732
|
+
error: "",
|
|
12733
|
+
errorKind: "",
|
|
12734
|
+
sessionId,
|
|
12735
|
+
seqNo,
|
|
12736
|
+
objectId,
|
|
12737
|
+
caseDir,
|
|
12738
|
+
attachments: rows.map((row) => ({
|
|
12739
|
+
fileId: row.fileId,
|
|
12740
|
+
fileName: row.fileName,
|
|
12741
|
+
localName: row.localName,
|
|
12742
|
+
fileSize: row.fileSize,
|
|
12743
|
+
nameTotal: row.nameTotal
|
|
12744
|
+
})),
|
|
12745
|
+
attachmentCount: rows.length,
|
|
12746
|
+
expiresAt: registered.expiresAt
|
|
12747
|
+
};
|
|
12748
|
+
}
|
|
12749
|
+
//#endregion
|
|
12750
|
+
//#region src/host/local-audit/consts.ts
|
|
12751
|
+
/** 一次性 handoff 的有效期(用户复制提示词后太久没发就会过期)。 */
|
|
12752
|
+
const LOCAL_AUDIT_HANDOFF_TTL_MS = 18e5;
|
|
12753
|
+
/**
|
|
12754
|
+
* 本地审核案例目录的**父目录名**(在员工选定的工作空间下)。
|
|
12755
|
+
*
|
|
12756
|
+
* ⚠️ 2026-10-11 用户实测后改的:本地审核的案例目录**曾经**在操作系统临时目录下,
|
|
12757
|
+
* 两个后果都不能接受 ——
|
|
12758
|
+
* ① 会话 cwd 只能是那个临时目录,于是这条会话**永远落「未分组」**(用户要求"创建在我
|
|
12759
|
+
* 环境信息规定的工作区下面");而把 cwd 设成工作空间的话,临时目录在沙箱之外,
|
|
12760
|
+
* 对话里的案例内工具(非特权)又写不进去;
|
|
12761
|
+
* ② 交付件躺在系统临时目录里,用户根本找不到(而且会被清理逻辑删掉,见 `handoff.ts`)。
|
|
12762
|
+
*
|
|
12763
|
+
* 现在它落在 `<工作空间>/本地审核/<handoffId>/`:会话 cwd = 工作空间(分组正确、沙箱边界
|
|
12764
|
+
* 正好罩住案例目录),交付件也在用户自己的目录里找得到。
|
|
12765
|
+
* 这符合仓规「插件只允许在工作空间下创建 Host 推导出来的案例目录」。
|
|
12766
|
+
*/
|
|
12767
|
+
const LOCAL_AUDIT_CASE_PARENT_DIR = "本地审核";
|
|
12768
|
+
/** 快照里材料目录名:与审核链路共用同一个名字(`host/tools/consts.ts` 的 `CASE_MATERIAL_DIR`)。 */
|
|
12769
|
+
const LOCAL_AUDIT_SOURCE_DIR = "材料-源";
|
|
12770
|
+
/** 快照里「读到了什么」的清单(人可读,模型也读它)。 */
|
|
12771
|
+
const LOCAL_AUDIT_MANIFEST_FILE = "本地审核清单.json";
|
|
12772
|
+
/** 快照元数据:handoffId、时刻、计数、指纹。 */
|
|
12773
|
+
const LOCAL_AUDIT_META_FILE = "快照元数据.json";
|
|
12774
|
+
//#endregion
|
|
12775
|
+
//#region src/host/local-audit/prompt.ts
|
|
12776
|
+
function localAuditPrompt(input) {
|
|
12777
|
+
const extra = input.extraPrompt.trim();
|
|
12778
|
+
const L = [];
|
|
12779
|
+
L.push("请对下面这批**本机文件**执行一次完整的资产评估审核(本地审核)。");
|
|
12780
|
+
L.push("");
|
|
12781
|
+
L.push("## 第一步:认领这次交接");
|
|
12782
|
+
L.push("");
|
|
12783
|
+
L.push("这是一次**一次性交接**,材料已经复制进一个案例目录(在工作空间下的 `本地审核/` 里)。先调用一次:");
|
|
12784
|
+
L.push("");
|
|
12785
|
+
L.push("```text");
|
|
12786
|
+
L.push(`crwu_audit_local_claim({ handoffId: "${input.handoffId}" })`);
|
|
12787
|
+
L.push("```");
|
|
12788
|
+
L.push("");
|
|
12789
|
+
L.push("它返回本轮**案例目录**(也是你的工作目录与可写范围)与材料清单。");
|
|
12790
|
+
L.push("之后所有案例内操作(`crwu_run_python_script` 等)的 `caseDir` 参数一律用返回的那条路径。");
|
|
12791
|
+
L.push("如果它报「已过期 / 已使用 / 快照不在了」:**立即停止**,把原文告诉我,不要自己找文件、不要重新扫描本机目录。");
|
|
12792
|
+
L.push("");
|
|
12793
|
+
L.push("## 审核范围");
|
|
12794
|
+
L.push("");
|
|
12795
|
+
L.push(`已提供 ${String(input.files.length)} 个文件:`);
|
|
12796
|
+
L.push("");
|
|
12797
|
+
for (const file of input.files) L.push(`- ${file.name} → ${file.relativePath}`);
|
|
12798
|
+
L.push("");
|
|
12799
|
+
if (input.skipped.length > 0) {
|
|
12800
|
+
L.push(`另有 ${String(input.skipped.length)} 个文件**没有进入审核范围**(跳过),必须如实记入「未审核」:`);
|
|
12801
|
+
L.push("");
|
|
12802
|
+
for (const item of input.skipped) L.push(`- ${item.name}(${item.relativePath}):${item.reason}`);
|
|
12803
|
+
L.push("");
|
|
12804
|
+
}
|
|
12805
|
+
L.push("## 固定审核要求");
|
|
12806
|
+
L.push("");
|
|
12807
|
+
L.push("1. **按 crwu-audit 技能执行**:先读 `references/14-orchestration-workflow.md`,按它的步骤 1–16 走完两阶段(阶段一独立审核并冻结,再进入阶段二复核对照)。");
|
|
12808
|
+
L.push("2. **材料只在快照目录里**:不要读我本机的其它目录,不要按文件名去别处找材料,也不要在本机做任何搜索。");
|
|
12809
|
+
L.push("3. **这次是本地审核,不是报告审核**:没有氚云记录、没有报告流水号,也没有人工复核件。");
|
|
12810
|
+
L.push(" 涉及氚云记录 / 附件 / 复核对照的步骤在没有对应材料时**记为不适用**,不要伪造,也不要当成材料缺失。");
|
|
12811
|
+
L.push("4. **禁止任何上传与回传**:不要调用 OSS 发布、钉钉归档、钉钉通知这三条由云端交付使用的工具,");
|
|
12812
|
+
L.push(" 也不要把材料或结论发到任何远端。交付件只落在案例目录里。");
|
|
12813
|
+
L.push("5. **交付件必须标记为「本地审核」**:`审核结果.<项目ID>.json` 与 `审核意见.<项目ID>.html` 都写成");
|
|
12814
|
+
L.push(" `本地审核` / `本地文件审核`,不要写成报告审核或氚云审核。");
|
|
12815
|
+
L.push("6. **HTML 必须明确区分四种状态**:没有该数据(中性灰)、数据存在但处理失败(错误色)、");
|
|
12816
|
+
L.push(" 当前类型不适用、以及**文件没有被审核**;并且逐条列出已审核 / 未审核 / 画像缺失与不可用字段。");
|
|
12817
|
+
L.push(" 不许只用颜色表达状态,也不要用大面积红色背景。");
|
|
12818
|
+
L.push("7. **算不出来的必须写「未核」**:禁止心算、估算,也禁止把「未核」写成「缺失」或「已核」。");
|
|
12819
|
+
L.push("8. **能力缺口如实登记**:缺什么就写什么,不擅自下确定性结论。");
|
|
12820
|
+
L.push("");
|
|
12821
|
+
L.push("## 汇报口径(对话里,不是交付件)");
|
|
12822
|
+
L.push("");
|
|
12823
|
+
L.push("开始审核时先给一句**简短阶段说明**(不要连续百分比、不要假装有进度条),例如:");
|
|
12824
|
+
L.push("");
|
|
12825
|
+
L.push("```text");
|
|
12826
|
+
L.push("本地审核已开始。");
|
|
12827
|
+
L.push("");
|
|
12828
|
+
L.push(`审核范围:${String(input.files.length)} 个文件`);
|
|
12829
|
+
L.push("当前阶段:正在分析文件内容");
|
|
12830
|
+
L.push("```");
|
|
12831
|
+
L.push("");
|
|
12832
|
+
L.push("审核结束后,必须按这个结构收尾:");
|
|
12833
|
+
L.push("");
|
|
12834
|
+
L.push("```text");
|
|
12835
|
+
L.push("本次本地审核已完成。");
|
|
12836
|
+
L.push("");
|
|
12837
|
+
L.push("交付件(**完整路径**,方便我直接点开):");
|
|
12838
|
+
L.push("- <caseDir>/审核意见.<项目ID>.html");
|
|
12839
|
+
L.push("- <caseDir>/审核结果.<项目ID>.json");
|
|
12840
|
+
L.push("");
|
|
12841
|
+
L.push("已审核:");
|
|
12842
|
+
L.push("- <文件名>");
|
|
12843
|
+
L.push("");
|
|
12844
|
+
L.push("未审核:");
|
|
12845
|
+
L.push("- <文件名>:<原因>");
|
|
12846
|
+
L.push("");
|
|
12847
|
+
L.push("画像缺失:");
|
|
12848
|
+
L.push("- <画像字段>:<为什么拿不到>");
|
|
12849
|
+
L.push("```");
|
|
12850
|
+
L.push("");
|
|
12851
|
+
L.push("⚠️ **交付件那一块必须写完整路径**(用 `crwu_audit_local_claim` 返回的 `caseDir` 拼上文件名),");
|
|
12852
|
+
L.push("不要只写文件名:界面里的文件链接是**按这条会话的工作空间根去解析**的,只写文件名会指到一个");
|
|
12853
|
+
L.push("不存在的路径(点开什么都没有)。同一句话里也不要写成 Markdown 链接的假地址。");
|
|
12854
|
+
L.push("");
|
|
12855
|
+
L.push("## 补充要求");
|
|
12856
|
+
L.push("");
|
|
12857
|
+
L.push(extra === "" ? "(我没有额外要求,按默认本地审核规则执行。)" : extra);
|
|
12858
|
+
return L.join("\n");
|
|
12859
|
+
}
|
|
12860
|
+
//#endregion
|
|
12861
|
+
//#region src/host/local-audit/snapshot.ts
|
|
12862
|
+
/** 生产实现:`node:fs/promises`。 */
|
|
12863
|
+
function nodeSnapshotIo() {
|
|
12864
|
+
return {
|
|
12865
|
+
async makeDir(path) {
|
|
12866
|
+
await mkdir(path, { recursive: true });
|
|
12867
|
+
},
|
|
12868
|
+
async copyFile(from, to) {
|
|
12869
|
+
await copyFile(from, to);
|
|
12870
|
+
},
|
|
12871
|
+
async writeText(path, content) {
|
|
12872
|
+
await writeFile(path, content, "utf8");
|
|
12873
|
+
},
|
|
12874
|
+
async readText(path) {
|
|
12875
|
+
return await readFile(path, "utf8");
|
|
12876
|
+
},
|
|
12877
|
+
async removeTree(path) {
|
|
12878
|
+
await rm(path, {
|
|
12879
|
+
recursive: true,
|
|
12880
|
+
force: true
|
|
12881
|
+
});
|
|
12882
|
+
}
|
|
12883
|
+
};
|
|
12884
|
+
}
|
|
12885
|
+
/**
|
|
12886
|
+
* 本地审核案例目录的父目录(在员工选定的工作空间下;空工作空间 = 不能开始本地审核)。
|
|
12887
|
+
*
|
|
12888
|
+
* 「工作空间」由 Host 从状态里取(`state.workspacePath`),不由调用方提交 ——
|
|
12889
|
+
* 与报告审核的案例目录同一条口径。
|
|
12890
|
+
*/
|
|
12891
|
+
function localAuditRootDir(workspacePath) {
|
|
12892
|
+
const root = trimTrailingSeparators(workspacePath);
|
|
12893
|
+
return root === "" ? "" : joinLocalPath(root, LOCAL_AUDIT_CASE_PARENT_DIR);
|
|
12894
|
+
}
|
|
12895
|
+
/** 一次 handoff 的案例目录(`<工作空间>/本地审核/<handoffId>`)。 */
|
|
12896
|
+
function localAuditCaseDir(workspacePath, handoffId) {
|
|
12897
|
+
const root = localAuditRootDir(workspacePath);
|
|
12898
|
+
return root === "" ? "" : joinLocalPath(root, handoffId);
|
|
12899
|
+
}
|
|
12900
|
+
/**
|
|
12901
|
+
* 建立快照。
|
|
12902
|
+
*
|
|
12903
|
+
* `handoffId` 由调用方(Host)生成 —— 它同时也是案例目录名,所以必须是安全的单段标识。
|
|
12904
|
+
*/
|
|
12905
|
+
async function prepareSnapshot(handoffId, candidates, io, options) {
|
|
12906
|
+
const casePath = options.casePath;
|
|
12907
|
+
const empty = {
|
|
12908
|
+
ok: false,
|
|
12909
|
+
error: "",
|
|
12910
|
+
errorKind: "",
|
|
12911
|
+
casePath,
|
|
12912
|
+
files: [],
|
|
12913
|
+
skipped: [],
|
|
12914
|
+
digest: ""
|
|
12915
|
+
};
|
|
12916
|
+
if (casePath === "") return {
|
|
12917
|
+
...empty,
|
|
12918
|
+
errorKind: "input",
|
|
12919
|
+
error: "还没有选定工作空间:请先在「环境信息」里选择工作空间"
|
|
12920
|
+
};
|
|
12921
|
+
if (candidates.length === 0) return {
|
|
12922
|
+
...empty,
|
|
12923
|
+
errorKind: "no-readable-files",
|
|
12924
|
+
error: "没有可审核的文件"
|
|
12925
|
+
};
|
|
12926
|
+
const files = [];
|
|
12927
|
+
const skipped = [];
|
|
12928
|
+
try {
|
|
12929
|
+
await io.makeDir(casePath);
|
|
12930
|
+
} catch (error) {
|
|
12931
|
+
return {
|
|
12932
|
+
...empty,
|
|
12933
|
+
errorKind: "infrastructure",
|
|
12934
|
+
error: `无法创建本地审核临时目录:${reasonOf(error)}`
|
|
12935
|
+
};
|
|
12936
|
+
}
|
|
12937
|
+
for (const candidate of candidates) {
|
|
12938
|
+
const parts = candidate.relativePath.split("/").filter((part) => part !== "");
|
|
12939
|
+
const target = joinLocalPath(casePath, LOCAL_AUDIT_SOURCE_DIR, ...parts);
|
|
12940
|
+
const targetDir = joinLocalPath(casePath, LOCAL_AUDIT_SOURCE_DIR, ...parts.slice(0, -1));
|
|
12941
|
+
try {
|
|
12942
|
+
await io.makeDir(targetDir);
|
|
12943
|
+
await io.copyFile(candidate.sourcePath, target);
|
|
12944
|
+
files.push({
|
|
12945
|
+
relativePath: `${LOCAL_AUDIT_SOURCE_DIR}/${candidate.relativePath}`,
|
|
12946
|
+
name: candidate.name,
|
|
12947
|
+
sizeBytes: candidate.sizeBytes
|
|
12948
|
+
});
|
|
12949
|
+
} catch (error) {
|
|
12950
|
+
skipped.push({
|
|
12951
|
+
relativePath: candidate.relativePath,
|
|
12952
|
+
name: candidate.name,
|
|
12953
|
+
reason: "快照读取失败"
|
|
12954
|
+
});
|
|
12955
|
+
}
|
|
12956
|
+
}
|
|
12957
|
+
if (files.length === 0) {
|
|
12958
|
+
await io.removeTree(casePath).catch(() => void 0);
|
|
12959
|
+
return {
|
|
12960
|
+
...empty,
|
|
12961
|
+
errorKind: "no-readable-files",
|
|
12962
|
+
error: "没有可审核的文件:请检查文件是否存在、是否被其他程序锁定,或重新选择文件",
|
|
12963
|
+
skipped
|
|
12964
|
+
};
|
|
12965
|
+
}
|
|
12966
|
+
const createdAt = (options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
|
|
12967
|
+
const digest = digestOf([
|
|
12968
|
+
handoffId,
|
|
12969
|
+
createdAt,
|
|
12970
|
+
...files.map((item) => canonicalJson(item))
|
|
12971
|
+
]);
|
|
12972
|
+
const manifest = {
|
|
12973
|
+
schema: "crwu.local-audit-manifest.v1",
|
|
12974
|
+
handoffId,
|
|
12975
|
+
createdAt,
|
|
12976
|
+
mode: "local",
|
|
12977
|
+
fileCount: files.length,
|
|
12978
|
+
skippedCount: skipped.length,
|
|
12979
|
+
files: files.map((item) => ({
|
|
12980
|
+
name: item.name,
|
|
12981
|
+
relativePath: item.relativePath,
|
|
12982
|
+
sizeBytes: item.sizeBytes
|
|
12983
|
+
})),
|
|
12984
|
+
skipped: skipped.map((item) => ({
|
|
12985
|
+
name: item.name,
|
|
12986
|
+
relativePath: item.relativePath,
|
|
12987
|
+
reason: item.reason
|
|
12988
|
+
}))
|
|
12989
|
+
};
|
|
12990
|
+
const meta = {
|
|
12991
|
+
schema: "crwu.local-audit-snapshot.v1",
|
|
12992
|
+
handoffId,
|
|
12993
|
+
createdAt,
|
|
12994
|
+
mode: "local",
|
|
12995
|
+
fileCount: files.length,
|
|
12996
|
+
skippedCount: skipped.length,
|
|
12997
|
+
digest
|
|
12998
|
+
};
|
|
12999
|
+
try {
|
|
13000
|
+
await io.writeText(joinLocalPath(casePath, LOCAL_AUDIT_MANIFEST_FILE), `${canonicalJson(manifest)}\n`);
|
|
13001
|
+
await io.writeText(joinLocalPath(casePath, LOCAL_AUDIT_META_FILE), `${canonicalJson(meta)}\n`);
|
|
13002
|
+
} catch (error) {
|
|
13003
|
+
await io.removeTree(casePath).catch(() => void 0);
|
|
13004
|
+
return {
|
|
13005
|
+
...empty,
|
|
13006
|
+
errorKind: "infrastructure",
|
|
13007
|
+
error: `写入本地审核清单失败:${reasonOf(error)}`
|
|
13008
|
+
};
|
|
13009
|
+
}
|
|
13010
|
+
return {
|
|
13011
|
+
ok: true,
|
|
13012
|
+
error: "",
|
|
13013
|
+
errorKind: "",
|
|
13014
|
+
casePath,
|
|
13015
|
+
files,
|
|
13016
|
+
skipped,
|
|
13017
|
+
digest
|
|
13018
|
+
};
|
|
13019
|
+
}
|
|
13020
|
+
/**
|
|
13021
|
+
* 读回清单(claim 时核对快照还在、内容与 handoff 记录一致)。
|
|
13022
|
+
*
|
|
13023
|
+
* ⚠️ 路径由**调用方给**(handoff 记录里的 `casePath`),不要在这里重新按 id 拼一次:
|
|
13024
|
+
* 一旦两处拼法漂移,"清单读不到"就会变成一次莫名其妙的 capability gap。
|
|
13025
|
+
*/
|
|
13026
|
+
async function readManifest(io, casePath, handoffId) {
|
|
13027
|
+
try {
|
|
13028
|
+
const raw = await io.readText(joinLocalPath(casePath, LOCAL_AUDIT_MANIFEST_FILE));
|
|
13029
|
+
const parsed = JSON.parse(raw);
|
|
13030
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
13031
|
+
const doc = parsed;
|
|
13032
|
+
if (doc.handoffId !== handoffId) return null;
|
|
13033
|
+
return {
|
|
13034
|
+
fileCount: typeof doc.fileCount === "number" ? doc.fileCount : 0,
|
|
13035
|
+
skippedCount: typeof doc.skippedCount === "number" ? doc.skippedCount : 0
|
|
13036
|
+
};
|
|
13037
|
+
} catch (error) {
|
|
13038
|
+
return null;
|
|
13039
|
+
}
|
|
13040
|
+
}
|
|
13041
|
+
function reasonOf(error) {
|
|
13042
|
+
return error instanceof Error ? error.message : String(error);
|
|
13043
|
+
}
|
|
13044
|
+
//#endregion
|
|
13045
|
+
//#region src/host/local-audit/handoff.ts
|
|
13046
|
+
function newHandoffId(now = Date.now()) {
|
|
13047
|
+
return `la-${String(now)}-${randomUUID().slice(0, 8)}`;
|
|
13048
|
+
}
|
|
13049
|
+
const MAX_RECORDS = 32;
|
|
13050
|
+
function createLocalAuditRegistry() {
|
|
13051
|
+
const handoffs = /* @__PURE__ */ new Map();
|
|
13052
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
13053
|
+
/**
|
|
13054
|
+
* 待清理的案例目录。
|
|
13055
|
+
*
|
|
13056
|
+
* 为什么单独一份:`drop()` 会在**同步**路径上被调用(新建时淘汰最旧的一条),那里没有 IO
|
|
13057
|
+
* 可做 —— 不记账的话,被淘汰那条的快照目录既不在 `handoffs` 里、也不会再被 `sweep` 遍历到,
|
|
13058
|
+
* 于是永久留在临时目录里。记在这里,下一次 `sweep` 顺手删掉。
|
|
13059
|
+
*/
|
|
13060
|
+
const orphans = /* @__PURE__ */ new Set();
|
|
13061
|
+
const drop = (id) => {
|
|
13062
|
+
const record = handoffs.get(id);
|
|
13063
|
+
handoffs.delete(id);
|
|
13064
|
+
const claimed = record?.used === true;
|
|
13065
|
+
for (const [sessionId, scope] of [...scopes]) if (scope.handoffId === id) scopes.delete(sessionId);
|
|
13066
|
+
if (record !== void 0 && record.casePath !== "" && !claimed) orphans.add(record.casePath);
|
|
13067
|
+
};
|
|
13068
|
+
return {
|
|
13069
|
+
create(input) {
|
|
13070
|
+
const now = input.now ?? Date.now();
|
|
13071
|
+
const id = input.id;
|
|
13072
|
+
const record = {
|
|
13073
|
+
id,
|
|
13074
|
+
casePath: input.casePath,
|
|
13075
|
+
createdAt: now,
|
|
13076
|
+
expiresAt: now + LOCAL_AUDIT_HANDOFF_TTL_MS,
|
|
13077
|
+
used: false,
|
|
13078
|
+
claimedSessionId: "",
|
|
13079
|
+
fileCount: input.files.length,
|
|
13080
|
+
skippedCount: input.skipped.length,
|
|
13081
|
+
files: [...input.files],
|
|
13082
|
+
skipped: [...input.skipped]
|
|
13083
|
+
};
|
|
13084
|
+
handoffs.set(id, record);
|
|
13085
|
+
while (handoffs.size > MAX_RECORDS) {
|
|
13086
|
+
const oldest = handoffs.keys().next().value;
|
|
13087
|
+
if (oldest === void 0 || oldest === id) break;
|
|
13088
|
+
drop(oldest);
|
|
13089
|
+
}
|
|
13090
|
+
return record;
|
|
13091
|
+
},
|
|
13092
|
+
peek: (id) => handoffs.get(id),
|
|
13093
|
+
async claim(id, sessionId, io, now = Date.now()) {
|
|
13094
|
+
const empty = {
|
|
13095
|
+
ok: false,
|
|
13096
|
+
error: "",
|
|
13097
|
+
errorKind: "",
|
|
13098
|
+
casePath: "",
|
|
13099
|
+
fileCount: 0,
|
|
13100
|
+
skippedCount: 0,
|
|
13101
|
+
files: [],
|
|
13102
|
+
skipped: []
|
|
13103
|
+
};
|
|
13104
|
+
const record = handoffs.get(id);
|
|
13105
|
+
if (record === void 0) return {
|
|
13106
|
+
...empty,
|
|
13107
|
+
errorKind: "policy",
|
|
13108
|
+
error: "本地审核提示词已过期。\n\n请回到 Workbench,重新选择或准备文件后再试。"
|
|
13109
|
+
};
|
|
13110
|
+
if (record.used) {
|
|
13111
|
+
if (record.claimedSessionId !== "" && record.claimedSessionId === sessionId) return {
|
|
13112
|
+
ok: true,
|
|
13113
|
+
error: "",
|
|
13114
|
+
errorKind: "",
|
|
13115
|
+
casePath: record.casePath,
|
|
13116
|
+
fileCount: record.fileCount,
|
|
13117
|
+
skippedCount: record.skippedCount,
|
|
13118
|
+
files: [...record.files],
|
|
13119
|
+
skipped: [...record.skipped]
|
|
13120
|
+
};
|
|
13121
|
+
return {
|
|
13122
|
+
...empty,
|
|
13123
|
+
errorKind: "policy",
|
|
13124
|
+
error: "这份本地审核提示词已经用过了:请回到 Workbench 重新准备文件后再试。"
|
|
13125
|
+
};
|
|
13126
|
+
}
|
|
13127
|
+
if (now > record.expiresAt) {
|
|
13128
|
+
drop(id);
|
|
13129
|
+
return {
|
|
13130
|
+
...empty,
|
|
13131
|
+
errorKind: "policy",
|
|
13132
|
+
error: "本地审核提示词已过期。\n\n请回到 Workbench,重新选择或准备文件后再试。"
|
|
13133
|
+
};
|
|
13134
|
+
}
|
|
13135
|
+
if (sessionId === "") return {
|
|
13136
|
+
...empty,
|
|
13137
|
+
errorKind: "policy",
|
|
13138
|
+
error: "无法确认调用者的会话身份:本地审核必须绑定到具体对话"
|
|
13139
|
+
};
|
|
13140
|
+
if (await readManifest(io, record.casePath, id) === null) {
|
|
13141
|
+
drop(id);
|
|
13142
|
+
return {
|
|
13143
|
+
...empty,
|
|
13144
|
+
errorKind: "capability-gap",
|
|
13145
|
+
error: "本地审核的临时快照已经不在了:请回到 Workbench 重新准备文件。"
|
|
13146
|
+
};
|
|
13147
|
+
}
|
|
13148
|
+
record.used = true;
|
|
13149
|
+
record.claimedSessionId = sessionId;
|
|
13150
|
+
scopes.set(sessionId, {
|
|
13151
|
+
handoffId: id,
|
|
13152
|
+
casePath: record.casePath,
|
|
13153
|
+
claimedAt: now
|
|
13154
|
+
});
|
|
13155
|
+
return {
|
|
13156
|
+
ok: true,
|
|
13157
|
+
error: "",
|
|
13158
|
+
errorKind: "",
|
|
13159
|
+
casePath: record.casePath,
|
|
13160
|
+
fileCount: record.fileCount,
|
|
13161
|
+
skippedCount: record.skippedCount,
|
|
13162
|
+
files: [...record.files],
|
|
13163
|
+
skipped: [...record.skipped]
|
|
13164
|
+
};
|
|
13165
|
+
},
|
|
13166
|
+
scopeOf: (sessionId) => sessionId === "" ? void 0 : scopes.get(sessionId),
|
|
13167
|
+
async sweep(io, now = Date.now()) {
|
|
13168
|
+
const dropped = [];
|
|
13169
|
+
for (const [id, record] of [...handoffs]) {
|
|
13170
|
+
if (record.used || now <= record.expiresAt) continue;
|
|
13171
|
+
dropped.push(id);
|
|
13172
|
+
drop(id);
|
|
13173
|
+
if (record.casePath !== "") orphans.add(record.casePath);
|
|
13174
|
+
}
|
|
13175
|
+
const removed = [];
|
|
13176
|
+
for (const casePath of [...orphans]) {
|
|
13177
|
+
orphans.delete(casePath);
|
|
13178
|
+
try {
|
|
13179
|
+
await io.removeTree(casePath);
|
|
13180
|
+
removed.push(casePath);
|
|
13181
|
+
} catch (error) {
|
|
13182
|
+
orphans.add(casePath);
|
|
13183
|
+
}
|
|
13184
|
+
}
|
|
13185
|
+
return {
|
|
13186
|
+
dropped,
|
|
13187
|
+
removed
|
|
13188
|
+
};
|
|
13189
|
+
},
|
|
13190
|
+
size: () => handoffs.size
|
|
13191
|
+
};
|
|
13192
|
+
}
|
|
13193
|
+
//#endregion
|
|
13194
|
+
//#region src/host/local-audit/scan.ts
|
|
13195
|
+
/** 生产实现:`node:fs/promises`。 */
|
|
13196
|
+
function nodeLocalAuditFs() {
|
|
13197
|
+
return {
|
|
13198
|
+
async kindOf(path) {
|
|
13199
|
+
try {
|
|
13200
|
+
const info = await lstat(path);
|
|
13201
|
+
if (info.isSymbolicLink()) return "symlink";
|
|
13202
|
+
if (info.isFile()) return "file";
|
|
13203
|
+
if (info.isDirectory()) return "directory";
|
|
13204
|
+
return "other";
|
|
13205
|
+
} catch (error) {
|
|
13206
|
+
if (isAbsent(error)) return "absent";
|
|
13207
|
+
return "other";
|
|
13208
|
+
}
|
|
13209
|
+
},
|
|
13210
|
+
async list(path) {
|
|
13211
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
13212
|
+
const out = [];
|
|
13213
|
+
for (const entry of entries) {
|
|
13214
|
+
const full = `${trimTrailingSeparators(path)}/${entry.name}`;
|
|
13215
|
+
let sizeBytes = 0;
|
|
13216
|
+
if (entry.isFile()) try {
|
|
13217
|
+
sizeBytes = (await stat(full)).size;
|
|
13218
|
+
} catch (error) {
|
|
13219
|
+
sizeBytes = 0;
|
|
13220
|
+
}
|
|
13221
|
+
out.push({
|
|
13222
|
+
name: entry.name,
|
|
13223
|
+
kind: entry.isSymbolicLink() ? "symlink" : entry.isFile() ? "file" : entry.isDirectory() ? "directory" : "other",
|
|
13224
|
+
sizeBytes
|
|
13225
|
+
});
|
|
13226
|
+
}
|
|
13227
|
+
out.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
13228
|
+
return out;
|
|
13229
|
+
},
|
|
13230
|
+
async canonical(path) {
|
|
13231
|
+
try {
|
|
13232
|
+
return await realpath(path);
|
|
13233
|
+
} catch (error) {
|
|
13234
|
+
return trimTrailingSeparators(path);
|
|
13235
|
+
}
|
|
13236
|
+
},
|
|
13237
|
+
async readable(path) {
|
|
13238
|
+
try {
|
|
13239
|
+
await access(path, constants.R_OK);
|
|
13240
|
+
return true;
|
|
13241
|
+
} catch (error) {
|
|
13242
|
+
return false;
|
|
13243
|
+
}
|
|
13244
|
+
},
|
|
13245
|
+
async sizeOf(path) {
|
|
13246
|
+
try {
|
|
13247
|
+
return (await stat(path)).size;
|
|
13248
|
+
} catch (error) {
|
|
13249
|
+
return 0;
|
|
13250
|
+
}
|
|
13251
|
+
}
|
|
13252
|
+
};
|
|
13253
|
+
}
|
|
13254
|
+
function isAbsent(error) {
|
|
13255
|
+
const code = error?.code;
|
|
13256
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
13257
|
+
}
|
|
13258
|
+
function emptyScan() {
|
|
13259
|
+
return {
|
|
13260
|
+
ok: false,
|
|
13261
|
+
error: "",
|
|
13262
|
+
errorKind: "",
|
|
13263
|
+
items: [],
|
|
13264
|
+
files: [],
|
|
13265
|
+
skipped: [],
|
|
13266
|
+
fileCount: 0,
|
|
13267
|
+
limit: 30,
|
|
13268
|
+
overLimit: false,
|
|
13269
|
+
readableCount: 0,
|
|
13270
|
+
skippedCount: 0,
|
|
13271
|
+
truncated: false
|
|
13272
|
+
};
|
|
13273
|
+
}
|
|
13274
|
+
/** 展示用的相对路径拼接:固定用 `/`(提示词与界面都读它,和平台无关)。 */
|
|
13275
|
+
function relativeOf(root, rest) {
|
|
13276
|
+
return rest === "" ? root : `${root}/${rest}`;
|
|
13277
|
+
}
|
|
13278
|
+
/**
|
|
13279
|
+
* 展开一次选择。
|
|
13280
|
+
*
|
|
13281
|
+
* `selection` 是用户在界面上选的全部入口(绝对路径,顺序即用户选择顺序)。
|
|
13282
|
+
*
|
|
13283
|
+
* `options.excluded` 是员工在界面上**逐个移除**掉的文件(绝对路径)—— 被排除的文件不进
|
|
13284
|
+
* 清单、不计入数量、也不进快照,因此"移除一个文件"这件事在重新扫描后依然成立。
|
|
13285
|
+
* 它只认**入口之内**的文件:入口本身要去掉就把它从 `selection` 里删掉(界面就是这么做的)。
|
|
13286
|
+
*/
|
|
13287
|
+
async function scanSelection(selection, fs, options = {}) {
|
|
13288
|
+
const scan = emptyScan();
|
|
13289
|
+
const roots = [];
|
|
13290
|
+
const seenRoots = /* @__PURE__ */ new Set();
|
|
13291
|
+
const excludedKeys = /* @__PURE__ */ new Set();
|
|
13292
|
+
for (const raw of options.excluded ?? []) {
|
|
13293
|
+
const path = trimTrailingSeparators(raw);
|
|
13294
|
+
if (path === "" || !isAbsolute(path)) continue;
|
|
13295
|
+
excludedKeys.add(await fs.canonical(path));
|
|
13296
|
+
}
|
|
13297
|
+
for (const raw of selection) {
|
|
13298
|
+
const path = trimTrailingSeparators(raw);
|
|
13299
|
+
if (path === "") continue;
|
|
13300
|
+
if (!isAbsolute(path)) return {
|
|
13301
|
+
...scan,
|
|
13302
|
+
ok: false,
|
|
13303
|
+
errorKind: "input",
|
|
13304
|
+
error: `本地审核只接受绝对路径:${path}`
|
|
13305
|
+
};
|
|
13306
|
+
const key = await fs.canonical(path);
|
|
13307
|
+
if (seenRoots.has(key)) continue;
|
|
13308
|
+
seenRoots.add(key);
|
|
13309
|
+
roots.push(path);
|
|
13310
|
+
}
|
|
13311
|
+
if (roots.length === 0) return scan;
|
|
13312
|
+
const items = [];
|
|
13313
|
+
const files = [];
|
|
13314
|
+
const skipped = [];
|
|
13315
|
+
const seenFiles = /* @__PURE__ */ new Set();
|
|
13316
|
+
let entries = 0;
|
|
13317
|
+
let truncated = false;
|
|
13318
|
+
/** 命中了多少条排除项(只用来把"全被移除"与"压根读不到"分开说)。 */
|
|
13319
|
+
let excludedHits = 0;
|
|
13320
|
+
const pushFile = async (sourcePath, relativePath, sizeBytes, parentPath) => {
|
|
13321
|
+
const key = await fs.canonical(sourcePath);
|
|
13322
|
+
if (seenFiles.has(key)) return;
|
|
13323
|
+
seenFiles.add(key);
|
|
13324
|
+
if (excludedKeys.has(key)) {
|
|
13325
|
+
excludedHits += 1;
|
|
13326
|
+
return;
|
|
13327
|
+
}
|
|
13328
|
+
if (files.length >= 2e3) {
|
|
13329
|
+
truncated = true;
|
|
13330
|
+
return;
|
|
13331
|
+
}
|
|
13332
|
+
if (!await fs.readable(sourcePath)) {
|
|
13333
|
+
skipped.push({
|
|
13334
|
+
relativePath,
|
|
13335
|
+
name: basenameLocalPath(relativePath),
|
|
13336
|
+
reason: "权限不足"
|
|
13337
|
+
});
|
|
13338
|
+
return;
|
|
13339
|
+
}
|
|
13340
|
+
files.push({
|
|
13341
|
+
sourcePath,
|
|
13342
|
+
relativePath,
|
|
13343
|
+
name: basenameLocalPath(relativePath),
|
|
13344
|
+
sizeBytes,
|
|
13345
|
+
parentPath
|
|
13346
|
+
});
|
|
13347
|
+
};
|
|
13348
|
+
const walk = async (dir, prefix, root, budget) => {
|
|
13349
|
+
let listing;
|
|
13350
|
+
try {
|
|
13351
|
+
listing = await fs.list(dir);
|
|
13352
|
+
} catch (error) {
|
|
13353
|
+
return false;
|
|
13354
|
+
}
|
|
13355
|
+
for (const entry of listing) {
|
|
13356
|
+
if (budget.left <= 0) {
|
|
13357
|
+
truncated = true;
|
|
13358
|
+
return true;
|
|
13359
|
+
}
|
|
13360
|
+
budget.left -= 1;
|
|
13361
|
+
entries += 1;
|
|
13362
|
+
if (entries > 2e4) {
|
|
13363
|
+
truncated = true;
|
|
13364
|
+
return true;
|
|
13365
|
+
}
|
|
13366
|
+
const child = `${trimTrailingSeparators(dir)}/${entry.name}`;
|
|
13367
|
+
const rel = relativeOf(prefix, entry.name);
|
|
13368
|
+
if (entry.kind === "symlink") {
|
|
13369
|
+
skipped.push({
|
|
13370
|
+
relativePath: rel,
|
|
13371
|
+
name: entry.name,
|
|
13372
|
+
reason: "符号链接不纳入审核范围"
|
|
13373
|
+
});
|
|
13374
|
+
continue;
|
|
13375
|
+
}
|
|
13376
|
+
if (entry.kind === "other") {
|
|
13377
|
+
skipped.push({
|
|
13378
|
+
relativePath: rel,
|
|
13379
|
+
name: entry.name,
|
|
13380
|
+
reason: "特殊文件(不是普通文件或目录)"
|
|
13381
|
+
});
|
|
13382
|
+
continue;
|
|
13383
|
+
}
|
|
13384
|
+
if (entry.kind === "directory") {
|
|
13385
|
+
if (await walk(child, rel, root, budget)) return true;
|
|
13386
|
+
continue;
|
|
13387
|
+
}
|
|
13388
|
+
await pushFile(child, rel, entry.sizeBytes, root);
|
|
13389
|
+
}
|
|
13390
|
+
return false;
|
|
13391
|
+
};
|
|
13392
|
+
for (const root of roots) {
|
|
13393
|
+
const name = basenameLocalPath(root) || root;
|
|
13394
|
+
const kind = await fs.kindOf(root);
|
|
13395
|
+
if (kind === "absent") {
|
|
13396
|
+
items.push({
|
|
13397
|
+
path: root,
|
|
13398
|
+
name,
|
|
13399
|
+
kind: "file",
|
|
13400
|
+
sizeBytes: 0,
|
|
13401
|
+
fileCount: 0,
|
|
13402
|
+
status: "unreadable",
|
|
13403
|
+
reason: "路径不存在或无法读取"
|
|
13404
|
+
});
|
|
13405
|
+
continue;
|
|
13406
|
+
}
|
|
13407
|
+
if (kind === "symlink") {
|
|
13408
|
+
items.push({
|
|
13409
|
+
path: root,
|
|
13410
|
+
name,
|
|
13411
|
+
kind: "file",
|
|
13412
|
+
sizeBytes: 0,
|
|
13413
|
+
fileCount: 0,
|
|
13414
|
+
status: "skipped",
|
|
13415
|
+
reason: "符号链接不纳入审核范围"
|
|
13416
|
+
});
|
|
13417
|
+
continue;
|
|
13418
|
+
}
|
|
13419
|
+
if (kind === "other") {
|
|
13420
|
+
items.push({
|
|
13421
|
+
path: root,
|
|
13422
|
+
name,
|
|
13423
|
+
kind: "file",
|
|
13424
|
+
sizeBytes: 0,
|
|
13425
|
+
fileCount: 0,
|
|
13426
|
+
status: "skipped",
|
|
13427
|
+
reason: "特殊文件(不是普通文件或目录)"
|
|
13428
|
+
});
|
|
13429
|
+
continue;
|
|
13430
|
+
}
|
|
13431
|
+
if (kind === "file") {
|
|
13432
|
+
const before = files.length;
|
|
13433
|
+
const size = await fs.sizeOf(root);
|
|
13434
|
+
const excludedHere = excludedKeys.has(await fs.canonical(root));
|
|
13435
|
+
await pushFile(root, name, size, root);
|
|
13436
|
+
const accepted = files.length > before;
|
|
13437
|
+
items.push({
|
|
13438
|
+
path: root,
|
|
13439
|
+
name,
|
|
13440
|
+
kind: "file",
|
|
13441
|
+
sizeBytes: size,
|
|
13442
|
+
fileCount: accepted ? 1 : 0,
|
|
13443
|
+
status: accepted ? "scanned" : excludedHere ? "removed" : "unreadable",
|
|
13444
|
+
reason: accepted ? "" : excludedHere ? "已移除,不参与本次审核" : "无法读取(权限不足或文件被占用)"
|
|
13445
|
+
});
|
|
13446
|
+
continue;
|
|
13447
|
+
}
|
|
13448
|
+
const before = files.length;
|
|
13449
|
+
const skillSkipped = skipped.length;
|
|
13450
|
+
const excludedBefore = excludedHits;
|
|
13451
|
+
if (await walk(root, name, root, { left: 2e4 - entries })) truncated = true;
|
|
13452
|
+
const added = files.length - before;
|
|
13453
|
+
const refused = skipped.length - skillSkipped;
|
|
13454
|
+
const removed = excludedHits - excludedBefore;
|
|
13455
|
+
items.push({
|
|
13456
|
+
path: root,
|
|
13457
|
+
name,
|
|
13458
|
+
kind: "directory",
|
|
13459
|
+
sizeBytes: 0,
|
|
13460
|
+
fileCount: added,
|
|
13461
|
+
status: added > 0 ? "scanned" : removed > 0 ? "removed" : "unreadable",
|
|
13462
|
+
reason: added > 0 ? "" : removed > 0 ? "展开后的文件都被移除了" : refused > 0 ? "展开后没有可读取的普通文件" : "目录为空或无法读取"
|
|
13463
|
+
});
|
|
13464
|
+
}
|
|
13465
|
+
const overLimit = files.length > 30;
|
|
13466
|
+
if (overLimit) {
|
|
13467
|
+
for (const item of items) if (item.status === "scanned") {
|
|
13468
|
+
item.status = "over-limit";
|
|
13469
|
+
item.reason = `展开后共 ${String(files.length)} 个文件,超过单次最多 ${String(30)} 个文件的限制`;
|
|
13470
|
+
}
|
|
13471
|
+
}
|
|
13472
|
+
return {
|
|
13473
|
+
...scan,
|
|
13474
|
+
ok: true,
|
|
13475
|
+
items,
|
|
13476
|
+
files,
|
|
13477
|
+
skipped,
|
|
13478
|
+
fileCount: files.length,
|
|
13479
|
+
overLimit,
|
|
13480
|
+
readableCount: files.length,
|
|
13481
|
+
skippedCount: skipped.length,
|
|
13482
|
+
truncated
|
|
13483
|
+
};
|
|
13484
|
+
}
|
|
13485
|
+
/** 绝对路径判据:与 `shared/utils/local-path.ts` 同口径,但这里只需要布尔。 */
|
|
13486
|
+
function isAbsolute(path) {
|
|
13487
|
+
if (path.startsWith("/")) return true;
|
|
13488
|
+
if (/^[A-Za-z]:[\\/]/.test(path)) return true;
|
|
13489
|
+
return /^[\\/]{2}[^\\/]/.test(path);
|
|
13490
|
+
}
|
|
13491
|
+
//#endregion
|
|
13492
|
+
//#region src/host/local-audit/ops.ts
|
|
13493
|
+
/** 本机访问操作名:读用户选中的本机文件并写入临时快照。 */
|
|
13494
|
+
const LOCAL_AUDIT_OPERATION = "system.local-audit-material.snapshot";
|
|
13495
|
+
function scanView(scan) {
|
|
13496
|
+
return {
|
|
13497
|
+
ok: scan.ok,
|
|
13498
|
+
error: scan.error === "" && scan.overLimit ? `当前选择会展开为 ${String(scan.fileCount)} 个文件,超过单次最多 ${String(30)} 个文件的限制。请移除部分文件或文件夹后再继续。` : scan.error,
|
|
13499
|
+
errorKind: scan.errorKind,
|
|
13500
|
+
fileCount: scan.fileCount,
|
|
13501
|
+
limit: 30,
|
|
13502
|
+
overLimit: scan.overLimit,
|
|
13503
|
+
readableCount: scan.readableCount,
|
|
13504
|
+
skippedCount: scan.skippedCount,
|
|
13505
|
+
items: scan.items,
|
|
13506
|
+
files: scan.files.map((item) => ({
|
|
13507
|
+
parentPath: item.parentPath,
|
|
13508
|
+
path: item.sourcePath,
|
|
13509
|
+
name: item.name,
|
|
13510
|
+
relativePath: item.relativePath,
|
|
13511
|
+
sizeBytes: item.sizeBytes
|
|
13512
|
+
})),
|
|
13513
|
+
skipped: scan.skipped
|
|
13514
|
+
};
|
|
13515
|
+
}
|
|
13516
|
+
function emptyStart() {
|
|
13517
|
+
return {
|
|
13518
|
+
ok: false,
|
|
13519
|
+
error: "",
|
|
13520
|
+
errorKind: "",
|
|
13521
|
+
handoffId: "",
|
|
13522
|
+
prompt: "",
|
|
13523
|
+
providedCount: 0,
|
|
13524
|
+
skippedCount: 0,
|
|
13525
|
+
expiresAt: 0,
|
|
13526
|
+
workspacePath: "",
|
|
13527
|
+
casePath: "",
|
|
13528
|
+
files: [],
|
|
13529
|
+
skipped: []
|
|
13530
|
+
};
|
|
13531
|
+
}
|
|
13532
|
+
/** 选择的规范化:只接受字符串数组,逐个 trim。 */
|
|
13533
|
+
function selectionOf(args) {
|
|
13534
|
+
const raw = args.selection;
|
|
13535
|
+
if (!Array.isArray(raw)) return [];
|
|
13536
|
+
return raw.map((item) => text(item).trim()).filter((item) => item !== "");
|
|
13537
|
+
}
|
|
13538
|
+
/**
|
|
13539
|
+
* 排除清单的规范化:员工在界面上**逐个移除**掉的文件(绝对路径)。
|
|
13540
|
+
*
|
|
13541
|
+
* 每个入口本身要不要审,是靠从 `selection` 里删掉它表达的;这里只装"入口之内的某个文件"。
|
|
13542
|
+
*/
|
|
13543
|
+
function excludedOf(args) {
|
|
13544
|
+
const raw = args.excluded;
|
|
13545
|
+
if (!Array.isArray(raw)) return [];
|
|
13546
|
+
return raw.map((item) => text(item).trim()).filter((item) => item !== "");
|
|
13547
|
+
}
|
|
13548
|
+
async function localAuditStatus(deps, args) {
|
|
13549
|
+
const selection = selectionOf(args);
|
|
13550
|
+
if (selection.length === 0) return { ...scanView({
|
|
13551
|
+
ok: false,
|
|
13552
|
+
error: "",
|
|
13553
|
+
errorKind: "",
|
|
13554
|
+
items: [],
|
|
13555
|
+
files: [],
|
|
13556
|
+
skipped: [],
|
|
13557
|
+
fileCount: 0,
|
|
13558
|
+
limit: 30,
|
|
13559
|
+
overLimit: false,
|
|
13560
|
+
readableCount: 0,
|
|
13561
|
+
skippedCount: 0,
|
|
13562
|
+
truncated: false
|
|
13563
|
+
}) };
|
|
13564
|
+
return scanView(await scanSelection(selection, deps.fs, { excluded: excludedOf(args) }));
|
|
13565
|
+
}
|
|
13566
|
+
async function localAuditStart(deps, args) {
|
|
13567
|
+
const selection = selectionOf(args);
|
|
13568
|
+
if (selection.length === 0) return {
|
|
13569
|
+
...emptyStart(),
|
|
13570
|
+
errorKind: "input",
|
|
13571
|
+
error: "请先选择文件或文件夹"
|
|
12361
13572
|
};
|
|
12362
|
-
|
|
12363
|
-
|
|
12364
|
-
|
|
12365
|
-
|
|
12366
|
-
|
|
12367
|
-
|
|
12368
|
-
|
|
12369
|
-
|
|
12370
|
-
|
|
12371
|
-
|
|
12372
|
-
}
|
|
12373
|
-
|
|
12374
|
-
|
|
12375
|
-
|
|
12376
|
-
|
|
12377
|
-
|
|
12378
|
-
const
|
|
12379
|
-
|
|
12380
|
-
|
|
12381
|
-
|
|
13573
|
+
const scan = await scanSelection(selection, deps.fs, { excluded: excludedOf(args) });
|
|
13574
|
+
if (!scan.ok) return {
|
|
13575
|
+
...emptyStart(),
|
|
13576
|
+
errorKind: scan.errorKind === "input" ? "input" : "infrastructure",
|
|
13577
|
+
error: scan.error
|
|
13578
|
+
};
|
|
13579
|
+
if (scan.overLimit) return {
|
|
13580
|
+
...emptyStart(),
|
|
13581
|
+
errorKind: "input",
|
|
13582
|
+
error: `当前选择会展开为 ${String(scan.fileCount)} 个文件,超过单次最多 ${String(30)} 个文件的限制。请移除部分文件或文件夹后再继续。`
|
|
13583
|
+
};
|
|
13584
|
+
if (scan.files.length === 0) return {
|
|
13585
|
+
...emptyStart(),
|
|
13586
|
+
errorKind: "input",
|
|
13587
|
+
error: "没有可审核的文件"
|
|
13588
|
+
};
|
|
13589
|
+
const workdir = await deps.world.workdir();
|
|
13590
|
+
const decision = deps.access.authorize({
|
|
13591
|
+
operation: LOCAL_AUDIT_OPERATION,
|
|
13592
|
+
source: "panel",
|
|
13593
|
+
...workdir === "" ? {} : { workdir }
|
|
12382
13594
|
});
|
|
12383
|
-
if (
|
|
12384
|
-
|
|
12385
|
-
|
|
12386
|
-
|
|
12387
|
-
|
|
12388
|
-
|
|
12389
|
-
|
|
12390
|
-
|
|
12391
|
-
|
|
12392
|
-
|
|
12393
|
-
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
13595
|
+
if (!decision.ok) return {
|
|
13596
|
+
...emptyStart(),
|
|
13597
|
+
errorKind: "policy",
|
|
13598
|
+
error: decision.error
|
|
13599
|
+
};
|
|
13600
|
+
/**
|
|
13601
|
+
* 案例目录落在**员工选定的工作空间**下:`<工作空间>/本地审核/<handoffId>`。
|
|
13602
|
+
*
|
|
13603
|
+
* 为什么必须有工作空间(2026-10-11 用户口径:「本地审核对话应该创建在我环境信息规定的
|
|
13604
|
+
* 工作区下面」):会话的分组与沙箱边界都由 **cwd** 决定 —— cwd 逐字等于工作空间路径时,
|
|
13605
|
+
* 这条会话才挂到那个工作空间下;而 cwd = 工作空间又要求案例目录**在工作空间之内**,
|
|
13606
|
+
* 否则对话里的案例内工具(非特权)写不进去。交付件也因此落在员工自己的目录里。
|
|
13607
|
+
*/
|
|
13608
|
+
const workspacePath = trimTrailingSeparators(text(deps.state.workspacePath) || text(deps.state.caseRoot));
|
|
13609
|
+
if (workspacePath === "") return {
|
|
13610
|
+
...emptyStart(),
|
|
13611
|
+
errorKind: "policy",
|
|
13612
|
+
error: "还没有选定工作空间:请先在「环境信息」里选择工作空间,再开始本地审核。"
|
|
13613
|
+
};
|
|
13614
|
+
const handoffId = newHandoffId();
|
|
13615
|
+
const snapshot = await prepareSnapshot(handoffId, scan.files, deps.io, { casePath: localAuditCaseDir(workspacePath, handoffId) });
|
|
13616
|
+
if (!snapshot.ok) {
|
|
13617
|
+
deps.access.note({
|
|
13618
|
+
operation: LOCAL_AUDIT_OPERATION,
|
|
13619
|
+
source: "panel"
|
|
13620
|
+
}, { errorClass: "infrastructure" });
|
|
13621
|
+
return {
|
|
13622
|
+
...emptyStart(),
|
|
13623
|
+
errorKind: snapshot.errorKind === "infrastructure" ? "infrastructure" : "input",
|
|
13624
|
+
error: snapshot.error
|
|
13625
|
+
};
|
|
13626
|
+
}
|
|
13627
|
+
deps.access.note({
|
|
13628
|
+
operation: LOCAL_AUDIT_OPERATION,
|
|
12397
13629
|
source: "panel"
|
|
12398
13630
|
});
|
|
12399
|
-
|
|
12400
|
-
const
|
|
12401
|
-
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
|
|
12405
|
-
|
|
12406
|
-
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
12411
|
-
"crwu",
|
|
12412
|
-
"h3yun",
|
|
12413
|
-
"records",
|
|
12414
|
-
"get",
|
|
12415
|
-
"--schema",
|
|
12416
|
-
form.code,
|
|
12417
|
-
"--id",
|
|
12418
|
-
objectId
|
|
12419
|
-
], runOptions);
|
|
12420
|
-
if (!recordRun.ok) return failed(base, recordRun.error !== "" ? "infrastructure" : "cli", `核对报告身份失败:${text(recordRun.error) || text(recordRun.stderr) || "records get 未成功"}`);
|
|
12421
|
-
const record = jsonObject(recordRun.stdout);
|
|
12422
|
-
const data = record !== null && record.data !== null && typeof record.data === "object" && !Array.isArray(record.data) ? record.data : null;
|
|
12423
|
-
if (data === null) return failed(base, "cli", "记录接口没有返回可解析的记录对象");
|
|
12424
|
-
const recordId = text(pick(data, [
|
|
12425
|
-
"ObjectId",
|
|
12426
|
-
"objectId",
|
|
12427
|
-
"Id",
|
|
12428
|
-
"id"
|
|
12429
|
-
]));
|
|
12430
|
-
const recordSeq = text(pick(data, ["SeqNo", "seqNo"]));
|
|
12431
|
-
if (recordId === "" || recordSeq === "") return failed(base, "cli", "记录缺少 ObjectId / SeqNo:无法确认材料属于这条报告");
|
|
12432
|
-
if (recordId !== objectId) return failed(base, "input", "取回的记录不是请求的那一条(ObjectId 不一致)");
|
|
12433
|
-
if (recordSeq !== seqNo) return failed(base, "input", "记录的流水号与请求的不一致:拒绝登记材料范围");
|
|
12434
|
-
const filesRun = await runCrwu(ctx, [
|
|
12435
|
-
"crwu",
|
|
12436
|
-
"h3yun",
|
|
12437
|
-
"files",
|
|
12438
|
-
"list",
|
|
12439
|
-
"--schema",
|
|
12440
|
-
form.code,
|
|
12441
|
-
"--id",
|
|
12442
|
-
objectId
|
|
12443
|
-
], runOptions);
|
|
12444
|
-
if (!filesRun.ok) return failed(base, filesRun.error !== "" ? "infrastructure" : "cli", `读取附件清单失败:${text(filesRun.error) || text(filesRun.stderr) || "files list 未成功"}`);
|
|
12445
|
-
const rows = attachmentsOf(filesRun.stdout);
|
|
12446
|
-
if (rows === null) return failed(base, "cli", "附件接口没有返回附件数组");
|
|
12447
|
-
const registered = deps.scopes.register({
|
|
12448
|
-
sessionId,
|
|
12449
|
-
seqNo,
|
|
12450
|
-
objectId,
|
|
12451
|
-
caseDir,
|
|
12452
|
-
allowedAttachmentIds: rows.map((row) => row.fileId)
|
|
13631
|
+
const extraPrompt = text(args.prompt);
|
|
13632
|
+
const prompt = localAuditPrompt({
|
|
13633
|
+
handoffId,
|
|
13634
|
+
files: snapshot.files,
|
|
13635
|
+
skipped: snapshot.skipped,
|
|
13636
|
+
extraPrompt
|
|
13637
|
+
});
|
|
13638
|
+
const record = deps.registry.create({
|
|
13639
|
+
id: handoffId,
|
|
13640
|
+
casePath: snapshot.casePath,
|
|
13641
|
+
files: snapshot.files,
|
|
13642
|
+
skipped: snapshot.skipped
|
|
12453
13643
|
});
|
|
12454
13644
|
return {
|
|
12455
13645
|
ok: true,
|
|
12456
13646
|
error: "",
|
|
12457
13647
|
errorKind: "",
|
|
12458
|
-
|
|
12459
|
-
|
|
12460
|
-
|
|
12461
|
-
|
|
12462
|
-
|
|
12463
|
-
|
|
12464
|
-
|
|
12465
|
-
|
|
12466
|
-
|
|
12467
|
-
|
|
13648
|
+
handoffId: record.id,
|
|
13649
|
+
prompt,
|
|
13650
|
+
providedCount: snapshot.files.length,
|
|
13651
|
+
skippedCount: snapshot.skipped.length,
|
|
13652
|
+
expiresAt: record.expiresAt,
|
|
13653
|
+
/**
|
|
13654
|
+
* 新会话的 **cwd**(= 员工选定的工作空间,逐字)。
|
|
13655
|
+
*
|
|
13656
|
+
* DSH 按 cwd 把会话归到工作空间下;同时它也是会话的沙箱边界,而案例目录就在它里面。
|
|
13657
|
+
* 只回给客户端,**不进提示词**。
|
|
13658
|
+
*/
|
|
13659
|
+
workspacePath,
|
|
13660
|
+
/** 本轮案例目录(`<工作空间>/本地审核/<handoffId>`);只回给客户端,不进提示词。 */
|
|
13661
|
+
casePath: snapshot.casePath,
|
|
13662
|
+
files: snapshot.files.map((item) => ({
|
|
13663
|
+
relativePath: item.relativePath,
|
|
13664
|
+
name: item.name
|
|
12468
13665
|
})),
|
|
12469
|
-
|
|
12470
|
-
|
|
13666
|
+
skipped: snapshot.skipped
|
|
13667
|
+
};
|
|
13668
|
+
}
|
|
13669
|
+
/**
|
|
13670
|
+
* 认领 handoff。
|
|
13671
|
+
*
|
|
13672
|
+
* `sessionId` **必须由调用方身份给出**:RPC 路由上它是客户端刚创建的那条会话(同源面板
|
|
13673
|
+
* 本来就知道自己建了哪条),Tool 上它是 `exec.agent.id`(不可伪造)。两者都走同一个注册表,
|
|
13674
|
+
* 所以"哪条会话能用这个案例目录"的判据只有一处。
|
|
13675
|
+
*/
|
|
13676
|
+
async function localAuditClaim(deps, args) {
|
|
13677
|
+
const handoffId = text(args.handoffId).trim();
|
|
13678
|
+
const sessionId = text(args.sessionId).trim();
|
|
13679
|
+
if (handoffId === "") return {
|
|
13680
|
+
ok: false,
|
|
13681
|
+
errorKind: "input",
|
|
13682
|
+
casePath: "",
|
|
13683
|
+
fileCount: 0,
|
|
13684
|
+
skippedCount: 0,
|
|
13685
|
+
files: [],
|
|
13686
|
+
skipped: [],
|
|
13687
|
+
error: "缺少 handoffId"
|
|
13688
|
+
};
|
|
13689
|
+
if (sessionId === "") return {
|
|
13690
|
+
ok: false,
|
|
13691
|
+
errorKind: "input",
|
|
13692
|
+
casePath: "",
|
|
13693
|
+
fileCount: 0,
|
|
13694
|
+
skippedCount: 0,
|
|
13695
|
+
files: [],
|
|
13696
|
+
skipped: [],
|
|
13697
|
+
error: "缺少会话标识:本地审核必须绑定到具体对话"
|
|
12471
13698
|
};
|
|
13699
|
+
return { ...await deps.registry.claim(handoffId, sessionId, deps.io) };
|
|
12472
13700
|
}
|
|
12473
13701
|
//#endregion
|
|
12474
13702
|
//#region src/host/environment/probe.ts
|
|
@@ -13204,6 +14432,28 @@ async function requireOss(deps) {
|
|
|
13204
14432
|
ossutil: lookup.path
|
|
13205
14433
|
};
|
|
13206
14434
|
}
|
|
14435
|
+
async function listOssDirectory(deps, ready, prefix, options = {}) {
|
|
14436
|
+
const argv = [
|
|
14437
|
+
ready.ossutil,
|
|
14438
|
+
"ls",
|
|
14439
|
+
`oss://${ready.oss.bucket}/${prefix}`
|
|
14440
|
+
];
|
|
14441
|
+
if (ready.oss.endpoint !== "") argv.push("--endpoint", ready.oss.endpoint);
|
|
14442
|
+
return await runOssutil({
|
|
14443
|
+
ctx: deps.ctx,
|
|
14444
|
+
access: deps.access,
|
|
14445
|
+
platform: deps.platform,
|
|
14446
|
+
source: deps.source,
|
|
14447
|
+
workdir: options.workdir ?? await shellWorkdir(deps)
|
|
14448
|
+
}, {
|
|
14449
|
+
operation: "oss.remote.read",
|
|
14450
|
+
argv,
|
|
14451
|
+
signal: options.signal,
|
|
14452
|
+
timeoutMs: options.timeoutMs ?? 9e4,
|
|
14453
|
+
stdoutMaxBytes: 4194304,
|
|
14454
|
+
summary: "ossutil ls(查询报告审核文件)"
|
|
14455
|
+
});
|
|
14456
|
+
}
|
|
13207
14457
|
/**
|
|
13208
14458
|
* 列举 OSS 上的交付件。
|
|
13209
14459
|
*
|
|
@@ -13230,26 +14480,14 @@ async function ossIndex(deps, args = {}) {
|
|
|
13230
14480
|
error: ready.error,
|
|
13231
14481
|
...empty
|
|
13232
14482
|
};
|
|
13233
|
-
const { oss
|
|
14483
|
+
const { oss } = ready;
|
|
13234
14484
|
const listedPrefix = seqNo === "" ? oss.prefix : `${oss.prefix}/${seqNo}`;
|
|
13235
14485
|
if (seqNo !== "" && stripPrefix(`${listedPrefix}/`, oss.prefix) === "") return {
|
|
13236
14486
|
ok: false,
|
|
13237
14487
|
error: "流水号越出配置的 OSS 前缀",
|
|
13238
14488
|
...empty
|
|
13239
14489
|
};
|
|
13240
|
-
const
|
|
13241
|
-
ossutil,
|
|
13242
|
-
"ls",
|
|
13243
|
-
`oss://${oss.bucket}/${listedPrefix}/`
|
|
13244
|
-
];
|
|
13245
|
-
if (oss.endpoint !== "") argv.push("--endpoint", oss.endpoint);
|
|
13246
|
-
const run = await runOssutil(await exec(deps), {
|
|
13247
|
-
operation: "oss.remote.read",
|
|
13248
|
-
argv,
|
|
13249
|
-
timeoutMs: 9e4,
|
|
13250
|
-
stdoutMaxBytes: 4194304,
|
|
13251
|
-
summary: "ossutil ls(列举交付件)"
|
|
13252
|
-
});
|
|
14490
|
+
const run = await listOssDirectory(deps, ready, `${listedPrefix}/`);
|
|
13253
14491
|
if (!run.ok) return {
|
|
13254
14492
|
ok: false,
|
|
13255
14493
|
error: (text(run.stderr) || text(run.error) || text(run.stdout) || "列举失败").trim().slice(0, 400),
|
|
@@ -13308,9 +14546,22 @@ async function ossResult(deps, args) {
|
|
|
13308
14546
|
info: auditInfoFromResult(doc)
|
|
13309
14547
|
};
|
|
13310
14548
|
}
|
|
13311
|
-
/**
|
|
14549
|
+
/**
|
|
14550
|
+
* 生成访问链接;**默认**顺便用系统默认程序打开,`open: false` 时只回链接。
|
|
14551
|
+
*
|
|
14552
|
+
* 为什么要有 `open: false`(2026-10-11 用户实测「点查看报告后浏览器很慢」):
|
|
14553
|
+
* 这条链路原来是**两次串行的提权进程** —— ①`ossutil sign` 出链接;②`open <url>` 拉起浏览器。
|
|
14554
|
+
* 第②步完全可以由**客户端**直接做:DSH 桌面端主进程的 `setWindowOpenHandler` 会把
|
|
14555
|
+
* `window.open(url)` 转成 `shell.openExternal` → 系统默认浏览器(本仓 notes §14.1 那张表里
|
|
14556
|
+
* 已经写明,登录流程正是**为了避开**它才改用内置浏览器)。少一次提权进程就少一段等待,
|
|
14557
|
+
* 而且 Web profile 下 `window.open` 开的就是浏览器标签页 —— 两种部署都对。
|
|
14558
|
+
*
|
|
14559
|
+
* 兼容性:旧宿主**不认**这个参数,照旧自己打开并回 `opened: true`;客户端看到
|
|
14560
|
+
* `opened === true` 就不再自己开一次,所以「新界面 + 旧宿主」不会开出两个窗口。
|
|
14561
|
+
*/
|
|
13312
14562
|
async function ossLink(deps, args) {
|
|
13313
14563
|
const key = text(args.key);
|
|
14564
|
+
const shouldOpen = args.open !== false;
|
|
13314
14565
|
const failed = (error) => ({
|
|
13315
14566
|
ok: false,
|
|
13316
14567
|
error,
|
|
@@ -13351,6 +14602,15 @@ async function ossLink(deps, args) {
|
|
|
13351
14602
|
if (url === "") return failed(`签名命令没有输出 URL:${text(run.stdout).slice(0, 200)}`);
|
|
13352
14603
|
}
|
|
13353
14604
|
if (url.startsWith("http://")) url = `https://${url.slice(7)}`;
|
|
14605
|
+
if (!shouldOpen) return {
|
|
14606
|
+
ok: true,
|
|
14607
|
+
error: "",
|
|
14608
|
+
url,
|
|
14609
|
+
mode: oss.linkMode,
|
|
14610
|
+
ttl: oss.linkTtl,
|
|
14611
|
+
opened: false,
|
|
14612
|
+
openError: ""
|
|
14613
|
+
};
|
|
13354
14614
|
const opened = await deps.access.runShell({
|
|
13355
14615
|
operation: "system.browser.open",
|
|
13356
14616
|
source: "panel",
|
|
@@ -13701,6 +14961,179 @@ async function runUploadWatch(deps) {
|
|
|
13701
14961
|
return Object.values(state.audits).some((record) => record.uploadedAt === "" && record.uploadError === "" && (record.uploading === true || record.key === state.activeKey));
|
|
13702
14962
|
}
|
|
13703
14963
|
//#endregion
|
|
14964
|
+
//#region src/host/oss/batch.ts
|
|
14965
|
+
const failed = (errorKind, error) => ({
|
|
14966
|
+
ok: false,
|
|
14967
|
+
errorKind,
|
|
14968
|
+
error,
|
|
14969
|
+
item: null
|
|
14970
|
+
});
|
|
14971
|
+
function createOssBatchQueue(resolveDeps, options = {}) {
|
|
14972
|
+
const jobs = [];
|
|
14973
|
+
const batches = /* @__PURE__ */ new Set();
|
|
14974
|
+
let active = 0;
|
|
14975
|
+
let disposed = false;
|
|
14976
|
+
const drain = () => {
|
|
14977
|
+
while (active < 3 && jobs.length > 0) {
|
|
14978
|
+
const job = jobs.shift();
|
|
14979
|
+
if (job.signal.aborted) {
|
|
14980
|
+
job.finish(job.cancelled());
|
|
14981
|
+
continue;
|
|
14982
|
+
}
|
|
14983
|
+
active++;
|
|
14984
|
+
job.run().then(job.finish, () => job.finish(failed("command", "查询审核文件失败,请重试"))).finally(() => {
|
|
14985
|
+
active--;
|
|
14986
|
+
drain();
|
|
14987
|
+
});
|
|
14988
|
+
}
|
|
14989
|
+
};
|
|
14990
|
+
const run = async (args, signal) => {
|
|
14991
|
+
const reject = (error) => ({
|
|
14992
|
+
ok: false,
|
|
14993
|
+
error,
|
|
14994
|
+
results: {}
|
|
14995
|
+
});
|
|
14996
|
+
if ("seqNo" in args || !Array.isArray(args.seqNos) || args.seqNos.length > 100 || args.seqNos.some((value) => typeof value !== "string" || !isSafeSeqNo(value))) return reject("报告流水号列表不合法:最多 100 项,且每项必须是有效流水号");
|
|
14997
|
+
const serials = [...new Set(args.seqNos.map((value) => value.trim()))];
|
|
14998
|
+
if (serials.length === 0) return {
|
|
14999
|
+
ok: true,
|
|
15000
|
+
error: "",
|
|
15001
|
+
results: {}
|
|
15002
|
+
};
|
|
15003
|
+
if (disposed || signal?.aborted) return reject("查询已取消");
|
|
15004
|
+
const controller = new AbortController();
|
|
15005
|
+
const batchJobs = /* @__PURE__ */ new Set();
|
|
15006
|
+
const results = {};
|
|
15007
|
+
batches.add(controller);
|
|
15008
|
+
let timedOut = false;
|
|
15009
|
+
const deadlineMs = options.deadlineMs ?? 9e4;
|
|
15010
|
+
const deadline = Date.now() + deadlineMs;
|
|
15011
|
+
const cancelled = () => timedOut ? failed("timeout", "查询审核文件超时,请重试") : failed("cancelled", "查询已取消");
|
|
15012
|
+
const abort = () => {
|
|
15013
|
+
controller.abort();
|
|
15014
|
+
};
|
|
15015
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
15016
|
+
const timer = setTimeout(() => {
|
|
15017
|
+
timedOut = true;
|
|
15018
|
+
controller.abort();
|
|
15019
|
+
drain();
|
|
15020
|
+
}, deadlineMs);
|
|
15021
|
+
let rejectPreparation = () => {};
|
|
15022
|
+
const interruption = new Promise((_resolve, reject) => {
|
|
15023
|
+
rejectPreparation = () => reject(/* @__PURE__ */ new Error("cancelled"));
|
|
15024
|
+
});
|
|
15025
|
+
const onAbort = () => {
|
|
15026
|
+
for (let i = jobs.length - 1; i >= 0; i--) if (jobs[i].signal === controller.signal) jobs.splice(i, 1)[0].finish(cancelled());
|
|
15027
|
+
for (const job of batchJobs) job.finish(cancelled());
|
|
15028
|
+
rejectPreparation();
|
|
15029
|
+
};
|
|
15030
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
15031
|
+
try {
|
|
15032
|
+
const prepared = await Promise.race([interruption, (async () => {
|
|
15033
|
+
const deps = await resolveDeps();
|
|
15034
|
+
if (controller.signal.aborted) return null;
|
|
15035
|
+
const ready = await requireOss(deps);
|
|
15036
|
+
if (!ready.ok || controller.signal.aborted) return null;
|
|
15037
|
+
return {
|
|
15038
|
+
deps,
|
|
15039
|
+
ready,
|
|
15040
|
+
workdir: await deps.workdir()
|
|
15041
|
+
};
|
|
15042
|
+
})()]);
|
|
15043
|
+
if (controller.signal.aborted) return {
|
|
15044
|
+
ok: true,
|
|
15045
|
+
error: "",
|
|
15046
|
+
results: Object.fromEntries(serials.map((seq) => [seq, cancelled()]))
|
|
15047
|
+
};
|
|
15048
|
+
if (prepared === null) return reject("OSS 查询环境未就绪,请在环境信息中检查配置和本机访问授权");
|
|
15049
|
+
const { deps, ready, workdir } = prepared;
|
|
15050
|
+
const read = async (seq) => {
|
|
15051
|
+
if (controller.signal.aborted) return cancelled();
|
|
15052
|
+
const prefix = `${ready.oss.prefix}/${seq}/`;
|
|
15053
|
+
const result = await listOssDirectory(deps, ready, prefix, {
|
|
15054
|
+
workdir,
|
|
15055
|
+
signal: controller.signal,
|
|
15056
|
+
timeoutMs: Math.max(1, deadline - Date.now())
|
|
15057
|
+
});
|
|
15058
|
+
if (controller.signal.aborted) return cancelled();
|
|
15059
|
+
if (result.timedOut) return failed("timeout", "查询审核文件超时,请重试");
|
|
15060
|
+
if (result.aborted) return failed("cancelled", "查询已取消");
|
|
15061
|
+
if (!result.ok) return failed(result.sandbox.denied ? "access" : "command", "查询审核文件失败,请检查 OSS 连接与访问权限后重试");
|
|
15062
|
+
if (result.truncated) return failed("truncated", "审核文件清单过大,查询结果不完整");
|
|
15063
|
+
if ([...result.stdout.matchAll(/oss:\/\/([^/\s]+)\//g)].some((match) => match[1] !== ready.oss.bucket)) return failed("invalid-result", "审核文件清单无法解析,请重试");
|
|
15064
|
+
const entries = parseLsEntries(result.stdout, ready.oss.bucket);
|
|
15065
|
+
const keys = parseLsObjects(result.stdout, ready.oss.bucket);
|
|
15066
|
+
const list = entries.length > 0 ? entries : keys;
|
|
15067
|
+
if (result.stdout.split(/\r?\n/).filter((line) => /^oss:\/\/|^\d{4}-\d{2}-\d{2} /u.test(line.trim())).length !== list.length) return failed("invalid-result", "审核文件清单无法解析,请重试");
|
|
15068
|
+
const declared = /Object Number is:\s*(\d+)/i.exec(result.stdout);
|
|
15069
|
+
if (declared === null) return failed("invalid-result", "审核文件清单无法解析,请重试");
|
|
15070
|
+
if (declared !== null && Number(declared[1]) !== list.length) return failed("invalid-result", "审核文件清单不完整,请重试");
|
|
15071
|
+
if (list.some((entry) => !(typeof entry === "string" ? entry : entry.key).startsWith(prefix))) return failed("invalid-result", "审核文件与报告流水号不匹配");
|
|
15072
|
+
if (list.length === 0) return {
|
|
15073
|
+
ok: true,
|
|
15074
|
+
error: "",
|
|
15075
|
+
item: null
|
|
15076
|
+
};
|
|
15077
|
+
const items = groupObjects(list, ready.oss.prefix);
|
|
15078
|
+
if (Object.keys(items).some((key) => key !== seq) || items[seq] === void 0) return failed("invalid-result", "审核文件与报告流水号不匹配");
|
|
15079
|
+
return {
|
|
15080
|
+
ok: true,
|
|
15081
|
+
error: "",
|
|
15082
|
+
item: items[seq]
|
|
15083
|
+
};
|
|
15084
|
+
};
|
|
15085
|
+
await Promise.all(serials.map((seq) => new Promise((resolve) => {
|
|
15086
|
+
if (controller.signal.aborted) {
|
|
15087
|
+
results[seq] = cancelled();
|
|
15088
|
+
resolve();
|
|
15089
|
+
return;
|
|
15090
|
+
}
|
|
15091
|
+
let settled = false;
|
|
15092
|
+
const job = {
|
|
15093
|
+
signal: controller.signal,
|
|
15094
|
+
run: () => read(seq),
|
|
15095
|
+
cancelled,
|
|
15096
|
+
finish: (result) => {
|
|
15097
|
+
if (settled) return;
|
|
15098
|
+
settled = true;
|
|
15099
|
+
batchJobs.delete(job);
|
|
15100
|
+
results[seq] = result;
|
|
15101
|
+
resolve();
|
|
15102
|
+
}
|
|
15103
|
+
};
|
|
15104
|
+
batchJobs.add(job);
|
|
15105
|
+
jobs.push(job);
|
|
15106
|
+
drain();
|
|
15107
|
+
})));
|
|
15108
|
+
return {
|
|
15109
|
+
ok: true,
|
|
15110
|
+
error: "",
|
|
15111
|
+
results: Object.fromEntries(serials.map((seq) => [seq, results[seq]]))
|
|
15112
|
+
};
|
|
15113
|
+
} catch {
|
|
15114
|
+
if (controller.signal.aborted) return {
|
|
15115
|
+
ok: true,
|
|
15116
|
+
error: "",
|
|
15117
|
+
results: Object.fromEntries(serials.map((seq) => [seq, results[seq] ?? cancelled()]))
|
|
15118
|
+
};
|
|
15119
|
+
return reject("OSS 查询环境不可用,请重试");
|
|
15120
|
+
} finally {
|
|
15121
|
+
clearTimeout(timer);
|
|
15122
|
+
signal?.removeEventListener("abort", abort);
|
|
15123
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
15124
|
+
batches.delete(controller);
|
|
15125
|
+
}
|
|
15126
|
+
};
|
|
15127
|
+
return {
|
|
15128
|
+
run,
|
|
15129
|
+
dispose: () => {
|
|
15130
|
+
disposed = true;
|
|
15131
|
+
for (const controller of batches) controller.abort();
|
|
15132
|
+
drain();
|
|
15133
|
+
}
|
|
15134
|
+
};
|
|
15135
|
+
}
|
|
15136
|
+
//#endregion
|
|
13704
15137
|
//#region src/host/report/files.ts
|
|
13705
15138
|
const LOCAL_MAX_DEPTH = 3;
|
|
13706
15139
|
const LOCAL_MAX_FILES = 300;
|
|
@@ -15028,6 +16461,7 @@ function missingAccessBroker() {
|
|
|
15028
16461
|
}
|
|
15029
16462
|
};
|
|
15030
16463
|
},
|
|
16464
|
+
note: () => void 0,
|
|
15031
16465
|
consent: () => missingLocalAccessView(),
|
|
15032
16466
|
diagnostics: () => [],
|
|
15033
16467
|
lastDiagnostic: () => null
|
|
@@ -16237,6 +17671,8 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16237
17671
|
access: resolvers.access
|
|
16238
17672
|
});
|
|
16239
17673
|
const watch = createUploadWatch(ctx, state, ossDeps);
|
|
17674
|
+
const ossBatch = createOssBatchQueue(ossDeps);
|
|
17675
|
+
ctx.effect(() => ossBatch.dispose, "crwu-workbench: OSS query queue");
|
|
16240
17676
|
/**
|
|
16241
17677
|
* 「我是谁」的进程内缓存(面板头部那句问候的姓名)。
|
|
16242
17678
|
*
|
|
@@ -16304,6 +17740,36 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16304
17740
|
*/
|
|
16305
17741
|
const guard = async (operation) => await guardOperation(gate, operation);
|
|
16306
17742
|
const updateOperations = extra.update ?? {};
|
|
17743
|
+
/**
|
|
17744
|
+
* 本地审核操作的依赖(逐次构造)。
|
|
17745
|
+
*
|
|
17746
|
+
* **不抓快照**:`ctx` / `world` 都是活的服务句柄,构造一次就固化的东西在这里没有意义 ——
|
|
17747
|
+
* 与上面 `ossDeps` 同一条理由。
|
|
17748
|
+
*
|
|
17749
|
+
* 三件依赖缺任何一件时返回 `null`(而不是抛 TypeError):TS 调用方必须传,
|
|
17750
|
+
* 但 JS / 旧调用点漏传时**失败关闭并说人话**,而不是把一个 `undefined.sweep` 抛到路由层。
|
|
17751
|
+
*/
|
|
17752
|
+
const localAuditDeps = () => {
|
|
17753
|
+
const registry = resolvers.localAudit;
|
|
17754
|
+
const fs = resolvers.localAuditFs;
|
|
17755
|
+
const io = resolvers.localAuditIo;
|
|
17756
|
+
if (registry === void 0 || fs === void 0 || io === void 0) return null;
|
|
17757
|
+
return {
|
|
17758
|
+
ctx,
|
|
17759
|
+
access: resolvers.access,
|
|
17760
|
+
state,
|
|
17761
|
+
world,
|
|
17762
|
+
registry,
|
|
17763
|
+
fs,
|
|
17764
|
+
io
|
|
17765
|
+
};
|
|
17766
|
+
};
|
|
17767
|
+
/** 没装配时的失败信封(形状与 `LocalAuditStartView` 兼容,界面按普通错误显示)。 */
|
|
17768
|
+
const localAuditUnwired = {
|
|
17769
|
+
ok: false,
|
|
17770
|
+
error: "本地审核没有装配:本插件实例不能准备本地审核文件",
|
|
17771
|
+
errorKind: "capability-gap"
|
|
17772
|
+
};
|
|
16307
17773
|
return {
|
|
16308
17774
|
...updateOperations,
|
|
16309
17775
|
ping: () => ({
|
|
@@ -16313,7 +17779,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16313
17779
|
buildKind: HOST_BUILD_KIND,
|
|
16314
17780
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16315
17781
|
builtAt: HOST_BUILD_STAMP,
|
|
16316
|
-
protocol:
|
|
17782
|
+
protocol: 31
|
|
16317
17783
|
}),
|
|
16318
17784
|
boot: async () => {
|
|
16319
17785
|
const home = await world.home();
|
|
@@ -16332,7 +17798,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16332
17798
|
});
|
|
16333
17799
|
return {
|
|
16334
17800
|
ok: true,
|
|
16335
|
-
protocol:
|
|
17801
|
+
protocol: 31,
|
|
16336
17802
|
permissionSchemaVersion: 1,
|
|
16337
17803
|
localAccess: await syncLocalAccessConsent({
|
|
16338
17804
|
ctx,
|
|
@@ -16380,6 +17846,9 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16380
17846
|
"oss-cred-save",
|
|
16381
17847
|
"report-files",
|
|
16382
17848
|
"discussion-material-open",
|
|
17849
|
+
"local-audit-status",
|
|
17850
|
+
"local-audit-start",
|
|
17851
|
+
"local-audit-claim",
|
|
16383
17852
|
"ifind-status",
|
|
16384
17853
|
"ifind-credential-save",
|
|
16385
17854
|
"ifind-credential-clear",
|
|
@@ -16433,7 +17902,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16433
17902
|
*/
|
|
16434
17903
|
trust: async () => ({
|
|
16435
17904
|
ok: false,
|
|
16436
|
-
error: `客户端与宿主的权限说明版本不一致:本机访问授权已改为版本化收据(协议 ${String(
|
|
17905
|
+
error: `客户端与宿主的权限说明版本不一致:本机访问授权已改为版本化收据(协议 ${String(31)}),请完全退出并重新打开 DeepSeek Harness 后在「账号连接」里按新的范围允许一次。`,
|
|
16437
17906
|
protocolMismatch: true,
|
|
16438
17907
|
consent: await readLocalAccessConsent({
|
|
16439
17908
|
ctx,
|
|
@@ -16611,6 +18080,60 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16611
18080
|
form: resolvers.form,
|
|
16612
18081
|
scopes: resolvers.discussionScopes
|
|
16613
18082
|
}, args),
|
|
18083
|
+
/**
|
|
18084
|
+
* **本地审核**(协议 28)。
|
|
18085
|
+
*
|
|
18086
|
+
* 三条操作共用一份依赖:注册表、扫描用的 fs、快照 IO。它们**不判环境门禁** ——
|
|
18087
|
+
* 本地审核的存在意义就是"不需要氚云 / OSS / 工作空间也能审本机文件";它唯一需要的是
|
|
18088
|
+
* **本机访问授权**,而那一条由 Broker 在自己的操作表里判(`local-audit-start` 内)。
|
|
18089
|
+
*/
|
|
18090
|
+
"local-audit-status": async (args) => {
|
|
18091
|
+
const ready = localAuditDeps();
|
|
18092
|
+
if (ready === null) return {
|
|
18093
|
+
...localAuditUnwired,
|
|
18094
|
+
fileCount: 0,
|
|
18095
|
+
limit: 30,
|
|
18096
|
+
overLimit: false,
|
|
18097
|
+
readableCount: 0,
|
|
18098
|
+
skippedCount: 0,
|
|
18099
|
+
items: [],
|
|
18100
|
+
skipped: []
|
|
18101
|
+
};
|
|
18102
|
+
return await localAuditStatus(ready, args);
|
|
18103
|
+
},
|
|
18104
|
+
"local-audit-start": async (args) => {
|
|
18105
|
+
const ready = localAuditDeps();
|
|
18106
|
+
if (ready === null) return {
|
|
18107
|
+
...localAuditUnwired,
|
|
18108
|
+
handoffId: "",
|
|
18109
|
+
prompt: "",
|
|
18110
|
+
providedCount: 0,
|
|
18111
|
+
skippedCount: 0,
|
|
18112
|
+
expiresAt: 0,
|
|
18113
|
+
workspacePath: "",
|
|
18114
|
+
casePath: "",
|
|
18115
|
+
files: [],
|
|
18116
|
+
skipped: []
|
|
18117
|
+
};
|
|
18118
|
+
await ready.registry.sweep(ready.io).catch(() => void 0);
|
|
18119
|
+
return await localAuditStart(ready, args);
|
|
18120
|
+
},
|
|
18121
|
+
/**
|
|
18122
|
+
* 认领。`sessionId` 由**客户端**提交(它刚创建了那条会话,这是它自己的事实),
|
|
18123
|
+
* Tool 侧走的是 `crwu_audit_local_claim`(身份来自 Agent,不经过这里)。
|
|
18124
|
+
*/
|
|
18125
|
+
"local-audit-claim": async (args) => {
|
|
18126
|
+
const ready = localAuditDeps();
|
|
18127
|
+
if (ready === null) return {
|
|
18128
|
+
...localAuditUnwired,
|
|
18129
|
+
casePath: "",
|
|
18130
|
+
fileCount: 0,
|
|
18131
|
+
skippedCount: 0,
|
|
18132
|
+
files: [],
|
|
18133
|
+
skipped: []
|
|
18134
|
+
};
|
|
18135
|
+
return await localAuditClaim(ready, args);
|
|
18136
|
+
},
|
|
16614
18137
|
"report-files": async (args) => await reportFiles({
|
|
16615
18138
|
ctx,
|
|
16616
18139
|
oss: await ossDeps(),
|
|
@@ -16620,7 +18143,7 @@ function createCoreOperations(ctx, config, state, world, resolvers, extra = {})
|
|
|
16620
18143
|
platform: await world.platform(),
|
|
16621
18144
|
workdir: () => world.workdir()
|
|
16622
18145
|
}, args),
|
|
16623
|
-
"oss-index": async (args) => await ossIndex(await ossDeps(), args),
|
|
18146
|
+
"oss-index": async (args, context) => "seqNos" in args ? await ossBatch.run(args, context?.signal) : await ossIndex(await ossDeps(), args),
|
|
16624
18147
|
"oss-result": async (args) => await ossResult(await ossDeps(), args),
|
|
16625
18148
|
"oss-link": async (args) => await ossLink(await ossDeps(), args),
|
|
16626
18149
|
"oss-upload": async (args) => {
|
|
@@ -18005,6 +19528,7 @@ function accessOf(deps) {
|
|
|
18005
19528
|
runnerFailed: false
|
|
18006
19529
|
}
|
|
18007
19530
|
}),
|
|
19531
|
+
note: () => void 0,
|
|
18008
19532
|
consent: () => missingLocalAccessView(),
|
|
18009
19533
|
diagnostics: () => [],
|
|
18010
19534
|
lastDiagnostic: () => null
|
|
@@ -18330,6 +19854,12 @@ const LOCAL_ACCESS_OPERATIONS = {
|
|
|
18330
19854
|
privileged: true,
|
|
18331
19855
|
allowedSources: [...AUDIT_SOURCES, "panel"]
|
|
18332
19856
|
},
|
|
19857
|
+
"system.local-audit-material.snapshot": {
|
|
19858
|
+
capability: "system-integration",
|
|
19859
|
+
transport: "filesystem",
|
|
19860
|
+
privileged: true,
|
|
19861
|
+
allowedSources: PANEL
|
|
19862
|
+
},
|
|
18333
19863
|
"python.script.run": {
|
|
18334
19864
|
capability: "system-integration",
|
|
18335
19865
|
transport: "shell",
|
|
@@ -18444,6 +19974,20 @@ function createLocalAccessBroker(deps) {
|
|
|
18444
19974
|
consent: consentOf,
|
|
18445
19975
|
diagnostics: () => diagnostics.list(),
|
|
18446
19976
|
lastDiagnostic: () => diagnostics.list()[0] ?? null,
|
|
19977
|
+
note(call, options = {}) {
|
|
19978
|
+
const requested = localAccessDescriptorOf(call.operation)?.privileged === true ? "danger-full-access" : "";
|
|
19979
|
+
record({
|
|
19980
|
+
operation: call.operation,
|
|
19981
|
+
source: call.source,
|
|
19982
|
+
...options.summary === void 0 ? {} : { summary: options.summary }
|
|
19983
|
+
}, options.errorClass ?? "", {
|
|
19984
|
+
requested,
|
|
19985
|
+
resolved: "",
|
|
19986
|
+
ran: "",
|
|
19987
|
+
denied: options.errorClass === "sandbox-denied",
|
|
19988
|
+
runnerFailed: false
|
|
19989
|
+
}, false);
|
|
19990
|
+
},
|
|
18447
19991
|
async runShell(call, command, options = {}) {
|
|
18448
19992
|
const descriptor = localAccessDescriptorOf(call.operation);
|
|
18449
19993
|
if (descriptor !== void 0 && descriptor.transport !== "shell") return failedShell(`${call.operation} 不是 shell 操作(transport=${descriptor.transport})`);
|
|
@@ -18722,6 +20266,8 @@ var H3yunFormResolver = class {
|
|
|
18722
20266
|
};
|
|
18723
20267
|
//#endregion
|
|
18724
20268
|
//#region src/host/apply.ts
|
|
20269
|
+
/** 过期快照的兜底清理间隔(面板每次准备也会顺手清一次)。 */
|
|
20270
|
+
const LOCAL_AUDIT_SWEEP_INTERVAL_MS = 6e5;
|
|
18725
20271
|
/** 装配 DSH 工作台 Host 插件。 */
|
|
18726
20272
|
function apply(ctx, pluginConfig) {
|
|
18727
20273
|
const config = resolveWorkbenchConfig(pluginConfig);
|
|
@@ -18747,7 +20293,7 @@ function apply(ctx, pluginConfig) {
|
|
|
18747
20293
|
"oss-config": ossConfigPath
|
|
18748
20294
|
},
|
|
18749
20295
|
hostVersion: PLUGIN_VERSION,
|
|
18750
|
-
protocolVersion:
|
|
20296
|
+
protocolVersion: 31
|
|
18751
20297
|
});
|
|
18752
20298
|
subscribeAuditEvents(ctx, state);
|
|
18753
20299
|
const form = new H3yunFormResolver({
|
|
@@ -18768,6 +20314,20 @@ function apply(ctx, pluginConfig) {
|
|
|
18768
20314
|
* 所以这份白名单必须挂在实例上(模块级会跨插件实例串味,落盘会引出新的暴露面)。
|
|
18769
20315
|
*/
|
|
18770
20316
|
const discussionScopes = createDiscussionScopeRegistry();
|
|
20317
|
+
/**
|
|
20318
|
+
* **本地审核的一次性 handoff 与案例 scope**(协议 28)。同样是进程内、随插件生命周期释放:
|
|
20319
|
+
* 产品口径是「审核结果只存在于这次对话里」,所以交接记录不落盘、不做历史、重启即失效。
|
|
20320
|
+
* 它的案例目录在操作系统临时目录下,普通会话判据覆盖不到,因此必须有自己的注册表。
|
|
20321
|
+
*/
|
|
20322
|
+
const localAudit = createLocalAuditRegistry();
|
|
20323
|
+
const localAuditIo = nodeSnapshotIo();
|
|
20324
|
+
const cleanupTimer = setInterval(() => {
|
|
20325
|
+
localAudit.sweep(localAuditIo).catch(() => void 0);
|
|
20326
|
+
}, LOCAL_AUDIT_SWEEP_INTERVAL_MS);
|
|
20327
|
+
cleanupTimer.unref?.();
|
|
20328
|
+
ctx.effect(() => () => {
|
|
20329
|
+
clearInterval(cleanupTimer);
|
|
20330
|
+
}, "crwu-workbench: local audit sweep");
|
|
18771
20331
|
ctx.effect(() => registerCrwuTools(ctx, {
|
|
18772
20332
|
ctx,
|
|
18773
20333
|
config,
|
|
@@ -18776,6 +20336,8 @@ function apply(ctx, pluginConfig) {
|
|
|
18776
20336
|
form,
|
|
18777
20337
|
access,
|
|
18778
20338
|
discussionScopes,
|
|
20339
|
+
localAudit,
|
|
20340
|
+
localAuditIo,
|
|
18779
20341
|
python
|
|
18780
20342
|
}), "crwu-workbench: tools");
|
|
18781
20343
|
const update = createUpdateOperations({
|
|
@@ -18790,7 +20352,10 @@ function apply(ctx, pluginConfig) {
|
|
|
18790
20352
|
form,
|
|
18791
20353
|
python,
|
|
18792
20354
|
access,
|
|
18793
|
-
discussionScopes
|
|
20355
|
+
discussionScopes,
|
|
20356
|
+
localAudit,
|
|
20357
|
+
localAuditFs: nodeLocalAuditFs(),
|
|
20358
|
+
localAuditIo
|
|
18794
20359
|
}, { update: update.operations });
|
|
18795
20360
|
ctx.effect(() => registerRpcRoute(ctx, operations), "crwu-workbench: rpc route");
|
|
18796
20361
|
ctx.logger?.info?.("中瑞世联工作台 Host 半(包形态骨架)已装配 %o", {
|