dsh-maoying 0.1.10 → 0.1.12

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/index.mjs CHANGED
@@ -51,9 +51,10 @@
51
51
  * 限额 JSON 读体、client.css 现读下发。
52
52
  */
53
53
 
54
- import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs'
54
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
55
55
  import { createHash } from 'node:crypto'
56
56
  import { pathToFileURL } from 'node:url'
57
+ import { getVersionInfo } from './lib/version.mjs'
57
58
  import { readFile } from 'node:fs/promises'
58
59
  import { createRequire } from 'node:module'
59
60
  import os from 'node:os'
@@ -65,6 +66,7 @@ import {
65
66
  mutateChapterStatus,
66
67
  mutateForeshadowStatus,
67
68
  mutatePhase,
69
+ mutateProject,
68
70
  parseStateSnapshot,
69
71
  readChapter,
70
72
  rebuildIndex,
@@ -137,6 +139,44 @@ export function preprocessPatchText(text) {
137
139
  .replaceAll("!!js process.platform !== 'win32'", isWin ? 'false' : 'true')
138
140
  }
139
141
 
142
+ /**
143
+ * 逐字节比较复制(预设文件与技能文件共用):dst 已存在且内容与 src 完全一致
144
+ * 时不写(保住 mtime,二跑零写盘);读不到/比较失败一律按「不一致」重写。
145
+ * 读/写失败原样抛出,由调用方决定 warn 还是中止——失败语义归调用方。
146
+ * @returns {boolean} 本次是否有复制动作
147
+ */
148
+ function syncFileIfChanged(src, dst) {
149
+ let same = false
150
+ try {
151
+ same = existsSync(dst) && readFileSync(dst).equals(readFileSync(src))
152
+ } catch {
153
+ same = false
154
+ }
155
+ if (same) return false
156
+ mkdirSync(path.dirname(dst), { recursive: true })
157
+ copyFileSync(src, dst)
158
+ return true
159
+ }
160
+
161
+ /**
162
+ * .installed-version 版本标记写入(预设目录与技能目录共用):内容与 version
163
+ * 一致即不重写(mtime 也是产物,少动一次是一次)。标记读不到按过期处理;
164
+ * 写失败原样抛出,warn 文案由调用方按现场拼。
165
+ * @returns {boolean} 本次是否有写入动作
166
+ */
167
+ function writeMarkerIfChanged(dir, version) {
168
+ const markerPath = path.join(dir, '.installed-version')
169
+ let current = false
170
+ try {
171
+ current = existsSync(markerPath) && readFileSync(markerPath, 'utf8').trim() === version
172
+ } catch {
173
+ current = false
174
+ }
175
+ if (current) return false
176
+ writeFileSync(markerPath, `${version}\n`, 'utf8')
177
+ return true
178
+ }
179
+
140
180
  // ── semver(手写,零依赖)────────────────────────────────────────────────────
141
181
 
142
182
  /** 解析 major.minor.patch[-prerelease][+build];解析不了返回 null。 */
@@ -466,16 +506,25 @@ export function parsePresetRows(yaml, agentCordisV2Text) {
466
506
  * 落空(注册表审计报 never started,0.1.8 host-debug 实测坐实)。Node ESM 对
467
507
  * file:// URL 是合法模块说明符,指向 <home>/.agent-presets/maoying/ 安装副本
468
508
  * (真实文件系统,任何进程可读)。
509
+ *
510
+ * 遍历策略:保守递归下钻声明行整棵结构(对象/数组逐层走),不只盯
511
+ * config.plugins 一层——v1 组合源的 cordis:group 子件就挂在 config 数组里,
512
+ * 未来再嵌套一层的 './' 本地名只盯单层会静默漏改。凡遍历到的 './' 开头字符串
513
+ * 一律视为插件名候选替换:声明行里其余字符串都是 persona/提示词类文案,约定
514
+ * 不会以 './' 开头,误替换面为零。
469
515
  */
470
516
  function absolutizeLocalPluginNames(rows, presetDir) {
471
- for (const row of rows ?? []) {
472
- for (const p of row?.config?.plugins ?? []) {
473
- if (typeof p?.name === 'string' && p.name.startsWith('./')) {
474
- p.name = pathToFileURL(path.join(presetDir, p.name.slice(2))).href
475
- }
517
+ const walk = (node) => {
518
+ if (typeof node === 'string') {
519
+ return node.startsWith('./') ? pathToFileURL(path.join(presetDir, node.slice(2))).href : node
476
520
  }
521
+ if (Array.isArray(node)) return node.map(walk)
522
+ if (node !== null && typeof node === 'object') {
523
+ return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, walk(v)]))
524
+ }
525
+ return node
477
526
  }
478
- return rows
527
+ return walk(rows)
479
528
  }
480
529
 
481
530
  export function composeManagedRows(home, logger = console) {
@@ -514,6 +563,9 @@ export function stringifyManagedBlock(yaml, rows) {
514
563
  // lineWidth: 0 关掉自动折行——plain 标量折行虽合法但产物不可读、diff 噪音大
515
564
  return `${MANAGED_BEGIN}\n${yaml.stringify(doc, { lineWidth: 0 }).replace(/\n+$/, '')}\n${MANAGED_END}`
516
565
  }
566
+ // JSON 回退分支生产不可达(宿主 <home>/node_modules 必带 yaml 包,loadYamlLib
567
+ // 读不到时 composeManagedRows 已提前返回 null rows)——仅测试直传 yaml=null
568
+ // 可达(test/installer-shape.test.mjs 验证 JSON 形态同为合法 YAML)。
517
569
  return `${MANAGED_BEGIN}\n${JSON.stringify(doc)}\n${MANAGED_END}`
518
570
  }
519
571
 
@@ -522,13 +574,22 @@ export function stringifyManagedBlock(yaml, rows) {
522
574
  /**
523
575
  * 在行数组里定位托管块 [begin, end](闭区间下标)。无起始标记返回 null;
524
576
  * 有起始无结束(托管块损坏,多半是手工删了半截)throw——拒绝写盘,绝不覆盖
525
- * 手工内容。
577
+ * 手工内容。只认首对:首对之后若再出现起始标记(用户误复制了一份托管块),
578
+ * warn 提醒手工清理,多余的块按普通用户内容原样保留、绝不动它。
526
579
  */
527
- export function findManagedSpan(lines) {
580
+ export function findManagedSpan(lines, { logger = console } = {}) {
528
581
  const begin = lines.findIndex((l) => l.trim() === MANAGED_BEGIN)
529
582
  if (begin === -1) return null
530
583
  for (let i = begin + 1; i < lines.length; i++) {
531
- if (lines[i].trim() === MANAGED_END) return { begin, end: i }
584
+ if (lines[i].trim() === MANAGED_END) {
585
+ if (lines.slice(i + 1).some((l) => l.trim() === MANAGED_BEGIN)) {
586
+ logger.warn?.(
587
+ 'maoying-installer: cordis.patch.yml 里检测到第二处 dsh-maoying 托管块起始标记——' +
588
+ '疑似误复制;本安装器只维护第一对,多余的块请手工删除',
589
+ )
590
+ }
591
+ return { begin, end: i }
592
+ }
532
593
  }
533
594
  throw new Error(
534
595
  `cordis.patch.yml 的 dsh-maoying 托管块已损坏(只有起始标记、找不到结束标记 "${MANAGED_END}"):` +
@@ -541,8 +602,16 @@ export function findManagedSpan(lines) {
541
602
  * 'skipped' — 托管块已在且内容与版本标记都一致,零写盘;
542
603
  * 'updated' — 整块替换(块外用户内容一字节不动);
543
604
  * 'created' — 原无托管块,文末追加(文件不存在则连头注释一起新建)。
605
+ *
606
+ * 并发与原子性考量(宿主级共享文件,CLI 与桌面可能双开):写盘走「同目录
607
+ * 临时文件 + renameSync 原子替换」,不做整文件截断覆写——rename 在同一文件
608
+ * 系统内要么整体成功要么不动,任何时刻盘上只有完整的旧内容或完整的新内容,
609
+ * 进程中途崩溃也绝不会留下半截 YAML。跨进程仍是「各自基于快照整文件替换」
610
+ * 的后写者赢语义(零依赖设计不引文件锁);配合写前重读对比,把「内容并无
611
+ * 变化」的二跑收敛为零写盘,交错写的实际危害只剩同刻并发更新的丢失,不会
612
+ * 产生损坏文件。
544
613
  */
545
- export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent }) {
614
+ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent, logger = console }) {
546
615
  const patchPath = path.join(dshHome, 'cordis.patch.yml')
547
616
  let text = null
548
617
  try {
@@ -554,7 +623,7 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
554
623
  const lines = text !== null ? text.split(/\r?\n/) : [HOME_PATCH_HEADER, '']
555
624
  const incoming = blockText.replace(/\r?\n/g, '\n')
556
625
 
557
- const span = findManagedSpan(lines) // 损坏块 throw,直接中止(未写任何盘)
626
+ const span = findManagedSpan(lines, { logger }) // 损坏块 throw,直接中止(未写任何盘)
558
627
  if (span !== null) {
559
628
  const existing = lines.slice(span.begin, span.end + 1).join('\n')
560
629
  if (versionMarkerCurrent && existing === incoming) return 'skipped'
@@ -565,8 +634,24 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
565
634
  if (text !== null && text.trim() !== '' && lines[lines.length - 1].trim() !== '') lines.push('')
566
635
  lines.push(...incoming.split('\n'), '')
567
636
  }
637
+ const content = lines.join(eol)
638
+ // 写前重读对比(幂等强化):首次读到真正写盘之间文件可能已被并发进程改成
639
+ // 与目标一致的内容——已一致就不再写盘,省一次 mtime 抖动、也收窄交错写窗口。
640
+ try {
641
+ if (readFileSync(patchPath, 'utf8') === content) return 'skipped'
642
+ } catch { /* 重读失败(文件被并发删/权限抖动)按首次写处理 */ }
643
+ // 原子落盘:临时文件带 pid 命名,多进程各写各的临时件互不踩踏。
568
644
  mkdirSync(dshHome, { recursive: true })
569
- writeFileSync(patchPath, lines.join(eol), 'utf8')
645
+ const tmpPath = path.join(dshHome, `.cordis.patch.yml.tmp-${process.pid}`)
646
+ writeFileSync(tmpPath, content, 'utf8')
647
+ try {
648
+ renameSync(tmpPath, patchPath)
649
+ } catch (err) {
650
+ // rename 失败(极端:目标被锁/跨设备等)清掉自己的临时件后原样抛出,
651
+ // 由调用方 warn 兜底——绝不让安装器炸宿主。
652
+ try { unlinkSync(tmpPath) } catch { /* 临时件清理失败无所谓,pid 命名不互踩 */ }
653
+ throw err
654
+ }
570
655
  return span !== null ? 'updated' : 'created'
571
656
  }
572
657
 
@@ -580,7 +665,9 @@ const SKILL_DIR_PREFIX = 'maoying-'
580
665
 
581
666
  /**
582
667
  * 递归收集目录下全部文件的相对路径(POSIX 分隔)。目录打不开/不存在返回
583
- * [],由调用方兜底。
668
+ * null——与「空目录返回 []」严格区分:源目录读失败时调用方必须跳过版本标记
669
+ * (否则标记谎报,下次启动误判无需重装);子目录读失败整棵按失败算。
670
+ * @returns {string[]|null} 相对路径列表;读取失败返回 null
584
671
  */
585
672
  function listSkillFiles(dir, relBase = '') {
586
673
  const files = []
@@ -588,12 +675,15 @@ function listSkillFiles(dir, relBase = '') {
588
675
  try {
589
676
  entries = readdirSync(dir, { withFileTypes: true })
590
677
  } catch {
591
- return files
678
+ return null
592
679
  }
593
680
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
594
681
  const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`
595
- if (entry.isDirectory()) files.push(...listSkillFiles(path.join(dir, entry.name), rel))
596
- else if (entry.isFile()) files.push(rel)
682
+ if (entry.isDirectory()) {
683
+ const sub = listSkillFiles(path.join(dir, entry.name), rel)
684
+ if (sub === null) return null
685
+ files.push(...sub)
686
+ } else if (entry.isFile()) files.push(rel)
597
687
  }
598
688
  return files
599
689
  }
@@ -621,25 +711,17 @@ export function installSkills(dshHome, { version, logger = console } = {}) {
621
711
  const dstDir = path.join(skillsHome, entry.name)
622
712
  let touched = false
623
713
  try {
624
- for (const rel of listSkillFiles(srcDir)) {
625
- const src = path.join(srcDir, rel)
626
- const dst = path.join(dstDir, rel)
627
- let same = false
628
- try {
629
- same = existsSync(dst) && readFileSync(dst).equals(readFileSync(src))
630
- } catch {
631
- same = false
632
- }
633
- if (!same) {
634
- mkdirSync(path.dirname(dst), { recursive: true })
635
- copyFileSync(src, dst)
636
- touched = true
637
- }
714
+ const files = listSkillFiles(srcDir)
715
+ if (files === null) {
716
+ // 源目录读失败:这次连「已装」的版本标记都不能写——标记谎报会让下次
717
+ // 启动误判无需重装。throw 进下面的 per-skill catch,warn 留痕待自愈。
718
+ throw new Error('包内技能源目录不可读')
719
+ }
720
+ for (const rel of files) {
721
+ if (syncFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) touched = true
638
722
  }
639
- const markerPath = path.join(dstDir, '.installed-version')
640
723
  try {
641
- const current = existsSync(markerPath) && readFileSync(markerPath, 'utf8').trim() === version
642
- if (!current) writeFileSync(markerPath, `${version}\n`, 'utf8')
724
+ writeMarkerIfChanged(dstDir, version)
643
725
  } catch (err) {
644
726
  logger.warn?.(`maoying-installer: 技能 ${entry.name} 的 .installed-version 标记写入失败(不影响技能本体): ${err?.message ?? err}`)
645
727
  }
@@ -704,46 +786,98 @@ function isTrustedApiRequest(req, trustedHosts) {
704
786
  if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
705
787
  const origin = header(req.headers, 'origin')
706
788
  if (origin === undefined) return true
707
- try { return new URL(origin).hostname === hostUrl.hostname } catch { return false }
789
+ // origin 与 Host 做 scheme+host+port 全串比较(URL.origin 对 URL.origin):
790
+ // 只比 hostname 会放过「同站不同端口」的页面——sec-fetch-site 对它是
791
+ // same-site 而非 cross-site,http://localhost:9999 打本面板端口必须挡下。
792
+ // 威胁模型边界:这道门防外网页面借浏览器发跨源请求(CSRF/DNS-rebinding),
793
+ // 不防本机其他进程/内核——本机攻击面由「只听回环 + 部署信任表」约束。
794
+ try { return new URL(origin).origin === hostUrl.origin } catch { return false }
708
795
  }
709
796
 
797
+ /** JSON 读体限额:5MB,对面板单章正文/设定批量写入量级余量充足。 */
798
+ const BODY_LIMIT_BYTES = 5 * 1024 * 1024
799
+
800
+ /** 读体 idle 超时:连续 30s 无任何数据到达即放弃(防挂起连接占住 handler)。 */
801
+ const BODY_IDLE_MS = 30_000
802
+
710
803
  /**
711
- * 读 JSON 请求体(限额 5MB)。超限 413、非法 JSON 400——错误带 statusCode,
712
- * 外层 catch 据此回对应状态码;客户端提前断开时 reject(响应已发出的场景
713
- * Promise 已结算,reject 是无害 no-op)。
804
+ * 读 JSON 请求体(限额 5MB + 30s idle 超时)。约定:
805
+ * - 超限 413 / idle 超时 408:本函数先向 res 回 JSON、再销毁连接(顺序反了
806
+ * 客户端只能收到 ECONNRESET,拿不到明确状态码),并 reject 带 responded
807
+ * 标记的错误——外层 catch 看到 responded 不再重复回包;
808
+ * - 非法 JSON 400、客户端提前断开(close/aborted/流错误)499/400——错误都
809
+ * 带 statusCode,外层 catch 据此回状态码,绝不落到 500;
810
+ * - 响应已发出的场景 Promise 已结算,后续 reject 是无害 no-op。
714
811
  */
715
- function readBody(req) {
812
+ function readBody(req, res) {
716
813
  return new Promise((resolve, reject) => {
717
814
  const chunks = []
718
815
  let size = 0
816
+ let done = false
817
+
818
+ const rejectWith = (statusCode, message) => {
819
+ if (done) return
820
+ done = true
821
+ clearTimeout(idleTimer)
822
+ const err = new Error(message)
823
+ err.statusCode = statusCode
824
+ reject(err)
825
+ }
826
+
827
+ // 先响应后断开(413/408 专用):响应写完再 destroy,客户端拿得到 JSON。
828
+ const respondThenClose = (statusCode, message) => {
829
+ if (done) return
830
+ done = true
831
+ clearTimeout(idleTimer)
832
+ json(res, statusCode, { ok: false, error: message })
833
+ req.destroy()
834
+ const err = new Error(message)
835
+ err.statusCode = statusCode
836
+ err.responded = true
837
+ reject(err)
838
+ }
839
+
840
+ // idle 超时:每收到一段数据重置一次;超时按 408 语义先回包再断开。
841
+ const idleTimer = setTimeout(() => respondThenClose(408, 'request body idle timeout (30s no data)'), BODY_IDLE_MS)
842
+
719
843
  req.on('data', (c) => {
844
+ idleTimer.refresh?.()
720
845
  size += c.length
721
- if (size > 5 * 1024 * 1024) {
722
- const err = new Error('request body too large')
723
- err.statusCode = 413
724
- reject(err)
725
- req.destroy()
846
+ if (size > BODY_LIMIT_BYTES) {
847
+ respondThenClose(413, `request body too large (limit ${BODY_LIMIT_BYTES} bytes)`)
726
848
  return
727
849
  }
728
850
  chunks.push(c)
729
851
  })
730
852
  req.on('end', () => {
853
+ if (done) return
854
+ clearTimeout(idleTimer)
731
855
  try {
732
- resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
856
+ // done 置真必须在解析成功之后:先置真再走 catch,rejectWith 的 done
857
+ // 守卫会把 400 拒绝吞掉——promise 永不结算,handler 挂死(坏 JSON 必挂)。
858
+ const parsed = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
859
+ done = true
860
+ resolve(parsed)
733
861
  } catch {
734
- const err = new Error('invalid JSON body')
735
- err.statusCode = 400
736
- reject(err)
862
+ rejectWith(400, 'invalid JSON body')
737
863
  }
738
864
  })
739
- req.on('error', reject)
740
- req.on('close', () => reject(new Error('request closed before body completed')))
741
- req.on('aborted', () => reject(new Error('request aborted')))
865
+ req.on('error', (err) => {
866
+ // 流级错误(连接重置等):无码按 400 语义兜底,不落 500
867
+ if (done) return
868
+ done = true
869
+ clearTimeout(idleTimer)
870
+ if (!Number.isInteger(err?.statusCode)) err.statusCode = 400
871
+ reject(err)
872
+ })
873
+ req.on('close', () => rejectWith(499, 'request closed before body completed'))
874
+ req.on('aborted', () => rejectWith(499, 'request aborted'))
742
875
  })
743
876
  }
744
877
 
745
878
  function json(res, status, payload) {
746
- const text = JSON.stringify(payload, null, 2)
879
+ // 无缩进序列化:状态快照等响应可到百 KB 量级,缩进纯增体积(客户端自行格式化)
880
+ const text = JSON.stringify(payload)
747
881
  res.writeHead(status, {
748
882
  'content-type': 'application/json; charset=utf-8',
749
883
  'content-length': Buffer.byteLength(text),
@@ -764,6 +898,20 @@ function sanitizeMessage(message) {
764
898
  return text
765
899
  }
766
900
 
901
+ /**
902
+ * 递归脱敏:结构里所有字符串值过一遍 sanitizeMessage(绝对路径换 ~)。host-debug
903
+ * 响应用——trace.via / runtime.argv / execPath / cwd 等带本机绝对路径的字段,
904
+ * 与错误 message 同一纪律:诊断信息照给,本机路径不出门。
905
+ */
906
+ function sanitizeDeep(value) {
907
+ if (typeof value === 'string') return sanitizeMessage(value)
908
+ if (Array.isArray(value)) return value.map(sanitizeDeep)
909
+ if (value !== null && typeof value === 'object') {
910
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sanitizeDeep(v)]))
911
+ }
912
+ return value
913
+ }
914
+
767
915
  // 宿主热切换竞态兜底:「禁用→启用」等操作会重建插件条目纤维,新 apply 先
768
916
  // 跑、旧实例的路由注销异步落地,注册瞬间旧路由常仍在表里,webServer.register
769
917
  // 对同 (kind, path) 抛 duplicate。只对 duplicate prefix route 这一种错误短
@@ -775,7 +923,10 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
775
923
  let lastError
776
924
  for (let attempt = 0; attempt < attempts; attempt++) {
777
925
  try {
778
- return webServer.register(route)
926
+ // 必须 await:宿主 register 可能返回 rejected promise(异步校验路径),
927
+ // 不 await 则 rejection 接不住(unhandled rejection),duplicate 短重试
928
+ // 形同虚设;await 对同步抛出的路径行为不变,兼容保留。
929
+ return await webServer.register(route)
779
930
  } catch (err) {
780
931
  if (!DUPLICATE_PREFIX_ROUTE_RE.test(String(err?.message ?? ''))) throw err
781
932
  lastError = err
@@ -786,7 +937,7 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
786
937
  }
787
938
 
788
939
  /**
789
- * 注册创作台全部路由(docs/creator-panel.md §2 的 8 条)。返回宿主 disposer;
940
+ * 注册创作台全部路由(docs/creator-panel.md §2 的 11 条)。返回宿主 disposer;
790
941
  * webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
791
942
  */
792
943
  export async function registerCreatorRoutes(ctx) {
@@ -809,9 +960,10 @@ export async function registerCreatorRoutes(ctx) {
809
960
  json(res, 403, { error: 'forbidden' })
810
961
  return
811
962
  }
812
- const url = new URL(req.url, 'http://dsh.internal')
813
- const route = url.pathname.replace(/^\/api\/maoying\/?/, '')
814
963
  try {
964
+ // url/route 解析进 try:畸形 requestTarget 不至于绕过错误通道裸崩
965
+ const url = new URL(req.url, 'http://dsh.internal')
966
+ const route = url.pathname.replace(/^\/api\/maoying\/?/, '')
815
967
  // ── 静态样式:面板 CSS 正文(lib/base.css)按请求现读下发 ──────────
816
968
  // 客户端只注入 <link href="/api/maoying/client.css">,正文现读——
817
969
  // 改样式无需重启,GUI 刷新即生效(no-cache)。同前缀、已被围栏把关。
@@ -831,6 +983,13 @@ export async function registerCreatorRoutes(ctx) {
831
983
  return
832
984
  }
833
985
 
986
+ if (route === 'update-check' && req.method === 'GET') {
987
+ // 新版检测(模仿 dsh-paoding):三级源 + 缓存都在 lib/version.mjs,
988
+ // 全失败也静默降级——这里只做转发,绝不影响面板主流程。
989
+ json(res, 200, { ok: true, ...(await getVersionInfo({ timeoutMs: 3000 })) })
990
+ return
991
+ }
992
+
834
993
  if (route === 'host-debug' && req.method === 'GET') {
835
994
  // 现场诊断:宿主版本五级探针轨迹 + 形状判定。与 client.css 同级信任
836
995
  // 围栏(loopback + 可信 Host 才放行),只暴露版本与路径类信息。
@@ -864,16 +1023,18 @@ export async function registerCreatorRoutes(ctx) {
864
1023
  registryDump.presets = jsonSafe(rows)
865
1024
  }
866
1025
  } catch (e) { registryDump.error = String(e?.message ?? e) }
1026
+ // 出口统一过 sanitizeDeep:trace.via / argv / execPath / cwd 带本机
1027
+ // 绝对路径,诊断照给、路径脱敏(与错误 message 同一纪律)。
867
1028
  json(res, 200, {
868
1029
  ok: true,
869
1030
  hostVersion: dbg.version,
870
- trace: dbg.trace,
1031
+ trace: sanitizeDeep(dbg.trace),
871
1032
  shape,
872
1033
  registry: registryDump,
873
1034
  runtime: {
874
- argv: process.argv,
875
- execPath: process.execPath,
876
- cwd: process.cwd(),
1035
+ argv: sanitizeDeep(process.argv),
1036
+ execPath: sanitizeMessage(process.execPath),
1037
+ cwd: sanitizeMessage(process.cwd()),
877
1038
  electron: process.versions?.electron ?? null,
878
1039
  node: process.version,
879
1040
  },
@@ -906,28 +1067,35 @@ export async function registerCreatorRoutes(ctx) {
906
1067
  }
907
1068
 
908
1069
  if (route === 'chapter-status' && req.method === 'POST') {
909
- const body = await readBody(req)
1070
+ const body = await readBody(req, res)
910
1071
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
911
1072
  json(res, 200, mutateChapterStatus(novelDir, body.file, body.status))
912
1073
  return
913
1074
  }
914
1075
 
915
1076
  if (route === 'foreshadow-status' && req.method === 'POST') {
916
- const body = await readBody(req)
1077
+ const body = await readBody(req, res)
917
1078
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
918
1079
  json(res, 200, mutateForeshadowStatus(novelDir, body.id, body.status))
919
1080
  return
920
1081
  }
921
1082
 
922
1083
  if (route === 'phase' && req.method === 'POST') {
923
- const body = await readBody(req)
1084
+ const body = await readBody(req, res)
924
1085
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
925
1086
  json(res, 200, mutatePhase(novelDir, body.phase))
926
1087
  return
927
1088
  }
928
1089
 
1090
+ if (route === 'project-save' && req.method === 'POST') {
1091
+ const body = await readBody(req, res)
1092
+ const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
1093
+ json(res, 200, mutateProject(novelDir, body.values))
1094
+ return
1095
+ }
1096
+
929
1097
  if (route === 'rebuild-index' && req.method === 'POST') {
930
- const body = await readBody(req)
1098
+ const body = await readBody(req, res)
931
1099
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
932
1100
  json(res, 200, rebuildIndex(novelDir))
933
1101
  return
@@ -936,8 +1104,11 @@ export async function registerCreatorRoutes(ctx) {
936
1104
  json(res, 404, { ok: false, error: `no such route: ${req.method} /api/maoying/${route}` })
937
1105
  } catch (err) {
938
1106
  // 错误原文(可能带本机绝对路径)留日志;对外 message 先脱敏。读体的
939
- // 413/400 带 statusCode,按其状态码回;其余业务错误自带码,裸异常 500。
1107
+ // 413/408 已由 readBody 先回包再断开(err.responded),这里只留日志不
1108
+ // 重复回包;其余按 statusCode 回(读体断开 499/400、业务错误自带码),
1109
+ // 裸异常 500。
940
1110
  ctx.logger?.warn?.('maoying 创作台: 请求处理失败: ' + String(err?.message ?? err))
1111
+ if (err?.responded) return
941
1112
  json(res, Number.isInteger(err?.statusCode) ? err.statusCode : 500, {
942
1113
  ok: false,
943
1114
  error: sanitizeMessage(err?.message ?? String(err)),
@@ -968,30 +1139,24 @@ export function ensurePresetInstalled(ctx = {}) {
968
1139
  const sourceDir = path.join(PLUGIN_ROOT, 'presets', PRESET_ID)
969
1140
  const presetDir = path.join(home, '.agent-presets', PRESET_ID)
970
1141
 
971
- // 1) 预设文件:内容一致不写(逐字节比较),变化才覆盖
1142
+ // 1) 预设文件:内容一致不写(逐字节比较),变化才覆盖。copyFileSync 必须在
1143
+ // per-file try 之内——单文件失败(如目标只读 EACCES)只 warn,不能冲出
1144
+ // 主流程把后面的版本标记/技能落盘/托管块全跳过(违背「各自可失败」;
1145
+ // 失败文件下次启动自愈重试)。
972
1146
  const copied = []
973
1147
  mkdirSync(presetDir, { recursive: true })
974
1148
  for (const file of PRESET_FILES) {
975
- const src = path.join(sourceDir, file)
976
- const dst = path.join(presetDir, file)
977
- let same = false
978
1149
  try {
979
- same = existsSync(dst) && readFileSync(dst).equals(readFileSync(src))
980
- } catch {
981
- same = false
982
- }
983
- if (!same) {
984
- copyFileSync(src, dst)
985
- copied.push(file)
1150
+ if (syncFileIfChanged(path.join(sourceDir, file), path.join(presetDir, file))) copied.push(file)
1151
+ } catch (err) {
1152
+ logger.warn?.(`maoying-installer: 预设文件 ${file} 复制失败(已跳过,不影响其余文件): ${err?.message ?? err}`)
986
1153
  }
987
1154
  }
988
1155
 
989
1156
  // 2) 版本标记:一致不重写(mtime 也是产物,少动一次是一次)
990
- const markerPath = path.join(presetDir, '.installed-version')
991
1157
  let markerCurrent = false
992
1158
  try {
993
- markerCurrent = existsSync(markerPath) && readFileSync(markerPath, 'utf8').trim() === version
994
- if (!markerCurrent) writeFileSync(markerPath, `${version}\n`, 'utf8')
1159
+ markerCurrent = !writeMarkerIfChanged(presetDir, version)
995
1160
  } catch (err) {
996
1161
  logger.warn?.(`maoying-installer: .installed-version 标记写入失败(不影响预设本体): ${err?.message ?? err}`)
997
1162
  }
@@ -1029,7 +1194,7 @@ export function ensurePresetInstalled(ctx = {}) {
1029
1194
  }
1030
1195
 
1031
1196
  const blockText = stringifyManagedBlock(composed.yaml, composed.rows)
1032
- const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent })
1197
+ const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent, logger })
1033
1198
 
1034
1199
  if (copied.length > 0) {
1035
1200
  logger.info?.(`maoying-installer: 预设文件已更新 ${PRESET_ID}: ${copied.join(', ')}`)
@@ -1069,13 +1234,10 @@ export function apply(ctx = {}, _config = {}) {
1069
1234
  // 按宿主版本自动选形状,所选形态带在安装日志里),不再需要降级提示。
1070
1235
 
1071
1236
  // 创作台路由(三期):与安装段彼此独立——注册失败只 warn,面板不可用不
1072
- // 拖累安装器语义。注册是异步的(含 duplicate 短重试),这里挂 .catch 兜住
1073
- // 异步失败;同步抛错走外层 try/catch,两条路都不让异常冒出 apply。
1074
- try {
1075
- registerCreatorRoutes(ctx).catch((error) => {
1076
- logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
1077
- })
1078
- } catch (error) {
1237
+ // 拖累安装器语义。registerCreatorRoutes 是 async 函数,同步阶段不可能 throw
1238
+ //(异常一律进 rejected promise),外层 try 对它是永假防御——只挂 .catch
1239
+ // 一条兜底通道即可,异常不会冒出 apply。
1240
+ registerCreatorRoutes(ctx).catch((error) => {
1079
1241
  logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
1080
- }
1242
+ })
1081
1243
  }