dr-claw 1.1.2 → 1.1.4
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/README.md +286 -177
- package/README.zh-CN.md +286 -174
- package/dist/assets/{_basePickBy-C0n1uo4u.js → _basePickBy-7CEpiVqc.js} +1 -1
- package/dist/assets/{_baseUniq-BRNmhFkc.js → _baseUniq-yh8GoKah.js} +1 -1
- package/dist/assets/{arc-DOMBmC_A.js → arc-xxYot-So.js} +1 -1
- package/dist/assets/{architectureDiagram-VXUJARFQ-CtMWglbD.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
- package/dist/assets/{blockDiagram-VD42YOAC-DK_gTkJy.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
- package/dist/assets/{c4Diagram-YG6GDRKO-DrDAvsn7.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
- package/dist/assets/channel-BJdHHkhR.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-CGbhdZcN.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-sGeFSxen.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
- package/dist/assets/{chunk-B4BG7PRW-RxgHAU_P.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
- package/dist/assets/{chunk-DI55MBZ5-CvHUXZrx.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Sn7cXVNU.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
- package/dist/assets/{chunk-QN33PNHL-bDKOHppN.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B9kN5LGm.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
- package/dist/assets/{chunk-TZMSLE5B-CCBe6len.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
- package/dist/assets/{classDiagram-2ON5EDUG-DQoMNYlY.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
- package/dist/assets/{classDiagram-v2-WZHVMYZB-DQoMNYlY.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
- package/dist/assets/clone-CwXTbwxl.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-r6sLiHBW.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
- package/dist/assets/{dagre-6UL2VRFP-AbsaR1Oa.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
- package/dist/assets/{diagram-PSM6KHXK-DjCvdUyy.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
- package/dist/assets/{diagram-QEK2KX5R-CqiocXkH.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
- package/dist/assets/{diagram-S2PKOQOG-D5ALeoII.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
- package/dist/assets/{erDiagram-Q2GNP2WA-C1AeOsiz.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
- package/dist/assets/{flowDiagram-NV44I4VS-C98ve_DO.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
- package/dist/assets/{ganttDiagram-JELNMOA3-3zdLvcxD.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
- package/dist/assets/{gitGraphDiagram-V2S2FVAM-nDuDMsHh.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
- package/dist/assets/{graph-Blm-emkR.js → graph-DGtZXuDY.js} +1 -1
- package/dist/assets/index-C-sL-ag0.css +1 -0
- package/dist/assets/index-VdqxIFPL.js +670 -0
- package/dist/assets/{infoDiagram-HS3SLOUP-CmzE3q0R.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
- package/dist/assets/{journeyDiagram-XKPGCS4Q-CYaW7LrP.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
- package/dist/assets/{kanban-definition-3W4ZIXB7-mkmW7p0j.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
- package/dist/assets/{layout-Ct2kFIId.js → layout-Cnc-DCSb.js} +1 -1
- package/dist/assets/{linear-CKG9EwIJ.js → linear-BtRgmV19.js} +1 -1
- package/dist/assets/{mermaid.core-CaKcUnnd.js → mermaid.core-B20uzv6N.js} +5 -5
- package/dist/assets/{mindmap-definition-VGOIOE7T-Bct-pYin.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
- package/dist/assets/{pieDiagram-ADFJNKIX-C8wFD4Jr.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
- package/dist/assets/{quadrantDiagram-AYHSOK5B-CuiE_f46.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
- package/dist/assets/{requirementDiagram-UZGBJVZJ-C4v1CqU8.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
- package/dist/assets/{sankeyDiagram-TZEHDZUN-keAqqs-Y.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
- package/dist/assets/{sequenceDiagram-WL72ISMW-ClNbvKTB.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
- package/dist/assets/{stateDiagram-FKZM4ZOC-D8We-2vq.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
- package/dist/assets/{timeline-definition-IT6M3QCI-DsvEMT5T.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
- package/dist/assets/{treemap-GDKQZRPO-TJrzN7Bj.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
- package/dist/assets/{xychartDiagram-PRI3JC2R-BdATmY5F.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
- package/dist/icons/nanoclaude.png +0 -0
- package/dist/index.html +2 -2
- package/dist/screenshots/screenshot_v1_2.png +0 -0
- package/package.json +61 -4
- package/public/icons/nanoclaude.png +0 -0
- package/public/screenshots/screenshot_v1_2.png +0 -0
- package/scripts/build-electron-icons.mjs +40 -0
- package/scripts/check-deps.js +28 -0
- package/scripts/native-runtime.mjs +128 -0
- package/server/__tests__/gemini-api.test.mjs +699 -0
- package/server/__tests__/gemini-session-index.test.mjs +95 -0
- package/server/__tests__/project-sync-dedup.test.mjs +173 -0
- package/server/__tests__/session-delete.test.mjs +152 -0
- package/server/claude-sdk.js +175 -14
- package/server/cli-chat.js +28 -17
- package/server/cli.js +48 -11
- package/server/data/news-log-arxiv.json +1 -0
- package/server/data/news-log-huggingface.json +1 -0
- package/server/data/news-results-arxiv.json +460 -0
- package/server/data/news-results-huggingface.json +817 -0
- package/server/data/research_interests_arxiv.json +50 -0
- package/server/data/research_interests_huggingface.json +4 -0
- package/server/database/db.js +101 -5
- package/server/database/init.sql +17 -1
- package/server/gemini-api.js +1838 -0
- package/server/gemini-cli.js +13 -2
- package/server/index.js +94 -16
- package/server/load-env.js +13 -1
- package/server/local-gpu.js +166 -24
- package/server/mcp-compute.js +223 -0
- package/server/middleware/auth.js +25 -6
- package/server/nano-claude-code.js +340 -0
- package/server/nanoSessionPaths.js +41 -0
- package/server/openai-codex.js +6 -2
- package/server/openrouter.js +37 -22
- package/server/projects.js +516 -110
- package/server/routes/agent.js +32 -7
- package/server/routes/auto-research.js +1 -1
- package/server/routes/claude-btw.js +55 -0
- package/server/routes/cli-auth.js +422 -58
- package/server/routes/commands.js +25 -1
- package/server/routes/community-tools.js +398 -0
- package/server/routes/compute.js +155 -0
- package/server/routes/git.js +9 -11
- package/server/routes/memory.js +143 -0
- package/server/routes/news.js +3 -0
- package/server/routes/skills.js +35 -0
- package/server/routes/user.js +3 -5
- package/server/utils/__tests__/safePath.test.js +93 -0
- package/server/utils/cliResolution.js +13 -1
- package/server/utils/codexSessionMessages.js +285 -0
- package/server/utils/geminiOAuth.js +333 -0
- package/server/utils/memoryPrompt.js +78 -0
- package/server/utils/safePath.js +51 -0
- package/server/utils/sessionIndex.js +2 -0
- package/server/utils/skillExpander.js +142 -0
- package/server/utils/spawnAsync.js +78 -0
- package/shared/modelConstants.js +19 -1
- package/dist/assets/channel-BFOE7OyC.js +0 -1
- package/dist/assets/clone-BulQSblH.js +0 -1
- package/dist/assets/index-B8Yutsnm.css +0 -1
- package/dist/assets/index-Cf6gyhc3.js +0 -654
- package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +0 -1
- package/scripts/install-python-tools.js +0 -64
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { memoryDb } from '../database/db.js';
|
|
3
|
+
import { syncMemoryFiles } from '../utils/memoryPrompt.js';
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
const MAX_CONTENT_LENGTH = 500;
|
|
7
|
+
const VALID_CATEGORIES = new Set(['general', 'preference', 'context', 'workflow']);
|
|
8
|
+
|
|
9
|
+
/** Parse and validate :id param as a positive integer. */
|
|
10
|
+
function parseId(raw) {
|
|
11
|
+
const id = parseInt(raw, 10);
|
|
12
|
+
return Number.isNaN(id) || id <= 0 ? null : id;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ===============================
|
|
16
|
+
// Memory CRUD
|
|
17
|
+
// ===============================
|
|
18
|
+
|
|
19
|
+
// Get all memories for the authenticated user
|
|
20
|
+
router.get('/', async (req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const memories = memoryDb.getAll(req.user.id);
|
|
23
|
+
res.json({ memories });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error('Error fetching memories:', error);
|
|
26
|
+
res.status(500).json({ error: 'Failed to fetch memories' });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Create a new memory
|
|
31
|
+
router.post('/', async (req, res) => {
|
|
32
|
+
try {
|
|
33
|
+
const { content, category } = req.body;
|
|
34
|
+
if (!content || !content.trim()) {
|
|
35
|
+
return res.status(400).json({ error: 'Memory content is required' });
|
|
36
|
+
}
|
|
37
|
+
if (content.length > MAX_CONTENT_LENGTH) {
|
|
38
|
+
return res.status(400).json({ error: `Memory content must be ${MAX_CONTENT_LENGTH} characters or less` });
|
|
39
|
+
}
|
|
40
|
+
const cat = VALID_CATEGORIES.has(category) ? category : 'general';
|
|
41
|
+
const memory = memoryDb.create(req.user.id, content.trim(), cat);
|
|
42
|
+
syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
|
|
43
|
+
res.status(201).json({ memory });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.message?.includes('Maximum of')) {
|
|
46
|
+
return res.status(400).json({ error: error.message });
|
|
47
|
+
}
|
|
48
|
+
console.error('Error creating memory:', error);
|
|
49
|
+
res.status(500).json({ error: 'Failed to create memory' });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Update a memory
|
|
54
|
+
router.put('/:id', async (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const id = parseId(req.params.id);
|
|
57
|
+
if (!id) return res.status(400).json({ error: 'Invalid memory ID' });
|
|
58
|
+
|
|
59
|
+
const { content, category } = req.body;
|
|
60
|
+
if (content !== undefined && content.length > MAX_CONTENT_LENGTH) {
|
|
61
|
+
return res.status(400).json({ error: `Memory content must be ${MAX_CONTENT_LENGTH} characters or less` });
|
|
62
|
+
}
|
|
63
|
+
const updates = {};
|
|
64
|
+
if (content !== undefined) updates.content = content.trim();
|
|
65
|
+
if (category !== undefined) updates.category = VALID_CATEGORIES.has(category) ? category : 'general';
|
|
66
|
+
|
|
67
|
+
const memory = memoryDb.update(req.user.id, id, updates);
|
|
68
|
+
if (!memory) {
|
|
69
|
+
return res.status(404).json({ error: 'Memory not found' });
|
|
70
|
+
}
|
|
71
|
+
syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
|
|
72
|
+
res.json({ memory });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Error updating memory:', error);
|
|
75
|
+
res.status(500).json({ error: 'Failed to update memory' });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Toggle memory enabled/disabled
|
|
80
|
+
router.patch('/:id/toggle', async (req, res) => {
|
|
81
|
+
try {
|
|
82
|
+
const id = parseId(req.params.id);
|
|
83
|
+
if (!id) return res.status(400).json({ error: 'Invalid memory ID' });
|
|
84
|
+
|
|
85
|
+
const memory = memoryDb.toggle(req.user.id, id);
|
|
86
|
+
if (!memory) {
|
|
87
|
+
return res.status(404).json({ error: 'Memory not found' });
|
|
88
|
+
}
|
|
89
|
+
syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
|
|
90
|
+
res.json({ memory });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error('Error toggling memory:', error);
|
|
93
|
+
res.status(500).json({ error: 'Failed to toggle memory' });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Delete a memory
|
|
98
|
+
router.delete('/:id', async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const id = parseId(req.params.id);
|
|
101
|
+
if (!id) return res.status(400).json({ error: 'Invalid memory ID' });
|
|
102
|
+
|
|
103
|
+
const deleted = memoryDb.delete(req.user.id, id);
|
|
104
|
+
if (!deleted) {
|
|
105
|
+
return res.status(404).json({ error: 'Memory not found' });
|
|
106
|
+
}
|
|
107
|
+
syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
|
|
108
|
+
res.json({ success: true });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('Error deleting memory:', error);
|
|
111
|
+
res.status(500).json({ error: 'Failed to delete memory' });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ===============================
|
|
116
|
+
// Per-User Memory Settings
|
|
117
|
+
// ===============================
|
|
118
|
+
|
|
119
|
+
// Get memory enabled state for the authenticated user
|
|
120
|
+
router.get('/settings', async (req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const enabled = memoryDb.getMemoryEnabled(req.user.id);
|
|
123
|
+
res.json({ enabled });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Error fetching memory settings:', error);
|
|
126
|
+
res.status(500).json({ error: 'Failed to fetch memory settings' });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Set memory enabled state for the authenticated user
|
|
131
|
+
router.patch('/settings', async (req, res) => {
|
|
132
|
+
try {
|
|
133
|
+
const { enabled } = req.body;
|
|
134
|
+
memoryDb.setMemoryEnabled(req.user.id, !!enabled);
|
|
135
|
+
syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
|
|
136
|
+
res.json({ enabled: !!enabled });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('Error updating memory settings:', error);
|
|
139
|
+
res.status(500).json({ error: 'Failed to update memory settings' });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export default router;
|
package/server/routes/news.js
CHANGED
|
@@ -488,6 +488,9 @@ router.post('/search/:source', (req, res) => handleSearch(req.params.source, req
|
|
|
488
488
|
// GET /api/news/logs/:source — poll search progress logs
|
|
489
489
|
router.get('/logs/:source', async (req, res) => {
|
|
490
490
|
try {
|
|
491
|
+
const entry = getSourceEntry(req.params.source);
|
|
492
|
+
if (!entry) return res.status(404).json({ error: `Unknown source: ${req.params.source}` });
|
|
493
|
+
|
|
491
494
|
const logPath = path.join(DATA_DIR, `news-log-${req.params.source}.json`);
|
|
492
495
|
const data = await fs.readFile(logPath, 'utf8');
|
|
493
496
|
res.json({ logs: JSON.parse(data) });
|
package/server/routes/skills.js
CHANGED
|
@@ -5,6 +5,7 @@ import os from 'os';
|
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { extractProjectDirectory, ensureProjectSkillLinks } from '../projects.js';
|
|
7
7
|
import { FORBIDDEN_PATHS } from './projects.js';
|
|
8
|
+
import { findSkillMdPath } from '../utils/skillExpander.js';
|
|
8
9
|
|
|
9
10
|
const GLOBAL_SKILLS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
|
|
10
11
|
|
|
@@ -223,6 +224,40 @@ async function validateZipBuffer(buffer) {
|
|
|
223
224
|
};
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
// GET /resolve — resolve a skill name to its SKILL.md path and content
|
|
228
|
+
router.get('/resolve', async (req, res) => {
|
|
229
|
+
try {
|
|
230
|
+
const { name, workingDir } = req.query;
|
|
231
|
+
if (!name || typeof name !== 'string') {
|
|
232
|
+
return res.status(400).json({ error: 'name query parameter is required' });
|
|
233
|
+
}
|
|
234
|
+
// Prevent path traversal
|
|
235
|
+
if (/[/\\]|\.\./.test(name)) {
|
|
236
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
237
|
+
}
|
|
238
|
+
const effectiveWorkingDir = (typeof workingDir === 'string' && workingDir.trim()) || process.cwd();
|
|
239
|
+
// Validate workingDir: must be absolute and not a forbidden system path
|
|
240
|
+
if (effectiveWorkingDir !== process.cwd()) {
|
|
241
|
+
if (!path.isAbsolute(effectiveWorkingDir)) {
|
|
242
|
+
return res.status(400).json({ error: 'workingDir must be an absolute path' });
|
|
243
|
+
}
|
|
244
|
+
const normalized = path.normalize(effectiveWorkingDir);
|
|
245
|
+
if (FORBIDDEN_PATHS.some((fp) => normalized === fp || normalized.startsWith(fp + path.sep))) {
|
|
246
|
+
return res.status(400).json({ error: 'Invalid workingDir' });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const skillMdPath = await findSkillMdPath(name, effectiveWorkingDir);
|
|
250
|
+
if (!skillMdPath) {
|
|
251
|
+
return res.status(404).json({ error: 'Skill not found' });
|
|
252
|
+
}
|
|
253
|
+
const content = await fs.readFile(skillMdPath, 'utf-8');
|
|
254
|
+
res.json({ path: skillMdPath, content });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error('[skills] resolve error:', error.message);
|
|
257
|
+
res.status(500).json({ error: error.message });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
226
261
|
// GET / — return the file tree of the global skills/ directory
|
|
227
262
|
router.get('/', async (req, res) => {
|
|
228
263
|
try {
|
package/server/routes/user.js
CHANGED
|
@@ -2,10 +2,8 @@ import express from 'express';
|
|
|
2
2
|
import { userDb } from '../database/db.js';
|
|
3
3
|
import { authenticateToken } from '../middleware/auth.js';
|
|
4
4
|
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
|
5
|
-
import
|
|
6
|
-
import { promisify } from 'util';
|
|
5
|
+
import spawnAsync from '../utils/spawnAsync.js';
|
|
7
6
|
|
|
8
|
-
const execAsync = promisify(exec);
|
|
9
7
|
const router = express.Router();
|
|
10
8
|
|
|
11
9
|
router.get('/git-config', authenticateToken, async (req, res) => {
|
|
@@ -55,8 +53,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
|
|
|
55
53
|
userDb.updateGitConfig(userId, gitName, gitEmail);
|
|
56
54
|
|
|
57
55
|
try {
|
|
58
|
-
await
|
|
59
|
-
await
|
|
56
|
+
await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
|
|
57
|
+
await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
|
|
60
58
|
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
|
61
59
|
} catch (gitError) {
|
|
62
60
|
console.error('Error applying git config:', gitError);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { safePath } from '../safePath.js';
|
|
6
|
+
|
|
7
|
+
// Use a real temporary directory so realpathSync works correctly
|
|
8
|
+
let ROOT;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
// Use realpathSync to normalize macOS /var -> /private/var symlink
|
|
12
|
+
ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'safepath-test-')));
|
|
13
|
+
fs.mkdirSync(path.join(ROOT, 'src'), { recursive: true });
|
|
14
|
+
fs.mkdirSync(path.join(ROOT, 'lib'), { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(ROOT, 'src', 'index.js'), '');
|
|
16
|
+
fs.writeFileSync(path.join(ROOT, 'lib', 'utils.js'), '');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('safePath', () => {
|
|
24
|
+
it('resolves relative paths within root', () => {
|
|
25
|
+
const result = safePath('src/index.js', ROOT);
|
|
26
|
+
expect(result).toBe(path.join(ROOT, 'src', 'index.js'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns root when path is empty/null/undefined', () => {
|
|
30
|
+
expect(safePath('', ROOT)).toBe(ROOT);
|
|
31
|
+
expect(safePath(null, ROOT)).toBe(ROOT);
|
|
32
|
+
expect(safePath(undefined, ROOT)).toBe(ROOT);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('allows absolute paths that land inside root', () => {
|
|
36
|
+
const absInside = path.join(ROOT, 'src', 'index.js');
|
|
37
|
+
const result = safePath(absInside, ROOT);
|
|
38
|
+
expect(result).toBe(absInside);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('blocks absolute paths outside root', () => {
|
|
42
|
+
expect(() => safePath('/etc/passwd', ROOT)).toThrow(/Path traversal blocked/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('blocks .. traversal above root', () => {
|
|
46
|
+
expect(() => safePath('../../../etc/shadow', ROOT)).toThrow(/Path traversal blocked/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('blocks .. traversal disguised in deeper path', () => {
|
|
50
|
+
expect(() => safePath('src/../../../../../../etc/passwd', ROOT)).toThrow(
|
|
51
|
+
/Path traversal blocked/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('allows .. that stays within root', () => {
|
|
56
|
+
const result = safePath('src/../lib/utils.js', ROOT);
|
|
57
|
+
expect(result).toBe(path.join(ROOT, 'lib', 'utils.js'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles non-existent target gracefully', () => {
|
|
61
|
+
// Non-existent file in existing directory — should work
|
|
62
|
+
const result = safePath('src/newfile.js', ROOT);
|
|
63
|
+
expect(result).toBe(path.join(ROOT, 'src', 'newfile.js'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('handles non-existent nested path gracefully', () => {
|
|
67
|
+
// Non-existent nested path — should still resolve within root
|
|
68
|
+
const result = safePath('deep/nested/new/file.js', ROOT);
|
|
69
|
+
expect(result.startsWith(ROOT + path.sep)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('normalizes allowedRoot for falsy input', () => {
|
|
73
|
+
// Pass a non-normalized root (with trailing segments) to verify
|
|
74
|
+
// the falsy-input path returns path.resolve(allowedRoot), not the raw string.
|
|
75
|
+
const nonNormalized = ROOT + path.sep + 'src' + path.sep + '..';
|
|
76
|
+
const result = safePath('', nonNormalized);
|
|
77
|
+
expect(result).toBe(ROOT);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('allows symlinks inside the project that point outside the root', () => {
|
|
81
|
+
// Simulate: project/data -> /tmp (an external location)
|
|
82
|
+
// This should NOT be blocked — legitimate workflow (shared datasets, etc.)
|
|
83
|
+
const linkPath = path.join(ROOT, 'external-data');
|
|
84
|
+
try {
|
|
85
|
+
fs.symlinkSync(os.tmpdir(), linkPath);
|
|
86
|
+
// Logical path is inside root, so safePath should allow it
|
|
87
|
+
const result = safePath('external-data/some-file.csv', ROOT);
|
|
88
|
+
expect(result).toBe(path.join(ROOT, 'external-data', 'some-file.csv'));
|
|
89
|
+
} finally {
|
|
90
|
+
try { fs.unlinkSync(linkPath); } catch { /* ignore cleanup errors */ }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -9,6 +9,7 @@ function isCommandNotFoundExitCode(code) {
|
|
|
9
9
|
*
|
|
10
10
|
* @param {Object} options
|
|
11
11
|
* @param {string} options.envVarName Environment variable name containing an override command.
|
|
12
|
+
* @param {string[]} [options.legacyEnvVarNames=[]] Older env var names checked after envVarName (migration).
|
|
12
13
|
* @param {string[]} options.defaultCommands Fallback command names in preference order.
|
|
13
14
|
* @param {string} [options.platform=process.platform] Runtime platform, used for Windows suffix handling.
|
|
14
15
|
* @param {boolean} [options.appendWindowsSuffixes=false] Whether to append .cmd/.exe candidates on Windows.
|
|
@@ -16,11 +17,19 @@ function isCommandNotFoundExitCode(code) {
|
|
|
16
17
|
*/
|
|
17
18
|
function getCliCommandCandidates({
|
|
18
19
|
envVarName,
|
|
20
|
+
legacyEnvVarNames = [],
|
|
19
21
|
defaultCommands,
|
|
20
22
|
platform = process.platform,
|
|
21
23
|
appendWindowsSuffixes = false
|
|
22
24
|
}) {
|
|
23
|
-
|
|
25
|
+
let envCommand = '';
|
|
26
|
+
for (const key of [envVarName, ...legacyEnvVarNames].filter(Boolean)) {
|
|
27
|
+
const s = String(process.env[key] || '').trim();
|
|
28
|
+
if (s) {
|
|
29
|
+
envCommand = s;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
24
33
|
const rawCandidates = [];
|
|
25
34
|
|
|
26
35
|
if (envCommand) {
|
|
@@ -131,6 +140,7 @@ function checkCommandAvailable(command, args = ['--help'], { platform = process.
|
|
|
131
140
|
*
|
|
132
141
|
* @param {Object} options
|
|
133
142
|
* @param {string} options.envVarName Environment variable with command override.
|
|
143
|
+
* @param {string[]} [options.legacyEnvVarNames=[]] Older env vars (after envVarName) for migration.
|
|
134
144
|
* @param {string[]} options.defaultCommands Fallback command names in preference order.
|
|
135
145
|
* @param {string[]} [options.args=['--help']] Probe arguments.
|
|
136
146
|
* @param {string} [options.platform=process.platform] Runtime platform.
|
|
@@ -140,6 +150,7 @@ function checkCommandAvailable(command, args = ['--help'], { platform = process.
|
|
|
140
150
|
*/
|
|
141
151
|
async function resolveAvailableCliCommand({
|
|
142
152
|
envVarName,
|
|
153
|
+
legacyEnvVarNames = [],
|
|
143
154
|
defaultCommands,
|
|
144
155
|
args = ['--help'],
|
|
145
156
|
platform = process.platform,
|
|
@@ -148,6 +159,7 @@ async function resolveAvailableCliCommand({
|
|
|
148
159
|
}) {
|
|
149
160
|
const candidates = getCliCommandCandidates({
|
|
150
161
|
envVarName,
|
|
162
|
+
legacyEnvVarNames,
|
|
151
163
|
defaultCommands,
|
|
152
164
|
platform,
|
|
153
165
|
appendWindowsSuffixes
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
const WINDOWS_ABS_PATH = /^[A-Za-z]:[\\/]/;
|
|
2
|
+
|
|
3
|
+
function normalizePath(value) {
|
|
4
|
+
return String(value || '').replace(/\\/g, '/').replace(/\/+/g, '/').trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isAbsolutePath(value) {
|
|
8
|
+
return value.startsWith('/') || WINDOWS_ABS_PATH.test(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseJsonObject(value) {
|
|
12
|
+
if (!value) return null;
|
|
13
|
+
if (typeof value === 'object') return value;
|
|
14
|
+
if (typeof value !== 'string') return null;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(value);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractTextContent(content) {
|
|
24
|
+
if (!Array.isArray(content)) return content;
|
|
25
|
+
return content
|
|
26
|
+
.map((item) => {
|
|
27
|
+
if (item?.type === 'input_text' || item?.type === 'output_text' || item?.type === 'text') {
|
|
28
|
+
return item.text || '';
|
|
29
|
+
}
|
|
30
|
+
return '';
|
|
31
|
+
})
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractSearchResultFiles(output) {
|
|
37
|
+
const files = new Set();
|
|
38
|
+
String(output || '')
|
|
39
|
+
.split('\n')
|
|
40
|
+
.forEach((line) => {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed) return;
|
|
43
|
+
|
|
44
|
+
const fileMatch = trimmed.match(/^(.+?):\d+(?::\d+)?:/);
|
|
45
|
+
if (fileMatch?.[1]) {
|
|
46
|
+
files.add(fileMatch[1].trim());
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return Array.from(files);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parsePatchFilePaths(patchText) {
|
|
54
|
+
const files = [];
|
|
55
|
+
String(patchText || '')
|
|
56
|
+
.split('\n')
|
|
57
|
+
.forEach((line) => {
|
|
58
|
+
// Known limitation: only matches Add/Delete/Update prefixes.
|
|
59
|
+
// Real-world Codex patches may also use "Create File" — extend if needed.
|
|
60
|
+
const addMatch = line.match(/^\*\*\* (?:Add|Delete|Update) File: (.+)$/);
|
|
61
|
+
if (addMatch?.[1]) {
|
|
62
|
+
files.push(addMatch[1].trim());
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const moveMatch = line.match(/^\*\*\* Move to: (.+)$/);
|
|
67
|
+
if (moveMatch?.[1]) {
|
|
68
|
+
files.push(moveMatch[1].trim());
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return Array.from(new Set(files));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toFileChangesToolInput(changes) {
|
|
76
|
+
return Object.entries(changes || {})
|
|
77
|
+
.map(([filePath, change]) => `${change?.type || 'update'}: ${filePath}`)
|
|
78
|
+
.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeCodexFunctionCall(name, rawArguments) {
|
|
82
|
+
const parsedArguments = parseJsonObject(rawArguments);
|
|
83
|
+
|
|
84
|
+
if (name === 'exec_command') {
|
|
85
|
+
return {
|
|
86
|
+
toolName: 'Bash',
|
|
87
|
+
toolInput: JSON.stringify({
|
|
88
|
+
command: parsedArguments?.cmd || '',
|
|
89
|
+
workdir: parsedArguments?.workdir || parsedArguments?.cwd || '',
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (name === 'write_stdin') {
|
|
95
|
+
return {
|
|
96
|
+
toolName: 'write_stdin',
|
|
97
|
+
toolInput: rawArguments,
|
|
98
|
+
skip: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (name === 'update_plan') {
|
|
103
|
+
return {
|
|
104
|
+
toolName: 'UpdatePlan',
|
|
105
|
+
toolInput: JSON.stringify(parsedArguments || {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (name === 'view_image') {
|
|
110
|
+
return {
|
|
111
|
+
toolName: 'ViewImage',
|
|
112
|
+
toolInput: JSON.stringify(parsedArguments || {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
toolName: name,
|
|
118
|
+
toolInput: typeof rawArguments === 'string' ? rawArguments : JSON.stringify(parsedArguments || {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeCodexCustomToolCall(name, input) {
|
|
123
|
+
if (name !== 'apply_patch') {
|
|
124
|
+
return {
|
|
125
|
+
toolName: name || 'custom_tool',
|
|
126
|
+
toolInput: input || '',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const filePaths = parsePatchFilePaths(input);
|
|
131
|
+
return {
|
|
132
|
+
toolName: 'Edit',
|
|
133
|
+
toolInput: JSON.stringify({
|
|
134
|
+
file_path: filePaths[0] || 'unknown',
|
|
135
|
+
file_paths: filePaths,
|
|
136
|
+
patch: input || '',
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeExecCommandEvent(payload, fallbackInput) {
|
|
142
|
+
const parsedCommand = Array.isArray(payload?.parsed_cmd) ? payload.parsed_cmd[0] : null;
|
|
143
|
+
const output = payload?.aggregated_output || payload?.stdout || payload?.stderr || '';
|
|
144
|
+
const isError = Number(payload?.exit_code ?? payload?.exitCode ?? 0) !== 0 || payload?.status === 'failed';
|
|
145
|
+
|
|
146
|
+
if (parsedCommand?.type === 'read' && parsedCommand?.path) {
|
|
147
|
+
return {
|
|
148
|
+
toolName: 'Read',
|
|
149
|
+
toolInput: JSON.stringify({ file_path: parsedCommand.path }),
|
|
150
|
+
toolResult: {
|
|
151
|
+
content: output,
|
|
152
|
+
isError,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (parsedCommand?.type === 'search') {
|
|
158
|
+
const filenames = extractSearchResultFiles(output);
|
|
159
|
+
return {
|
|
160
|
+
toolName: 'Grep',
|
|
161
|
+
toolInput: JSON.stringify({
|
|
162
|
+
pattern: parsedCommand.query || '',
|
|
163
|
+
path: parsedCommand.path || payload?.cwd || '',
|
|
164
|
+
command: parsedCommand.cmd || fallbackInput?.command || '',
|
|
165
|
+
}),
|
|
166
|
+
toolResult: {
|
|
167
|
+
content: output,
|
|
168
|
+
isError,
|
|
169
|
+
toolUseResult: filenames.length > 0 ? { filenames } : null,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (parsedCommand?.type === 'list_files') {
|
|
175
|
+
return {
|
|
176
|
+
toolName: 'LS',
|
|
177
|
+
toolInput: JSON.stringify({
|
|
178
|
+
path: parsedCommand.path || payload?.cwd || '',
|
|
179
|
+
command: parsedCommand.cmd || fallbackInput?.command || '',
|
|
180
|
+
}),
|
|
181
|
+
toolResult: {
|
|
182
|
+
content: output,
|
|
183
|
+
isError,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
toolName: 'Bash',
|
|
190
|
+
toolInput: JSON.stringify({
|
|
191
|
+
command: fallbackInput?.command || '',
|
|
192
|
+
workdir: fallbackInput?.workdir || payload?.cwd || '',
|
|
193
|
+
parsed_cmd: payload?.parsed_cmd || [],
|
|
194
|
+
}),
|
|
195
|
+
toolResult: {
|
|
196
|
+
content: output,
|
|
197
|
+
isError,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizePatchApplyEvent(payload) {
|
|
203
|
+
const changes = payload?.changes || {};
|
|
204
|
+
const content = [payload?.stdout, payload?.stderr].filter(Boolean).join('\n').trim();
|
|
205
|
+
return {
|
|
206
|
+
toolResult: {
|
|
207
|
+
content,
|
|
208
|
+
isError: payload?.success === false || payload?.status === 'failed',
|
|
209
|
+
toolUseResult: { changes },
|
|
210
|
+
},
|
|
211
|
+
fileChangesToolInput: toFileChangesToolInput(changes),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeWebSearchCall(payload) {
|
|
216
|
+
const action = payload?.action || {};
|
|
217
|
+
const type = action?.type;
|
|
218
|
+
|
|
219
|
+
if (type === 'search') {
|
|
220
|
+
return {
|
|
221
|
+
toolName: 'WebSearch',
|
|
222
|
+
toolInput: JSON.stringify({
|
|
223
|
+
query: action.query || '',
|
|
224
|
+
queries: action.queries || [],
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (type === 'open_page') {
|
|
230
|
+
return {
|
|
231
|
+
toolName: 'OpenPage',
|
|
232
|
+
toolInput: JSON.stringify({
|
|
233
|
+
url: action.url || '',
|
|
234
|
+
}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (type === 'find_in_page') {
|
|
239
|
+
return {
|
|
240
|
+
toolName: 'FindInPage',
|
|
241
|
+
toolInput: JSON.stringify({
|
|
242
|
+
url: action.url || '',
|
|
243
|
+
pattern: action.pattern || '',
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
toolName: 'WebSearch',
|
|
250
|
+
toolInput: JSON.stringify(action || {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildToolUseMessage(timestamp, toolName, toolInput, toolCallId) {
|
|
255
|
+
return {
|
|
256
|
+
type: 'tool_use',
|
|
257
|
+
timestamp,
|
|
258
|
+
toolName,
|
|
259
|
+
toolInput,
|
|
260
|
+
toolCallId,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildToolResultMessage(timestamp, toolCallId, output, extra = {}) {
|
|
265
|
+
return {
|
|
266
|
+
type: 'tool_result',
|
|
267
|
+
timestamp,
|
|
268
|
+
toolCallId,
|
|
269
|
+
output,
|
|
270
|
+
...extra,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export {
|
|
275
|
+
buildToolResultMessage,
|
|
276
|
+
buildToolUseMessage,
|
|
277
|
+
extractTextContent,
|
|
278
|
+
normalizeCodexCustomToolCall,
|
|
279
|
+
normalizeCodexFunctionCall,
|
|
280
|
+
normalizeExecCommandEvent,
|
|
281
|
+
normalizePatchApplyEvent,
|
|
282
|
+
normalizeWebSearchCall,
|
|
283
|
+
parseJsonObject,
|
|
284
|
+
parsePatchFilePaths,
|
|
285
|
+
};
|