Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
43 changes: 34 additions & 9 deletions cmd/codeaf/appconvo.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"github.com/Agent-Field/codeaf/internal/redact"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/skills"
"github.com/Agent-Field/codeaf/internal/tui3"
)

Expand Down Expand Up @@ -973,9 +974,25 @@ func (b *appBackend) statFile(workspace string, changed map[string]appwire.FileC
}
full = filepath.Clean(full)
stat := appwire.FileStat{Path: path, State: appwire.FileExists}
if rel, err := filepath.Rel(workspace, full); workspace == "" || err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
stat.State = appwire.FileOutside
return stat
// One folder can be spelled two ways. macOS reaches /tmp and /var through
// links, and a project can sit under a linked folder, so a file a picker or
// a drag names /private/tmp/demo/notes.md is inside a workspace typed as
// /tmp/demo. The spelled test stays first, so nothing inside by spelling is
// newly called outside; only a path outside by spelling is resolved, and it
// is inside when its resolved place is. same is that file in the
// workspace's own spelling and rel its path inside the workspace: the keys a
// change recorded under another spelling of it is found by.
same, rel := full, ""
if appWithin(workspace, full) {
rel, _ = filepath.Rel(workspace, full)
} else {
root, place := appCanonical(workspace), appCanonical(full)
inside, err := filepath.Rel(root, place)
if err != nil || !appWithin(root, place) {
stat.State = appwire.FileOutside
return stat
}
same, rel = filepath.Join(workspace, inside), inside
}
info, err := os.Stat(full)
if err != nil {
Expand All @@ -984,12 +1001,12 @@ func (b *appBackend) statFile(workspace string, changed map[string]appwire.FileC
}
size := info.Size()
stat.Size = &size
if !info.IsDir() {
if file, err := os.Open(full); err == nil {
head := make([]byte, 8000)
n, _ := io.ReadFull(file, head)
_ = file.Close()
stat.Binary = bytes.IndexByte(head[:n], 0) >= 0
// The first bytes are read only from a regular file, through the opener
// that also refuses a FIFO or a device put in its place between the stat
// and the open: opening either would wait for a writer and hold the request.
if info.Mode().IsRegular() {
if head, err := skills.ReadRegularHead(full, 8000); err == nil {
stat.Binary = bytes.IndexByte(head, 0) >= 0
}
}
if change, ok := changed[full]; ok {
Expand All @@ -1000,6 +1017,14 @@ func (b *appBackend) statFile(workspace string, changed map[string]appwire.FileC
stat.State = appwire.FileChanged
add, del := change.Add, change.Del
stat.Add, stat.Del = &add, &del
} else if change, ok := changed[same]; ok {
stat.State = appwire.FileChanged
add, del := change.Add, change.Del
stat.Add, stat.Del = &add, &del
} else if change, ok := changed[rel]; ok && rel != "" {
stat.State = appwire.FileChanged
add, del := change.Add, change.Del
stat.Add, stat.Del = &add, &del
}
return stat
}
Expand Down
127 changes: 127 additions & 0 deletions cmd/codeaf/appconvo_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package main

import (
"os"
"path/filepath"
"sync"
"testing"

"github.com/Agent-Field/codeaf/internal/appwire"
"github.com/Agent-Field/codeaf/internal/session"
)

// aliasBackend is a desktop backend with one chat whose workspace is spelled
// through a symbolic link the test made, so the alias exists on every platform
// and not only where the temporary folder is reached through one (macOS /var).
func aliasBackend(t *testing.T, spelledThroughLink bool) (backend *appBackend, agent appwire.Agent, real, link string) {
t.Helper()
base := t.TempDir()
real = filepath.Join(base, "real")
if err := os.Mkdir(real, 0o755); err != nil {
t.Fatal(err)
}
link = filepath.Join(base, "link")
if err := os.Symlink(real, link); err != nil {
t.Skipf("this filesystem cannot make a symbolic link: %v", err)
}
resolved, err := filepath.EvalSymlinks(real)
if err != nil {
t.Fatal(err)
}
real = resolved
if err := os.WriteFile(filepath.Join(real, "notes.md"), []byte("# notes\n"), 0o644); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var opens, releases int
conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}
backend = newFakeBackend(newAppRecorder(), fakeOpener(conv, &opens, &releases, &mu))
t.Cleanup(func() { backend.Close() })
backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)}
workspace := real
if spelledThroughLink {
workspace = link
}
agent, err = mustCreate(t, backend, appwire.CreateAsk{Workspace: workspace})
if err != nil {
t.Fatal(err)
}
return backend, agent, real, link
}

