cc-viewer 1.9.15 → 1.9.16

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 (45) hide show
  1. package/cli.js +1 -0
  2. package/concepts/ar/UltraPlan.md +9 -9
  3. package/concepts/da/UltraPlan.md +9 -9
  4. package/concepts/de/UltraPlan.md +9 -9
  5. package/concepts/en/UltraPlan.md +9 -9
  6. package/concepts/es/UltraPlan.md +9 -9
  7. package/concepts/fr/UltraPlan.md +9 -9
  8. package/concepts/it/UltraPlan.md +9 -9
  9. package/concepts/ja/UltraPlan.md +9 -9
  10. package/concepts/ko/UltraPlan.md +9 -9
  11. package/concepts/no/UltraPlan.md +9 -9
  12. package/concepts/pl/UltraPlan.md +9 -9
  13. package/concepts/pt-BR/UltraPlan.md +9 -9
  14. package/concepts/ru/UltraPlan.md +9 -9
  15. package/concepts/th/UltraPlan.md +9 -9
  16. package/concepts/tr/UltraPlan.md +9 -9
  17. package/concepts/uk/UltraPlan.md +9 -9
  18. package/concepts/zh/UltraPlan.md +9 -9
  19. package/concepts/zh-TW/UltraPlan.md +9 -9
  20. package/dist/assets/App-BvVdj099.css +1 -0
  21. package/dist/assets/App-CYdOioAz.js +2 -0
  22. package/dist/assets/{MdxEditorPanel-DRLFmdU5.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
  23. package/dist/assets/Mobile-CfNYOgRa.js +1 -0
  24. package/dist/assets/{ProxyStatsModal-Ba1E1lu4.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
  25. package/dist/assets/index-sgW8MlD3.js +2 -0
  26. package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
  27. package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +2 -0
  28. package/dist/index.html +1 -1
  29. package/package.json +1 -1
  30. package/server/lib/ask/perm-bridge.js +4 -1
  31. package/server/lib/auto-approve.js +160 -0
  32. package/server/lib/file-access-policy.js +24 -2
  33. package/server/lib/sdk-manager.js +96 -9
  34. package/server/routes/ask-perm.js +54 -3
  35. package/server/routes/files-content.js +245 -3
  36. package/server/routes/skills.js +19 -8
  37. package/server/server.js +81 -10
  38. package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
  39. package/ultraAgents/code-expert.json +1 -1
  40. package/dist/assets/App-BCDnm2Lh.js +0 -2
  41. package/dist/assets/App-CCnPkf2_.css +0 -1
  42. package/dist/assets/Mobile-C8VJqBwa.js +0 -1
  43. package/dist/assets/index-Sp3JYYKo.js +0 -2
  44. package/dist/assets/seqResourceLoaders-BUfjTaCV.css +0 -41
  45. package/dist/assets/seqResourceLoaders-CHmtPDFO.js +0 -2
@@ -1,8 +1,10 @@
1
1
  // Read-oriented file-access routes (moved verbatim from server.js handleRequest).
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream } from 'node:fs';
3
- import { join, basename, dirname, resolve, sep } from 'node:path';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream, readdirSync } from 'node:fs';
3
+ import { join, basename, dirname, resolve, relative, sep } from 'node:path';
4
+ import { homedir } from 'node:os';
4
5
  import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
5
6
  import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
7
+ import { listSkills } from '../lib/skills-api.js';
6
8
  import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
7
9
  import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
8
10
  import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
@@ -12,6 +14,8 @@ import { reportSwallowed } from '../lib/error-report.js';
12
14
  import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
13
15
  import { getClaudeConfigDir } from '../../findcc.js';
14
16
  import { _projectName } from '../interceptor.js';
17
+ import { isAdminReq } from '../lib/is-admin.js';
18
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
15
19
 
16
20
  // Multi-project file operations use the same viewed root for reads and writes.
17
21
  // Every file-browser mutation enforces realpath containment in that root;
@@ -537,7 +541,7 @@ function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
537
541
  }
538
542
  }
539
543
 
