From 6f029c49ab0483791167deb680e3d07f4705ff51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Fri, 14 Aug 2026 15:48:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=89=E5=85=A8=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E4=B8=8E=E4=BF=AE=E6=94=B9=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai-project/state.yaml | 4 +- .../conversations/conversation-pane.ts | 2 + docs/FEATURES.md | 8 +- packages/agent-core/src/index.ts | 3 + .../agent-core/src/ports/workspace-port.ts | 29 +++- .../src/use-cases/workspace-service.ts | 51 +++++- .../src/use-cases/workspace-tools.ts | 43 +++++ packages/local-files/src/index.ts | 156 +++++++++++++++++- .../src/local-workspace-resolver.test.ts | 62 ++++++- 9 files changed, 346 insertions(+), 12 deletions(-) diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index 4c6f751..2dc7ec9 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -44,10 +44,10 @@ 状态: "已完成" - 编号: "F-007" 名称: "文件创建与安全修改" - 状态: "进行中" + 状态: "已完成" - 编号: "F-008" 名称: "会话管理与个人设置" - 状态: "待开始" + 状态: "进行中" - 编号: "F-009" 名称: "Claude Desktop 视觉与交互收口" 状态: "待开始" diff --git a/apps/web/src/features/conversations/conversation-pane.ts b/apps/web/src/features/conversations/conversation-pane.ts index d82f806..a259dbe 100644 --- a/apps/web/src/features/conversations/conversation-pane.ts +++ b/apps/web/src/features/conversations/conversation-pane.ts @@ -276,6 +276,8 @@ function toolName(name: string): string { list_directory: "浏览文件", search_files: "搜索文件", read_text_file: "读取文件", + create_text_file: "创建文件", + apply_text_patch: "修改文件", } as Record )[name] ?? name ); diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8415150..9156b25 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -89,13 +89,17 @@ ### F-007——文件创建与安全修改 -- 状态:进行中 +- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核) - 用户可见结果:Agent 在正确工作区内创建和安全修改文本文件。 - 主要验收:AC-008、AC-009、AC-023、AC-028。 +- 实现结果:新增 `create_text_file` 和 `apply_text_patch` 两个模型工具。新建文件使用排他写入,目标已存在时不会覆盖;修改必须携带最近一次 `read_text_file` 返回的 SHA-256 内容哈希,并使用 1 至 50 项精确文本替换。待替换文本不存在、默认模式下出现多次、文件已被外部改动或修改没有产生变化时均明确失败。 +- 写入安全:新文件先解析真实父目录并再次检查工作区边界,阻止通过目录符号链接写到外部;已有文件修改在同目录写临时文件、保留原权限并原子替换,替换前再次核对内容哈希。继续限制 UTF-8 文本和 2 MiB 上限,不增加删除、移动、批量覆盖或 Shell 能力。 +- 页面结果:创建和修改与其他文件工具共用已有工具状态卡片,分别显示“创建文件”和“修改文件”;成功与失败仍使用 `tool.completed` 和 `tool.failed`,不会混入主要回复消息或用户交互卡片。 +- 自动化验证结果:2026-08-14 通过全仓类型检查、31 项测试和 109 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;覆盖排他创建、哈希修改、过期哈希、缺失替换、歧义替换、路径穿越及目录符号链接逃逸。 ### F-008——会话管理与个人设置 -- 状态:待开始 +- 状态:进行中 - 用户可见结果:重命名和删除会话,查看并修改非敏感设置。 - 主要验收:AC-003、AC-018。 diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index aff66b1..b8b0851 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -36,6 +36,9 @@ export type { WorkspaceFileInfo, WorkspacePort, WorkspaceSearchMatch, + WorkspaceTextFile, + WorkspaceWriteResult, + TextReplacement, } from "./ports/workspace-port"; export { WorkspaceService, diff --git a/packages/agent-core/src/ports/workspace-port.ts b/packages/agent-core/src/ports/workspace-port.ts index 4b23e1d..8f745d1 100644 --- a/packages/agent-core/src/ports/workspace-port.ts +++ b/packages/agent-core/src/ports/workspace-port.ts @@ -20,9 +20,36 @@ export type WorkspaceFileInfo = Readonly<{ readableAsText: boolean; }>; +export type WorkspaceTextFile = WorkspaceFileInfo & + Readonly<{ content: string; hash: string }>; + +export type TextReplacement = Readonly<{ + oldText: string; + newText: string; + replaceAll?: boolean; +}>; + +export type WorkspaceWriteResult = Readonly<{ + path: string; + hash: string; + bytes: number; + summary: string; +}>; + export interface WorkspacePort { list(root: string, path?: string): Promise; search(root: string, query: string): Promise; inspect(root: string, path: string): Promise; - readText(root: string, path: string): Promise; + readText(root: string, path: string): Promise; + createText( + root: string, + path: string, + content: string, + ): Promise; + applyTextPatch( + root: string, + path: string, + expectedHash: string, + replacements: readonly TextReplacement[], + ): Promise; } diff --git a/packages/agent-core/src/use-cases/workspace-service.ts b/packages/agent-core/src/use-cases/workspace-service.ts index a3a761a..0b78d51 100644 --- a/packages/agent-core/src/use-cases/workspace-service.ts +++ b/packages/agent-core/src/use-cases/workspace-service.ts @@ -31,7 +31,8 @@ export class WorkspaceService { } async read(context: WorkspaceContext, path: string) { - return this.files.readText(await this.resolveRoot(context), path); + return (await this.files.readText(await this.resolveRoot(context), path)) + .content; } async createAttachments( @@ -108,7 +109,29 @@ export class WorkspaceService { await this.search(context, readString(input, "query")), ); if (name === "read_text_file") - return await this.read(context, readString(input, "path")); + return JSON.stringify( + await this.files.readText( + await this.resolveRoot(context), + readString(input, "path"), + ), + ); + if (name === "create_text_file") + return JSON.stringify( + await this.files.createText( + await this.resolveRoot(context), + readString(input, "path"), + readString(input, "content", true), + ), + ); + if (name === "apply_text_patch") + return JSON.stringify( + await this.files.applyTextPatch( + await this.resolveRoot(context), + readString(input, "path"), + readString(input, "expectedHash"), + readReplacements(input.replacements), + ), + ); throw new CoreError("TOOL_NOT_SUPPORTED", "模型请求了尚未支持的工具"); } @@ -138,6 +161,30 @@ export class WorkspaceService { } } +function readReplacements(value: unknown) { + if (!Array.isArray(value) || value.length === 0 || value.length > 50) + throw new CoreError( + "TOOL_ARGUMENTS_INVALID", + "文本修改必须包含 1 至 50 项替换", + ); + return value.map((item) => { + if (!item || typeof item !== "object") + throw new CoreError("TOOL_ARGUMENTS_INVALID", "文本替换格式无效"); + const input = item as Record; + if (typeof input.oldText !== "string" || !input.oldText) + throw new CoreError("TOOL_ARGUMENTS_INVALID", "待替换文本不能为空"); + if (typeof input.newText !== "string") + throw new CoreError("TOOL_ARGUMENTS_INVALID", "替换后的文本格式无效"); + if (input.replaceAll !== undefined && typeof input.replaceAll !== "boolean") + throw new CoreError("TOOL_ARGUMENTS_INVALID", "replaceAll 必须是布尔值"); + return { + oldText: input.oldText, + newText: input.newText, + ...(input.replaceAll === true ? { replaceAll: true } : {}), + }; + }); +} + async function mapAttachments( conversation: Conversation, transform: ( diff --git a/packages/agent-core/src/use-cases/workspace-tools.ts b/packages/agent-core/src/use-cases/workspace-tools.ts index c24eb7b..d7b8b74 100644 --- a/packages/agent-core/src/use-cases/workspace-tools.ts +++ b/packages/agent-core/src/use-cases/workspace-tools.ts @@ -37,6 +37,49 @@ export const workspaceTools: readonly ModelTool[] = [ additionalProperties: false, }, }, + { + name: "create_text_file", + description: + "在当前会话工作区内创建新的 UTF-8 文本文件;文件已存在时失败。", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + additionalProperties: false, + }, + }, + { + name: "apply_text_patch", + description: + "使用读取文件时得到的 SHA-256 哈希,对文本执行精确替换。默认每项旧文本必须只出现一次。", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + expectedHash: { type: "string" }, + replacements: { + type: "array", + minItems: 1, + maxItems: 50, + items: { + type: "object", + properties: { + oldText: { type: "string" }, + newText: { type: "string" }, + replaceAll: { type: "boolean" }, + }, + required: ["oldText", "newText"], + additionalProperties: false, + }, + }, + }, + required: ["path", "expectedHash", "replacements"], + additionalProperties: false, + }, + }, ]; export function isWorkspaceTool(name: string): boolean { diff --git a/packages/local-files/src/index.ts b/packages/local-files/src/index.ts index b1c3333..8606d67 100644 --- a/packages/local-files/src/index.ts +++ b/packages/local-files/src/index.ts @@ -1,7 +1,18 @@ -import { access, readFile, readdir, realpath, stat } from "node:fs/promises"; +import { + access, + readFile, + readdir, + realpath, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; import { constants } from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; import { basename, + dirname, extname, isAbsolute, relative, @@ -10,11 +21,14 @@ import { } from "node:path"; import { CoreError, + type TextReplacement, type WorkspaceEntry, type WorkspaceFileInfo, type WorkspacePort, type WorkspaceResolverPort, type WorkspaceSearchMatch, + type WorkspaceTextFile, + type WorkspaceWriteResult, } from "@great-agent/agent-core"; export class LocalWorkspaceResolver implements WorkspaceResolverPort { @@ -88,7 +102,7 @@ export class LocalWorkspaceFiles implements WorkspacePort { if (matches.length >= 200) break; try { const content = await this.readText(root, path); - const lines = content.split("\n"); + const lines = content.content.split("\n"); let count = 0; for (let index = 0; index < lines.length && count < 20; index++) { if (!lines[index]?.toLocaleLowerCase().includes(normalized)) continue; @@ -124,7 +138,7 @@ export class LocalWorkspaceFiles implements WorkspacePort { }; } - async readText(root: string, path: string): Promise { + async readText(root: string, path: string): Promise { const info = await this.inspect(root, path); if (info.size > MAX_READ_BYTES) throw new CoreError("FILE_TOO_LARGE", "文件超过 2 MiB,暂不支持读取"); @@ -133,7 +147,70 @@ export class LocalWorkspaceFiles implements WorkspacePort { "FILE_TYPE_UNSUPPORTED", "该文件不是支持的 UTF-8 文本", ); - return readFile(await safeExistingPath(root, path), "utf8"); + const content = await readFile(await safeExistingPath(root, path), "utf8"); + return { ...info, content, hash: hash(content) }; + } + + async createText( + root: string, + path: string, + content: string, + ): Promise { + validateContent(content); + const target = await safeNewPath(root, path); + try { + await writeFile(target, content, { encoding: "utf8", flag: "wx" }); + } catch (cause) { + if (hasCode(cause, "EEXIST")) + throw new CoreError("FILE_ALREADY_EXISTS", "文件已存在,不能覆盖创建"); + throw new CoreError("FILE_WRITE_FAILED", "新文件写入失败"); + } + return writeResult(await realpath(root), target, content, "已创建文本文件"); + } + + async applyTextPatch( + root: string, + path: string, + expectedHash: string, + replacements: readonly TextReplacement[], + ): Promise { + const current = await this.readText(root, path); + if (!/^[a-f\d]{64}$/u.test(expectedHash) || current.hash !== expectedHash) + throw new CoreError( + "FILE_CHANGED", + "文件内容已发生变化,请重新读取后再修改", + ); + let next = current.content; + for (const replacement of replacements) + next = applyReplacement(next, replacement); + validateContent(next); + if (next === current.content) + throw new CoreError("PATCH_NO_CHANGES", "文本修改没有产生变化"); + const target = await safeExistingPath(root, path); + const temporary = resolve( + dirname(target), + `.${basename(target)}.${randomUUID()}.tmp`, + ); + try { + const mode = (await stat(target)).mode; + await writeFile(temporary, next, { encoding: "utf8", flag: "wx", mode }); + if ((await this.readText(root, path)).hash !== current.hash) + throw new CoreError( + "FILE_CHANGED", + "文件内容已发生变化,请重新读取后再修改", + ); + await rename(temporary, target); + } catch (cause) { + await unlink(temporary).catch(() => {}); + if (cause instanceof CoreError) throw cause; + throw new CoreError("FILE_WRITE_FAILED", "文件修改写入失败"); + } + return writeResult( + await realpath(root), + target, + next, + `已完成 ${replacements.length} 项文本替换`, + ); } } @@ -215,3 +292,74 @@ function mediaTypeFor(path: string): string { }; return types[extname(path).toLocaleLowerCase()] ?? "application/octet-stream"; } + +async function safeNewPath(root: string, input: string): Promise { + validateRelativePath(input); + if (!input || input.endsWith("/") || input.endsWith("\\")) + throw new CoreError("FILE_PATH_INVALID", "新文件路径无效"); + try { + const realRoot = await realpath(root); + const target = resolve(realRoot, input); + const realParent = await realpath(dirname(target)); + if (!within(realRoot, realParent)) + throw new CoreError( + "WORKSPACE_PATH_FORBIDDEN", + "不能访问工作区之外的路径", + ); + if (!(await stat(realParent)).isDirectory()) + throw new CoreError("WORKSPACE_NOT_DIRECTORY", "目标父路径不是文件夹"); + return target; + } catch (cause) { + if (cause instanceof CoreError) throw cause; + throw new CoreError( + "FILE_PARENT_UNAVAILABLE", + "新文件的父目录不存在或无法访问", + ); + } +} + +function applyReplacement( + content: string, + replacement: TextReplacement, +): string { + const count = content.split(replacement.oldText).length - 1; + if (count === 0) + throw new CoreError("PATCH_TARGET_NOT_FOUND", "没有找到待替换文本"); + if (!replacement.replaceAll && count !== 1) + throw new CoreError( + "PATCH_TARGET_AMBIGUOUS", + "待替换文本出现多次,需要明确允许全部替换", + ); + return replacement.replaceAll + ? content.split(replacement.oldText).join(replacement.newText) + : content.replace(replacement.oldText, replacement.newText); +} + +function validateContent(content: string): void { + if (Buffer.byteLength(content, "utf8") > MAX_READ_BYTES) + throw new CoreError("FILE_TOO_LARGE", "文本内容超过 2 MiB"); + if (content.includes("\0")) + throw new CoreError("FILE_TYPE_UNSUPPORTED", "文本内容不能包含空字节"); +} + +function hash(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function writeResult( + root: string, + target: string, + content: string, + summary: string, +): WorkspaceWriteResult { + return { + path: toRelative(root, target), + hash: hash(content), + bytes: Buffer.byteLength(content, "utf8"), + summary, + }; +} + +function hasCode(cause: unknown, code: string): boolean { + return cause instanceof Error && "code" in cause && cause.code === code; +} diff --git a/packages/local-files/src/local-workspace-resolver.test.ts b/packages/local-files/src/local-workspace-resolver.test.ts index fc4cfac..d8d0e7c 100644 --- a/packages/local-files/src/local-workspace-resolver.test.ts +++ b/packages/local-files/src/local-workspace-resolver.test.ts @@ -40,7 +40,9 @@ describe("LocalWorkspaceFiles", () => { line: 2, matchedBy: "content", }); - expect(await files.readText(root, "docs/说明.md")).toContain("第一行"); + expect((await files.readText(root, "docs/说明.md")).content).toContain( + "第一行", + ); } finally { await rm(root, { recursive: true }); } @@ -52,6 +54,7 @@ describe("LocalWorkspaceFiles", () => { try { await writeFile(join(outside, "secret.txt"), "secret"); await symlink(join(outside, "secret.txt"), join(root, "shortcut.txt")); + await symlink(outside, join(root, "outside-directory")); const files = new LocalWorkspaceFiles(); await expect(files.readText(root, "../secret.txt")).rejects.toMatchObject( { @@ -66,6 +69,9 @@ describe("LocalWorkspaceFiles", () => { await expect(files.readText(root, "shortcut.txt")).rejects.toMatchObject({ code: "WORKSPACE_PATH_FORBIDDEN", }); + await expect( + files.createText(root, "outside-directory/new.txt", "escape"), + ).rejects.toMatchObject({ code: "WORKSPACE_PATH_FORBIDDEN" }); } finally { await rm(root, { recursive: true }); await rm(outside, { recursive: true }); @@ -87,4 +93,58 @@ describe("LocalWorkspaceFiles", () => { await rm(root, { recursive: true }); } }); + + test("排他创建并使用内容哈希安全修改文本", async () => { + const root = await mkdtemp(join(tmpdir(), "great-agent-write-")); + try { + const files = new LocalWorkspaceFiles(); + const created = await files.createText(root, "notes.txt", "旧内容\n"); + expect(created.path).toBe("notes.txt"); + expect(created.hash).toHaveLength(64); + await expect( + files.createText(root, "notes.txt", "不能覆盖"), + ).rejects.toMatchObject({ code: "FILE_ALREADY_EXISTS" }); + const changed = await files.applyTextPatch( + root, + "notes.txt", + created.hash, + [{ oldText: "旧内容", newText: "新内容" }], + ); + expect(changed.hash).not.toBe(created.hash); + expect((await files.readText(root, "notes.txt")).content).toBe( + "新内容\n", + ); + await expect( + files.applyTextPatch(root, "notes.txt", created.hash, [ + { oldText: "新内容", newText: "危险覆盖" }, + ]), + ).rejects.toMatchObject({ code: "FILE_CHANGED" }); + } finally { + await rm(root, { recursive: true }); + } + }); + + test("修改目标缺失或不唯一时不写文件", async () => { + const root = await mkdtemp(join(tmpdir(), "great-agent-patch-")); + try { + await writeFile(join(root, "repeat.txt"), "相同\n相同\n"); + const files = new LocalWorkspaceFiles(); + const current = await files.readText(root, "repeat.txt"); + await expect( + files.applyTextPatch(root, "repeat.txt", current.hash, [ + { oldText: "不存在", newText: "内容" }, + ]), + ).rejects.toMatchObject({ code: "PATCH_TARGET_NOT_FOUND" }); + await expect( + files.applyTextPatch(root, "repeat.txt", current.hash, [ + { oldText: "相同", newText: "内容" }, + ]), + ).rejects.toMatchObject({ code: "PATCH_TARGET_AMBIGUOUS" }); + expect((await files.readText(root, "repeat.txt")).content).toBe( + "相同\n相同\n", + ); + } finally { + await rm(root, { recursive: true }); + } + }); });