cc-viewer 1.8.10 → 1.8.11

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.
Files changed (81) hide show
  1. package/dist/assets/App-vpm6omRY.js +2 -0
  2. package/dist/assets/{MdxEditorPanel-CTCAo0Gg.js → MdxEditorPanel-CpknU7SR.js} +1 -1
  3. package/dist/assets/{Mobile-Uo_qQRwl.js → Mobile-BHIBdqHs.js} +1 -1
  4. package/dist/assets/{ProxyStatsModal-BnPXrvTb.js → ProxyStatsModal-DdV7wN2V.js} +1 -1
  5. package/dist/assets/{_baseUniq-D2aO8mv4.js → _baseUniq-C2FCldN4.js} +1 -1
  6. package/dist/assets/{arc-DOjcnBTh.js → arc-CSzd_bdT.js} +1 -1
  7. package/dist/assets/{architectureDiagram-Q4EWVU46-Drea2H7X.js → architectureDiagram-Q4EWVU46-CjEbBe2R.js} +1 -1
  8. package/dist/assets/{blockDiagram-DXYQGD6D-Bc9eijBD.js → blockDiagram-DXYQGD6D-BYzl2ukW.js} +1 -1
  9. package/dist/assets/{c4Diagram-AHTNJAMY-D-umzFnM.js → c4Diagram-AHTNJAMY-0x0_8O_X.js} +1 -1
  10. package/dist/assets/{channel-DUff3FBt.js → channel-xMmQtLq9.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-CFnsROfz.js → chunk-4BX2VUAB-DnFoMzQG.js} +1 -1
  12. package/dist/assets/{chunk-4TB4RGXK-C95jMPjB.js → chunk-4TB4RGXK-Dc5reuwv.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-CeWf2Y2b.js → chunk-55IACEB6-DTKTLKcJ.js} +1 -1
  14. package/dist/assets/{chunk-EDXVE4YY-ClUoTJxv.js → chunk-EDXVE4YY-BNqgeN9r.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-Dk62kqb8.js → chunk-FMBD7UC4-DzJVZ11t.js} +1 -1
  16. package/dist/assets/{chunk-OYMX7WX6-CZuykSsu.js → chunk-OYMX7WX6-CjYwzMor.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-DYtx68Um.js → chunk-QZHKN3VN-C0KVKecr.js} +1 -1
  18. package/dist/assets/{chunk-YZCP3GAM-rLSq0tSC.js → chunk-YZCP3GAM-3VhNeNVm.js} +1 -1
  19. package/dist/assets/classDiagram-6PBFFD2Q-DdUtH789.js +1 -0
  20. package/dist/assets/classDiagram-v2-HSJHXN6E-DdUtH789.js +1 -0
  21. package/dist/assets/clone-C6d2XDvR.js +1 -0
  22. package/dist/assets/{cose-bilkent-S5V4N54A-B5H6ewon.js → cose-bilkent-S5V4N54A-DGE02sJS.js} +1 -1
  23. package/dist/assets/{dagre-KV5264BT-BO2R2WP7.js → dagre-KV5264BT-CY4eCcwS.js} +1 -1
  24. package/dist/assets/{diagram-5BDNPKRD-Y3GNif-g.js → diagram-5BDNPKRD-DHq2THYn.js} +1 -1
  25. package/dist/assets/{diagram-G4DWMVQ6-Du3_TylQ.js → diagram-G4DWMVQ6-DD_76oJg.js} +1 -1
  26. package/dist/assets/{diagram-MMDJMWI5-DB_Ynyjc.js → diagram-MMDJMWI5-BvQxk1jl.js} +1 -1
  27. package/dist/assets/{diagram-TYMM5635-_Rfjm82W.js → diagram-TYMM5635-B_tMXPJl.js} +1 -1
  28. package/dist/assets/{erDiagram-SMLLAGMA-D5XhBiiV.js → erDiagram-SMLLAGMA-DpuGbN9e.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-CxWJ7sAQ.js → flowDiagram-DWJPFMVM-BW0xGFBf.js} +1 -1
  30. package/dist/assets/{ganttDiagram-T4ZO3ILL-D-a8fh00.js → ganttDiagram-T4ZO3ILL-BSzTiT93.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-q5WMk0Ce.js → gitGraphDiagram-UUTBAWPF-1tsgBlcn.js} +1 -1
  32. package/dist/assets/{graph-Bi8xMAuo.js → graph-CwiXMdRR.js} +1 -1
  33. package/dist/assets/{index-BRtbpMRz.js → index-9e6BGquk.js} +1 -1
  34. package/dist/assets/{index-BWPHHS6P.js → index-BmpXfVQ8.js} +1 -1
  35. package/dist/assets/{index-O2Mx9uLd.js → index-CZWNaOo8.js} +1 -1
  36. package/dist/assets/{index-BBEH6Ppx.js → index-Cixs7aRD.js} +1 -1
  37. package/dist/assets/{index-C3wsDB-y.js → index-Ct6Fi103.js} +1 -1
  38. package/dist/assets/index-D1yGak8I.css +1 -0
  39. package/dist/assets/{index-3Z3ucdlB.js → index-DIAMN9QO.js} +1 -1
  40. package/dist/assets/{index-BB66PLo6.js → index-DhjwHRxk.js} +1 -1
  41. package/dist/assets/{index-C4Jei4gi.js → index-tnWldUm3.js} +2 -2
  42. package/dist/assets/{infoDiagram-42DDH7IO-CE2mUuLF.js → infoDiagram-42DDH7IO-Dk1ESZFk.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-C5sNQIVL.js → ishikawaDiagram-UXIWVN3A-DcPyNwkI.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-g7HXOeyX.js → journeyDiagram-VCZTEJTY-CZWieu1U.js} +1 -1
  45. package/dist/assets/{jszip.min-x3xOZxY_.js → jszip.min-Bq1JxqcN.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-D3Mus6hc.js → kanban-definition-6JOO6SKY-S1sSSTKx.js} +1 -1
  47. package/dist/assets/{layout-05qDuweB.js → layout-BQFiWq71.js} +1 -1
  48. package/dist/assets/{linear-DQEG_N-F.js → linear-BNV4po81.js} +1 -1
  49. package/dist/assets/{mermaid.core-DcaXqkk5.js → mermaid.core-DvwEdatr.js} +2 -2
  50. package/dist/assets/{min-DSpvw5zG.js → min-DFik8k4L.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-DomUIop7.js → mindmap-definition-QFDTVHPH-CebeX76u.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-COTLcC5G.js → pieDiagram-DEJITSTG-DMB5JbMP.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-DMg8C3Up.js → quadrantDiagram-34T5L4WZ-BAYzoLVo.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-DF9beYd7.js → requirementDiagram-MS252O5E-CUQoAuOc.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-MvERK2UP.js → sankeyDiagram-XADWPNL6-Dt0AQuLW.js} +1 -1
  56. package/dist/assets/seqResourceLoaders-C-QHRkUq.js +2 -0
  57. package/dist/assets/{sequenceDiagram-FGHM5R23-B8k_nyzc.js → sequenceDiagram-FGHM5R23-HyZFUlMU.js} +1 -1
  58. package/dist/assets/{stateDiagram-FHFEXIEX-D-9fTYas.js → stateDiagram-FHFEXIEX-B8x24fuD.js} +1 -1
  59. package/dist/assets/stateDiagram-v2-QKLJ7IA2-2qtHlnwO.js +1 -0
  60. package/dist/assets/{timeline-definition-GMOUNBTQ-CbK1g9CO.js → timeline-definition-GMOUNBTQ-TnbjDVtB.js} +1 -1
  61. package/dist/assets/{vendor-antd-FhK5cQx7.js → vendor-antd-BTBRwbax.js} +1 -1
  62. package/dist/assets/{vendor-codemirror-DL3yyKRQ.js → vendor-codemirror-CPWlhuQ1.js} +1 -1
  63. package/dist/assets/{vendor-mdxeditor-DgyrvHzS.js → vendor-mdxeditor-Cy5Xg_90.js} +2 -2
  64. package/dist/assets/{vendor-qrcode-BAwwP1Nd.js → vendor-qrcode-E9ld0lSz.js} +1 -1
  65. package/dist/assets/{vendor-virtuoso-B_7lc2Ot.js → vendor-virtuoso-Bq6QMhgG.js} +1 -1
  66. package/dist/assets/{vennDiagram-DHZGUBPP-DYjGtMA5.js → vennDiagram-DHZGUBPP-BqgIjkBO.js} +1 -1
  67. package/dist/assets/{wardley-RL74JXVD-Djh5alV9.js → wardley-RL74JXVD-BFcIn2-Q.js} +1 -1
  68. package/dist/assets/{wardleyDiagram-NUSXRM2D-al50Jlxp.js → wardleyDiagram-NUSXRM2D-CYDWQLb6.js} +1 -1
  69. package/dist/assets/{xychartDiagram-5P7HB3ND-Bl969-3U.js → xychartDiagram-5P7HB3ND-zfusE3BC.js} +1 -1
  70. package/dist/index.html +6 -6
  71. package/package.json +1 -1
  72. package/server/lib/skills-api.js +48 -26
  73. package/server/routes/files-fs.js +76 -1
  74. package/server/routes/skills.js +44 -26
  75. package/dist/assets/App-DJIVcbMa.js +0 -2
  76. package/dist/assets/classDiagram-6PBFFD2Q-DT8RQXlO.js +0 -1
  77. package/dist/assets/classDiagram-v2-HSJHXN6E-DT8RQXlO.js +0 -1
  78. package/dist/assets/clone-_dhVVwEt.js +0 -1
  79. package/dist/assets/index-D3GEPgde.css +0 -1
  80. package/dist/assets/seqResourceLoaders-DfTPNrje.js +0 -2
  81. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CO0tv8rD.js +0 -1
