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.
Files changed (113) hide show
  1. package/README.md +286 -177
  2. package/README.zh-CN.md +286 -174
  3. package/dist/assets/{_basePickBy-C0n1uo4u.js → _basePickBy-7CEpiVqc.js} +1 -1
  4. package/dist/assets/{_baseUniq-BRNmhFkc.js → _baseUniq-yh8GoKah.js} +1 -1
  5. package/dist/assets/{arc-DOMBmC_A.js → arc-xxYot-So.js} +1 -1
  6. package/dist/assets/{architectureDiagram-VXUJARFQ-CtMWglbD.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
  7. package/dist/assets/{blockDiagram-VD42YOAC-DK_gTkJy.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
  8. package/dist/assets/{c4Diagram-YG6GDRKO-DrDAvsn7.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
  9. package/dist/assets/channel-BJdHHkhR.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-CGbhdZcN.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-sGeFSxen.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
  12. package/dist/assets/{chunk-B4BG7PRW-RxgHAU_P.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
  13. package/dist/assets/{chunk-DI55MBZ5-CvHUXZrx.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-Sn7cXVNU.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
  15. package/dist/assets/{chunk-QN33PNHL-bDKOHppN.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-B9kN5LGm.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
  17. package/dist/assets/{chunk-TZMSLE5B-CCBe6len.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
  18. package/dist/assets/{classDiagram-2ON5EDUG-DQoMNYlY.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-WZHVMYZB-DQoMNYlY.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
  20. package/dist/assets/clone-CwXTbwxl.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-r6sLiHBW.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
  22. package/dist/assets/{dagre-6UL2VRFP-AbsaR1Oa.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
  23. package/dist/assets/{diagram-PSM6KHXK-DjCvdUyy.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
  24. package/dist/assets/{diagram-QEK2KX5R-CqiocXkH.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
  25. package/dist/assets/{diagram-S2PKOQOG-D5ALeoII.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
  26. package/dist/assets/{erDiagram-Q2GNP2WA-C1AeOsiz.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
  27. package/dist/assets/{flowDiagram-NV44I4VS-C98ve_DO.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
  28. package/dist/assets/{ganttDiagram-JELNMOA3-3zdLvcxD.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-V2S2FVAM-nDuDMsHh.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
  30. package/dist/assets/{graph-Blm-emkR.js → graph-DGtZXuDY.js} +1 -1
  31. package/dist/assets/index-C-sL-ag0.css +1 -0
  32. package/dist/assets/index-VdqxIFPL.js +670 -0
  33. package/dist/assets/{infoDiagram-HS3SLOUP-CmzE3q0R.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
  34. package/dist/assets/{journeyDiagram-XKPGCS4Q-CYaW7LrP.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
  35. package/dist/assets/{kanban-definition-3W4ZIXB7-mkmW7p0j.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
  36. package/dist/assets/{layout-Ct2kFIId.js → layout-Cnc-DCSb.js} +1 -1
  37. package/dist/assets/{linear-CKG9EwIJ.js → linear-BtRgmV19.js} +1 -1
  38. package/dist/assets/{mermaid.core-CaKcUnnd.js → mermaid.core-B20uzv6N.js} +5 -5
  39. package/dist/assets/{mindmap-definition-VGOIOE7T-Bct-pYin.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
  40. package/dist/assets/{pieDiagram-ADFJNKIX-C8wFD4Jr.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-AYHSOK5B-CuiE_f46.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
  42. package/dist/assets/{requirementDiagram-UZGBJVZJ-C4v1CqU8.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-TZEHDZUN-keAqqs-Y.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-WL72ISMW-ClNbvKTB.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
  45. package/dist/assets/{stateDiagram-FKZM4ZOC-D8We-2vq.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
  47. package/dist/assets/{timeline-definition-IT6M3QCI-DsvEMT5T.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
  48. package/dist/assets/{treemap-GDKQZRPO-TJrzN7Bj.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
  49. package/dist/assets/{xychartDiagram-PRI3JC2R-BdATmY5F.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
  50. package/dist/icons/nanoclaude.png +0 -0
  51. package/dist/index.html +2 -2
  52. package/dist/screenshots/screenshot_v1_2.png +0 -0
  53. package/package.json +61 -4
  54. package/public/icons/nanoclaude.png +0 -0
  55. package/public/screenshots/screenshot_v1_2.png +0 -0
  56. package/scripts/build-electron-icons.mjs +40 -0
  57. package/scripts/check-deps.js +28 -0
  58. package/scripts/native-runtime.mjs +128 -0
  59. package/server/__tests__/gemini-api.test.mjs +699 -0
  60. package/server/__tests__/gemini-session-index.test.mjs +95 -0
  61. package/server/__tests__/project-sync-dedup.test.mjs +173 -0
  62. package/server/__tests__/session-delete.test.mjs +152 -0
  63. package/server/claude-sdk.js +175 -14
  64. package/server/cli-chat.js +28 -17
  65. package/server/cli.js +48 -11
  66. package/server/data/news-log-arxiv.json +1 -0
  67. package/server/data/news-log-huggingface.json +1 -0
  68. package/server/data/news-results-arxiv.json +460 -0
  69. package/server/data/news-results-huggingface.json +817 -0
  70. package/server/data/research_interests_arxiv.json +50 -0
  71. package/server/data/research_interests_huggingface.json +4 -0
  72. package/server/database/db.js +101 -5
  73. package/server/database/init.sql +17 -1
  74. package/server/gemini-api.js +1838 -0
  75. package/server/gemini-cli.js +13 -2
  76. package/server/index.js +94 -16
  77. package/server/load-env.js +13 -1
  78. package/server/local-gpu.js +166 -24
  79. package/server/mcp-compute.js +223 -0
  80. package/server/middleware/auth.js +25 -6
  81. package/server/nano-claude-code.js +340 -0
  82. package/server/nanoSessionPaths.js +41 -0
  83. package/server/openai-codex.js +6 -2
  84. package/server/openrouter.js +37 -22
  85. package/server/projects.js +516 -110
  86. package/server/routes/agent.js +32 -7
  87. package/server/routes/auto-research.js +1 -1
  88. package/server/routes/claude-btw.js +55 -0
  89. package/server/routes/cli-auth.js +422 -58
  90. package/server/routes/commands.js +25 -1
  91. package/server/routes/community-tools.js +398 -0
  92. package/server/routes/compute.js +155 -0
  93. package/server/routes/git.js +9 -11
  94. package/server/routes/memory.js +143 -0
  95. package/server/routes/news.js +3 -0
  96. package/server/routes/skills.js +35 -0
  97. package/server/routes/user.js +3 -5
  98. package/server/utils/__tests__/safePath.test.js +93 -0
  99. package/server/utils/cliResolution.js +13 -1
  100. package/server/utils/codexSessionMessages.js +285 -0
  101. package/server/utils/geminiOAuth.js +333 -0
  102. package/server/utils/memoryPrompt.js +78 -0
  103. package/server/utils/safePath.js +51 -0
  104. package/server/utils/sessionIndex.js +2 -0
  105. package/server/utils/skillExpander.js +142 -0
  106. package/server/utils/spawnAsync.js +78 -0
  107. package/shared/modelConstants.js +19 -1
  108. package/dist/assets/channel-BFOE7OyC.js +0 -1
  109. package/dist/assets/clone-BulQSblH.js +0 -1
  110. package/dist/assets/index-B8Yutsnm.css +0 -1
  111. package/dist/assets/index-Cf6gyhc3.js +0 -654
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +0 -1
  113. 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;
@@ -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) });
@@ -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 {
@@ -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 { exec } from 'child_process';
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 execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
59
- await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
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
- const envCommand = String(process.env[envVarName] || '').trim();
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
+ };