Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,8 @@ async function fetchProviderModels(providerName, overrides = {}) {
const {
resolveAgentsFilePath,
validateAgentsBaseDir,
detectProjectClaudeMdDir,
validateClaudeMdBaseDir,
resolveClaudeMdFilePath,
readClaudeMdFile,
applyClaudeMdFile,
Expand Down Expand Up @@ -11876,10 +11878,16 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
case 'apply-claude-md-file':
result = applyClaudeMdFile(params || {});
if (result && !result.error) {
const mdTarget = (params && params.targetPath) ? String(params.targetPath) : 'CLAUDE.md';
notifyWebhook('claude-md-edit', 'CLAUDE.md modified: ' + mdTarget, { targetPath: mdTarget }).catch(function () { });
const mdBaseDir = params && params.baseDir ? String(params.baseDir).trim() : '';
const mdTarget = mdBaseDir
? path.join(mdBaseDir, 'CLAUDE.md')
: ((params && params.targetPath) ? String(params.targetPath) : 'CLAUDE.md');
notifyWebhook('claude-md-edit', 'CLAUDE.md modified: ' + mdTarget, { targetPath: mdTarget, projectPath: mdBaseDir }).catch(function () { });
}
break;
case 'detect-project-claude-md':
result = detectProjectClaudeMdDir((params && params.baseDir) || '');
break;
case 'preview-agents-diff':
result = buildAgentsDiff(params || {});
break;
Expand Down
125 changes: 97 additions & 28 deletions cli/agents-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,57 +53,119 @@ function createAgentsFileController(deps = {}) {
return { ok: true, dirPath };
}

function resolveClaudeMdFilePath() {
return path.join(CLAUDE_DIR, CLAUDE_MD_FILE_NAME);
function detectProjectClaudeMdDir(baseDir) {
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return { error: 'project path is required' };
}
const root = baseDir.trim();
const rootPath = path.join(root, CLAUDE_MD_FILE_NAME);
const dotDirPath = path.join(root, '.claude', CLAUDE_MD_FILE_NAME);
try {
if (fs.statSync(rootPath).isFile()) {
return { path: rootPath, source: 'root', dir: root };
}
} catch (_) {}
try {
if (fs.statSync(dotDirPath).isFile()) {
return { path: dotDirPath, source: 'dotdir', dir: path.join(root, '.claude') };
}
} catch (_) {}
return { path: rootPath, source: 'root', dir: root };
}

function validateClaudeMdBaseDir(filePath) {
const dirPath = path.dirname(filePath);
try {
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
return { error: 'project directory is not a directory: ' + dirPath };
}
} catch (e) {
return { error: 'project directory does not exist: ' + dirPath };
}
return { ok: true, dirPath };
}

function resolveClaudeMdFilePath(params = {}) {
const baseDir = typeof params.baseDir === 'string' && params.baseDir.trim()
? params.baseDir.trim()
: '';
if (!baseDir) {
return { filePath: path.join(CLAUDE_DIR, CLAUDE_MD_FILE_NAME), isProject: false };
}
var detected = detectProjectClaudeMdDir(baseDir);
if (detected.error) {
return { filePath: path.join(CLAUDE_DIR, CLAUDE_MD_FILE_NAME), isProject: false, detectionError: detected.error };
}
return { filePath: detected.path, isProject: true, detectionSource: detected.source, projectPath: baseDir };
}

function readClaudeMdFile(params = {}) {
const filePath = resolveClaudeMdFilePath();
const lineEndingFallback = os.EOL === '\r\n' ? '\r\n' : '\n';
var resolved = resolveClaudeMdFilePath(params);
var filePath = resolved.filePath;
var lineEndingFallback = os.EOL === '\r\n' ? '\r\n' : '\n';
var base = {
path: filePath,
lineEnding: lineEndingFallback
};
if (resolved.isProject) {
base.detectionSource = resolved.detectionSource;
base.projectPath = resolved.projectPath;
}
if (resolved.detectionError) {
base.detectionError = resolved.detectionError;
}
if (resolved.isProject) {
var dirCheck = validateClaudeMdBaseDir(filePath);
if (dirCheck.error) {
return { error: dirCheck.error };
}
}
Comment on lines +118 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't fail reads for a missing project directory.

applyClaudeMdFile() explicitly supports creating nested directories, but this branch turns a missing baseDir into res.error. In the current UI flow, loadPromptsContent() returns immediately on that error, so a manual project path that does not exist yet can never open as a blank editor and reach the save path. Treat ENOENT here as exists: false and keep the hard error only for "path exists but is not a directory".

Possible fix
-        if (resolved.isProject) {
-            var dirCheck = validateClaudeMdBaseDir(filePath);
-            if (dirCheck.error) {
-                return { error: dirCheck.error };
-            }
-        }
+        if (resolved.isProject) {
+            var dirPath = path.dirname(filePath);
+            try {
+                var stat = fs.statSync(dirPath);
+                if (!stat.isDirectory()) {
+                    return { error: 'project directory is not a directory: ' + dirPath };
+                }
+            } catch (e) {
+                if (e && e.code && e.code !== 'ENOENT') {
+                    return { error: 'read CLAUDE.md failed: ' + e.message };
+                }
+                return Object.assign({ exists: false, content: '' }, base);
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/agents-files.js` around lines 118 - 123, The branch that calls
validateClaudeMdBaseDir(filePath) when resolved.isProject should not convert an
ENOENT (missing directory) into a fatal error because applyClaudeMdFile() can
create nested dirs and loadPromptsContent() must allow opening a blank editor;
update the logic in cli/agents-files.js around resolved.isProject to check
dirCheck.error.code === 'ENOENT' and in that case return an object indicating
exists: false (or otherwise signal "not found" rather than error), while
preserving the hard error for cases where the path exists but is not a directory
(e.g., dirCheck.error && dirCheck.error.code !== 'ENOENT' -> return { error:
dirCheck.error }); reference functions/vars: validateClaudeMdBaseDir,
applyClaudeMdFile, loadPromptsContent, resolved.isProject, filePath, and ENOENT.

if (!fs.existsSync(filePath)) {
return {
exists: false,
path: filePath,
content: '',
lineEnding: lineEndingFallback
};
return Object.assign({ exists: false, content: '' }, base);
}
if (params.metaOnly) {
return {
exists: true,
path: filePath,
content: '',
lineEnding: lineEndingFallback
};
return Object.assign({ exists: true, content: '' }, base);
}
try {
const raw = fs.readFileSync(filePath, 'utf-8');
return {
var raw = fs.readFileSync(filePath, 'utf-8');
var result = {
exists: true,
path: filePath,
content: stripUtf8Bom(raw),
lineEnding: detectLineEnding(raw)
};
if (resolved.isProject) {
result.detectionSource = resolved.detectionSource;
result.projectPath = resolved.projectPath;
}
return result;
} catch (e) {
return { error: `读取 CLAUDE.md 失败: ${e.message}` };
return { error: 'read CLAUDE.md failed: ' + e.message };
}
}

function applyClaudeMdFile(params = {}) {
const filePath = resolveClaudeMdFilePath();
const content = typeof params.content === 'string' ? params.content : '';
var resolved = resolveClaudeMdFilePath(params);
var filePath = resolved.filePath;
var content = typeof params.content === 'string' ? params.content : '';
if (content.length > 2 * 1024 * 1024) {
return { error: '内容过大(最大 2MB' };
return { error: 'content too large (max 2MB)' };
}
const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
const normalized = normalizeLineEnding(content, lineEnding);
const finalContent = ensureUtf8Bom(normalized);
var lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
var normalized = normalizeLineEnding(content, lineEnding);
var finalContent = ensureUtf8Bom(normalized);
Comment on lines +151 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce the 2MB limit on bytes, not JS string length.

content.length counts UTF-16 code units, so non-ASCII prompt content can exceed 2MB on disk while still passing this check. That breaks the new "files >2MB rejected" contract for UTF-8 writes. Validate the encoded payload size after line-ending normalization and BOM insertion instead.

Possible fix
-        var content = typeof params.content === 'string' ? params.content : '';
-        if (content.length > 2 * 1024 * 1024) {
-            return { error: 'content too large (max 2MB)' };
-        }
+        var content = typeof params.content === 'string' ? params.content : '';
         var lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
         var normalized = normalizeLineEnding(content, lineEnding);
         var finalContent = ensureUtf8Bom(normalized);
+        if (Buffer.byteLength(finalContent, 'utf8') > 2 * 1024 * 1024) {
+            return { error: 'content too large (max 2MB)' };
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/agents-files.js` around lines 151 - 157, The current size check uses JS
string length (params.content / content) which counts UTF-16 code units and can
undercount UTF-8 bytes; move the 2MB check to after normalization and BOM
insertion by computing the UTF-8 byte size of the final payload created by
normalizeLineEnding(...) and ensureUtf8Bom(...), e.g. use
Buffer.byteLength(finalContent, 'utf8') (or Buffer.from(finalContent,
'utf8').length) and return the same { error: 'content too large (max 2MB)' } if
the byte length exceeds 2*1024*1024; apply this check where finalContent is
produced so normalizeLineEnding and ensureUtf8Bom are included in the measured
bytes.

try {
ensureDir(CLAUDE_DIR);
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, finalContent, 'utf-8');
return { success: true, path: filePath };
var result = { success: true, path: filePath };
if (resolved.isProject) {
result.projectPath = resolved.projectPath;
result.detectionSource = resolved.detectionSource;
}
return result;
} catch (e) {
return { error: `写入 CLAUDE.md 失败: ${e.message}` };
return { error: 'write CLAUDE.md failed: ' + e.message };
}
}

Expand Down Expand Up @@ -181,6 +243,11 @@ function createAgentsFileController(deps = {}) {
let readResult;
if (context === 'claude-md') {
readResult = readClaudeMdFile({ metaOnly });
} else if (context === 'claude-project') {
if (!params.baseDir || !String(params.baseDir).trim()) {
return { error: 'project path is required for claude-project context' };
}
readResult = readClaudeMdFile({ ...params, metaOnly });
} else if (context === 'openclaw') {
readResult = readOpenclawAgentsFile({ metaOnly });
} else if (context === 'openclaw-workspace') {
Expand Down Expand Up @@ -215,6 +282,8 @@ function createAgentsFileController(deps = {}) {
return {
resolveAgentsFilePath,
validateAgentsBaseDir,
detectProjectClaudeMdDir,
validateClaudeMdBaseDir,
resolveClaudeMdFilePath,
readClaudeMdFile,
applyClaudeMdFile,
Expand Down
Loading
Loading