@@ -6,7 +6,7 @@ import {
6
6
  import { join, dirname, basename, resolve, sep } from 'node:path';
7
7
  import { homedir, tmpdir } from 'node:os';
8
8
  import { execFile, spawn } from 'node:child_process';
9
- import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
9
+ import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
10
10
  import { validateImportDir } from '../lib/file-api.js';
11
11
  import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
12
12
  import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
@@ -659,6 +659,80 @@ function resolvePath(req, res, parsedUrl, isLocal, deps) {
659
659
  });
660
660
  }
661
661
 
662
+ // Lightweight existence probe for the chat markdown code-span feature: the web
663
+ // client batches path-looking inline code spans here and only renders them
664
+ // clickable when the file provably exists. Uniform {exists:false} for every
665
+ // not-readable-as-regular-file outcome (missing / outside allowlist / denied /
666
+ // directory) — never echoes policy reasons, so this is a strictly weaker oracle
667
+ // than /api/file-content's 404/403 split.
668
+ const FILES_EXISTS_MAX_BATCH = 50;
669
+ const FILES_EXISTS_MAX_PATH = 1024;
670
+ const FILES_EXISTS_MAX_BODY = 256 * 1024; // 50 × 1024 + JSON overhead ≪ this
671
+
672
+ function filesExists(req, res, parsedUrl, isLocal, deps) {
673
+ let body = '';
674
+ let aborted = false;
675
+ req.on('data', chunk => {
676
+ body += chunk;
677
+ if (body.length > FILES_EXISTS_MAX_BODY && !aborted) {
678
+ aborted = true;
679
+ res.writeHead(413, { 'Content-Type': 'application/json' });
680
+ res.end(JSON.stringify({ error: 'Request too large' }));
681
+ req.destroy();
682
+ }
683
+ });
684
+ req.on('end', () => {
685
+ if (aborted) return;
686
+ let parsed;
687
+ try { parsed = JSON.parse(body); } catch {
688
+ res.writeHead(400, { 'Content-Type': 'application/json' });
689
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
690
+ return;
691
+ }
692
+ const paths = parsed && parsed.paths;
693
+ if (!Array.isArray(paths)) {
694
+ res.writeHead(400, { 'Content-Type': 'application/json' });
695
+ res.end(JSON.stringify({ error: 'Invalid paths' }));
696
+ return;
697
+ }
698
+ if (paths.length > FILES_EXISTS_MAX_BATCH) {
699
+ res.writeHead(400, { 'Content-Type': 'application/json' });
700
+ res.end(JSON.stringify({ error: 'Too many paths' }));
701
+ return;
702
+ }
703
+ for (const p of paths) {
704
+ if (typeof p !== 'string' || p.length > FILES_EXISTS_MAX_PATH) {
705
+ res.writeHead(400, { 'Content-Type': 'application/json' });
706
+ res.end(JSON.stringify({ error: 'Invalid path' }));
707
+ return;
708
+ }
709
+ }
710
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
711
+ const results = paths.map(p => ({ path: p, exists: probeFileExists(p, cwd) }));
712
+ res.writeHead(200, { 'Content-Type': 'application/json' });
713
+ res.end(JSON.stringify({ results }));
714
+ });
715
+ }
716
+
717
+ function probeFileExists(p, cwd) {
718
+ try {
719
+ const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
720
+ // Mirrors /api/file-content: a relative `..` segment can never be opened by
721
+ // the click path either, so report it as non-existent rather than 400.
722
+ // Segment-wise check — a legit filename like `foo..bar.md` must not match.
723
+ if (!isAbs && p.split(/[\\/]/).includes('..')) return false;
724
+ // Resolve BEFORE isReadAllowed: realpathSync inside the policy resolves
725
+ // relative input against process.cwd(), which is the app dir (not the
726
+ // project) in packaged Electron runs.
727
+ const absPath = isAbs ? p : resolve(cwd, p);
728
+ const policy = isReadAllowed(absPath);
729
+ if (!policy.ok) return false;
730
+ return statSync(policy.real).isFile();
731
+ } catch {
732
+ return false; // one bad path must not sink the whole batch
733
+ }
734
+ }
735
+
662
736
  function createFile(req, res, parsedUrl, isLocal, deps) {
663
737
  let body = '';
664
738
  req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
@@ -964,6 +1038,7 @@ export const filesFsRoutes = [
964
1038
  { method: 'POST', match: 'exact', path: '/api/reveal-file', handler: revealFile },
965
1039
  { method: 'POST', match: 'exact', path: '/api/open-file', handler: openFile },
966
1040
  { method: 'POST', match: 'exact', path: '/api/resolve-path', handler: resolvePath },
1041
+ { method: 'POST', match: 'exact', path: '/api/files-exists', handler: filesExists },
967
1042
  { method: 'POST', match: 'exact', path: '/api/create-file', handler: createFile },
968
1043
  { method: 'POST', match: 'exact', path: '/api/open-terminal', handler: openTerminal },
969
1044
  { method: 'POST', match: 'exact', path: '/api/create-dir', handler: createDir },
@@ -2,7 +2,7 @@
2
2
  import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { join, resolve, sep, dirname } from 'node:path';
4
4
  import { getClaudeConfigDir } from '../../findcc.js';
5
- import { listSkills, moveSkill, deleteSkill, validateSkillName } from '../lib/skills-api.js';
5
+ import { listSkills, moveSkill, deleteSkill, validateSkillName, parseSkillFrontmatter } from '../lib/skills-api.js';
6
6
  import { isAdminReq } from '../lib/is-admin.js';
7
7
 
8
8
  async function skillsList(req, res) {
@@ -81,22 +81,35 @@ function skillsDelete(req, res, parsedUrl, isLocal) {
81
81
  // 设计要点(与原实现一致):
82
82
  // · 扩展名白名单只放 zip / md(忽略大小写);其他类型 415;
83
83
  // · zip 内必须含 SKILL.md(任意子目录、忽略大小写),取最浅的那个所在目录作为 skill 根;
84
- // · skill 名优先取 SKILL.md frontmatter 的 name,回落 zip 根目录名 / 文件名(去扩展名);
84
+ // · Strict spec check (unified across all three entries): SKILL.md frontmatter must contain
85
+ // a name (must pass validateSkillName) and a description; violations reject with
86
+ // INVALID_FRONTMATTER / MISSING_NAME / INVALID_NAME / MISSING_DESCRIPTION — no more
87
+ // filename / zip-root-dir fallback;
85
88
  // · zip bomb 防护:单文件 ≤50MB,总解压 ≤200MB;拒绝 symlink entry。
86
89
 
87
- const parseNameFromMd = (text) => {
88
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
89
- if (!m) return null;
90
- const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
91
- if (!nm) return null;
92
- return nm[1].trim().replace(/^["']|["']$/g, '');
93
- };
90
+ // Shared size caps for the zip path (declared-size pass + actual-size re-check).
91
+ const MAX_SKILL_FILE = 50 * 1024 * 1024;
92
+ const MAX_SKILL_TOTAL = 200 * 1024 * 1024;
94
93
 
95
- const fallbackBaseName = (filename, stripExt) => {
96
- let n = filename.replace(/^.*[\\/]/, '');
97
- if (stripExt) n = n.replace(/\.[^.]+$/, '');
98
- return n;
99
- };
94
+ // Strict skill-spec validation shared by all three import entries (folder / zip / SKILL.md).
95
+ // Check order is fixed (tests depend on it): no frontmatter block → INVALID_FRONTMATTER;
96
+ // missing name → MISSING_NAME; invalid name → INVALID_NAME; missing description → MISSING_DESCRIPTION.
97
+ function requireSkillSpec(text) {
98
+ const fm = parseSkillFrontmatter(text);
99
+ if (!fm) {
100
+ throw Object.assign(new Error('SKILL.md is missing valid frontmatter'), { status: 400, code: 'INVALID_FRONTMATTER' });
101
+ }
102
+ if (!fm.name) {
103
+ throw Object.assign(new Error('SKILL.md frontmatter is missing a name'), { status: 400, code: 'MISSING_NAME' });
104
+ }
105
+ if (!validateSkillName(fm.name)) {
106
+ throw Object.assign(new Error(`Invalid skill name: ${fm.name}`), { status: 400, code: 'INVALID_NAME' });
107
+ }
108
+ if (!fm.description) {
109
+ throw Object.assign(new Error('SKILL.md frontmatter is missing a description'), { status: 400, code: 'MISSING_DESCRIPTION' });
110
+ }
111
+ return fm;
112
+ }
100
113
 
101
114
  /**
102
115
  * 解析一段 multipart body(已组装好的 Buffer)→ { skillName, files: [{relPath, data}] }。
@@ -136,7 +149,7 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
136
149
 
137
150
  if (isMd) {
138
151
  const text = fileData.toString('utf8');
139
- skillName = parseNameFromMd(text) || fallbackBaseName(originalName, true);
152
+ skillName = requireSkillSpec(text).name;
140
153
  skillFiles = [{ relPath: 'SKILL.md', data: fileData }];
141
154
  } else {
142
155
  const AdmZip = (await import('adm-zip')).default;
@@ -147,8 +160,6 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
147
160
  throw Object.assign(new Error('Invalid zip archive'), { status: 400, code: 'INVALID_ZIP' });
148
161
  }
149
162
  const entries = zip.getEntries();
150
- const MAX_PER_FILE = 50 * 1024 * 1024;
151
- const MAX_TOTAL_UNCOMPRESSED = 200 * 1024 * 1024;
152
163
  let totalUncompressed = 0;
153
164
  for (const e of entries) {
154
165
  if (e.isDirectory) continue;
@@ -157,11 +168,11 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
157
168
  throw Object.assign(new Error('Symlinks not allowed in zip'), { status: 400, code: 'INVALID_ZIP' });
158
169
  }
159
170
  const sizeRaw = e.header?.size || 0;
160
- if (sizeRaw > MAX_PER_FILE) {
171
+ if (sizeRaw > MAX_SKILL_FILE) {
161
172
  throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
162
173
  }
163
174
  totalUncompressed += sizeRaw;
164
- if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
175
+ if (totalUncompressed > MAX_SKILL_TOTAL) {
165
176
  throw Object.assign(new Error('Archive expands too large'), { status: 400, code: 'ZIP_BOMB' });
166
177
  }
167
178
  }
@@ -179,12 +190,16 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
179
190
  if (!bestSkillEntry) {
180
191
  throw Object.assign(new Error('SKILL.md not found in zip'), { status: 400, code: 'MISSING_SKILL_MD' });
181
192
  }
193
+ // Guard BEFORE inflating: header.size is attacker-controlled, but a declared oversize at
194
+ // least lets us reject before spending memory on getData() (actual-size re-check below
195
+ // still catches a lying small header).
196
+ if ((bestSkillEntry.header?.size || 0) > MAX_SKILL_FILE) {
197
+ throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
198
+ }
182
199
  const lastSlash = bestSkillEntry.entryName.lastIndexOf('/');
183
200
  const skillRootPrefix = lastSlash >= 0 ? bestSkillEntry.entryName.slice(0, lastSlash + 1) : '';
184
201
  const skillMdText = bestSkillEntry.getData().toString('utf8');
185
- skillName = parseNameFromMd(skillMdText)
186
- || (skillRootPrefix ? skillRootPrefix.replace(/\/$/, '').split('/').pop() : null)
187
- || fallbackBaseName(originalName, true);
202
+ skillName = requireSkillSpec(skillMdText).name;
188
203
 
189
204
  // 二次校验:header.size 来自 zip 中央目录是攻击者可控的(可谎报 size=0),用真实 data.length 复核。
190
205
  let actualTotal = 0;
@@ -194,11 +209,11 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
194
209
  const rel = skillRootPrefix ? e.entryName.slice(skillRootPrefix.length) : e.entryName;
195
210
  if (!rel || rel.includes('..')) continue;
196
211
  const data = e.getData();
197
- if (data.length > MAX_PER_FILE) {
212
+ if (data.length > MAX_SKILL_FILE) {
198
213
  throw Object.assign(new Error('File actual size too large'), { status: 400, code: 'ZIP_BOMB' });
199
214
  }
200
215
  actualTotal += data.length;
201
- if (actualTotal > MAX_TOTAL_UNCOMPRESSED) {
216
+ if (actualTotal > MAX_SKILL_TOTAL) {
202
217
  throw Object.assign(new Error('Archive actual size too large'), { status: 400, code: 'ZIP_BOMB' });
203
218
  }
204
219
  const finalRel = rel.split('/').pop().toLowerCase() === 'skill.md'
@@ -208,6 +223,8 @@ export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
208
223
  }
209
224
  }
210
225
 
226
+ // defense-in-depth: requireSkillSpec already validated the name (and would have
227
+ // thrown first); keep this re-check so future refactors can't silently skip it.
211
228
  if (!validateSkillName(skillName)) {
212
229
  throw Object.assign(new Error(`Invalid skill name: ${skillName}`), { status: 400, code: 'INVALID_NAME' });
213
230
  }
@@ -264,7 +281,8 @@ export function importSkillTo(req, res, { skillsRoot, windowsReserved }) {
264
281
  const contentLength = parseInt(req.headers['content-length'] || '0', 10);
265
282
  if (contentLength > MAX_UPLOAD) {
266
283
  res.writeHead(413, { 'Content-Type': 'application/json' });
267
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
284
+ // code 字段供前端 skillImportErrorKey 映射成 i18n 文案,而不是裸英文
285
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)', code: 'TOO_LARGE' }));
268
286
  return;
269
287
  }
270
288
  const boundary = boundaryMatch[1].trim().replace(/^["']|["']$/g, '');
@@ -276,7 +294,7 @@ export function importSkillTo(req, res, { skillsRoot, windowsReserved }) {
276
294
  if (totalSize > MAX_UPLOAD) {
277
295
  aborted = true;
278
296
  res.writeHead(413, { 'Content-Type': 'application/json' });
279
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
297
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)', code: 'TOO_LARGE' }));
280
298
  req.destroy();
281
299
  return;
282
300
  }