@zzusp/ccsm 1.0.1 → 1.0.2
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/LICENSE +21 -21
- package/README.md +236 -236
- package/bin/cli.mjs +52 -52
- package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
- package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
- package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- package/dist/assets/index-CrWxV6sb.css +1 -0
- package/dist/assets/index-DTbWl1jb.js +11 -0
- package/dist/assets/index-DTbWl1jb.js.map +1 -0
- package/dist/assets/markdown-Bag5rX3T.js +30 -0
- package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
- package/dist/index.html +26 -26
- package/package.json +85 -83
- package/server/index.ts +130 -130
- package/server/lib/active-sessions.test.ts +119 -119
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -182
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -126
- package/server/lib/claude-paths.ts +43 -43
- package/server/lib/cleanup-suggestions.ts +131 -131
- package/server/lib/constants.ts +8 -8
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -244
- package/server/lib/delete.ts +192 -192
- package/server/lib/disk-usage.ts +81 -81
- package/server/lib/encode-cwd.ts +24 -24
- package/server/lib/export-bundle.ts +236 -236
- package/server/lib/export-import-bundle.test.ts +337 -337
- package/server/lib/fs-size.ts +38 -38
- package/server/lib/import-bundle.ts +488 -488
- package/server/lib/load-memory.ts +120 -120
- package/server/lib/load-session.ts +209 -209
- package/server/lib/modified-files.test.ts +280 -280
- package/server/lib/modified-files.ts +228 -228
- package/server/lib/open-folder.ts +47 -47
- package/server/lib/parse-jsonl.ts +160 -139
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -41
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -73
- package/server/lib/safe-remove.ts +25 -25
- package/server/lib/scan.ts +289 -286
- package/server/lib/search-all.ts +130 -130
- package/server/lib/search-session.ts +203 -203
- package/server/lib/system-tags.ts +20 -20
- package/server/lib/update.ts +67 -67
- package/server/lib/version.test.ts +39 -39
- package/server/lib/version.ts +117 -117
- package/server/routes/disk-cleanup.ts +54 -54
- package/server/routes/disk.ts +9 -9
- package/server/routes/import.ts +87 -87
- package/server/routes/projects.ts +104 -104
- package/server/routes/search.ts +79 -79
- package/server/routes/sessions.ts +130 -130
- package/server/routes/version.ts +34 -34
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -7
- package/shared/types.ts +513 -511
- package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
- package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
- package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
- package/dist/assets/index-7aMrnHJG.js +0 -7
- package/dist/assets/index-7aMrnHJG.js.map +0 -1
- package/dist/assets/index-BOeI_J4B.css +0 -1
|
@@ -1,139 +1,160 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import readline from 'node:readline';
|
|
3
|
-
import { INTERRUPTED_MARKER_RE } from './constants.ts';
|
|
4
|
-
import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
|
|
5
|
-
|
|
6
|
-
export interface JsonlMeta {
|
|
7
|
-
title: string;
|
|
8
|
-
/** Latest `custom-title` record value, or null if never renamed. */
|
|
9
|
-
customTitle: string | null;
|
|
10
|
-
firstAt: string | null;
|
|
11
|
-
lastAt: string | null;
|
|
12
|
-
messageCount: number;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
let
|
|
27
|
-
let
|
|
28
|
-
let
|
|
29
|
-
let
|
|
30
|
-
let
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
let
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { INTERRUPTED_MARKER_RE } from './constants.ts';
|
|
4
|
+
import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
|
|
5
|
+
|
|
6
|
+
export interface JsonlMeta {
|
|
7
|
+
title: string;
|
|
8
|
+
/** Latest `custom-title` record value, or null if never renamed. */
|
|
9
|
+
customTitle: string | null;
|
|
10
|
+
firstAt: string | null;
|
|
11
|
+
lastAt: string | null;
|
|
12
|
+
messageCount: number;
|
|
13
|
+
/** Count of tool_result blocks flagged `is_error` across the session. */
|
|
14
|
+
errorCount: number;
|
|
15
|
+
cwdFromMessages: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* The last conversation turn is unfinished — Claude still owes output. True when
|
|
18
|
+
* the final `user`/`assistant` record is either a `user` message (and not an
|
|
19
|
+
* abort marker) or an `assistant` message that ends on a `tool_use` block. This
|
|
20
|
+
* is the structural half of "working"; liveness gating happens in the caller.
|
|
21
|
+
*/
|
|
22
|
+
lastTurnIncomplete: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
|
|
26
|
+
let firstUserTitle = '';
|
|
27
|
+
let aiTitle: string | null = null;
|
|
28
|
+
let customTitle: string | null = null;
|
|
29
|
+
let firstAt: string | null = null;
|
|
30
|
+
let lastAt: string | null = null;
|
|
31
|
+
let messageCount = 0;
|
|
32
|
+
let errorCount = 0;
|
|
33
|
+
let cwdFromMessages: string | null = null;
|
|
34
|
+
// Re-evaluated on every conversation record so it reflects the *last* turn once
|
|
35
|
+
// the scan finishes.
|
|
36
|
+
let lastTurnIncomplete = false;
|
|
37
|
+
|
|
38
|
+
const rl = readline.createInterface({
|
|
39
|
+
input: fs.createReadStream(filePath, { encoding: 'utf8' }),
|
|
40
|
+
crlfDelay: Infinity,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
for await (const raw of rl) {
|
|
44
|
+
const line = raw.trim();
|
|
45
|
+
if (!line) continue;
|
|
46
|
+
let obj: Record<string, unknown>;
|
|
47
|
+
try {
|
|
48
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
54
|
+
if (ts) {
|
|
55
|
+
if (!firstAt) firstAt = ts;
|
|
56
|
+
lastAt = ts;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (obj.cwd && typeof obj.cwd === 'string' && !cwdFromMessages) {
|
|
60
|
+
cwdFromMessages = obj.cwd;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
|
|
64
|
+
customTitle = obj.customTitle;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Claude Code rewrites this record every turn; the latest copy is canonical.
|
|
68
|
+
if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
|
|
69
|
+
aiTitle = obj.aiTitle;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
73
|
+
messageCount += 1;
|
|
74
|
+
const msg = obj.message as { content?: unknown } | undefined;
|
|
75
|
+
errorCount += countErrorResults(msg?.content);
|
|
76
|
+
|
|
77
|
+
if (obj.type === 'assistant') {
|
|
78
|
+
lastTurnIncomplete = endsWithToolUse(msg?.content);
|
|
79
|
+
} else {
|
|
80
|
+
const candidate = extractUserText(msg?.content);
|
|
81
|
+
// A trailing user record means Claude owes a reply — unless it is the
|
|
82
|
+
// synthetic abort marker, which means the operator stopped the turn.
|
|
83
|
+
lastTurnIncomplete = !INTERRUPTED_MARKER_RE.test(candidate);
|
|
84
|
+
|
|
85
|
+
if (!firstUserTitle && candidate && !SYSTEM_TAG_RE.test(candidate)) {
|
|
86
|
+
const usable = pickTitleText(candidate);
|
|
87
|
+
if (usable) {
|
|
88
|
+
firstUserTitle = usable.slice(0, 80).replace(/\s+/g, ' ').trim();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `claude code resume` keys off file mtime, which advances even when Claude Code
|
|
96
|
+
// rewrites untimestamped meta records (ai-title rotate, custom-title/agent-name on
|
|
97
|
+
// rename, last-prompt, permission-mode). Reconcile so the UI agrees with resume.
|
|
98
|
+
const mtimeIso = fs.statSync(filePath).mtime.toISOString();
|
|
99
|
+
const reconciledLastAt = !lastAt || mtimeIso > lastAt ? mtimeIso : lastAt;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
title: aiTitle || firstUserTitle || '(untitled)',
|
|
103
|
+
customTitle,
|
|
104
|
+
firstAt,
|
|
105
|
+
lastAt: reconciledLastAt,
|
|
106
|
+
messageCount,
|
|
107
|
+
errorCount,
|
|
108
|
+
cwdFromMessages,
|
|
109
|
+
lastTurnIncomplete,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// An assistant message that ends on a `tool_use` block (Anthropic `stop_reason:
|
|
114
|
+
// "tool_use"`) is mid-work: a tool is pending and Claude will continue once it
|
|
115
|
+
// returns. Verified 1:1 against `stop_reason` across real sessions.
|
|
116
|
+
function endsWithToolUse(content: unknown): boolean {
|
|
117
|
+
if (!Array.isArray(content)) return false;
|
|
118
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
119
|
+
const block = content[i];
|
|
120
|
+
if (block && typeof block === 'object' && typeof (block as { type?: unknown }).type === 'string') {
|
|
121
|
+
return (block as { type: string }).type === 'tool_use';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractUserText(content: unknown): string {
|
|
128
|
+
if (typeof content === 'string') return content;
|
|
129
|
+
if (Array.isArray(content)) {
|
|
130
|
+
for (const block of content) {
|
|
131
|
+
if (
|
|
132
|
+
block &&
|
|
133
|
+
typeof block === 'object' &&
|
|
134
|
+
'type' in block &&
|
|
135
|
+
block.type === 'text' &&
|
|
136
|
+
'text' in block &&
|
|
137
|
+
typeof (block as { text: unknown }).text === 'string'
|
|
138
|
+
) {
|
|
139
|
+
return (block as { text: string }).text;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function countErrorResults(content: unknown): number {
|
|
147
|
+
if (!Array.isArray(content)) return 0;
|
|
148
|
+
let n = 0;
|
|
149
|
+
for (const block of content) {
|
|
150
|
+
if (
|
|
151
|
+
block &&
|
|
152
|
+
typeof block === 'object' &&
|
|
153
|
+
(block as { type?: unknown }).type === 'tool_result' &&
|
|
154
|
+
(block as { is_error?: unknown }).is_error === true
|
|
155
|
+
) {
|
|
156
|
+
n += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return n;
|
|
160
|
+
}
|
package/server/lib/port.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import { createServer } from 'node:net';
|
|
2
|
-
|
|
3
|
-
export async function findAvailablePort(
|
|
4
|
-
start: number,
|
|
5
|
-
end: number,
|
|
6
|
-
host = '127.0.0.1',
|
|
7
|
-
): Promise<number> {
|
|
8
|
-
for (let port = start; port <= end; port++) {
|
|
9
|
-
if (await isPortFree(port, host)) return port;
|
|
10
|
-
}
|
|
11
|
-
throw new Error(`No free port in range ${start}..${end} on ${host}`);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function isPortFree(port: number, host: string): Promise<boolean> {
|
|
15
|
-
return new Promise((resolve) => {
|
|
16
|
-
const server = createServer();
|
|
17
|
-
server.once('error', () => resolve(false));
|
|
18
|
-
server.once('listening', () => {
|
|
19
|
-
server.close(() => resolve(true));
|
|
20
|
-
});
|
|
21
|
-
server.listen(port, host);
|
|
22
|
-
});
|
|
23
|
-
}
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
|
|
3
|
+
export async function findAvailablePort(
|
|
4
|
+
start: number,
|
|
5
|
+
end: number,
|
|
6
|
+
host = '127.0.0.1',
|
|
7
|
+
): Promise<number> {
|
|
8
|
+
for (let port = start; port <= end; port++) {
|
|
9
|
+
if (await isPortFree(port, host)) return port;
|
|
10
|
+
}
|
|
11
|
+
throw new Error(`No free port in range ${start}..${end} on ${host}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPortFree(port: number, host: string): Promise<boolean> {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const server = createServer();
|
|
17
|
+
server.once('error', () => resolve(false));
|
|
18
|
+
server.once('listening', () => {
|
|
19
|
+
server.close(() => resolve(true));
|
|
20
|
+
});
|
|
21
|
+
server.listen(port, host);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { isSafeId } from './safe-id.ts';
|
|
3
|
-
|
|
4
|
-
// 守门:URL 参数里的 sessionId / projectId 走到任何 fs.* 之前必须先过这一关,
|
|
5
|
-
// 漏一类就会让攻击者用 ../ 跳出 ~/.claude,所以四个拒绝点逐条钉死。
|
|
6
|
-
describe('isSafeId', () => {
|
|
7
|
-
it('接受常规 uuid / 编码后的 cwd', () => {
|
|
8
|
-
expect(isSafeId('019410ce-49fb-7d5c-b0a4-2d7d2b6a4b7d')).toBe(true);
|
|
9
|
-
expect(isSafeId('-Users-sunpeng-workspace-claude-code-session')).toBe(true);
|
|
10
|
-
expect(isSafeId('C--Users-sunpeng')).toBe(true);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('拒绝空字符串', () => {
|
|
14
|
-
expect(isSafeId('')).toBe(false);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('拒绝包含正斜杠的 id(path-traversal 入口)', () => {
|
|
18
|
-
expect(isSafeId('a/b')).toBe(false);
|
|
19
|
-
expect(isSafeId('../etc/passwd')).toBe(false);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('拒绝包含反斜杠的 id(Windows path-traversal)', () => {
|
|
23
|
-
expect(isSafeId('a\\b')).toBe(false);
|
|
24
|
-
expect(isSafeId('..\\windows')).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('拒绝包含 .. 的 id(即便不带分隔符)', () => {
|
|
28
|
-
expect(isSafeId('foo..bar')).toBe(false);
|
|
29
|
-
expect(isSafeId('..')).toBe(false);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('拒绝以 . 开头的 id(屏蔽 dotfile)', () => {
|
|
33
|
-
expect(isSafeId('.hidden')).toBe(false);
|
|
34
|
-
expect(isSafeId('.bak-1700000000')).toBe(false);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('单点开头的拒绝不影响中间含 . 的合法 id', () => {
|
|
38
|
-
expect(isSafeId('memory.md')).toBe(true);
|
|
39
|
-
expect(isSafeId('file.imported-abcd1234.md')).toBe(true);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isSafeId } from './safe-id.ts';
|
|
3
|
+
|
|
4
|
+
// 守门:URL 参数里的 sessionId / projectId 走到任何 fs.* 之前必须先过这一关,
|
|
5
|
+
// 漏一类就会让攻击者用 ../ 跳出 ~/.claude,所以四个拒绝点逐条钉死。
|
|
6
|
+
describe('isSafeId', () => {
|
|
7
|
+
it('接受常规 uuid / 编码后的 cwd', () => {
|
|
8
|
+
expect(isSafeId('019410ce-49fb-7d5c-b0a4-2d7d2b6a4b7d')).toBe(true);
|
|
9
|
+
expect(isSafeId('-Users-sunpeng-workspace-claude-code-session')).toBe(true);
|
|
10
|
+
expect(isSafeId('C--Users-sunpeng')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('拒绝空字符串', () => {
|
|
14
|
+
expect(isSafeId('')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('拒绝包含正斜杠的 id(path-traversal 入口)', () => {
|
|
18
|
+
expect(isSafeId('a/b')).toBe(false);
|
|
19
|
+
expect(isSafeId('../etc/passwd')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('拒绝包含反斜杠的 id(Windows path-traversal)', () => {
|
|
23
|
+
expect(isSafeId('a\\b')).toBe(false);
|
|
24
|
+
expect(isSafeId('..\\windows')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('拒绝包含 .. 的 id(即便不带分隔符)', () => {
|
|
28
|
+
expect(isSafeId('foo..bar')).toBe(false);
|
|
29
|
+
expect(isSafeId('..')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('拒绝以 . 开头的 id(屏蔽 dotfile)', () => {
|
|
33
|
+
expect(isSafeId('.hidden')).toBe(false);
|
|
34
|
+
expect(isSafeId('.bak-1700000000')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('单点开头的拒绝不影响中间含 . 的合法 id', () => {
|
|
38
|
+
expect(isSafeId('memory.md')).toBe(true);
|
|
39
|
+
expect(isSafeId('file.imported-abcd1234.md')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/server/lib/safe-id.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export function isSafeId(id: string): boolean {
|
|
2
|
-
if (!id) return false;
|
|
3
|
-
if (id.includes('/') || id.includes('\\') || id.includes('..')) return false;
|
|
4
|
-
if (id.startsWith('.')) return false;
|
|
5
|
-
return true;
|
|
6
|
-
}
|
|
1
|
+
export function isSafeId(id: string): boolean {
|
|
2
|
+
if (!id) return false;
|
|
3
|
+
if (id.includes('/') || id.includes('\\') || id.includes('..')) return false;
|
|
4
|
+
if (id.startsWith('.')) return false;
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
// safeRemove 是 deleteSessions 与 deleteOrphan 共用的"路径校验 + 实际 rm"唯一入口。
|
|
7
|
-
// 这里把 claude-paths.ts mock 到一个独立 tmp root(通过 process.env.CCSM_TEST_ROOT 桥接,
|
|
8
|
-
// 与 delete.test.ts 同套路),校验它只删 root 子树内的东西、逃出去的一律抛错。
|
|
9
|
-
|
|
10
|
-
let fakeRoot: string;
|
|
11
|
-
|
|
12
|
-
vi.mock('./claude-paths.ts', () => {
|
|
13
|
-
return {
|
|
14
|
-
isUnderClaudeRoot(target: string): boolean {
|
|
15
|
-
const root = process.env.CCSM_TEST_ROOT!;
|
|
16
|
-
const resolved = path.resolve(target);
|
|
17
|
-
return resolved === root || resolved.startsWith(root + path.sep);
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-test-'));
|
|
24
|
-
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
vi.restoreAllMocks();
|
|
29
|
-
delete process.env.CCSM_TEST_ROOT;
|
|
30
|
-
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('safeRemove', () => {
|
|
34
|
-
it('删 root 子树内的文件,返回 true', async () => {
|
|
35
|
-
const { safeRemove } = await import('./safe-remove.ts');
|
|
36
|
-
const f = path.join(fakeRoot, 'a.jsonl');
|
|
37
|
-
fs.writeFileSync(f, 'x');
|
|
38
|
-
|
|
39
|
-
expect(safeRemove(f)).toBe(true);
|
|
40
|
-
expect(fs.existsSync(f)).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('删 root 子树内的目录(recursive),返回 true', async () => {
|
|
44
|
-
const { safeRemove } = await import('./safe-remove.ts');
|
|
45
|
-
const dir = path.join(fakeRoot, 'file-history', 'sid-1');
|
|
46
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
-
fs.writeFileSync(path.join(dir, 'nested.txt'), 'y');
|
|
48
|
-
|
|
49
|
-
expect(safeRemove(dir)).toBe(true);
|
|
50
|
-
expect(fs.existsSync(dir)).toBe(false);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('目标不存在返回 false(幂等,不抛)', async () => {
|
|
54
|
-
const { safeRemove } = await import('./safe-remove.ts');
|
|
55
|
-
expect(safeRemove(path.join(fakeRoot, 'missing'))).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('逃出 ~/.claude 子树的目标一律抛错,且不删任何东西', async () => {
|
|
59
|
-
const { safeRemove } = await import('./safe-remove.ts');
|
|
60
|
-
// 在 fakeRoot 之外铺一个文件,确认它不会被删
|
|
61
|
-
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-outside-'));
|
|
62
|
-
const victim = path.join(outside, 'do-not-delete.txt');
|
|
63
|
-
fs.writeFileSync(victim, 'keep me');
|
|
64
|
-
try {
|
|
65
|
-
expect(() => safeRemove(victim)).toThrow(/outside ~\/\.claude/);
|
|
66
|
-
expect(fs.existsSync(victim)).toBe(true);
|
|
67
|
-
// 兄弟目录(同前缀但非子树)也必须拒绝
|
|
68
|
-
expect(() => safeRemove(fakeRoot + '_evil')).toThrow(/outside ~\/\.claude/);
|
|
69
|
-
} finally {
|
|
70
|
-
fs.rmSync(outside, { recursive: true, force: true });
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
});
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// safeRemove 是 deleteSessions 与 deleteOrphan 共用的"路径校验 + 实际 rm"唯一入口。
|
|
7
|
+
// 这里把 claude-paths.ts mock 到一个独立 tmp root(通过 process.env.CCSM_TEST_ROOT 桥接,
|
|
8
|
+
// 与 delete.test.ts 同套路),校验它只删 root 子树内的东西、逃出去的一律抛错。
|
|
9
|
+
|
|
10
|
+
let fakeRoot: string;
|
|
11
|
+
|
|
12
|
+
vi.mock('./claude-paths.ts', () => {
|
|
13
|
+
return {
|
|
14
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
15
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
16
|
+
const resolved = path.resolve(target);
|
|
17
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-test-'));
|
|
24
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
30
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('safeRemove', () => {
|
|
34
|
+
it('删 root 子树内的文件,返回 true', async () => {
|
|
35
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
36
|
+
const f = path.join(fakeRoot, 'a.jsonl');
|
|
37
|
+
fs.writeFileSync(f, 'x');
|
|
38
|
+
|
|
39
|
+
expect(safeRemove(f)).toBe(true);
|
|
40
|
+
expect(fs.existsSync(f)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('删 root 子树内的目录(recursive),返回 true', async () => {
|
|
44
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
45
|
+
const dir = path.join(fakeRoot, 'file-history', 'sid-1');
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(dir, 'nested.txt'), 'y');
|
|
48
|
+
|
|
49
|
+
expect(safeRemove(dir)).toBe(true);
|
|
50
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('目标不存在返回 false(幂等,不抛)', async () => {
|
|
54
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
55
|
+
expect(safeRemove(path.join(fakeRoot, 'missing'))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('逃出 ~/.claude 子树的目标一律抛错,且不删任何东西', async () => {
|
|
59
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
60
|
+
// 在 fakeRoot 之外铺一个文件,确认它不会被删
|
|
61
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-outside-'));
|
|
62
|
+
const victim = path.join(outside, 'do-not-delete.txt');
|
|
63
|
+
fs.writeFileSync(victim, 'keep me');
|
|
64
|
+
try {
|
|
65
|
+
expect(() => safeRemove(victim)).toThrow(/outside ~\/\.claude/);
|
|
66
|
+
expect(fs.existsSync(victim)).toBe(true);
|
|
67
|
+
// 兄弟目录(同前缀但非子树)也必须拒绝
|
|
68
|
+
expect(() => safeRemove(fakeRoot + '_evil')).toThrow(/outside ~\/\.claude/);
|
|
69
|
+
} finally {
|
|
70
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import { isUnderClaudeRoot } from './claude-paths.ts';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ~/.claude/ 下所有删除的唯一入口:先过 isUnderClaudeRoot 路径校验,再真正 rm。
|
|
6
|
-
*
|
|
7
|
-
* deleteSessions(5 处级联)和 deleteOrphan(孤儿目录)共用这一份"路径校验 + 实际 rm",
|
|
8
|
-
* 把"目标必须落在 ~/.claude 子树内"这条安全网集中到一处——以后改删除约束
|
|
9
|
-
* (加路径校验、改 rm 行为、加新防护)只改这里,不会两边各写一份、改一边漏一边。
|
|
10
|
-
*
|
|
11
|
-
* 文件和目录都走 recursive: true(对文件无副作用),所以单一入口能覆盖两种形态。
|
|
12
|
-
*
|
|
13
|
-
* @returns 是否真的删了东西(目标不存在 → false)
|
|
14
|
-
* @throws 目标逃出 ~/.claude 子树 —— 最后一道兜底,绝不 silently 删 root 外的东西。
|
|
15
|
-
* 调用方应在更早处用 isUnderClaudeRoot 做 graceful 预检并给出跳过原因,
|
|
16
|
-
* 走到这里抛错说明前置校验漏了,是 bug 不是正常流程。
|
|
17
|
-
*/
|
|
18
|
-
export function safeRemove(target: string): boolean {
|
|
19
|
-
if (!isUnderClaudeRoot(target)) {
|
|
20
|
-
throw new Error(`refuse to remove path outside ~/.claude: ${target}`);
|
|
21
|
-
}
|
|
22
|
-
if (!fs.existsSync(target)) return false;
|
|
23
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { isUnderClaudeRoot } from './claude-paths.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ~/.claude/ 下所有删除的唯一入口:先过 isUnderClaudeRoot 路径校验,再真正 rm。
|
|
6
|
+
*
|
|
7
|
+
* deleteSessions(5 处级联)和 deleteOrphan(孤儿目录)共用这一份"路径校验 + 实际 rm",
|
|
8
|
+
* 把"目标必须落在 ~/.claude 子树内"这条安全网集中到一处——以后改删除约束
|
|
9
|
+
* (加路径校验、改 rm 行为、加新防护)只改这里,不会两边各写一份、改一边漏一边。
|
|
10
|
+
*
|
|
11
|
+
* 文件和目录都走 recursive: true(对文件无副作用),所以单一入口能覆盖两种形态。
|
|
12
|
+
*
|
|
13
|
+
* @returns 是否真的删了东西(目标不存在 → false)
|
|
14
|
+
* @throws 目标逃出 ~/.claude 子树 —— 最后一道兜底,绝不 silently 删 root 外的东西。
|
|
15
|
+
* 调用方应在更早处用 isUnderClaudeRoot 做 graceful 预检并给出跳过原因,
|
|
16
|
+
* 走到这里抛错说明前置校验漏了,是 bug 不是正常流程。
|
|
17
|
+
*/
|
|
18
|
+
export function safeRemove(target: string): boolean {
|
|
19
|
+
if (!isUnderClaudeRoot(target)) {
|
|
20
|
+
throw new Error(`refuse to remove path outside ~/.claude: ${target}`);
|
|
21
|
+
}
|
|
22
|
+
if (!fs.existsSync(target)) return false;
|
|
23
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
24
|
+
return true;
|
|
25
|
+
}
|