// A file the picker names in its resolved spelling is inside a workspace typed
// through a link, and the other way round; a missing file there is missing,
// not outside; a file that is really elsewhere stays outside.
func TestAppFileStatUsesResolvedWorkspaceContainment(t *testing.T) {
for _, throughLink := range []bool{true, false} {
backend, agent, real, link := aliasBackend(t, throughLink)
other := real
if !throughLink {
other = link
}
elsewhere := filepath.Join(t.TempDir(), "elsewhere.md")
if err := os.WriteFile(elsewhere, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
stats, err := backend.StatFiles(agent.ID, []string{
"notes.md",
filepath.Join(other, "notes.md"),
filepath.Join(other, "gone.md"),
elsewhere,
})
if err != nil || len(stats) != 4 {
t.Fatalf("workspace %q: files.stat = %+v %v", agent.Workspace, stats, err)
}
want := []string{appwire.FileExists, appwire.FileExists, appwire.FileMissing, appwire.FileOutside}
for i, stat := range stats {
if stat.State != want[i] {
t.Errorf("workspace %q: %s is %q, want %q", agent.Workspace, stat.Path, stat.State, want[i])
}
}
if stats[1].Path != filepath.Join(other, "notes.md") {
t.Errorf("the stat renamed the path it was asked about: %q", stats[1].Path)
}
chip, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachFile, Path: filepath.Join(other, "notes.md")}, "")
if err != nil || chip.State != appwire.FileExists {
t.Errorf("workspace %q: the attachment chip = %+v %v", agent.Workspace, chip, err)
}
}
}

// A change recorded under the workspace's own spelling is found for the same
// file named in the other spelling.
func TestAppFileStatFindsAChangeRecordedUnderTheWorkspaceSpelling(t *testing.T) {
backend, agent, real, link := aliasBackend(t, true)
changed := map[string]appwire.FileChange{
filepath.Join(link, "notes.md"): {Path: filepath.Join(link, "notes.md"), Add: 3, Del: 1},
}
stat := backend.statFile(agent.Workspace, changed, filepath.Join(real, "notes.md"))
if stat.State != appwire.FileChanged || stat.Add == nil || *stat.Add != 3 || stat.Del == nil || *stat.Del != 1 {
t.Fatalf("the change was not found through the other spelling: %+v", stat)
}
// A tool call that named the file relative to the workspace records it so.
relative := map[string]appwire.FileChange{"notes.md": {Path: "notes.md", Add: 2, Del: 0}}
for _, spelled := range []string{filepath.Join(real, "notes.md"), filepath.Join(link, "notes.md")} {
stat := backend.statFile(agent.Workspace, relative, spelled)
if stat.State != appwire.FileChanged || stat.Add == nil || *stat.Add != 2 {
t.Fatalf("a change recorded as notes.md was not found for %s: %+v", spelled, stat)
}
}
}

// A link inside the workspace that points elsewhere keeps the answer it had:
// it is inside by spelling, and this change only adds what is inside by place.
func TestAppFileStatKeepsALinkInsideTheWorkspaceInside(t *testing.T) {
backend, agent, real, _ := aliasBackend(t, false)
elsewhere := t.TempDir()
if err := os.WriteFile(filepath.Join(elsewhere, "far.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, filepath.Join(real, "far")); err != nil {
t.Skipf("this filesystem cannot make a symbolic link: %v", err)
}
stats, err := backend.StatFiles(agent.ID, []string{"far/far.md"})
if err != nil || len(stats) != 1 || stats[0].State != appwire.FileExists {
t.Fatalf("a file through a link inside the workspace = %+v %v", stats, err)
}
}
31 changes: 31 additions & 0 deletions cmd/codeaf/appconvo_alias_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//go:build !windows

package main

import (
"path/filepath"
"syscall"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/appwire"
)

// A FIFO in the workspace is stat'ed, never opened: opening it would wait for
// a writer that never comes. The alias spelling reaches it the same way.
func TestAppFileStatNeverOpensAFIFO(t *testing.T) {
backend, agent, real, _ := aliasBackend(t, true)
if err := syscall.Mkfifo(filepath.Join(real, "pipe"), 0o600); err != nil {
t.Skipf("this filesystem cannot make a FIFO: %v", err)
}
done := make(chan appwire.FileStat, 1)
go func() { done <- backend.statFile(agent.Workspace, nil, filepath.Join(real, "pipe")) }()
select {
case stat := <-done:
if stat.State != appwire.FileExists || stat.Binary {
t.Fatalf("the FIFO's stat = %+v", stat)
}
case <-time.After(5 * time.Second):
t.Fatal("stat'ing a FIFO in the workspace waited for a writer")
}
}
14 changes: 14 additions & 0 deletions docs/changes/unreleased/2038-file-stat-alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
kind: fixed
title: A desktop file chip in the chat's folder is not called outside through a linked path
pr: 2038
surface: [engine]
invalidates:
- "A file inside the chat's workspace read as outside this workspace when the file and the workspace were spelled through different links, such as /private/tmp/demo and /tmp/demo on macOS. Containment now also compares resolved paths."
---

A workspace typed as `/tmp/demo` (or through a linked project folder) and a
file a picker or a drag names `/private/tmp/demo/notes.md` are one place. The
desktop's attachment chips and `files.stat` now answer `exists`, `changed` or
`missing` for it instead of `outside`. A path inside the workspace by spelling
keeps the answer it had, and a file that is really elsewhere is still outside.
Loading