Files
easynode/server/app/ai/write-preview.js
T

156 lines
5.2 KiB
JavaScript

/**
* write_file 审批快照
*
* 用户批准的必须是“将要落盘的那一份内容”。审批前读取当前文件并生成
* 完整替换 diff,同时把旧文件状态与新内容一起做哈希;执行前重新生成
* 快照,任何 TOCTOU 变化都会让原审批失效。
*/
import path from 'node:path'
import { createHash } from 'node:crypto'
import { withSftp } from './ssh.js'
export const MAX_WRITE_FILE_BYTES = 256 * 1024
function sftpStat(sftp, pathname) {
return new Promise((resolve, reject) => {
sftp.stat(pathname, (error, stats) => (error ? reject(error) : resolve(stats)))
})
}
function sftpRealpath(sftp, pathname) {
return new Promise((resolve) => {
sftp.realpath(pathname, (error, resolved) => resolve(error ? pathname : resolved))
})
}
function readStream(sftp, pathname) {
return new Promise((resolve, reject) => {
const chunks = []
const stream = sftp.createReadStream(pathname)
stream.on('data', (chunk) => chunks.push(chunk))
stream.on('error', reject)
stream.on('end', () => resolve(Buffer.concat(chunks)))
})
}
function hash(value) {
return createHash('sha256').update(value).digest('hex')
}
function lineCount(content) {
if (!content) return 0
return content.endsWith('\n') ? content.split('\n').length - 1 : content.split('\n').length
}
function prefixLines(content, prefix) {
if (!content) return []
const trailingNewline = content.endsWith('\n')
const lines = content.split('\n')
if (trailingNewline) lines.pop()
const output = lines.map((line) => `${ prefix }${ line }`)
if (!trailingNewline) output.push('\\ No newline at end of file')
return output
}
/**
* 使用“完整替换”形式而非最小 diff:审批界面必须让用户看到全部旧内容和
* 全部新内容,不能因为 diff 算法省略上下文而隐藏模型将写入的行。
*/
export function buildFullReplacementDiff(pathname, oldContent, newContent, created) {
const oldLabel = created ? '/dev/null' : `${ pathname } (current)`
return [
`--- ${ oldLabel }`,
`+++ ${ pathname } (proposed)`,
`@@ -1,${ lineCount(oldContent) } +1,${ lineCount(newContent) } @@`,
...prefixLines(oldContent, '-'),
...prefixLines(newContent, '+')
].join('\n')
}
export function validateWriteFileInput(input = {}) {
const pathname = String(input.path || '')
if (!path.posix.isAbsolute(pathname)) {
throw new Error('write_file 只允许绝对路径,以便准确展示和审批目标文件')
}
const bytes = Buffer.byteLength(String(input.content ?? ''), 'utf8')
if (bytes > MAX_WRITE_FILE_BYTES) {
throw new Error(`写入内容超过安全预览上限 ${ MAX_WRITE_FILE_BYTES } 字节,请改用人工上传或受审脚本`)
}
if (String(input.content ?? '').includes('\0')) {
throw new Error('write_file 只支持可完整预览的文本文件,不允许写入二进制内容')
}
if (input.mode !== undefined && !/^[0-7]{3,4}$/.test(String(input.mode))) {
throw new Error('文件权限必须是 3 或 4 位八进制数字,例如 644 或 0755')
}
return { pathname, bytes }
}
export async function buildWriteFilePreviewWithSftp(sftp, hostId, input = {}) {
const { pathname, bytes: newBytes } = validateWriteFileInput(input)
let stats = null
try {
stats = await sftpStat(sftp, pathname)
} catch {
// 不存在即新建
}
if (stats?.isDirectory()) throw new Error(`${ pathname } 是目录,无法写入`)
if (stats?.size > MAX_WRITE_FILE_BYTES) {
throw new Error(`原文件超过安全预览上限 ${ MAX_WRITE_FILE_BYTES } 字节,不能在未完整展示差异时覆盖`)
}
const realPath = await sftpRealpath(sftp, pathname)
const oldBuffer = stats ? await readStream(sftp, pathname) : Buffer.alloc(0)
if (oldBuffer.length > MAX_WRITE_FILE_BYTES) {
throw new Error(`原文件超过安全预览上限 ${ MAX_WRITE_FILE_BYTES } 字节,不能在未完整展示差异时覆盖`)
}
let oldContent
try {
oldContent = new TextDecoder('utf-8', { fatal: true }).decode(oldBuffer)
} catch {
throw new Error('原文件不是有效的 UTF-8 文本,无法生成可信的完整差异')
}
if (oldContent.includes('\0')) throw new Error('原文件包含二进制内容,无法生成可信的完整差异')
const newContent = String(input.content ?? '')
const created = !stats
const oldMode = stats ? (stats.mode & 0o7777).toString(8).padStart(3, '0') : null
const newMode = input.mode ? String(input.mode) : oldMode
const oldHash = hash(oldBuffer)
const contentHash = hash(Buffer.from(newContent))
const snapshotHash = hash(JSON.stringify({
hostId,
pathname,
realPath,
created,
oldHash,
oldMode,
newMode,
backup: input.backup !== false,
contentHash
}))
return {
type: 'write_file',
path: pathname,
realPath,
operation: created ? 'create' : 'overwrite',
backup: input.backup !== false,
oldMode,
newMode,
oldBytes: oldBuffer.length,
newBytes,
oldHash,
contentHash,
snapshotHash,
diff: buildFullReplacementDiff(pathname, oldContent, newContent, created)
}
}
export function buildWriteFilePreview(hostId, input = {}) {
return withSftp(hostId, (sftp) => buildWriteFilePreviewWithSftp(sftp, hostId, input))
}