@shgroup/dsh-serenity-hooks 1.24.0 → 1.24.1

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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.24.0",
3
+ "version": "1.24.1",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):真实 DSH 工具 cc_fs/session/acc_msm/eap/neat/cce/handyman/session_rebuild + 拦截缝机械约束(safe-mode/路径守卫)+ 高级设定面板(双端口网关/账号管理)。适配 DSH 公开版(0.1.0-rc,deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
package/lib/client.js CHANGED
@@ -308,7 +308,7 @@ window.__ModuleLoader__.load({
308
308
  return `The user provided ${paths.length} images:\n${paths.map((p) => `- ${p}`).join("\n")}`;
309
309
  }
310
310
  /** 浏览器 File → base64(与 ui-conversation serializeImages 等价的最小实现) */
311
- function fileToBase64(file) {
311
+ function fileToBase64$1(file) {
312
312
  return file.arrayBuffer().then((buffer) => {
313
313
  const bytes = new Uint8Array(buffer);
314
314
  let binary = "";
@@ -322,7 +322,7 @@ window.__ModuleLoader__.load({
322
322
  * sessionId 必传:node half 经会话 header.cwd 解析 CCC 根(进程 cwd 不可靠)。
323
323
  */
324
324
  async function uploadImage(file, sessionId) {
325
- const data = await fileToBase64(file);
325
+ const data = await fileToBase64$1(file);
326
326
  const res = await fetch(UPLOAD_PATH, {
327
327
  method: "POST",
328
328
  headers: {
@@ -417,6 +417,120 @@ window.__ModuleLoader__.load({
417
417
  return null;
418
418
  }
419
419
  //#endregion
420
+ //#region src/client/file-fallback-api.ts
421
+ /**
422
+ * file-fallback-api.ts — 任意文件粘贴自动落盘的浏览器操作面(v1.24.1)
423
+ *
424
+ * 图片链路(v1.20)是「发送失败补救」被动式;任意文件 DSH 原生发不了
425
+ * (InputBar.onPaste 把所有文件交给 intakeImages → 非图片被 addImages 拒绝),
426
+ * 因此本模块提供**主动拦截**面:
427
+ * - collectNonImageFiles(data) → 剪贴板中非图片 File(纯函数,可单测)
428
+ * - uploadFile(file, sessionId) → POST /serenity/file-upload(node half 写 _tmp/files_from_user/)
429
+ * - fileNoteTemplate(paths) → 消息模板(draft 追加,随发送进消息——用户拍板)
430
+ */
431
+ /** 文件落盘接口路径(node half api.ts,client 专属 x-serenity-ui 头) */
432
+ const FILE_UPLOAD_PATH = "/serenity/file-upload";
433
+ /**
434
+ * 从剪贴板收集非图片文件(图片交给 DSH 原生 rail 链路;仅非图片需要落盘)。
435
+ * 纯函数(items 可注入)——jsdom 环境下 DataTransfer 不可用,测试注入 items 数组。
436
+ */
437
+ function collectNonImageFiles(items) {
438
+ const out = [];
439
+ for (const item of items) {
440
+ if (item?.kind !== "file") continue;
441
+ if (typeof item.type === "string" && item.type.startsWith("image/")) continue;
442
+ const file = typeof item.getAsFile === "function" ? item.getAsFile() : null;
443
+ if (file !== null) out.push(file);
444
+ }
445
+ return out;
446
+ }
447
+ /**
448
+ * 文件提示消息模板(协议固有,用户拍板:draft 追加 + 对话里写名具体路径,agent 直接可用):
449
+ * 单文件:The user provided a file (path: ...);多文件:每行一条路径
450
+ */
451
+ function fileNoteTemplate(paths) {
452
+ if (paths.length === 1) return `The user provided a file (path: ${paths[0]})`;
453
+ return `The user provided ${paths.length} files:\n${paths.map((p) => `- ${p}`).join("\n")}`;
454
+ }
455
+ /** 浏览器 File → base64(与 image-fallback-api fileToBase64 等价的最小实现,保持模块独立) */
456
+ function fileToBase64(file) {
457
+ return file.arrayBuffer().then((buffer) => {
458
+ const bytes = new Uint8Array(buffer);
459
+ let binary = "";
460
+ const chunk = 32768;
461
+ for (let offset = 0; offset < bytes.length; offset += chunk) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk));
462
+ return btoa(binary);
463
+ });
464
+ }
465
+ /**
466
+ * 上传一个文件到 CCC _tmp/files_from_user/,返回相对路径(如 _tmp/files_from_user/<ts>-<rand>-x.pdf)。
467
+ * sessionId 必传:node half 经会话 header.cwd 解析 CCC 根(进程 cwd 不可靠)。
468
+ */
469
+ async function uploadFile(file, sessionId) {
470
+ const data = await fileToBase64(file);
471
+ const res = await fetch(FILE_UPLOAD_PATH, {
472
+ method: "POST",
473
+ headers: {
474
+ "content-type": "application/json",
475
+ "x-serenity-ui": "1"
476
+ },
477
+ body: JSON.stringify({
478
+ sessionId,
479
+ name: file.name,
480
+ data
481
+ })
482
+ });
483
+ const body = await res.json();
484
+ if (!res.ok || typeof body.path !== "string") throw new Error(`serenity file upload failed: ${body.error ?? res.status}`);
485
+ return body.path;
486
+ }
487
+ //#endregion
488
+ //#region src/client/FileFallbackDock.tsx
489
+ /** input.dock 条目(静默):非图片文件粘贴自动落盘 + draft 追加,无 UI */
490
+ function FileFallbackDock(props) {
491
+ const zone = props;
492
+ const sessionId = props.sessionId;
493
+ const { uploadFile } = props;
494
+ const inputActions = props.inputActions;
495
+ const inputRef = (0, react.useRef)(zone.input);
496
+ inputRef.current = zone.input;
497
+ const pendingRef = (0, react.useRef)([]);
498
+ (0, react.useEffect)(() => {
499
+ const handler = (e) => {
500
+ const data = e.clipboardData;
501
+ if (!data) return;
502
+ const items = Array.from(data.items);
503
+ const files = collectNonImageFiles(items);
504
+ if (files.length === 0) return;
505
+ const hasImage = items.some((item) => item.kind === "file" && typeof item.type === "string" && item.type.startsWith("image/"));
506
+ const hasText = data.getData("text/plain") !== "";
507
+ if (!hasImage && !hasText) e.preventDefault();
508
+ (async () => {
509
+ try {
510
+ const saved = [];
511
+ for (const file of files) saved.push(await uploadFile(file, String(sessionId)));
512
+ const note = fileNoteTemplate(saved);
513
+ const base = inputRef.current?.draft ?? "";
514
+ pendingRef.current.push(note);
515
+ const text = [base, ...pendingRef.current].filter(Boolean).join("\n");
516
+ pendingRef.current = [];
517
+ if (inputActions?.setDraft !== void 0) inputActions.setDraft(text);
518
+ else console.warn("[serenity] file fallback: inputActions.setDraft unavailable — file saved but draft not annotated");
519
+ } catch (err) {
520
+ console.warn(`[serenity] file fallback failed: ${String(err?.message ?? err)}`);
521
+ }
522
+ })();
523
+ };
524
+ document.addEventListener("paste", handler, true);
525
+ return () => document.removeEventListener("paste", handler, true);
526
+ }, [
527
+ sessionId,
528
+ uploadFile,
529
+ inputActions
530
+ ]);
531
+ return null;
532
+ }
533
+ //#endregion
420
534
  //#region src/client/accounts-api.ts
421
535
  /** wire 账号 → 本地编辑行 */
422
536
  function accountDraftFromWire(a) {
@@ -1301,6 +1415,12 @@ Instruction-following style:
1301
1415
  resendText: (sessionId, text) => resendText(scope, sessionId, text)
1302
1416
  })
1303
1417
  }, ImageFallbackDock), "serenity: image fallback dock");
1418
+ scope.effect(() => scope.slots.register({
1419
+ name: "conversation.input.dock",
1420
+ id: "serenity-file-fallback",
1421
+ order: 110,
1422
+ inject: () => ({ uploadFile: (file, sessionId) => uploadFile(file, sessionId) })
1423
+ }, FileFallbackDock), "serenity: file fallback dock");
1304
1424
  const serenityScope = scope.get("settingsScope").bind(SERENITY_SCOPE_SPEC);
1305
1425
  scope.effect(() => scope.slots.register({
1306
1426
  name: "settings.section",
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { defineTool } from "@deepseek-ai/dsh-tools";
3
3
  import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
4
- import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
5
5
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
6
6
  import { homedir, platform } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
@@ -7514,6 +7514,7 @@ function registerCompactRetention(ctx, opts = {}) {
7514
7514
  const ROUTE_PATH = "/serenity/status";
7515
7515
  const HANDYMEN_PATH = "/serenity/handymen";
7516
7516
  const UPLOAD_PATH = "/serenity/image-upload";
7517
+ const FILE_UPLOAD_PATH = "/serenity/file-upload";
7517
7518
  const CONFIG_PATH = "/serenity/config";
7518
7519
  /** 图片落盘目录(CCC 根相对;S142 图片自动识别基础设施——粘贴图片落盘供 agent 经 CCC vlm MSM 自主处理) */
7519
7520
  const IMAGE_UPLOAD_DIR = "_tmp/images_from_user";
@@ -7530,6 +7531,28 @@ const EXT_BY_MEDIA = {
7530
7531
  "image/gif": "gif"
7531
7532
  };
7532
7533
  const MAX_IMAGE_BYTES = 10485760;
7534
+ /** 文件落盘目录(CCC 根相对;v1.24.1 粘贴任意文件自动落盘——agent 经 CCC 既有 MSM(pdf-extract/archive-extract 等)自主处理) */
7535
+ const FILE_UPLOAD_DIR = "_tmp/files_from_user";
7536
+ /** 拒绝的可执行/危险扩展名(安全边界:不落盘可执行文件,防 agent 被诱导执行) */
7537
+ const BLOCKED_FILE_EXTS = /* @__PURE__ */ new Set([
7538
+ "exe",
7539
+ "dll",
7540
+ "msi",
7541
+ "bat",
7542
+ "cmd",
7543
+ "ps1",
7544
+ "com",
7545
+ "scr",
7546
+ "lnk",
7547
+ "sh",
7548
+ "vbs",
7549
+ "bin",
7550
+ "app",
7551
+ "deb",
7552
+ "rpm",
7553
+ "jar"
7554
+ ]);
7555
+ const MAX_FILE_BYTES = 10485760;
7533
7556
  /**
7534
7557
  * 图片落盘核心逻辑(可测):校验 mediaType 白名单 + base64 解码 + 大小上限 →
7535
7558
  * 写 CCC 根 _tmp/images_from_user/<ts>-<rand>.<ext>,返回相对路径。
@@ -7547,6 +7570,34 @@ function saveImageToTmp(root, mediaType, data) {
7547
7570
  writeFileSync(join(dir, filename), bytes);
7548
7571
  return `${IMAGE_UPLOAD_DIR}/${filename}`;
7549
7572
  }
7573
+ /**
7574
+ * 文件名脱敏(路径逃逸 + 非法字符):取 basename(去 / 与 \),剥离前导点/空,
7575
+ * 非法字符 → '-',限长。返回空则 'file'。
7576
+ */
7577
+ function sanitizeFileName(name) {
7578
+ const cleaned = (name.split(/[\\/]/).pop() ?? "").replace(/[<>:"|?*\u0000-\u001f]/g, "-").replace(/^\.+/, "").trim();
7579
+ if (cleaned === "") return "file";
7580
+ return cleaned.slice(0, 100);
7581
+ }
7582
+ /**
7583
+ * 任意文件落盘核心逻辑(可测):文件名校验 + 可执行扩展名拒绝 + base64 解码 +
7584
+ * 大小上限(10MB)→ 写 CCC 根 _tmp/files_from_user/<ts>-<rand>-<safeName>,返回相对路径。
7585
+ * 校验失败抛 Error(handler 转 400)。
7586
+ */
7587
+ function saveFileToTmp(root, fileName, data) {
7588
+ if (typeof fileName !== "string" || fileName.length === 0) throw new Error("missing file name");
7589
+ const ext = extname(fileName).slice(1).toLowerCase();
7590
+ if (ext !== "" && BLOCKED_FILE_EXTS.has(ext)) throw new Error(`blocked executable file type: .${ext}`);
7591
+ if (typeof data !== "string" || data.length === 0) throw new Error("missing file data");
7592
+ const bytes = Buffer.from(data, "base64");
7593
+ if (bytes.length === 0 || bytes.length > MAX_FILE_BYTES) throw new Error(`file size out of range: ${bytes.length} bytes (max ${MAX_FILE_BYTES})`);
7594
+ const dir = join(root, FILE_UPLOAD_DIR);
7595
+ mkdirSync(dir, { recursive: true });
7596
+ const safeName = sanitizeFileName(fileName);
7597
+ const filename = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
7598
+ writeFileSync(join(dir, filename), bytes);
7599
+ return `${FILE_UPLOAD_DIR}/${filename}`;
7600
+ }
7550
7601
  function readBody$1(req, maxBytes) {
7551
7602
  return new Promise((resolve, reject) => {
7552
7603
  let data = "";
@@ -7647,6 +7698,36 @@ function registerStatusApi(ctx, opts = {}) {
7647
7698
  }
7648
7699
  }
7649
7700
  });
7701
+ ctx.webServer.register({
7702
+ kind: "exact",
7703
+ path: FILE_UPLOAD_PATH,
7704
+ handler: async (req, res) => {
7705
+ try {
7706
+ if (req.method !== "POST") {
7707
+ sendJson(res, 405, { error: "method not allowed" });
7708
+ return;
7709
+ }
7710
+ if (req.headers["x-serenity-ui"] !== "1") {
7711
+ sendJson(res, 403, { error: "文件落盘仅限 WebUI(client 专用)" });
7712
+ return;
7713
+ }
7714
+ const raw = await readBody$1(req, 20971520);
7715
+ const body = JSON.parse(raw);
7716
+ const workspace = resolveWorkspace(ctx, {
7717
+ sessionId: body.sessionId,
7718
+ workspace: body.workspace
7719
+ });
7720
+ const root = findSerenityRoot(workspace);
7721
+ if (!root) {
7722
+ sendJson(res, 404, { error: `no CCC found from workspace: ${workspace}` });
7723
+ return;
7724
+ }
7725
+ sendJson(res, 200, { path: saveFileToTmp(root, body.name ?? "", body.data ?? "") });
7726
+ } catch (err) {
7727
+ sendJson(res, 400, { error: err.message ?? String(err) });
7728
+ }
7729
+ }
7730
+ });
7650
7731
  ctx.webServer.register({
7651
7732
  kind: "exact",
7652
7733
  path: ROUTE_PATH,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.24.0",
3
+ "version": "1.24.1",
4
4
  "description": "宁静号 ACC harness — Native Cordis 插件(DeepSeek Harness 运行时)。真实 DSH 工具注册(cc_fs/session/acc_msm 等 9 工具)+ 拦截缝机械约束(safe-mode/路径守卫/会话落盘)+ 系统提示词注入(ACC/CCE/Constraints/SKILL/Session 五块)。适配 DSH 公开版(deepseek-ai/deepseek-harness 0.1.0-rc)。",
5
5
  "license": "MIT",
6
6
  "repository": {