dsh-maoying 0.1.11 → 0.1.13

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,7 +51,7 @@
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
57
  import { getVersionInfo } from './lib/version.mjs'
@@ -66,6 +66,7 @@ import {
66
66
  mutateChapterStatus,
67
67
  mutateForeshadowStatus,
68
68
  mutatePhase,
69
+ mutateProject,
69
70
  parseStateSnapshot,
70
71
  readChapter,
71
72
  rebuildIndex,
@@ -138,6 +139,44 @@ export function preprocessPatchText(text) {
138
139
  .replaceAll("!!js process.platform !== 'win32'", isWin ? 'false' : 'true')
139
140
  }
140
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
+
141
180
  // ── semver(手写,零依赖)────────────────────────────────────────────────────
142
181
 
143
182
  /** 解析 major.minor.patch[-prerelease][+build];解析不了返回 null。 */
@@ -467,16 +506,25 @@ export function parsePresetRows(yaml, agentCordisV2Text) {
467
506
  * 落空(注册表审计报 never started,0.1.8 host-debug 实测坐实)。Node ESM 对
468
507
  * file:// URL 是合法模块说明符,指向 <home>/.agent-presets/maoying/ 安装副本
469
508
  * (真实文件系统,任何进程可读)。
509
+ *
510
+ * 遍历策略:保守递归下钻声明行整棵结构(对象/数组逐层走),不只盯
511
+ * config.plugins 一层——v1 组合源的 cordis:group 子件就挂在 config 数组里,
512
+ * 未来再嵌套一层的 './' 本地名只盯单层会静默漏改。凡遍历到的 './' 开头字符串
513
+ * 一律视为插件名候选替换:声明行里其余字符串都是 persona/提示词类文案,约定
514
+ * 不会以 './' 开头,误替换面为零。
470
515
  */
471
516
  function absolutizeLocalPluginNames(rows, presetDir) {
472
- for (const row of rows ?? []) {
473
- for (const p of row?.config?.plugins ?? []) {
474
- if (typeof p?.name === 'string' && p.name.startsWith('./')) {
475
- p.name = pathToFileURL(path.join(presetDir, p.name.slice(2))).href
476
- }
517
+ const walk = (node) => {
518
+ if (typeof node === 'string') {
519
+ return node.startsWith('./') ? pathToFileURL(path.join(presetDir, node.slice(2))).href : node
477
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
478
526
  }
479
- return rows
527
+ return walk(rows)
480
528
  }
481
529
 
482
530
  export function composeManagedRows(home, logger = console) {
@@ -515,6 +563,9 @@ export function stringifyManagedBlock(yaml, rows) {
515
563
  // lineWidth: 0 关掉自动折行——plain 标量折行虽合法但产物不可读、diff 噪音大
516
564
  return `${MANAGED_BEGIN}\n${yaml.stringify(doc, { lineWidth: 0 }).replace(/\n+$/, '')}\n${MANAGED_END}`
517
565
  }
566
+ // JSON 回退分支生产不可达(宿主 <home>/node_modules 必带 yaml 包,loadYamlLib
567
+ // 读不到时 composeManagedRows 已提前返回 null rows)——仅测试直传 yaml=null
568
+ // 可达(test/installer-shape.test.mjs 验证 JSON 形态同为合法 YAML)。
518
569
  return `${MANAGED_BEGIN}\n${JSON.stringify(doc)}\n${MANAGED_END}`
519
570
  }
520
571
 
@@ -523,13 +574,22 @@ export function stringifyManagedBlock(yaml, rows) {
523
574
  /**
524
575
  * 在行数组里定位托管块 [begin, end](闭区间下标)。无起始标记返回 null;
525
576
  * 有起始无结束(托管块损坏,多半是手工删了半截)throw——拒绝写盘,绝不覆盖
526
- * 手工内容。
577
+ * 手工内容。只认首对:首对之后若再出现起始标记(用户误复制了一份托管块),
578
+ * warn 提醒手工清理,多余的块按普通用户内容原样保留、绝不动它。
527
579
  */
528
- export function findManagedSpan(lines) {
580
+ export function findManagedSpan(lines, { logger = console } = {}) {
529
581
  const begin = lines.findIndex((l) => l.trim() === MANAGED_BEGIN)
530
582
  if (begin === -1) return null
531
583
  for (let i = begin + 1; i < lines.length; i++) {
532
- 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
+ }
533
593
  }
534
594
  throw new Error(
535
595
  `cordis.patch.yml 的 dsh-maoying 托管块已损坏(只有起始标记、找不到结束标记 "${MANAGED_END}"):` +
@@ -542,8 +602,16 @@ export function findManagedSpan(lines) {
542
602
  * 'skipped' — 托管块已在且内容与版本标记都一致,零写盘;
543
603
  * 'updated' — 整块替换(块外用户内容一字节不动);
544
604
  * 'created' — 原无托管块,文末追加(文件不存在则连头注释一起新建)。
605
+ *
606
+ * 并发与原子性考量(宿主级共享文件,CLI 与桌面可能双开):写盘走「同目录
607
+ * 临时文件 + renameSync 原子替换」,不做整文件截断覆写——rename 在同一文件
608
+ * 系统内要么整体成功要么不动,任何时刻盘上只有完整的旧内容或完整的新内容,
609
+ * 进程中途崩溃也绝不会留下半截 YAML。跨进程仍是「各自基于快照整文件替换」
610
+ * 的后写者赢语义(零依赖设计不引文件锁);配合写前重读对比,把「内容并无
611
+ * 变化」的二跑收敛为零写盘,交错写的实际危害只剩同刻并发更新的丢失,不会
612
+ * 产生损坏文件。
545
613
  */
546
- export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent }) {
614
+ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent, logger = console }) {
547
615
  const patchPath = path.join(dshHome, 'cordis.patch.yml')
548
616
  let text = null
549
617
  try {
@@ -555,7 +623,7 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
555
623
  const lines = text !== null ? text.split(/\r?\n/) : [HOME_PATCH_HEADER, '']
556
624
  const incoming = blockText.replace(/\r?\n/g, '\n')
557
625
 
558
- const span = findManagedSpan(lines) // 损坏块 throw,直接中止(未写任何盘)
626
+ const span = findManagedSpan(lines, { logger }) // 损坏块 throw,直接中止(未写任何盘)
559
627
  if (span !== null) {
560
628
  const existing = lines.slice(span.begin, span.end + 1).join('\n')
561
629
  if (versionMarkerCurrent && existing === incoming) return 'skipped'
@@ -566,8 +634,24 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
566
634
  if (text !== null && text.trim() !== '' && lines[lines.length - 1].trim() !== '') lines.push('')
567
635
  lines.push(...incoming.split('\n'), '')
568
636
  }
637
+ const content = lines.join(eol)
638
+ // 写前重读对比(幂等强化):首次读到真正写盘之间文件可能已被并发进程改成
639
+ // 与目标一致的内容——已一致就不再写盘,省一次 mtime 抖动、也收窄交错写窗口。
640
+ try {
641
+ if (readFileSync(patchPath, 'utf8') === content) return 'skipped'
642
+ } catch { /* 重读失败(文件被并发删/权限抖动)按首次写处理 */ }
643
+ // 原子落盘:临时文件带 pid 命名,多进程各写各的临时件互不踩踏。
569
644
  mkdirSync(dshHome, { recursive: true })
570
- 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
+ }
571
655
  return span !== null ? 'updated' : 'created'
572
656
  }
573
657
 
@@ -581,7 +665,9 @@ const SKILL_DIR_PREFIX = 'maoying-'
581
665
 
582
666
  /**
583
667
  * 递归收集目录下全部文件的相对路径(POSIX 分隔)。目录打不开/不存在返回
584
- * [],由调用方兜底。
668
+ * null——与「空目录返回 []」严格区分:源目录读失败时调用方必须跳过版本标记
669
+ * (否则标记谎报,下次启动误判无需重装);子目录读失败整棵按失败算。
670
+ * @returns {string[]|null} 相对路径列表;读取失败返回 null
585
671
  */
586
672
  function listSkillFiles(dir, relBase = '') {
587
673
  const files = []
@@ -589,12 +675,15 @@ function listSkillFiles(dir, relBase = '') {
589
675
  try {
590
676
  entries = readdirSync(dir, { withFileTypes: true })
591
677
  } catch {
592
- return files
678
+ return null
593
679
  }
594
680
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
595
681
  const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`
596
- if (entry.isDirectory()) files.push(...listSkillFiles(path.join(dir, entry.name), rel))
597
- 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)
598
687
  }
599
688
  return files
600
689
  }
@@ -622,25 +711,17 @@ export function installSkills(dshHome, { version, logger = console } = {}) {
622
711
  const dstDir = path.join(skillsHome, entry.name)
623
712
  let touched = false
624
713
  try {
625
- for (const rel of listSkillFiles(srcDir)) {
626
- const src = path.join(srcDir, rel)
627
- const dst = path.join(dstDir, rel)
628
- let same = false
629
- try {
630
- same = existsSync(dst) && readFileSync(dst).equals(readFileSync(src))
631
- } catch {
632
- same = false
633
- }
634
- if (!same) {
635
- mkdirSync(path.dirname(dst), { recursive: true })
636
- copyFileSync(src, dst)
637
- touched = true
638
- }
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
639
722
  }
640
- const markerPath = path.join(dstDir, '.installed-version')
641
723
  try {
642
- const current = existsSync(markerPath) && readFileSync(markerPath, 'utf8').trim() === version
643
- if (!current) writeFileSync(markerPath, `${version}\n`, 'utf8')
724
+ writeMarkerIfChanged(dstDir, version)
644
725
  } catch (err) {
645
726
  logger.warn?.(`maoying-installer: 技能 ${entry.name} 的 .installed-version 标记写入失败(不影响技能本体): ${err?.message ?? err}`)
646
727
  }
@@ -705,46 +786,98 @@ function isTrustedApiRequest(req, trustedHosts) {
705
786
  if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
706
787
  const origin = header(req.headers, 'origin')
707
788
  if (origin === undefined) return true
708
- 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 }
709
795
  }
710
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
+
711
803
  /**
712
- * 读 JSON 请求体(限额 5MB)。超限 413、非法 JSON 400——错误带 statusCode,
713
- * 外层 catch 据此回对应状态码;客户端提前断开时 reject(响应已发出的场景
714
- * 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。
715
811
  */
716
- function readBody(req) {
812
+ function readBody(req, res) {
717
813
  return new Promise((resolve, reject) => {
718
814
  const chunks = []
719
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
+
720
843
  req.on('data', (c) => {
844
+ idleTimer.refresh?.()
721
845
  size += c.length
722
- if (size > 5 * 1024 * 1024) {
723
- const err = new Error('request body too large')
724
- err.statusCode = 413
725
- reject(err)
726
- req.destroy()
846
+ if (size > BODY_LIMIT_BYTES) {
847
+ respondThenClose(413, `request body too large (limit ${BODY_LIMIT_BYTES} bytes)`)
727
848
  return
728
849
  }
729
850
  chunks.push(c)
730
851
  })
731
852
  req.on('end', () => {
853
+ if (done) return
854
+ clearTimeout(idleTimer)
732
855
  try {
733
- 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)
734
861
  } catch {
735
- const err = new Error('invalid JSON body')
736
- err.statusCode = 400
737
- reject(err)
862
+ rejectWith(400, 'invalid JSON body')
738
863
  }
739
864
  })
740
- req.on('error', reject)
741
- req.on('close', () => reject(new Error('request closed before body completed')))
742
- 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'))
743
875
  })
744
876
  }
745
877
 
746
878
  function json(res, status, payload) {
747
- const text = JSON.stringify(payload, null, 2)
879
+ // 无缩进序列化:状态快照等响应可到百 KB 量级,缩进纯增体积(客户端自行格式化)
880
+ const text = JSON.stringify(payload)
748
881
  res.writeHead(status, {
749
882
  'content-type': 'application/json; charset=utf-8',
750
883
  'content-length': Buffer.byteLength(text),
@@ -765,6 +898,20 @@ function sanitizeMessage(message) {
765
898
  return text
766
899
  }
767
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
+
768
915
  // 宿主热切换竞态兜底:「禁用→启用」等操作会重建插件条目纤维,新 apply 先
769
916
  // 跑、旧实例的路由注销异步落地,注册瞬间旧路由常仍在表里,webServer.register
770
917
  // 对同 (kind, path) 抛 duplicate。只对 duplicate prefix route 这一种错误短
@@ -776,7 +923,10 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
776
923
  let lastError
777
924
  for (let attempt = 0; attempt < attempts; attempt++) {
778
925
  try {
779
- return webServer.register(route)
926
+ // 必须 await:宿主 register 可能返回 rejected promise(异步校验路径),
927
+ // 不 await 则 rejection 接不住(unhandled rejection),duplicate 短重试
928
+ // 形同虚设;await 对同步抛出的路径行为不变,兼容保留。
929
+ return await webServer.register(route)
780
930
  } catch (err) {
781
931
  if (!DUPLICATE_PREFIX_ROUTE_RE.test(String(err?.message ?? ''))) throw err
782
932
  lastError = err
@@ -787,7 +937,7 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
787
937
  }
788
938
 
789
939
  /**
790
- * 注册创作台全部路由(docs/creator-panel.md §2 的 8 条)。返回宿主 disposer;
940
+ * 注册创作台全部路由(docs/creator-panel.md §2 的 11 条)。返回宿主 disposer;
791
941
  * webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
792
942
  */
793
943
  export async function registerCreatorRoutes(ctx) {
@@ -810,9 +960,10 @@ export async function registerCreatorRoutes(ctx) {
810
960
  json(res, 403, { error: 'forbidden' })
811
961
  return
812
962
  }
813
- const url = new URL(req.url, 'http://dsh.internal')
814
- const route = url.pathname.replace(/^\/api\/maoying\/?/, '')
815
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\/?/, '')
816
967
  // ── 静态样式:面板 CSS 正文(lib/base.css)按请求现读下发 ──────────
817
968
  // 客户端只注入 <link href="/api/maoying/client.css">,正文现读——
818
969
  // 改样式无需重启,GUI 刷新即生效(no-cache)。同前缀、已被围栏把关。
@@ -872,16 +1023,18 @@ export async function registerCreatorRoutes(ctx) {
872
1023
  registryDump.presets = jsonSafe(rows)
873
1024
  }
874
1025
  } catch (e) { registryDump.error = String(e?.message ?? e) }
1026
+ // 出口统一过 sanitizeDeep:trace.via / argv / execPath / cwd 带本机
1027
+ // 绝对路径,诊断照给、路径脱敏(与错误 message 同一纪律)。
875
1028
  json(res, 200, {
876
1029
  ok: true,
877
1030
  hostVersion: dbg.version,
878
- trace: dbg.trace,
1031
+ trace: sanitizeDeep(dbg.trace),
879
1032
  shape,
880
1033
  registry: registryDump,
881
1034
  runtime: {
882
- argv: process.argv,
883
- execPath: process.execPath,
884
- cwd: process.cwd(),
1035
+ argv: sanitizeDeep(process.argv),
1036
+ execPath: sanitizeMessage(process.execPath),
1037
+ cwd: sanitizeMessage(process.cwd()),
885
1038
  electron: process.versions?.electron ?? null,
886
1039
  node: process.version,
887
1040
  },
@@ -914,28 +1067,35 @@ export async function registerCreatorRoutes(ctx) {
914
1067
  }
915
1068
 
916
1069
  if (route === 'chapter-status' && req.method === 'POST') {
917
- const body = await readBody(req)
1070
+ const body = await readBody(req, res)
918
1071
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
919
1072
  json(res, 200, mutateChapterStatus(novelDir, body.file, body.status))
920
1073
  return
921
1074
  }
922
1075
 
923
1076
  if (route === 'foreshadow-status' && req.method === 'POST') {
924
- const body = await readBody(req)
1077
+ const body = await readBody(req, res)
925
1078
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
926
1079
  json(res, 200, mutateForeshadowStatus(novelDir, body.id, body.status))
927
1080
  return
928
1081
  }
929
1082
 
930
1083
  if (route === 'phase' && req.method === 'POST') {
931
- const body = await readBody(req)
1084
+ const body = await readBody(req, res)
932
1085
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
933
1086
  json(res, 200, mutatePhase(novelDir, body.phase))
934
1087
  return
935
1088
  }
936
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
+
937
1097
  if (route === 'rebuild-index' && req.method === 'POST') {
938
- const body = await readBody(req)
1098
+ const body = await readBody(req, res)
939
1099
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
940
1100
  json(res, 200, rebuildIndex(novelDir))
941
1101
  return
@@ -944,8 +1104,11 @@ export async function registerCreatorRoutes(ctx) {
944
1104
  json(res, 404, { ok: false, error: `no such route: ${req.method} /api/maoying/${route}` })
945
1105
  } catch (err) {
946
1106
  // 错误原文(可能带本机绝对路径)留日志;对外 message 先脱敏。读体的
947
- // 413/400 带 statusCode,按其状态码回;其余业务错误自带码,裸异常 500。
1107
+ // 413/408 已由 readBody 先回包再断开(err.responded),这里只留日志不
1108
+ // 重复回包;其余按 statusCode 回(读体断开 499/400、业务错误自带码),
1109
+ // 裸异常 500。
948
1110
  ctx.logger?.warn?.('maoying 创作台: 请求处理失败: ' + String(err?.message ?? err))
1111
+ if (err?.responded) return
949
1112
  json(res, Number.isInteger(err?.statusCode) ? err.statusCode : 500, {
950
1113
  ok: false,
951
1114
  error: sanitizeMessage(err?.message ?? String(err)),
@@ -976,30 +1139,24 @@ export function ensurePresetInstalled(ctx = {}) {
976
1139
  const sourceDir = path.join(PLUGIN_ROOT, 'presets', PRESET_ID)
977
1140
  const presetDir = path.join(home, '.agent-presets', PRESET_ID)
978
1141
 
979
- // 1) 预设文件:内容一致不写(逐字节比较),变化才覆盖
1142
+ // 1) 预设文件:内容一致不写(逐字节比较),变化才覆盖。copyFileSync 必须在
1143
+ // per-file try 之内——单文件失败(如目标只读 EACCES)只 warn,不能冲出
1144
+ // 主流程把后面的版本标记/技能落盘/托管块全跳过(违背「各自可失败」;
1145
+ // 失败文件下次启动自愈重试)。
980
1146
  const copied = []
981
1147
  mkdirSync(presetDir, { recursive: true })
982
1148
  for (const file of PRESET_FILES) {
983
- const src = path.join(sourceDir, file)
984
- const dst = path.join(presetDir, file)
985
- let same = false
986
1149
  try {
987
- same = existsSync(dst) && readFileSync(dst).equals(readFileSync(src))
988
- } catch {
989
- same = false
990
- }
991
- if (!same) {
992
- copyFileSync(src, dst)
993
- 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}`)
994
1153
  }
995
1154
  }
996
1155
 
997
1156
  // 2) 版本标记:一致不重写(mtime 也是产物,少动一次是一次)
998
- const markerPath = path.join(presetDir, '.installed-version')
999
1157
  let markerCurrent = false
1000
1158
  try {
1001
- markerCurrent = existsSync(markerPath) && readFileSync(markerPath, 'utf8').trim() === version
1002
- if (!markerCurrent) writeFileSync(markerPath, `${version}\n`, 'utf8')
1159
+ markerCurrent = !writeMarkerIfChanged(presetDir, version)
1003
1160
  } catch (err) {
1004
1161
  logger.warn?.(`maoying-installer: .installed-version 标记写入失败(不影响预设本体): ${err?.message ?? err}`)
1005
1162
  }
@@ -1037,7 +1194,7 @@ export function ensurePresetInstalled(ctx = {}) {
1037
1194
  }
1038
1195
 
1039
1196
  const blockText = stringifyManagedBlock(composed.yaml, composed.rows)
1040
- const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent })
1197
+ const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent, logger })
1041
1198
 
1042
1199
  if (copied.length > 0) {
1043
1200
  logger.info?.(`maoying-installer: 预设文件已更新 ${PRESET_ID}: ${copied.join(', ')}`)
@@ -1077,13 +1234,10 @@ export function apply(ctx = {}, _config = {}) {
1077
1234
  // 按宿主版本自动选形状,所选形态带在安装日志里),不再需要降级提示。
1078
1235
 
1079
1236
  // 创作台路由(三期):与安装段彼此独立——注册失败只 warn,面板不可用不
1080
- // 拖累安装器语义。注册是异步的(含 duplicate 短重试),这里挂 .catch 兜住
1081
- // 异步失败;同步抛错走外层 try/catch,两条路都不让异常冒出 apply。
1082
- try {
1083
- registerCreatorRoutes(ctx).catch((error) => {
1084
- logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
1085
- })
1086
- } catch (error) {
1237
+ // 拖累安装器语义。registerCreatorRoutes 是 async 函数,同步阶段不可能 throw
1238
+ //(异常一律进 rejected promise),外层 try 对它是永假防御——只挂 .catch
1239
+ // 一条兜底通道即可,异常不会冒出 apply。
1240
+ registerCreatorRoutes(ctx).catch((error) => {
1087
1241
  logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
1088
- }
1242
+ })
1089
1243
  }