mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
fix(builtin): bind file views to complete returned reads
Problem: whole-file rewrites can discard changes hidden by a later overlay version, a paged read, or an oversized read that clears the previous guard. Root cause: read_file renders one read and records another. Missing and incomplete observations also collapse into the permissive never-seen path. Fix: observe the renderer's decoded stream with bounded capture, refresh only complete returned reads, retain older guards, and track first partial reads explicitly. Keep never-seen compatibility and disabled protection behavior. Verification: compiling old-owner controls fail five behavior regressions; fixed focused race tests and real boot.Build checks pass. Full builtin, root-package tests, go vet, make check and full lint are recorded in the PR. Written bilingual security bounds and release notes accompany this slice. A first partial read refuses replacement with the file's line count and the read_file limit that certifies it. Without that, a default-limited read of a file longer than 2000 lines can never be certified by rereading as told. Refs #14025 Refs #14133
This commit is contained in:
1 parent
33abf2ef03
commit
3d27a1a4e1
6 files changed
+498
-28
No files matched your search
@@ -0,0 +1,96 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-24
|
||||
---
|
||||
|
||||
# File-view provenance / 文件读取版本来源
|
||||
|
||||
## Cause and scope / 根因与范围
|
||||
|
||||
`read_file` previously rendered one read, then independently reopened the file
|
||||
or editor overlay to record its overwrite guard. A later buffer version could
|
||||
therefore be certified without reaching the caller. A page also certified the
|
||||
unreturned tail; an oversized read removed a previous guard.
|
||||
|
||||
旧实现展示内容后再次读取文件或编辑器缓冲区来记录版本,因此可能记录代理没
|
||||
看到的后续内容。分页会误更新整个文件的记录,超限读取还会清掉已有保护。
|
||||
|
||||
The fix observes the same decoded reader consumed by the renderer:
|
||||
|
||||
- A successful complete read from offset zero updates the digest, within the
|
||||
existing 8 MiB decoded-content bound.
|
||||
- Partial, oversized and failed reads retain the earlier digest.
|
||||
- A first incomplete successful read records that incompleteness; `write_file`
|
||||
requires a complete reread or a targeted edit instead of treating it as unseen.
|
||||
- Capture is bounded and streaming; it does not drain an unreturned tail.
|
||||
- A nil `FileViews` leaves the reader unwrapped.
|
||||
|
||||
修复直接观察渲染器使用的解码流。只有成功、从零开始、完整返回且不超过
|
||||
8 MiB 的读取才更新摘要;分页、超限和失败读取保留先前记录。首次不完整读取
|
||||
会记录其不完整状态,完整覆盖前必须完整重读或使用局部编辑。捕获过程有界,
|
||||
不额外读完未展示的尾部;禁用保护时保持原读取路径。
|
||||
|
||||
## Authority and sinks / 权限与写入入口
|
||||
|
||||
The host still binds `FileViews` through `boot.bindFileViews` when changed-file
|
||||
protection is enabled. The input boundaries remain:
|
||||
|
||||
| Input | Existing boundary |
|
||||
| --- | --- |
|
||||
| Disk reader | Path resolution, read confinement, network roots, forbidden roots and opened-file checks precede capture. |
|
||||
| Editor buffer | The host's `FileOverlay` is consulted after its existing path checks. |
|
||||
| Observation | A digest is stored under the resolved path; decoded bytes live only for that call. No write authority is granted. |
|
||||
|
||||
宿主仍按原配置绑定保护记录。路径解析、读取范围、网络根、禁止目录及已打开
|
||||
文件检查继续在捕获前执行;编辑器来源也沿用原路径检查。该改动不增加权限,
|
||||
记录仅保存解析路径对应的摘要,解码内容只在调用期间临时保留。
|
||||
|
||||
The consumer remains `FileViews.checkOverwrite` in `write_file`; this patch
|
||||
changes which observation can refresh it. Existing edit/write routes, encoding,
|
||||
permissions, hooks, approval and protection opt-out remain in their owners.
|
||||
|
||||
There is no new shell command, disk format, wire field, tool schema or prefix
|
||||
mutation. See #14133 for the preserved complete implementation. This independent
|
||||
slice does not activate the pending transaction or isolation layers.
|
||||
|
||||
消费方仍是 `write_file` 的覆盖检查;本批只修正更新记录的来源。编辑、写回、
|
||||
编码、权限、hooks、审批及关闭保护的行为继续由原模块负责。不新增命令、
|
||||
持久格式、协议字段或工具定义;事务、隔离层仍分批审查。
|
||||
|
||||
## Limits and review evidence / 限制与审查证据
|
||||
|
||||
The guard has explicit limits that need the later runtime and provenance slices:
|
||||
|
||||
| Boundary | Remaining limit |
|
||||
| --- | --- |
|
||||
| Atomicity | This is neither compare-and-swap nor a file lock. External writes can race after the overwrite check. |
|
||||
| Actor ownership | Parent and child registries can still share a view record. |
|
||||
| Never-seen files | Legacy overwrites without any prior successful read remain allowed. A successful partial read now requires a complete reread or targeted edit. |
|
||||
| Model delivery | Downstream truncation or summaries are outside this tool-level receipt; rendered bytes are not proof of later model visibility. |
|
||||
| Authorization | The guard grants no access and does not replace permission checks. |
|
||||
|
||||
这仍是过期视图检查,不是原子比较写入、文件锁或权限控制;外部写入仍可能与
|
||||
检查后的写回竞争。完全未读过的文件沿用原覆盖规则。父子代理独立记录、所有
|
||||
已有文件的强制读取,以及下游截断后的模型可见性,均留给后续批次;不能把本批
|
||||
称为整套并发问题已解决。
|
||||
|
||||
Deterministic regressions reproduce a changing overlay, three paging shapes,
|
||||
and loss of the old guard on oversized input. These cases fail on the unmodified
|
||||
owner before the fix.
|
||||
|
||||
The real `boot.Build` regression follows the production path:
|
||||
|
||||
1. Read the whole file, then apply an external edit.
|
||||
2. Read only the header, then attempt a stale overwrite.
|
||||
3. Check retained user bytes and the refusal delivered to the provider.
|
||||
|
||||
Neighbour checks cover complete reads, empty files, final newlines, CRLF, BOM,
|
||||
UTF-16, nested verbatim reads, first partial reads, streaming, own edits and the
|
||||
protection opt-out.
|
||||
|
||||
确定性回归覆盖变化的编辑器缓冲区、三种分页及超限读取。真实装配测试在完整
|
||||
读取后模拟外部修改,只重读首行再尝试覆盖,并同时检查用户内容和模型收到的
|
||||
拒绝结果;旧实现会失败。相邻测试覆盖完整读取、空文件、换行、编码、嵌套
|
||||
读取、流式读取、自身编辑及原有关闭保护行为。作者说明不等于独立安全审批。
|
||||
@@ -0,0 +1,77 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
func TestEffectPagedReadDoesNotRefreshUnseenFileContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
home, dir := statedBootHome(t), robustTempDir(t)
|
||||
target := filepath.Join(dir, "notes.md")
|
||||
writeFile(t, dir, "notes.md", "header\noriginal tail\n")
|
||||
const current = "header\nthe user's new tail\n"
|
||||
rec := &browserScriptProvider{rounds: []func(string) *provider.ToolCall{
|
||||
func(string) *provider.ToolCall {
|
||||
return browserCall("whole", "read_file", map[string]any{"path": target})
|
||||
},
|
||||
func(last string) *provider.ToolCall {
|
||||
if !strings.Contains(last, "original tail") {
|
||||
t.Errorf("whole read did not reach the provider: %q", last)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte(current), 0600); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return browserCall("page", "read_file", map[string]any{"path": target, "limit": 1})
|
||||
},
|
||||
func(last string) *provider.ToolCall {
|
||||
if !strings.Contains(last, "header") || strings.Contains(last, "new tail") {
|
||||
t.Errorf("page did not reach the provider as a partial read: %q", last)
|
||||
}
|
||||
return browserCall("overwrite", "write_file", map[string]any{"path": target, "content": "changed header\noriginal tail\n"})
|
||||
},
|
||||
}}
|
||||
kind := "boot-fileview-" + strings.ToLower(t.Name())
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
|
||||
runErr := ctrl.Run(context.Background(), "update the header in notes.md")
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil || string(got) != current {
|
||||
t.Fatalf("unseen user edit was overwritten: %q; read=%v; run=%v", got, err, runErr)
|
||||
}
|
||||
if runErr != nil {
|
||||
t.Fatal(runErr)
|
||||
}
|
||||
reqs := rec.requests()
|
||||
if len(reqs) < 4 {
|
||||
t.Fatalf("missing requests: %d", len(reqs))
|
||||
}
|
||||
results := effectToolResults(reqs[len(reqs)-1])
|
||||
if len(results) != 3 || !strings.Contains(results[2], "modified after you last read or wrote it") {
|
||||
t.Fatalf("stale overwrite refusal did not reach the provider: %q", results)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
@@ -16,18 +17,26 @@ import (
|
||||
// holds what the agent last read or wrote there.
|
||||
var ErrFileChangedSinceSeen = errors.New("file changed since the agent last saw it")
|
||||
|
||||
// ErrFileNotFullySeen marks a whole-file overwrite after an incomplete read.
|
||||
var ErrFileNotFullySeen = fmt.Errorf("%w: not read in full", ErrFileChangedSinceSeen)
|
||||
|
||||
// FileViews is what one run's agent last saw of each file: the content a read
|
||||
// returned it from, or what one of its own writes left there. write_file checks
|
||||
// it so a whole-file overwrite cannot discard a change made since. A nil
|
||||
// *FileViews records and checks nothing.
|
||||
type FileViews struct {
|
||||
mu sync.Mutex
|
||||
seen map[string][sha256.Size]byte
|
||||
seen map[string]fileView
|
||||
}
|
||||
|
||||
type fileView struct {
|
||||
content [sha256.Size]byte
|
||||
complete bool
|
||||
}
|
||||
|
||||
// NewFileViews returns an empty record.
|
||||
func NewFileViews() *FileViews {
|
||||
return &FileViews{seen: map[string][sha256.Size]byte{}}
|
||||
return &FileViews{seen: map[string]fileView{}}
|
||||
}
|
||||
|
||||
func (v *FileViews) saw(path, content string) {
|
||||
@@ -36,27 +45,70 @@ func (v *FileViews) saw(path, content string) {
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.seen[filepath.Clean(path)] = sha256.Sum256([]byte(content))
|
||||
v.seen[filepath.Clean(path)] = fileView{content: sha256.Sum256([]byte(content)), complete: true}
|
||||
}
|
||||
|
||||
// maxViewBytes bounds the files a read records: past it the view is dropped
|
||||
// rather than paid for with a second whole-file read.
|
||||
func (v *FileViews) sawPartial(path string) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
path = filepath.Clean(path)
|
||||
if _, ok := v.seen[path]; !ok {
|
||||
v.seen[path] = fileView{}
|
||||
}
|
||||
}
|
||||
|
||||
// maxViewBytes bounds decoded content retained while rendering a read.
|
||||
const maxViewBytes = 8 << 20
|
||||
|
||||
// sawFile records the file's current content, as the edit tools would read it.
|
||||
func (v *FileViews) sawFile(ctx context.Context, overlay FileOverlay, path string) {
|
||||
// Only a complete returned read can replace an earlier observation. A page,
|
||||
// failed read or oversize input must not certify content the caller did not see.
|
||||
func (v *FileViews) captureRead(path string, emit func(io.Reader, int, int) (string, error)) func(io.Reader, int, int) (string, error) {
|
||||
if v == nil {
|
||||
return
|
||||
return emit
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && info.Size() <= maxViewBytes {
|
||||
if src, err := readEditSource(ctx, overlay, path); err == nil {
|
||||
v.saw(path, src.content)
|
||||
return
|
||||
return func(src io.Reader, offset, limit int) (string, error) {
|
||||
if offset != 0 {
|
||||
out, err := emit(src, offset, limit)
|
||||
if err == nil {
|
||||
v.sawPartial(path)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
read := &fileViewRead{src: src}
|
||||
out, err := emit(read, offset, limit)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
v.sawPartial(path)
|
||||
data := read.data
|
||||
if read.eof && len(data) <= maxViewBytes {
|
||||
lines := bytes.Count(data, []byte{'\n'})
|
||||
if len(data) > 0 && data[len(data)-1] != '\n' {
|
||||
lines++
|
||||
}
|
||||
if limit <= 0 || lines <= limit {
|
||||
v.saw(path, string(data))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
v.mu.Lock()
|
||||
delete(v.seen, filepath.Clean(path))
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
type fileViewRead struct {
|
||||
src io.Reader
|
||||
data []byte
|
||||
eof bool
|
||||
}
|
||||
|
||||
func (r *fileViewRead) Read(p []byte) (int, error) {
|
||||
n, err := r.src.Read(p)
|
||||
if remaining := maxViewBytes + 1 - len(r.data); remaining > 0 {
|
||||
r.data = append(r.data, p[:min(n, remaining)]...)
|
||||
}
|
||||
if err == io.EOF {
|
||||
r.eof = true
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// checkOverwrite refuses replacing current when the agent saw different
|
||||
@@ -69,7 +121,14 @@ func (v *FileViews) checkOverwrite(path, current string) error {
|
||||
v.mu.Lock()
|
||||
want, ok := v.seen[filepath.Clean(path)]
|
||||
v.mu.Unlock()
|
||||
if !ok || want == sha256.Sum256([]byte(current)) {
|
||||
if ok && !want.complete {
|
||||
lines := strings.Count(current, "\n")
|
||||
if current != "" && !strings.HasSuffix(current, "\n") {
|
||||
lines++
|
||||
}
|
||||
return fmt.Errorf("%w: write_file replaces all %d lines of %s; read it whole first (read_file with offset 0 and limit >= %d), or use edit_file for a targeted change", ErrFileNotFullySeen, lines, path, lines)
|
||||
}
|
||||
if !ok || want.content == sha256.Sum256([]byte(current)) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %s was modified after you last read or wrote it, by the user or another process; read it again and apply your change to what it holds now", ErrFileChangedSinceSeen, path)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
fileenc "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
type changingReadOverlay struct {
|
||||
reads int
|
||||
writes int
|
||||
}
|
||||
|
||||
func (o *changingReadOverlay) ReadTextFile(context.Context, string) (string, bool) {
|
||||
o.reads++
|
||||
if o.reads == 1 {
|
||||
return "before\n", true
|
||||
}
|
||||
return "another writer\n", true
|
||||
}
|
||||
|
||||
func (o *changingReadOverlay) WriteTextFile(context.Context, string, string) (bool, error) {
|
||||
o.writes++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestFileViewTracksTheReturnedReadRatherThanASecondRead(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.txt")
|
||||
if err := os.WriteFile(path, []byte("before\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
views, overlay := NewFileViews(), &changingReadOverlay{}
|
||||
if err := runViewTool(t, readFile{views: views, overlay: overlay}, map[string]string{"path": path}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runViewTool(t, writeFile{views: views, overlay: overlay}, map[string]string{"path": path, "content": "overwrite\n"})
|
||||
if !errors.Is(err, ErrFileChangedSinceSeen) || overlay.writes != 0 {
|
||||
t.Fatalf("stale read certified a later version: %v; writes=%d", err, overlay.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedReadDoesNotCertifyUnseenFileContent(t *testing.T) {
|
||||
for _, page := range []string{
|
||||
`{"path":"a.txt","limit":1}`,
|
||||
`{"path":"a.txt","offset":1}`,
|
||||
`{"path":"a.txt","offset":99}`,
|
||||
} {
|
||||
t.Run(page, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "a.txt")
|
||||
if err := os.WriteFile(path, []byte("header\noriginal tail\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
views := NewFileViews()
|
||||
reader := readFile{workDir: root, views: views}
|
||||
if _, err := reader.Execute(t.Context(), json.RawMessage(`{"path":"a.txt"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("header\nother writer's tail\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := reader.Execute(t.Context(), json.RawMessage(page)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := (writeFile{workDir: root, views: views}).Execute(t.Context(), json.RawMessage(`{"path":"a.txt","content":"edited header\noriginal tail\n"}`))
|
||||
if !errors.Is(err, ErrFileChangedSinceSeen) {
|
||||
t.Fatalf("paged read authorized stale overwrite: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != "header\nother writer's tail\n" {
|
||||
t.Fatalf("lost unseen content: %q; %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeReadDoesNotForgetAnEarlierFileView(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "a.txt")
|
||||
if err := os.WriteFile(path, []byte("before\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
views := NewFileViews()
|
||||
reader := readFile{workDir: root, views: views}
|
||||
if _, err := reader.Execute(t.Context(), json.RawMessage(`{"path":"a.txt"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := strings.Repeat("external writer\n", maxViewBytes/16+1)
|
||||
if err := os.WriteFile(path, []byte(current), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := reader.Execute(t.Context(), json.RawMessage(`{"path":"a.txt","limit":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := views.checkOverwrite(path, current); !errors.Is(err, ErrFileChangedSinceSeen) {
|
||||
t.Fatalf("oversize read forgot the previous view: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWholeReadRefreshesTheFileView(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
enc fileenc.Kind
|
||||
nested bool
|
||||
}{
|
||||
{name: "empty"},
|
||||
{name: "final newline", content: "new\n"},
|
||||
{name: "no final newline", content: "new"},
|
||||
{name: "CRLF", content: "new\r\n"},
|
||||
{name: "UTF8 BOM", content: "new\n", enc: fileenc.UTF8BOM},
|
||||
{name: "UTF16", content: "new\n", enc: fileenc.UTF16LE},
|
||||
{name: "nested verbatim", content: "new\r\n", nested: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.txt")
|
||||
if err := writeFileEncoded(path, tc.content, tc.enc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
views := NewFileViews()
|
||||
views.saw(path, "before\n")
|
||||
ctx := t.Context()
|
||||
if tc.nested {
|
||||
ctx = tool.MarkNested(ctx)
|
||||
}
|
||||
args, err := json.Marshal(map[string]any{"path": path, "limit": 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (readFile{views: views}).Execute(ctx, args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runViewTool(t, writeFile{views: views}, map[string]string{"path": path, "content": "edited\n"}); err != nil {
|
||||
t.Fatalf("whole returned read did not refresh the view: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileViewCaptureKeepsPagedReadsStreaming(t *testing.T) {
|
||||
content := strings.Repeat("line\n", maxViewBytes)
|
||||
src := strings.NewReader(content)
|
||||
views := NewFileViews()
|
||||
views.saw("a.txt", "before\n")
|
||||
out, err := views.captureRead("a.txt", (readFile{}).scan)(src, 0, 1)
|
||||
if err != nil || !strings.Contains(out, "1→line") {
|
||||
t.Fatalf("read page: %q; %v", out, err)
|
||||
}
|
||||
if consumed := len(content) - src.Len(); consumed > readFileDetectSample {
|
||||
t.Fatalf("recording a page consumed %d unseen bytes", consumed)
|
||||
}
|
||||
if err := views.checkOverwrite("a.txt", content); !errors.Is(err, ErrFileChangedSinceSeen) {
|
||||
t.Fatalf("streamed page certified unread content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstIncompleteReadCannotPermitALaterStaleOverwrite(t *testing.T) {
|
||||
for _, args := range []string{
|
||||
`{"path":"a.txt","limit":1}`,
|
||||
`{"path":"a.txt","offset":1}`,
|
||||
} {
|
||||
t.Run(args, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "a.txt")
|
||||
if err := os.WriteFile(path, []byte("before\ntail\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
views := NewFileViews()
|
||||
if _, err := (readFile{workDir: root, views: views}).Execute(t.Context(), json.RawMessage(args)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("user edit\ntail\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runViewTool(t, writeFile{workDir: root, views: views}, map[string]string{"path": path, "content": "stale rewrite"})
|
||||
if !errors.Is(err, ErrFileChangedSinceSeen) {
|
||||
t.Fatalf("first incomplete read permitted a stale overwrite: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstPartialReadRequiresAWholeReread(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "a.txt")
|
||||
if err := os.WriteFile(path, []byte("header\nunseen tail\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
views := NewFileViews()
|
||||
read := readFile{workDir: root, views: views}
|
||||
write := writeFile{workDir: root, views: views}
|
||||
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"a.txt","limit":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runViewTool(t, write, map[string]string{"path": path, "content": "replacement\n"})
|
||||
if !errors.Is(err, ErrFileChangedSinceSeen) {
|
||||
t.Fatalf("unseen tail was certified for replacement: %v", err)
|
||||
}
|
||||
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"a.txt"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runViewTool(t, write, map[string]string{"path": path, "content": "replacement\n"}); err != nil {
|
||||
t.Fatalf("whole reread did not permit replacement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultLimitedReadNamesTheReadThatCertifiesReplacement(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "long.txt")
|
||||
lines := readFileDefaultLimit + 500
|
||||
if err := os.WriteFile(path, []byte(strings.Repeat("line\n", lines)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
views := NewFileViews()
|
||||
read := readFile{workDir: root, views: views}
|
||||
write := writeFile{workDir: root, views: views}
|
||||
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"long.txt"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runViewTool(t, write, map[string]string{"path": path, "content": "short\n"})
|
||||
if !errors.Is(err, ErrFileNotFullySeen) || !strings.Contains(err.Error(), "limit >= 2500") {
|
||||
t.Fatalf("a default-limited read was refused without naming the read that certifies it: %v", err)
|
||||
}
|
||||
if _, err := read.Execute(t.Context(), json.RawMessage(`{"path":"long.txt","limit":2500}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runViewTool(t, write, map[string]string{"path": path, "content": "short\n"}); err != nil {
|
||||
t.Fatalf("the named read did not certify replacement: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -93,16 +93,7 @@ func (r readFile) ReadTarget(args json.RawMessage) string {
|
||||
}
|
||||
|
||||
func (r readFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
out, err := r.read(ctx, args)
|
||||
if err == nil && r.views != nil {
|
||||
var p struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if json.Unmarshal(args, &p) == nil {
|
||||
r.views.sawFile(ctx, r.overlay, resolveReadablePath(r.workDir, p.Path, r.paths).Path)
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
return r.read(ctx, args)
|
||||
}
|
||||
|
||||
// refuse is the read boundary: outside the read roots, or a forbidden path that
|
||||
@@ -146,6 +137,7 @@ func (r readFile) read(ctx context.Context, args json.RawMessage) (string, error
|
||||
p.Offset = 0
|
||||
}
|
||||
emit, limit := r.render(ctx, p.Limit)
|
||||
emit = r.views.captureRead(p.Path, emit)
|
||||
p.Limit = limit
|
||||
|
||||
// The host overlay (unsaved editor buffers) wins over the disk when it can
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## 修复
|
||||
|
||||
- File change protection records the complete returned read; pages and oversized
|
||||
reads preserve the earlier guard. (#14025)
|
||||
- 文件变更保护现在记录完整读取实际返回的内容,避免二次读取误记录后续文件或
|
||||
编辑器版本;分页和超限读取会保留已有的过期覆盖保护。 (#14025)
|
||||
- After a partial read, read the file in full before a whole-file overwrite, or
|
||||
use a targeted edit. (#14025)
|
||||
- 首次只读取一部分内容后,完整覆盖前需要完整重读,或改用局部编辑。 (#14025)
|
||||
Reference in new issue
Block a user