540
- function fileContentPost(req, res, parsedUrl) {
544
+ function fileContentPost(req, res, parsedUrl, isLocal) {
541
545
  // The shared read root keeps its raw cwd for projectMemory's store-key
542
546
  // encoding. Normalize only this mutation's containment comparisons.
543
547
  let writeRoot;
@@ -609,6 +613,13 @@ function fileContentPost(req, res, parsedUrl) {
609
613
  }
610
614
  // Check containment before creating any missing parent directories.
611
615
  // Explicit $EDITOR sessions retain their allowlisted external paths.
616
+ // editorSession is client-claimed — require admin to honor it, otherwise
617
+ // any caller could use it as a write-root bypass (see /api/target-files).
618
+ if (editorSession && !isAdminReq(req, isLocal)) {
619
+ res.writeHead(403, { 'Content-Type': 'application/json' });
620
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
621
+ return;
622
+ }
612
623
  if (!editorSession && !isInsideWriteRoot(ancestorPolicy.real)) {
613
624
  res.writeHead(403, { 'Content-Type': 'application/json' });
614
625
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
@@ -628,6 +639,11 @@ function fileContentPost(req, res, parsedUrl) {
628
639
  return;
629
640
  }
630
641
 
642
+ if (editorSession && !isAdminReq(req, isLocal)) {
643
+ res.writeHead(403, { 'Content-Type': 'application/json' });
644
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
645
+ return;
646
+ }
631
647
  if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
632
648
  res.writeHead(403, { 'Content-Type': 'application/json' });
633
649
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
@@ -646,6 +662,231 @@ function fileContentPost(req, res, parsedUrl) {
646
662
  });
647
663
  }
648
664
 
665
+ function targetError(status, message) {
666
+ return Object.assign(new Error(message), { status });
667
+ }
668
+
669
+ // Map fs errno to a sensible HTTP status: missing → 404, permission → 403,
670
+ // symlink loop → 400 (treat as malformed), anything else → 404 (don't leak).
671
+ function errnoToStatus(err) {
672
+ const code = err?.code;
673
+ if (code === 'EACCES' || code === 'EPERM') return 403;
674
+ if (code === 'ELOOP') return 400;
675
+ return 404;
676
+ }
677
+
678
+ function realDirectory(path, label) {
679
+ let real;
680
+ try { real = realpathSync(path); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
681
+ let st;
682
+ try { st = statSync(real); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
683
+ if (!st.isDirectory()) throw targetError(400, `${label} is not a directory`);
684
+ return real;
685
+ }
686
+
687
+ function checkedDirectory(path, label) {
688
+ const real = realDirectory(path, label);
689
+ const policy = isReadAllowed(real);
690
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), `${label} not readable`);
691
+ return policy.real;
692
+ }
693
+
694
+ function insidePath(path, root) {
695
+ return path === root || path.startsWith(root + sep);
696
+ }
697
+
698
+ function targetNavigation(targetReal, cwd) {
699
+ const projectReal = realDirectory(cwd, 'Project');
700
+ if (insidePath(targetReal, projectReal)) return { rootReal: projectReal, rootKind: 'project' };
701
+ // $HOME becomes the navigation root for home-resident targets (user skills,
702
+ // plugins, the global CLAUDE.md). Entries outside the allowlist (e.g. ~/.ssh,
703
+ // ~/.aws) are filtered per-entry by isReadAllowed, and sensitive paths inside
704
+ // ~/.claude are covered by file-access-policy's denylist. The /api/target-files
705
+ // handler additionally gates this endpoint behind isAdminReq + same-origin so
706
+ // a cross-site page cannot enumerate the user's home tree.
707
+ const homeReal = realDirectory(homedir(), 'Home');
708
+ if (insidePath(targetReal, homeReal)) {
709
+ checkedDirectory(targetReal, 'Directory');
710
+ return { rootReal: homeReal, rootKind: 'home' };
711
+ }
712
+ const configReal = realDirectory(getClaudeConfigDir(), 'Claude config');
713
+ if (insidePath(targetReal, configReal)) {
714
+ checkedDirectory(targetReal, 'Directory');
715
+ return { rootReal: realDirectory(dirname(configReal), 'Config root'), rootKind: 'config' };
716
+ }
717
+ // Allowlist fallback: targets that live inside another allowlisted root
718
+ // (registered workspaces, /tmp/cc-viewer-uploads, etc.) are reachable via
719
+ // /api/claude-md's discovery (which uses isReadAllowed), so we must not
720
+ // 403 them here. Use the target's own directory as the navigation root —
721
+ // no parent browsing, but at least the entry the user clicked actually opens.
722
+ const policy = isReadAllowed(targetReal);
723
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), 'Target not readable');
724
+ return { rootReal: realDirectory(targetReal, 'Target'), rootKind: 'target' };
725
+ }
726
+
727
+ function targetScope(targetReal, cwd) {
728
+ const navigation = targetNavigation(targetReal, cwd);
729
+ return { ...navigation, targetPath: relative(navigation.rootReal, targetReal).split(sep).join('/') };
730
+ }
731
+
732
+ function targetRelPath(raw) {
733
+ if (raw == null) return null;
734
+ if (raw === '') return [];
735
+ if (typeof raw !== 'string' || raw.length > 2048 || raw.includes('\0') || raw.includes('\\')
736
+ || raw.startsWith('/') || /^[a-zA-Z]:/.test(raw)) {
737
+ throw targetError(400, 'Invalid path');
738
+ }
739
+ const segments = raw.split('/');
740
+ if (segments.some(segment => !segment || segment === '.' || segment === '..')) {
741
+ throw targetError(400, 'Invalid path');
742
+ }
743
+ // Hard cap on navigation depth: each segment triggers at least one
744
+ // synchronous readdir + per-entry realpath/stat in `ancestors`, and a 2KB
745
+ // free-form path could otherwise fan out into hundreds of syscalls per
746
+ // request. 32 is far deeper than any realistic skill / CLAUDE.md location.
747
+ if (segments.length > 32) throw targetError(400, 'Path too deep');
748
+ return segments;
749
+ }
750
+
751
+ function resolveTargetRoot(parsedUrl, cwd) {
752
+ const kind = parsedUrl.searchParams.get('kind');
753
+ if (kind === 'claude-md') {
754
+ const id = parsedUrl.searchParams.get('id') || '';
755
+ if (!/^[0-9a-f]{12}$/i.test(id)) throw targetError(400, 'Invalid id');
756
+ const candidate = discoverClaudeMdCandidates({
757
+ cwd,
758
+ claudeConfigDir: getClaudeConfigDir(),
759
+ isReadAllowedFn: isReadAllowed,
760
+ }).find(entry => entry.id === id);
761
+ if (!candidate || basename(candidate.realPath) !== 'CLAUDE.md') throw targetError(404, 'Target not found');
762
+ return { ...targetScope(realDirectory(dirname(candidate.realPath), 'Directory'), cwd), targetKind: 'claude-md' };
763
+ }
764
+ if (kind !== 'skill') throw targetError(400, 'Invalid target kind');
765
+ const source = parsedUrl.searchParams.get('source');
766
+ const targetPath = parsedUrl.searchParams.get('targetPath');
767
+ if (!['user', 'project', 'plugin'].includes(source)) throw targetError(400, 'Invalid skill source');
768
+ if (typeof targetPath !== 'string' || !targetPath || targetPath.length > 4096 || targetPath.includes('\0')) {
769
+ throw targetError(400, 'Invalid skill path');
770
+ }
771
+ const skill = listSkills({ projectDir: cwd })
772
+ .find(entry => entry.source === source && entry.path === targetPath);
773
+ if (!skill) throw targetError(404, 'Target not found');
774
+ const rootReal = realDirectory(skill.path, 'Directory');
775
+ const scope = targetScope(rootReal, cwd);
776
+ const anchor = source === 'project'
777
+ ? scope.rootReal
778
+ : dirname(dirname(skill.path));
779
+ const anchorReal = realDirectory(anchor, 'Skill root');
780
+ if (!insidePath(rootReal, anchorReal)) throw targetError(403, 'Target outside skill root');
781
+ // Plugin root must follow the configured Claude config dir, not the default
782
+ // homedir()/.claude — otherwise plugin skills break under CLAUDE_CONFIG_DIR.
783
+ if (source === 'plugin' && !insidePath(rootReal, realDirectory(join(getClaudeConfigDir(), 'plugins'), 'Plugin root'))) {
784
+ throw targetError(403, 'Target outside plugin root');
785
+ }
786
+ return { ...scope, targetKind: 'skill' };
787
+ }
788
+
789
+ function listTargetDirectory(rootReal, segments, deps, requireReadable = true) {
790
+ const dirReal = requireReadable
791
+ ? checkedDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory')
792
+ : realDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory');
793
+ if (!isPathContained(dirReal, rootReal)) throw targetError(403, 'Path outside target');
794
+ const ignored = deps?.IGNORED_PATTERNS || new Set();
795
+ const entries = [];
796
+ let dirents;
797
+ try {
798
+ dirents = readdirSync(dirReal, { withFileTypes: true });
799
+ } catch (err) {
800
+ // EACCES → 403 (permission), ENOENT/ENOTDIR → 404 (race with rename/delete),
801
+ // anything else falls back to 404 so we don't leak internal state via 500.
802
+ throw targetError(errnoToStatus(err), 'Directory not readable');
803
+ }
804
+ // Hard cap on returned entries: this is a synchronous loop with realpath +
805
+ // stat + isReadAllowed per entry, and the endpoint fans out via `ancestors`,
806
+ // so an unbounded listing of a huge dir (e.g. ~/.claude/projects/<hash>/)
807
+ // would block the event loop and return a giant JSON payload. 5000 is well
808
+ // past any realistic skill/CLAUDE.md folder.
809
+ const MAX_ENTRIES = 5000;
810
+ for (const entry of dirents) {
811
+ if (entries.length >= MAX_ENTRIES) break;
812
+ if (ignored.has(entry.name)) continue;
813
+ const fullPath = join(dirReal, entry.name);
814
+ let real;
815
+ try { real = realpathSync(fullPath); } catch (err) { reportSwallowed('targetFiles.entry.realpath', err); continue; }
816
+ if (!insidePath(real, rootReal)) continue;
817
+ const policy = isReadAllowed(real);
818
+ if (!policy.ok) continue;
819
+ let st;
820
+ try { st = statSync(policy.real); } catch (err) { reportSwallowed('targetFiles.entry.stat', err); continue; }
821
+ if (!st.isDirectory() && !st.isFile()) continue;
822
+ entries.push({ name: entry.name, type: st.isDirectory() ? 'directory' : 'file', absPath: policy.real });
823
+ }
824
+ entries.sort((a, b) => a.type !== b.type
825
+ ? (a.type === 'directory' ? -1 : 1)
826
+ : a.name.localeCompare(b.name));
827
+ return entries;
828
+ }
829
+
830
+ function targetFiles(req, res, parsedUrl, isLocal, deps) {
831
+ try {
832
+ // /api/target-files exposes directory listings under the user's home and
833
+ // ~/.claude (which contains transcripts, prompt history, env snapshots).
834
+ // Combined with the global `Access-Control-Allow-Origin: *` and loopback's
835
+ // no-auth path, a bare GET would let any web page enumerate the user's
836
+ // ~/.claude tree. Require admin AND a same-origin browser request.
837
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
838
+ res.writeHead(403, { 'Content-Type': 'application/json' });
839
+ res.end(JSON.stringify({ error: 'forbidden' }));
840
+ return;
841
+ }
842
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
843
+ if (!cwd) return;
844
+ const scope = resolveTargetRoot(parsedUrl, cwd);
845
+ const rawPath = parsedUrl.searchParams.get('path');
846
+ const requestedSegments = targetRelPath(rawPath);
847
+ const segments = requestedSegments === null ? scope.targetPath.split('/').filter(Boolean) : requestedSegments;
848
+ const entries = listTargetDirectory(scope.rootReal, segments, deps, segments.length > 0 || scope.rootKind !== 'home');
849
+ const rootEntries = requestedSegments === null && segments.length > 0
850
+ ? listTargetDirectory(scope.rootReal, [], deps, scope.rootKind === 'project')
851
+ : entries;
852
+ const targetEntries = requestedSegments === null
853
+ ? entries.filter(entry => insidePath(entry.absPath, join(scope.rootReal, ...segments)))
854
+ : entries;
855
+ const targetSegments = scope.targetPath ? scope.targetPath.split('/') : [];
856
+ const ancestors = [];
857
+ // Ancestors are the chain from the navigation root down to the directory
858
+ // being listed. When the caller passed no `path` that's the chain leading
859
+ // to the target itself; when they passed `path`, the chain is the requested
860
+ // segments. We deliberately DO NOT use path.relative(targetPath, rawPath)
861
+ // here — that helper happily produces "../.." for sibling branches, which
862
+ // would pollute the cache with pseudo-paths and re-list directories the
863
+ // user never navigated into.
864
+ const chainSegments = requestedSegments === null ? targetSegments : segments;
865
+ // Request-scoped memo: targetFiles lists the same directory multiple times
866
+ // (entries + ancestors chain often share prefixes), so we cache by dir key
867
+ // to avoid duplicate readdirSync storms on large directories.
868
+ const listMemo = new Map();
869
+ const listOnce = (segs, requireReadable) => {
870
+ const key = segs.join('/') + (requireReadable ? '|r' : '|n');
871
+ if (!listMemo.has(key)) listMemo.set(key, listTargetDirectory(scope.rootReal, segs, deps, requireReadable));
872
+ return listMemo.get(key);
873
+ };
874
+ const ancestorCount = Math.max(0, chainSegments.length - (requestedSegments === null ? 0 : 1));
875
+ for (let i = 0; i <= ancestorCount; i++) {
876
+ const path = chainSegments.slice(0, i).join('/');
877
+ ancestors.push({ path, entries: listOnce(path ? path.split('/') : [], false) });
878
+ }
879
+ const { rootReal, ...response } = scope;
880
+ res.writeHead(200, { 'Content-Type': 'application/json' });
881
+ res.end(JSON.stringify({ ...response, targetPath: segments.join('/'), entries: rootEntries, targetEntries, ancestors }));
882
+ } catch (err) {
883
+ const status = err.status || ERROR_STATUS_MAP[err.code] || 500;
884
+ if (status === 500) console.error('[api/target-files]', err);
885
+ res.writeHead(status, { 'Content-Type': 'application/json' });
886
+ res.end(JSON.stringify({ error: status === 500 ? 'Internal error' : err.message }));
887
+ }
888
+ }
889
+
649
890
  export const filesContentRoutes = [
650
891
  { method: 'GET', match: 'exact', path: '/api/plan-file', handler: planFile },
651
892
  { method: 'GET', match: 'exact', path: '/api/file-content', handler: fileContentGet },
@@ -654,4 +895,5 @@ export const filesContentRoutes = [
654
895
  { predicate: (url, method) => (url === '/api/file-raw' || url.startsWith('/api/file-raw/')) && (method === 'GET' || method === 'HEAD'), handler: fileRaw },
655
896
  { predicate: (url, method) => url === '/api/download-file' && (method === 'GET' || method === 'HEAD'), handler: fileDownload },
656
897
  { method: 'POST', match: 'exact', path: '/api/file-content', handler: fileContentPost },
898
+ { method: 'GET', match: 'exact', path: '/api/target-files', handler: targetFiles },
657
899
  ];
@@ -9,13 +9,20 @@ import { loadWorkspaces } from '../workspace-registry.js';
9
9
  import { listLivePtys } from '../pty-manager.js';
10
10
 
11
11
  // Multi-project (2026-10): the skills LIST resolves the viewed project via
12
- // the shared viewRootOrReply (?project=). toggle/delete (mutations) keep the
13
- // bound root.
14
- function _viewRootOrReply(req, res, parsedUrl) {
12
+ // the shared viewRootOrReply (?project=). toggle/delete used to keep the
13
+ // bound root — that was a deliberate safety choice, but it breaks multi-view
14
+ // semantics: the list shows skills for project B while a click on toggle or
15
+ // delete silently rewrote project A's same-named skill. Resolve mutations
16
+ // through the same viewRootOrReply (with body-level project/instance) so the
17
+ // action targets the project the user is actually looking at; the helper
18
+ // still constrains the resolved root to bound/registered/live projects.
19
+ function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
15
20
  return viewRootOrReply(req, res, parsedUrl, {
16
21
  boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
17
22
  loadWorkspaces,
18
23
  listLivePtys,
24
+ bodyProject,
25
+ bodyInstance,
19
26
  });
20
27
  }
21
28
 
@@ -34,18 +41,20 @@ async function skillsList(req, res, parsedUrl) {
34
41
  }
35
42
 
36
43
  // Skill 动态装卸 —— 切换单个 skill(在 skills/ 和 skills-skip/ 之间 move)
37
- function skillsToggle(req, res) {
44
+ function skillsToggle(req, res, parsedUrl) {
38
45
  let body = '';
39
46
  req.on('data', chunk => { body += chunk; if (body.length > 4096) req.destroy(); });
40
47
  req.on('end', async () => {
41
48
  try {
42
- const { source, name, enable } = JSON.parse(body);
49
+ const { source, name, enable, project, instance } = JSON.parse(body);
50
+ const projectDir = _viewRootOrReply(req, res, parsedUrl, project, instance);
51
+ if (!projectDir) return;
43
52
  // 不加进程锁:moveSkill 里 existsSync 前置 + renameSync 原子性已能让并发
44
53
  // toggle 落到合理分支(一个成功、另一个拿 SOURCE_MISSING 或 DUPLICATE);
45
54
  // 前端 toggling:Set 也已防同 tab 连点,两者叠加足够安全
46
55
  moveSkill({
47
56
  source, name, enable: !!enable,
48
- projectDir: process.env.CCV_PROJECT_DIR || process.cwd(),
57
+ projectDir,
49
58
  });
50
59
  res.writeHead(200, { 'Content-Type': 'application/json' });
51
60
  res.end(JSON.stringify({ ok: true }));
@@ -75,10 +84,12 @@ function skillsDelete(req, res, parsedUrl, isLocal) {
75
84
  req.on('data', chunk => { body += chunk; if (body.length > 4096) req.destroy(); });
76
85
  req.on('end', async () => {
77
86
  try {
78
- const { source, name, enabled } = JSON.parse(body);
87
+ const { source, name, enabled, project, instance } = JSON.parse(body);
88
+ const projectDir = _viewRootOrReply(req, res, parsedUrl, project, instance);
89
+ if (!projectDir) return;
79
90
  deleteSkill({
80
91
  source, name, enabled: !!enabled,
81
- projectDir: process.env.CCV_PROJECT_DIR || process.cwd(),
92
+ projectDir,
82
93
  });
83
94
  res.writeHead(200, { 'Content-Type': 'application/json' });
84
95
  res.end(JSON.stringify({ ok: true }));
package/server/server.js CHANGED
@@ -51,6 +51,7 @@ import * as imCore from './lib/im/im-bridge-core.js';
51
51
  import * as imProcMgr from './lib/im/im-process-manager.js';
52
52
  import * as chatQueue from './lib/chat-queue.js';
53
53
  import { reportSwallowed } from './lib/error-report.js';
54
+ import { cancelAutoApproveState } from './lib/auto-approve.js';
54
55
  import { initScheduledTaskScheduler, startScheduledTaskScheduler, stopScheduledTaskScheduler } from './lib/scheduled-task-scheduler.js';
55
56
  import { executeScheduledTask } from './lib/scheduled-task-executor.js';
56
57
  import './lib/adapters/dingtalk-adapter.js'; // side-effect: registers the DingTalk adapter
@@ -636,6 +637,15 @@ function getAllLocalIps() {
636
637
  return ips;
637
638
  }
638
639
 
640
+ // Approval/cancel message id validator — the single source for the ≤256-char +
641
+ // [a-zA-Z0-9_-] whitelist shared by ask-cancel / sdk-plan-auto-cancel /
642
+ // perm-hook-auto-cancel (keeps the three branches consistent).
643
+ function validateApprovalId(raw) {
644
+ if (raw == null) return null;
645
+ const id = String(raw);
646
+ return id.length > 0 && id.length <= 256 && /^[a-zA-Z0-9_-]+$/.test(id) ? id : null;
647
+ }
648
+
639
649
  // ─── Route dependency bag ──────────────────────────────────────────
640
650
  // Single dependency object handed to every per-domain route handler (server/routes/*).
641
651
  // Built once; route bodies were moved out of handleRequest's if-chain verbatim, with
@@ -1614,6 +1624,7 @@ function _buildPluginInteractions() {
1614
1624
  const entry = pendingPermHooks.get(id);
1615
1625
  if (!entry) return false;
1616
1626
  clearTimeout(entry.timer);
1627
+ if (entry.auto?.timer) clearTimeout(entry.auto.timer);
1617
1628
  pendingPermHooks.delete(id);
1618
1629
  try {
1619
1630
  if (!entry.res.headersSent) {
@@ -2161,7 +2172,14 @@ async function setupTerminalWebSocket(httpServer) {
2161
2172
  // 直到超时 silent deny)。连接时重发,前端 perm 分支按 id 去重,天然幂等。
2162
2173
  for (const [id, entry] of pendingPermHooks) {
2163
2174
  try {
2164
- ws.send(JSON.stringify({ type: 'perm-hook-pending', id, toolName: entry.toolName, input: entry.input }));
2175
+ ws.send(JSON.stringify({
2176
+ type: 'perm-hook-pending', id, toolName: entry.toolName, input: entry.input,
2177
+ autoApproveAt: entry.autoCancelled ? null : (entry.auto?.autoApproveAt ?? null),
2178
+ // 0 = server says OFF (fork-off / npm publish floor): never let a reconnecting
2179
+ // client fall back to its local pref and re-arm a countdown the server disabled.
2180
+ autoApproveSeconds: entry.auto?.seconds ?? 0,
2181
+ autoCancelled: entry.autoCancelled === true,
2182
+ }));
2165
2183
  } catch {}
2166
2184
  }
2167
2185
 
@@ -2208,6 +2226,13 @@ async function setupTerminalWebSocket(httpServer) {
2208
2226
  if (p.kind === 'ask') {
2209
2227
  payload.startedAt = Date.now();
2210
2228
  payload.timeoutMs = p.remainingMs;
2229
+ } else {
2230
+ // plan/perm: carry the server-side auto-approve deadline so a reconnected
2231
+ // client renders the REMAINING time (and a cancelled one never re-arms)
2232
+ // instead of restarting a full-length countdown.
2233
+ payload.autoApproveAt = p.autoApproveAt ?? null;
2234
+ payload.autoCancelled = p.autoCancelled === true;
2235
+ if (p.autoApproveSeconds != null) payload.autoApproveSeconds = p.autoApproveSeconds;
2211
2236
  }
2212
2237
  try { ws.send(JSON.stringify(payload)); } catch (err) { reportSwallowed('sdk.approval-replay', err); }
2213
2238
  }
@@ -2670,6 +2695,7 @@ async function setupTerminalWebSocket(httpServer) {
2670
2695
  if (hookEntry) {
2671
2696
  const { res: hookRes, timer } = hookEntry;
2672
2697
  clearTimeout(timer);
2698
+ if (hookEntry.auto?.timer) clearTimeout(hookEntry.auto.timer);
2673
2699
  pendingPermHooks.delete(msg.id);
2674
2700
  permAnswered = true;
2675
2701
  try {
@@ -2706,7 +2732,7 @@ async function setupTerminalWebSocket(httpServer) {
2706
2732
  // cancelId/Reason 校验:与 toolUseId 同套白名单(≤256 字符 + [a-zA-Z0-9_-])+ reason ≤500
2707
2733
  // 防恶意/buggy client 塞超长 key 撑大 _pendingApprovals 或塞 1MB reason 打爆 broadcast。
2708
2734
  const rawId = msg.id != null ? String(msg.id) : null;
2709
- const cancelId = rawId && rawId.length > 0 && rawId.length <= 256 && /^[a-zA-Z0-9_-]+$/.test(rawId) ? rawId : null;
2735
+ const cancelId = validateApprovalId(msg.id);
2710
2736
  if (rawId && !cancelId) {
2711
2737
  console.warn('[server] ask-cancel rejected: invalid id format');
2712
2738
  return;
@@ -2782,20 +2808,63 @@ async function setupTerminalWebSocket(httpServer) {
2782
2808
  }
2783
2809
  }
2784
2810
  } else if (msg.type === 'sdk-plan-auto-cancel') {
2785
- // Cancellation stops the automatic timers on other viewers too. It is
2786
- // not an approval/denial: the plan remains pending for manual review.
2787
- if (_sdkResolveApproval && msg.id) broadcastWsMessage({
2788
- type: 'sdk-plan-auto-cancel', id: msg.id,
2811
+ // Cancellation stops the automatic timers on every viewer AND the
2812
+ // server-side fallback timer. It is not an approval/denial: the plan
2813
+ // remains pending for manual review; the cancelled state is replayed
2814
+ // to fresh clients so they never re-arm the countdown.
2815
+ const rawPlanCancelId = msg.id != null ? String(msg.id) : null;
2816
+ const planCancelId = validateApprovalId(msg.id);
2817
+ if (rawPlanCancelId && !planCancelId) {
2818
+ console.warn('[server] sdk-plan-auto-cancel rejected: invalid id format');
2819
+ return;
2820
+ }
2821
+ let handled = false;
2822
+ if (planCancelId && _sdkAutoCancelApproval) {
2823
+ try { handled = _sdkAutoCancelApproval(planCancelId, 'plan') === true; } catch (err) { reportSwallowed('server.plan-auto-cancel', err); }
2824
+ }
2825
+ // Legacy relay fallback: no SDK pending matched (PTY-mode senders or a
2826
+ // stale id) — keep the old behavior so other clients still stop counting.
2827
+ if (!handled && planCancelId) broadcastWsMessage({
2828
+ type: 'sdk-plan-auto-cancel', id: planCancelId,
2789
2829
  cwd: msg.cwd || null, project: msg.project || null,
2790
2830
  instanceKey: msg.instanceKey || null,
2791
2831
  });
2832
+ } else if (msg.type === 'perm-hook-auto-cancel') {
2833
+ // Symmetric to sdk-plan-auto-cancel for permission approvals: destroys the
2834
+ // server-side fallback timer (SDK pending or PTY-hook long-poll), keeps the
2835
+ // approval pending for a manual decision, and notifies all clients.
2836
+ const rawPermCancelId = msg.id != null ? String(msg.id) : null;
2837
+ const permCancelId = validateApprovalId(msg.id);
2838
+ if (rawPermCancelId && !permCancelId) {
2839
+ console.warn('[server] perm-hook-auto-cancel rejected: invalid id format');
2840
+ return;
2841
+ }
2842
+ let permCancelHandled = false;
2843
+ if (permCancelId && _sdkAutoCancelApproval) {
2844
+ try { permCancelHandled = _sdkAutoCancelApproval(permCancelId, 'perm') === true; } catch (err) { reportSwallowed('server.perm-auto-cancel', err); }
2845
+ }
2846
+ if (!permCancelHandled && permCancelId) {
2847
+ const hookEntry = pendingPermHooks.get(permCancelId);
2848
+ if (hookEntry) {
2849
+ cancelAutoApproveState(hookEntry);
2850
+ permCancelHandled = true;
2851
+ if (terminalWss) {
2852
+ const cmsg = JSON.stringify({ type: 'perm-hook-auto-cancel', id: permCancelId });
2853
+ terminalWss.clients.forEach((c) => {
2854
+ if (c.readyState === 1) try { c.send(cmsg); } catch {}
2855
+ });
2856
+ }
2857
+ }
2858
+ }
2792
2859
  } else if (msg.type === 'sdk-plan-answer') {
2793
2860
  // Plan approval in SDK mode
2794
- if (_sdkResolveApproval) {
2795
- _sdkResolveApproval(msg.id, { approve: msg.approve !== false, feedback: msg.feedback || '' });
2861
+ let planAnswered = false;
2862
+ if (_sdkResolveApproval && msg.id) {
2863
+ planAnswered = _sdkResolveApproval(msg.id, { approve: msg.approve !== false, feedback: msg.feedback || '' }) === true;
2796
2864
  }
2797
- // Broadcast resolved to other clients
2798
- if (terminalWss) {
2865
+ // Broadcast resolved to other clients only when an answer was actually
2866
+ // processed — a stale/unknown id must not close other viewers' plan modal.
2867
+ if (planAnswered && terminalWss) {
2799
2868
  const rmsg = JSON.stringify({ type: 'sdk-plan-resolved', id: msg.id });
2800
2869
  terminalWss.clients.forEach((c) => {
2801
2870
  if (c !== ws && c.readyState === 1) try { c.send(rmsg); } catch {}
@@ -3715,6 +3784,8 @@ function _broadcastApprovalCloses(cancelled, reason) {
3715
3784
  /** Reference to sdk-manager's resolveApproval (set by cli.js after import). */
3716
3785
  let _sdkResolveApproval = null;
3717
3786
  export function setSdkResolveApproval(fn) { _sdkResolveApproval = fn; }
3787
+ let _sdkAutoCancelApproval = null;
3788
+ export function setSdkAutoCancelApproval(fn) { _sdkAutoCancelApproval = fn; }
3718
3789
 
3719
3790
  /** Reference to sdk-manager's cancelApproval (set by cli.js after import). */
3720
3791
  // 与 _sdkResolveApproval 平行——但语义不同:cancelApproval 让 _waitForApproval resolve
@@ -22,6 +22,7 @@ IMPORTANT: Never generate or guess URLs unless you are confident they help the u
22
22
  - Convergence budget: once you have enough evidence to answer, stop searching and answer; a partial report beats endless exploration.
23
23
  - When a request could be read either as a question or as a change to make, treat it as a task and carry it out. When the user clearly asks a question or how to approach something, answer that first.
24
24
  - Deliver exactly what was asked and nothing more: no unrequested CLI wrappers, configuration options, logging, progress output, or abstractions. This is very important to your performance.
25
+ - Do not schedule real-environment verification (deploy checks, live E2E, manual smoke runs) unless the user explicitly asks — it burns tokens for little signal.
25
26
  - Never assume a library or framework is available — check the project's manifest or neighboring files before using it.
26
27
  - Do not introduce security vulnerabilities (injection, XSS, path traversal, and the rest of the OWASP top 10); fix insecure code you write immediately.
27
28
  - Iterate to green: run the relevant tests or code path, read the failure, fix the cause, and run again.
@@ -41,5 +41,5 @@
41
41
  "tr": "Kıdemli mühendis: çoklu ajan keşfi, plan incelemesi ve yüksek kaliteli uygulama — kod incelemesi, yeniden düzenleme, performans, sürdürülebilirlik.",
42
42
  "uk": "Senior-інженер: багатоагентне дослідження, рев'ю плану та якісна реалізація — рев'ю коду, рефакторинг, продуктивність, супровід."
43
43
  },
44
- "content": "<system-reminder>\n[SCOPED INSTRUCTION] The following instructions apply only to the next 1–3 interactions. Once the task is complete, these instructions should gradually decrease in priority and no longer affect subsequent interactions. You should be adept at utilizing tools such as `AskUserQuestion`, `EnterPlanMode`, and `TeamCreate`, rather than relying solely on plain text processing. Before execution, you must ensure that the `EnterPlanMode`, `ExitPlanMode`, `SendMessage`, `TaskCreate`, `TaskUpdate`, `TaskStop`, `TaskGet`, `TaskOutput` and `TaskList` tools are loaded.\n\nPre-requisite: Use `AskUserQuestion` to clarify user intent whenever the request is ambiguous (target element, interaction style, scope of platforms, etc.). Skip only if the intent is unambiguous.\n\nLeverage a multi-agent exploration mechanism to formulate a highly detailed implementation plan.\n\nInstructions:\n1. Use the `Agent` tool to spawn parallel agents that simultaneously explore different aspects of the codebase:\n- If necessary, assign a preliminary researcher to use the `webSearch` tool to first investigate cutting-edge solutions in the relevant industry domain;\n- One agent responsible for understanding the relevant existing code and architecture;\n- One agent responsible for identifying all files that need to be modified;\n- One agent responsible for identifying potential risks, edge cases, and dependencies;\n- You may add other roles or deploy additional agents beyond the three listed above; the maximum number of concurrently dispatched agents is 5.\n\n2. Synthesize the findings from all agents into a detailed, step-by-step implementation plan.\n\n3. Use the `Agent` tool to spawn 2-3 review agents that examine the plan from different perspectives, checking for missing steps, potential risks, or corresponding mitigation strategies.\n\n4. Integrate the feedback gathered during the review process, then call `ExitPlanMode` to submit your final plan.\n\n5. Once `ExitPlanMode` returns a result:\n- If approved: proceed to execute the plan within this session.\n- If rejected: revise the plan based on the feedback provided and call `ExitPlanMode` again.\n- If an error occurs (including receiving a \"Not in Plan Mode\" message): do **not** follow the suggestions provided in the error message; instead, prompt the user for further instructions.\n\nYour final plan must include the following elements:\n- A clear summary of the implementation strategy;\n- An ordered list of files to be created or modified, with precise details of the required changes for each file;\n- A step-by-step execution sequence;\n- Testing and validation procedures;\n- Potential risks and their corresponding mitigation strategies;\n\n6. After the final plan has been successfully executed:\nFirst run `git diff --quiet && git diff --cached --quiet` (or equivalent) to detect whether the working tree actually has non-trivial changes; if there are no real changes (or only whitespace/comment-only edits), skip the UltraReview step.\nOtherwise, if the project is managed with Git:\nInitiate a team (`TeamCreate`), dynamically allocating the number of teammates based on task complexity (5 is recommended);\nIf the `TeamCreate` tool is unavailable, spawn teammates directly with the `Agent` tool (note: as teammates, not plain subagents);\nTask: Conduct a Code Review of the current git changes from multiple perspectives;\nPre-requisites:\n- The git repository may be located in a subdirectory of the current directory; prefer `git rev-parse --show-toplevel` (fall back to recursive lookup) before proceeding;\n- In the case of multiple repositories, tasks may be executed separately;\nThe team's goal is to analyze the current Git change log and validate each modification from different perspectives, specifically including:\n- Whether requirements/objectives have been met and functionality is complete;\n- Whether newly added code introduces side effects, breaks existing functionality, or poses potential risks;\n- Code quality: naming, readability, complexity, technical debt, maintainability;\n- Testing and documentation: whether there is adequate test coverage, and whether critical logic has necessary comments or documentation;\n- Dependencies and compatibility: whether new dependencies or version compatibility issues have been introduced;\nWorkflow:\n- Each teammate, according to their own role, covers the review dimensions one by one and independently outputs a report;\n- After consolidating the reports, perform a cross-review to identify conflicts or shared concerns;\n- Distill specific, actionable modification suggestions and annotate them with priority levels (P0/P1/P2/P3);\n- Upon completion, adopt P0 items; adopt P1 and P2 items selectively based on evaluation; disregard P3 items.\n- After execution is complete, close the team (`TeamDelete`);\n- If the `TeamDelete` tool is unavailable, notify each teammate to exit individually;\n</system-reminder>"
44
+ "content": "<system-reminder>\n[SCOPED INSTRUCTION] The following instructions apply only to the next 1–3 interactions. Once the task is complete, these instructions should gradually decrease in priority and no longer affect subsequent interactions. You should be adept at utilizing tools such as `AskUserQuestion`, `EnterPlanMode`, and `TeamCreate`, rather than relying solely on plain text processing. Before execution, you must ensure that the `EnterPlanMode`, `ExitPlanMode`, `SendMessage`, `TaskCreate`, `TaskUpdate`, `TaskStop`, `TaskGet`, `TaskOutput` and `TaskList` tools are loaded.\n\nPre-requisite: Use `AskUserQuestion` to clarify user intent whenever the request is ambiguous (target element, interaction style, scope of platforms, etc.). Skip only if the intent is unambiguous.\n\nLeverage a multi-agent exploration mechanism to formulate a highly detailed implementation plan.\n\nInstructions:\n1. Use the `Agent` tool to spawn parallel agents that simultaneously explore different aspects of the codebase:\n- If necessary, assign a preliminary researcher to use the `webSearch` tool to first investigate cutting-edge solutions in the relevant industry domain;\n- One agent responsible for understanding the relevant existing code and architecture;\n- One agent responsible for identifying all files that need to be modified;\n- One agent responsible for identifying potential risks, edge cases, and dependencies;\n- You may add other roles or deploy additional agents beyond the three listed above; the maximum number of concurrently dispatched agents is 5.\n\n2. Synthesize the findings from all agents into a detailed, step-by-step implementation plan.\n\n3. Use the `Agent` tool to spawn 2-3 review agents that examine the plan from different perspectives, checking for missing steps, potential risks, or corresponding mitigation strategies.\n\n4. Integrate the feedback gathered during the review process, then call `ExitPlanMode` to submit your final plan.\n\n5. Once `ExitPlanMode` returns a result:\n- If approved: proceed to execute the plan within this session.\n- If rejected: revise the plan based on the feedback provided and call `ExitPlanMode` again.\n- If an error occurs (including receiving a \"Not in Plan Mode\" message): do **not** follow the suggestions provided in the error message; instead, prompt the user for further instructions.\n\nYour final plan must include the following elements:\n- A clear summary of the implementation strategy;\n- An ordered list of files to be created or modified, with precise details of the required changes for each file;\n- A step-by-step execution sequence;\n- Testing and validation procedures;\n- Potential risks and their corresponding mitigation strategies;\n\n6. After the final plan has been successfully executed:\nFirst run `git diff --quiet && git diff --cached --quiet` (or equivalent) to detect whether the working tree actually has non-trivial changes; if there are no real changes (or only whitespace/comment-only edits), skip the UltraReview step.\nOtherwise, if the project is managed with Git:\nUse the `Agent` tool to build a team, dynamically allocating the number of teammates based on task complexity (5+ is recommended);\nNote: teammates are named (pass `name`), not plain subagents (which have no name);\nTask: Conduct a Code Review of the current git changes from multiple roles and perspectives;\nPre-requisites:\n- The git repository may be located in a subdirectory of the current directory; recursively locate the .git directory first (prefer `git rev-parse --show-toplevel`) before proceeding;\n- In the case of multiple repositories, review tasks may be executed separately per repository;\nReview dimensions (a reference framework — each role covers them as its perspective requires, aiming to be as comprehensive as possible):\n- Whether requirements/objectives have been met and functionality is complete;\n- Whether newly added code introduces side effects, breaks existing functionality, or poses potential risks;\n- Code quality: naming, readability, complexity, technical debt, maintainability;\n- Testing and documentation: whether there is adequate test coverage, and whether critical logic has necessary comments or documentation;\n- Dependencies and compatibility: whether new dependencies or version compatibility issues have been introduced;\n- Any other dimensions you see fit;\nWorkflow:\n- Each teammate, according to their own role, covers the review dimensions one by one and independently outputs a report;\n- After consolidating the reports, perform a cross-review to identify conflicts or shared concerns;\n- Distill specific, actionable modification suggestions and annotate them with priority levels (P0/P1/P2/P3);\n- Upon completion, fix all P0 and P1 items; evaluate P2 items dialectically (use `AskUserQuestion` to consult the user when any item needs a special decision);\n- After execution is complete, close the team: notify each teammate to exit individually;\n</system-reminder>"
45
45
  }