From c0405f200a248b7814e009f1608b992b1877dfec Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 9 Nov 2026 12:45:11 -0500 Subject: [PATCH 1/7] Let a computer that only opened codeaf join another without --replace Opening codeaf once and quitting writes an empty conversation: its environment inventory, an empty owned working folder with its own Git repository, a capture policy, empty memories and session files, and a transcript with no turns. Pairing counted those as local work and refused with 'this computer has local work', although the computer had no chats and the code meant to let a started-only computer join. They now count as starting, so code pairing and link pairing both go through. One sent turn, saved memories and real settings still refuse, and the refusal names the chat count. Fixes #2538 Co-Authored-By: Claude Opus 5.5 --- internal/identity/identitytest/started.go | 78 ++++++++++++++++ internal/identity/opened_chat_test.go | 48 ++++++++++ internal/identity/pristine.go | 7 +- internal/identity/pristine_judges.go | 40 ++++++++ internal/inventory/inventory.go | 3 +- internal/inventory/layout/layout.go | 6 ++ .../manual/chat/add-or-remove-a-computer.md | 6 ++ internal/pair/opened_chat_test.go | 93 +++++++++++++++++++ 8 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 internal/identity/identitytest/started.go create mode 100644 internal/identity/opened_chat_test.go create mode 100644 internal/inventory/layout/layout.go create mode 100644 internal/pair/opened_chat_test.go diff --git a/internal/identity/identitytest/started.go b/internal/identity/identitytest/started.go new file mode 100644 index 0000000000..2caaff4751 --- /dev/null +++ b/internal/identity/identitytest/started.go @@ -0,0 +1,78 @@ +// Package identitytest builds a closed, unused chat through its storage owners. +package identitytest + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellbudget" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/inventory" + "github.com/Agent-Field/codeaf/internal/session" +) + +// OpenAndQuit exercises session creation without sending a request to a model. +// Its extra surface files were observed after skipping setup and typing /quit. +func OpenAndQuit(t *testing.T, home string, owned bool) session.Place { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + t.Setenv("CODEAF_NO_AUTOMATIONS", "1") + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + c, err := cell.CreateIn(filepath.Join(home, "v3/projects/project"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + place := session.Place{Dir: c.Root, Workspace: t.TempDir(), Owned: owned} + if owned { + place.Workspace = filepath.Join(c.Root, "work") + if err := os.MkdirAll(place.Workspace, 0o700); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=codeaf", "-c", "user.email=test@localhost", "commit", "--allow-empty", "--no-verify", "-qm", "session opened"}} { + cmd := exec.Command("git", args...) //codeaf:plumbing An owned chat starts with an empty repository. + cmd.Dir = place.Workspace + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("prepare empty workspace: %s: %v", out, err) + } + } + } + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: place.Workspace, Owned: owned}); err != nil { + t.Fatal(err) + } + a, err := session.New(session.Config{Workspace: place.Workspace, Model: "stub/scripted", BaseURL: "http://127.0.0.1:9/api/v1", APIKey: "stub", Place: place, SessionFile: place.Transcript()}) + if err != nil { + t.Fatal(err) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + if err := inventory.Record(c.Root, func(inv *inventory.Inventory) { inv.Workspace = place.Workspace }); err != nil { + t.Fatal(err) + } + if err := (cellstore.Guard{Home: home}).Screen(c, place.Workspace, filepath.Join(c.Root, cell.StateDir), nil); err != nil { + t.Fatal(err) + } + manager := cellbudget.New(cellstore.Engine{DataRoot: filepath.Join(home, "v3/stores")}, func(cell.Cell) string { return place.Workspace }, nil) + if err := manager.Open(t.Context(), c); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "v3/draft-started.json": `{"version":1,"owner":"chat"}`, + "v3/history.jsonl": "{\"text\":\"/quit\"}\n", + } { + if err := os.WriteFile(filepath.Join(home, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + return place +} diff --git a/internal/identity/opened_chat_test.go b/internal/identity/opened_chat_test.go new file mode 100644 index 0000000000..837beb0405 --- /dev/null +++ b/internal/identity/opened_chat_test.go @@ -0,0 +1,48 @@ +package identity_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/identity/identitytest" +) + +func TestAComputerThatOnlyOpenedAndQuitAChatCanJoin(t *testing.T) { + for _, owned := range []bool{false, true} { + t.Run(map[bool]string{false: "borrowed project", true: "owned project"}[owned], func(t *testing.T) { + home := t.TempDir() + place := identitytest.OpenAndQuit(t, home, owned) + if ok, err := identity.Joinable(home); !ok || err != nil { + t.Fatalf("an opened and quit chat cannot join: %v, %v", ok, err) + } + for _, name := range []string{"memories.jsonl", "state.json", "env/notes.txt"} { + path := filepath.Join(place.Dir, ".cell", name) + if err := os.WriteFile(path, []byte("keep my work"), 0o600); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); ok || err != nil { + t.Fatalf("%s must keep the home from joining: %v, %v", name, ok, err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(place.Dir, ".cell", "env/inventory.json") + for _, body := range []string{ + `{"V":1,"platform":{},"annotations":{"note":{"text":"keep"}}}`, + `{"V":1,"platform":{"notes":"keep"}}`, + `{"V":2,"platform":{}}`, + `{"V":1,"platform":{}} {"notes":"keep"}`, + } { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); ok || err != nil { + t.Fatalf("inventory notes or unknown contents must remain work: %v, %v: %s", ok, err, body) + } + } + }) + } +} diff --git a/internal/identity/pristine.go b/internal/identity/pristine.go index 2bd60c7395..6e04e4e329 100644 --- a/internal/identity/pristine.go +++ b/internal/identity/pristine.go @@ -4,6 +4,8 @@ import ( "errors" "io/fs" "os" + + "github.com/Agent-Field/codeaf/internal/inventory/layout" ) // Pristine says whether the home holds nothing but what starting codeaf makes: @@ -72,8 +74,11 @@ var started = within(startupRules) // its transcript and memories are the chat itself. var withinSession = within(rules{ "meta.json": never, "meta.lock": never, "presence.json": never, + // An owned chat starts with an empty repository before any request is sent. + "work": within(rules{".git": unopenedRepository}), ".cell": within(rules{ - "meta.json": never, "session.json": never, ".furrowpolicy": never, "env": empty, + "meta.json": never, "session.json": never, ".furrowpolicy": never, + "env": within(rules{layout.File: startupInventory}), "memories.jsonl": empty, "transcript.jsonl": noTurns, }), }) diff --git a/internal/identity/pristine_judges.go b/internal/identity/pristine_judges.go index 5a3cef782a..58bb857b59 100644 --- a/internal/identity/pristine_judges.go +++ b/internal/identity/pristine_judges.go @@ -3,14 +3,17 @@ package identity import ( "bufio" "bytes" + "context" "database/sql" "encoding/json" "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/sqliteuri" "io" "os" + "os/exec" "path/filepath" "strings" + "time" _ "modernc.org/sqlite" // reads the memory graph, which is a SQLite file ) @@ -72,6 +75,43 @@ func pickerCache(path string) (bool, error) { // unknown is the judge of a name nobody declared, and it is always use. func unknown(string) (bool, error) { return true, nil } +// startupInventory admits the platform and locations recorded before any call. +// Observed tools, notes and all other fields still count as work. +func startupInventory(path string) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return true, err + } + var inv struct { + V uint16 + Platform struct{ OS, Arch string } + Workspace string + ChatDir string `json:"chat_dir"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&inv) != nil || inv.V != 1 { + return true, nil + } + return decoder.Decode(new(json.RawMessage)) != io.EOF, nil +} + +// unopenedRepository keeps even deleted or staged project work from being +// mistaken for the empty repository made when an owned chat first opens. +func unopenedRepository(path string) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, args := range [][]string{{"log", "--all", "--format=", "--name-only"}, {"ls-files", "--cached"}} { + cmd := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) //codeaf:plumbing An empty owned repository contains no saved or staged files. + cmd.Dir = filepath.Dir(path) + raw, err := cmd.Output() + if err != nil || len(bytes.TrimSpace(raw)) > 0 { + return true, err + } + } + return false, nil +} + // empty is for what holds the person's words and bytes when it holds any: a // file with no bytes, or a folder of such files. func empty(path string) (bool, error) { diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index fdec670f3f..85df6ee212 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -10,10 +10,11 @@ package inventory import ( "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/inventory/layout" ) // Path is the cell-relative location of the inventory file. -const Path = cell.EnvPath + "/inventory.json" +const Path = cell.EnvPath + "/" + layout.File const schemaV = 1 diff --git a/internal/inventory/layout/layout.go b/internal/inventory/layout/layout.go new file mode 100644 index 0000000000..25b9dd71c5 --- /dev/null +++ b/internal/inventory/layout/layout.go @@ -0,0 +1,6 @@ +// Package layout names inventory storage without depending on its live observers. +package layout + +// File is shared by inventory writers and the unused-home reader, which cannot +// import the live inventory package because its key storage depends on identity. +const File = "inventory.json" diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index b16765d7c2..a9cc47c9f5 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -93,6 +93,12 @@ With a self-hosted address (`CODEAF_SYNC_URL=` or `--via
`), the l A link is good for 10 minutes and one approval. If it ran out, run `codeaf pair` on the new device again. Ctrl+c while it waits ends with `Cancelled. Nothing was paired.` +## I only opened codeaf and quit — can this computer pair without --replace + +Yes. Skipping setup and quitting an empty chat counts as starting codeaf, with either a pairing code or a link. Its empty transcript, draft, command history, inventory and empty working folder do not require `--replace`. + +Once you send a message, that chat belongs to this computer. With one such chat, pairing says `this computer already has 1 chat of its own, so nothing was changed.` The rest of the message explains `--replace`: local chats stay here but stop syncing, and shared memories and settings are cleared. Saved knowledge, automations, project work, chat memories and unknown local files keep their existing protection. + ## The four-digit check — how I know the right computer is asking Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. diff --git a/internal/pair/opened_chat_test.go b/internal/pair/opened_chat_test.go new file mode 100644 index 0000000000..ae94d27e39 --- /dev/null +++ b/internal/pair/opened_chat_test.go @@ -0,0 +1,93 @@ +package pair + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/identity/identitytest" +) + +func TestCodeAndLinkPairingAcceptAnOpenedChatAndKeepASentChat(t *testing.T) { + for _, road := range []string{"code", "link"} { + t.Run(road, func(t *testing.T) { + for _, sent := range []bool{false, true} { + t.Run(map[bool]string{false: "only opened", true: "one sent turn"}[sent], func(t *testing.T) { + home := t.TempDir() + place := identitytest.OpenAndQuit(t, home, true) + before, err := identity.Load(home) + if err != nil { + t.Fatal(err) + } + if sent { + f, err := os.OpenFile(place.Transcript(), os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + _, writeErr := f.WriteString("{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}\n") + if err := errors.Join(writeErr, f.Close()); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var want identity.Identity + if road == "code" { + r := newChatRig(t) + want = r.a + ui := newScreen() + offer := offerAs(t, r, ui) + _, err = r.join(ctx, home, ui.nextCode(t).Shown(), newScreen()) + if !sent && err == nil { + if end := offer.wait(t); end.err != nil { + t.Fatal(end.err) + } + } + } else { + r := newLinkRig(t) + r.new = home + want, _ = identity.Load(r.old) + ui := &invites{got: make(chan Invite, 1)} + done := make(chan result, 1) + guard.Go("pair/opened-chat", func() { + joined, joinErr := JoinByLink(ctx, r.approver.Requests, r.joining(false), ui) + done <- result{joined, joinErr} + }) + if !sent { + select { + case in := <-ui.got: + asking, lookErr := r.approver.Look(ctx, in.Ref) + if lookErr != nil { + t.Fatal(lookErr) + } + if approveErr := r.approver.Approve(ctx, asking); approveErr != nil { + t.Fatal(approveErr) + } + case res := <-done: + t.Fatalf("an opened chat refused link pairing: %v", res.err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + err = waitResult(t, done).err + } + after, loadErr := identity.Load(home) + if loadErr != nil { + t.Fatal(loadErr) + } + if sent { + if !errors.Is(err, ErrDifferentChats) || !strings.HasPrefix(err.Error(), "this computer already has 1 chat of its own, so nothing was changed.") || after.ID() != before.ID() { + t.Fatalf("sent chat was not kept with its counted refusal: %v", err) + } + } else if err != nil || after.ID() != want.ID() { + t.Fatalf("an opened chat did not pair: %v", err) + } + }) + } + }) + } +} From e7e3e9c7452390d24b907a9aad36c43f6a35cfbe Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 9 Nov 2026 12:45:11 -0500 Subject: [PATCH 2/7] Save and move a chat whose project folder Git ignores Asked from inside a directory that the enclosing repository ignores, git ls-files --ignored --directory fails with an internal error, so codeaf could not read Git's ignore answer and refused to seal the chat at all; when the whole project was ignored the engine also dropped the chat's own state. codeaf now asks Git about the project folder itself before listing, and keeps an ignored project's files out while the chat's own words still save and move. Nested repositories and discovery ceilings keep Git's meaning, and a genuine Git failure still stops saving rather than admitting ignored private files. Fixes #2539 Co-Authored-By: Claude Opus 5.5 --- internal/cellstore/captureplan.go | 15 +-- internal/cellstore/ignored.go | 36 ++++-- internal/cellstore/ignored_workspace_test.go | 104 ++++++++++++++++++ internal/cellstore/withheld.go | 47 ++++++-- .../continuing-a-chat-on-another-computer.md | 8 ++ internal/syncsetup/origin_test.go | 50 +++++++++ 6 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 internal/cellstore/ignored_workspace_test.go diff --git a/internal/cellstore/captureplan.go b/internal/cellstore/captureplan.go index 9ceb58525e..2e287c824a 100644 --- a/internal/cellstore/captureplan.go +++ b/internal/cellstore/captureplan.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/keys" @@ -213,17 +214,11 @@ func (g Guard) screenPlan(c cell.Cell, tree, dir string, plan capturePlan) (Scre } func ignoredFiles(tree string) ([]string, error) { - if !hasGitMarker(tree) { - return nil, nil - } - cmd := exec.Command("git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Ignored individual secrets still belong in the vault. - cmd.Dir = tree - raw, err := cmd.Output() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + raw, err := ignoredListing(ctx, tree) if err != nil { - if exit, ok := err.(*exec.ExitError); ok && strings.Contains(string(exit.Stderr), "not a git repository") { - return nil, nil - } - return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) + return nil, err } var paths []string var directories []string diff --git a/internal/cellstore/ignored.go b/internal/cellstore/ignored.go index d06126b4d9..3f7713b033 100644 --- a/internal/cellstore/ignored.go +++ b/internal/cellstore/ignored.go @@ -46,15 +46,9 @@ func gitIgnored(tree, path string) bool { func ignoredDirectories(tree string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Git names ignored directories without opening them. - cmd.Dir = tree - raw, err := cmd.Output() + raw, err := ignoredListing(ctx, tree) if err != nil { - var exit *exec.ExitError - if (errors.As(err, &exit) && strings.Contains(string(exit.Stderr), "not a git repository")) || !hasGitMarker(tree) { - return nil, nil - } - return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) + return nil, err } var candidates []string for _, name := range strings.Split(string(raw), "\x00") { @@ -86,6 +80,32 @@ func ignoredDirectories(tree string) ([]string, error) { return dirs, nil } +// ignoredListing asks about the project itself before listing its children. +// Git cannot list below an ignored ancestor, but can answer that the whole +// project is ignored. The cell is captured separately and keeps its words. +func ignoredListing(ctx context.Context, tree string) ([]byte, error) { + check := exec.CommandContext(ctx, "git", "--no-optional-locks", "check-ignore", "-q", "--", ".") //codeaf:plumbing An ignored project needs no listing of its children. + check.Dir = tree + _, err := check.Output() + if err == nil { + return []byte("./\x00"), nil + } + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 { + if (exit != nil && strings.Contains(string(exit.Stderr), "not a git repository (or any")) || (errors.Is(err, exec.ErrNotFound) && !hasGitMarker(tree)) { + return nil, nil + } + return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) + } + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Git names ignored directories without opening them. + cmd.Dir = tree + raw, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) + } + return raw, nil +} + // hasGitMarker keeps a failed Git call from licensing ignored files, while a // tree with no surrounding repository still works when Git is unavailable. func hasGitMarker(tree string) bool { diff --git a/internal/cellstore/ignored_workspace_test.go b/internal/cellstore/ignored_workspace_test.go new file mode 100644 index 0000000000..bb31408100 --- /dev/null +++ b/internal/cellstore/ignored_workspace_test.go @@ -0,0 +1,104 @@ +package cellstore + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/keys" +) + +func TestAChatInsideAnIgnoredFolderSavesItsWordsWithoutIgnoredFiles(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + for _, layout := range []string{"folder ignored", "parent ignored", "nested repository", "workspace ceiling", "ancestor ceiling", "no repository", "broken repository"} { + t.Run(layout, func(t *testing.T) { + r := newRig(t) + repo := t.TempDir() + r.tree = filepath.Join(repo, "area", "work") + r.write("included.txt", "project words\n") + r.write("secret.txt", "CONFIDENTIAL_BOARD_MINUTES_W3\n") + if layout != "no repository" { + fixtureGit(t, repo, "init", "-q") + pattern := "area/work/\n" + if layout == "parent ignored" { + pattern = "area/\n" + } + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(pattern), 0o600); err != nil { + t.Fatal(err) + } + } + ceiling := "" + if layout == "workspace ceiling" { + ceiling = r.tree + } else if layout == "ancestor ceiling" { + ceiling = filepath.Dir(r.tree) + } else if layout == "nested repository" { + fixtureGit(t, r.tree, "init", "-q") + r.write(".gitignore", "secret.txt\n") + } else if layout == "broken repository" { + r.write(".git", "gitdir: missing-directory\n") + } + t.Setenv(gitCeilingVar, ceiling) + if found := (keys.Scanner{}).Paths(r.tree, []string{"secret.txt"}); len(found) != 0 { + t.Fatalf("the marker must not match the credential scanner: %+v", found) + } + oracle := exec.Command("git", "check-ignore", "-q", "--", "secret.txt") //codeaf:plumbing Git decides whether the fixture's marker is ignored. + oracle.Dir = r.tree + ignored := oracle.Run() == nil + wantIgnored := layout != "ancestor ceiling" && layout != "no repository" && layout != "broken repository" + if ignored != wantIgnored { + t.Fatalf("Git ignored secret.txt = %v, want %v", ignored, wantIgnored) + } + transcript, err := r.cell.Path(cell.TranscriptPath) + if err != nil { + t.Fatal(err) + } + words := []byte("{\"type\":\"message\",\"role\":\"user\",\"content\":\"chat words always save\"}\n") + if err := os.WriteFile(transcript, words, 0o600); err != nil { + t.Fatal(err) + } + e := realEngine(t) + e.Workspace, e.Guard = r.tree, r.guard + sealed, err := e.Seal(t.Context(), r.cell, TurnInfo{}) + if layout == "broken repository" { + if err == nil || !strings.Contains(err.Error(), ignoredReadFailure) { + t.Fatalf("a genuine Git failure must refuse saving: %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + target := e.target(newCell(t)) + target.Tree, target.CellDir = t.TempDir(), t.TempDir() + if _, err := e.transport().Do(t.Context(), target, materializeOp{Head: sealed.Turn.ID}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(target.CellDir, "transcript.jsonl")) + if err != nil || !bytes.Equal(raw, words) { + t.Fatalf("the saved chat lost its words: %q, %v", raw, err) + } + raw, err = os.ReadFile(filepath.Join(target.Tree, "secret.txt")) + if ignored { + if !os.IsNotExist(err) { + t.Fatalf("Git-ignored confidential words entered saved history: %q, %v", raw, err) + } + if hits := grepStore(t, e.StoreDir(r.cell), "CONFIDENTIAL_BOARD_MINUTES_W3"); len(hits) != 0 { + t.Fatalf("ignored confidential words entered saved objects: %v", hits) + } + } else if err != nil || string(raw) != "CONFIDENTIAL_BOARD_MINUTES_W3\n" { + t.Fatalf("Git did not ignore the ordinary project file: %q, %v", raw, err) + } + _, err = os.Stat(filepath.Join(target.Tree, "included.txt")) + whole := ignored && layout != "nested repository" + if (whole && !os.IsNotExist(err)) || (!whole && err != nil) { + t.Fatalf("the whole-project ignore answer was not respected: %v", err) + } + }) + } + }) +} diff --git a/internal/cellstore/withheld.go b/internal/cellstore/withheld.go index 487a440ad5..de14c1ae20 100644 --- a/internal/cellstore/withheld.go +++ b/internal/cellstore/withheld.go @@ -2,6 +2,7 @@ package cellstore import ( "errors" + "fmt" "os" "path/filepath" "runtime" @@ -23,7 +24,9 @@ import ( // with no permissions, a folder it may not list) and those whose name this // machine's file system cannot keep beside another's (written when a chat is // taken here, never by a seal). -// The harness owns only those four blocks. The first three are rewritten whole +// A fifth block leaves project entries out when Git ignores the whole folder, +// so its ignore rules cannot also hide the separately composed conversation. +// The harness owns only those five blocks. The first three are rewritten whole // at every seal, so a path a person has cleaned of its secret, or whose // permissions now allow a read, goes back into the next snapshot; the held block // is kept exactly as it was read. @@ -34,6 +37,7 @@ const ( // unreadableTag and heldTag open the blocks of paths set apart one by one. unreadableTag = "# codeaf: left out because they cannot be read here (managed block)" heldTag = "# codeaf: left out because this machine's file system cannot keep both names (managed block)" + ignoredTag = "# codeaf: left out because Git ignores the whole project (managed block)" excludeWord = "exclude " ) @@ -42,10 +46,12 @@ var controlDirs = map[string]bool{".git": true, ".furrow": true, ".cell": true} // policyFile is a tree's .furrowpolicy split at the marker. type policyFile struct { - gitFenced bool - user []string // lines above the block, kept verbatim - withheld []string // paths the harness withholds because they hold secrets, sorted - rebuilt []string // install folders the harness leaves out, sorted + gitFenced bool + ignoredProject bool + ignoredProjectPaths []string + user []string // lines above the block, kept verbatim + withheld []string // paths the harness withholds because they hold secrets, sorted + rebuilt []string // install folders the harness leaves out, sorted // unreadable is the paths this machine cannot read, sorted; a seal recomputes it. unreadable []string // held is the paths whose name this machine cannot keep; only a take writes it. @@ -64,6 +70,28 @@ func readPolicy(tree, dir string) (policyFile, error) { if err != nil || dir == tree { return p, err } + p.ignoredDirs, err = ignoredDirectories(tree) + if err != nil { + return p, err + } + p.ignoredProject = inRules(p.ignoredDirs, ".") + p.ignoredProjectPaths = nil + if p.ignoredProject { + // Only list the project root's names; ignored children are never opened. + // Explicit exclusions let the engine retain the separately composed cell. + entries, err := os.ReadDir(tree) + if err != nil { + return p, err + } + for _, entry := range entries { + if !controlDirs[entry.Name()] && entry.Name() != policyName { + if strings.TrimSpace(entry.Name()) != entry.Name() || strings.ContainsAny(entry.Name(), "\r\n") { + return p, fmt.Errorf("%s: an ignored filename cannot be safely excluded", ignoredReadFailure) + } + p.ignoredProjectPaths = append(p.ignoredProjectPaths, entry.Name()) + } + } + } theirs, err := readPolicyAt(tree) p.outside = theirs.user return p, err @@ -93,6 +121,8 @@ func parsePolicy(text string) policyFile { block = &p.unreadable case heldTag: block = &p.held + case ignoredTag: + block = &p.ignoredProjectPaths default: *block = append(*block, managedLine(block == &p.user, line)) } @@ -155,7 +185,7 @@ func sorted(paths []string) []string { func (p policyFile) String() string { lines := make([]string, 0, len(p.user)+1) for _, line := range p.user { - if !p.gitFenced || strings.TrimSpace(line) != "gitignore" { + if (!p.gitFenced && !p.ignoredProject) || strings.TrimSpace(line) != "gitignore" { lines = append(lines, line) } } @@ -163,13 +193,14 @@ func (p policyFile) String() string { for _, line := range lines { hasIgnore = hasIgnore || strings.TrimSpace(line) == "gitignore" } - if !hasIgnore && !p.gitFenced { + if !hasIgnore && !p.gitFenced && !p.ignoredProject { lines = append(lines, "gitignore") } lines = appendBlock(lines, withheldTag, p.withheld) lines = appendBlock(lines, rebuiltTag, p.rebuilt) lines = appendBlock(lines, unreadableTag, p.unreadable) lines = appendBlock(lines, heldTag, p.held) + lines = appendBlock(lines, ignoredTag, p.ignoredProjectPaths) if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") { return "" } @@ -225,7 +256,7 @@ func (p policyFile) userRules() []string { // inRules is the engine's own test: a rule names a path or a subtree. func inRules(rules []string, rel string) bool { for _, rule := range rules { - if rel == rule || strings.HasPrefix(rel, rule+"/") { + if rule == "." || rel == rule || strings.HasPrefix(rel, rule+"/") { return true } } diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 8d1cb293d3..3df7175441 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -212,6 +212,14 @@ If the workspace's own `.furrowpolicy` contains `gitignore`, the native capture If an older build saved an empty chat and the workspace does not enable `gitignore` in its own `.furrowpolicy`, open the original chat in this build, send another message and close it before trying `continue here` again. +## Saving a chat in a Git-ignored project folder or inside an ignored parent + +A chat in an ignored folder can save, close and move to a paired computer. Its conversation still moves even when Git ignores the entire project folder. In that case the project's files stay on this computer, including ordinary ignored files such as `secret.txt`; the chat's own saved words and state travel separately. + +A repository inside that folder uses its own ignore rules. A ceiling equal to the project folder still allows Git to discover an enclosing repository. An ancestor ceiling that prevents discovery, or a folder with no repository, leaves ordinary project files eligible to move, subject to the usual secret screening and capture policy. + +If Git fails to answer, saving stops with `cannot read which folders Git ignores here` and the cause. It never admits ignored project files to make saving succeed. + ## This chat is not syncing — the sync service is not answering, working offline On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). diff --git a/internal/syncsetup/origin_test.go b/internal/syncsetup/origin_test.go index de15e17173..46cdf75227 100644 --- a/internal/syncsetup/origin_test.go +++ b/internal/syncsetup/origin_test.go @@ -5,14 +5,64 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" "testing" "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/preflight" "github.com/Agent-Field/codeaf/internal/session" ) +func TestContinuingAChatInsideAnIgnoredDirectoryKeepsItsWordsAndLeavesItsFiles(t *testing.T) { + h := newTwoHomes(t) + repo := t.TempDir() + work := filepath.Join(repo, "ignored", "project") + mustNil(t, os.MkdirAll(work, 0o700)) + git := exec.Command("git", "init", "-q", repo) //codeaf:plumbing The enclosing repository ignores this project's parent. + if out, err := git.CombinedOutput(); err != nil { + t.Fatalf("prepare repository: %s: %v", out, err) + } + mustNil(t, os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("ignored/\n"), 0o600)) + marker := []byte("CONFIDENTIAL_BOARD_MINUTES_W3\n") + mustNil(t, os.WriteFile(filepath.Join(work, "secret.txt"), marker, 0o600)) + if found := (keys.Scanner{}).Paths(work, []string{"secret.txt"}); len(found) != 0 { + t.Fatalf("the marker must not match the credential scanner: %+v", found) + } + t.Setenv("GIT_CEILING_DIRECTORIES", "") + h.awayFromA() + h.work, h.engine.Workspace = work, work + h.backToA() + h.engine.Transport, h.engB.Transport = nil, nil + journal := transcriptOf(h.cell) + header, err := os.ReadFile(journal) + mustNil(t, err) + mustNil(t, os.WriteFile(journal, bytes.Replace(header, []byte(`"version":3`), []byte(`"version":1`), 1), 0o600)) + h.spokenFixture() + observer, err := preflight.OpenMachine(h.cell.Root, work) + mustNil(t, err) + a := h.openObserved(h.a, h.engine, h.cell, work, nameA, observer.Observer()) + a.mustSay("words from the ignored project") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + c := h.continuerB() + c.opt.BucketFor = func(origin string) string { return filepath.Join(h.rootsB, "copy-"+filepath.Base(origin)) } + got, err := c.Take(t.Context(), h.cell.ID) + mustNil(t, err) + words, err := os.ReadFile(transcriptOf(got.Taken.Cell)) + mustNil(t, err) + if !bytes.Contains(words, []byte("words from the ignored project")) || bytes.Contains(words, marker) { + t.Fatalf("the moved chat lost its words or captured confidential words: %s", words) + } + if _, err := os.Stat(filepath.Join(workspaceOf(got.Taken.Cell.Root), "secret.txt")); !os.IsNotExist(err) { + t.Fatalf("the ignored file moved to the other computer: %v", err) + } + b := h.openOn(h.b, h.engB, got.Taken.Cell, workspaceOf(got.Taken.Cell.Root), nameB) + b.mustSay("words added on the second computer") + mustNil(t, b.drive.Close(t.Context())) +} + func TestContinuingAChatInsideAGitDiscoveryBoundaryRestoresItsFilesInAnotherHome(t *testing.T) { fixture, err := os.MkdirTemp(".", "continue-ignored-") mustNil(t, err) From 62a06d6e0dce6417f79697f3465858f0376c7a67 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 9 Nov 2026 12:45:44 -0500 Subject: [PATCH 3/7] Add the change entry for #2540 Co-Authored-By: Claude Opus 5.5 --- .../2540-pair-fresh-and-ignored-workspace.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md diff --git a/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md b/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md new file mode 100644 index 0000000000..e7ff164757 --- /dev/null +++ b/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md @@ -0,0 +1,17 @@ +--- +kind: fixed +title: Empty chats can pair, and chats in Git-ignored project folders can save and move +pr: 2540 +surface: [chat, engine, remote, docs] +invalidates: + - "Opening and quitting an empty chat could prevent pairing without --replace; startup inventory and an empty owned working folder now allow code and link pairing while submitted work keeps its refusal." + - "A project inside a Git-ignored parent could fail to save or lose the conversation; Git's answer now excludes the project files while the chat's own words still save and move." +--- +Both pairing roads accept a computer that only opened codeaf. A submitted chat +still produces the counted refusal, and saved memories and settings retain their +existing checks. + +When Git ignores the whole project, its entries stay out of saved history and +the separately saved conversation can move. Nested repositories and Git +discovery ceilings keep Git's own meaning. A genuine Git failure still stops +saving instead of admitting private files. From f087bf6c07c45311517999a3f43e89e8f1df5d82 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 9 Nov 2026 12:55:09 -0500 Subject: [PATCH 4/7] Take the ignored-project fix out of this pull request Review found that saving a chat whose project folder Git ignores cannot be fixed safely in codeaf alone. The workaround let an ignored project's own .furrowpolicy into saved history unscreened, still lost the chat when that policy turned on gitignore, failed past the engine's 1,024-rule limit and on non-English Git, and added several Git processes to every save. The engine has to keep the chat's own state when Git ignores the whole folder. #2539 stays open with these findings. This reverts commit e7e3e9c74. Refs #2539 Co-Authored-By: Claude Opus 5.5 --- internal/cellstore/captureplan.go | 15 ++- internal/cellstore/ignored.go | 36 ++---- internal/cellstore/ignored_workspace_test.go | 104 ------------------ internal/cellstore/withheld.go | 47 ++------ .../continuing-a-chat-on-another-computer.md | 8 -- internal/syncsetup/origin_test.go | 50 --------- 6 files changed, 26 insertions(+), 234 deletions(-) delete mode 100644 internal/cellstore/ignored_workspace_test.go diff --git a/internal/cellstore/captureplan.go b/internal/cellstore/captureplan.go index 2e287c824a..9ceb58525e 100644 --- a/internal/cellstore/captureplan.go +++ b/internal/cellstore/captureplan.go @@ -11,7 +11,6 @@ import ( "os/exec" "path/filepath" "strings" - "time" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/keys" @@ -214,11 +213,17 @@ func (g Guard) screenPlan(c cell.Cell, tree, dir string, plan capturePlan) (Scre } func ignoredFiles(tree string) ([]string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - raw, err := ignoredListing(ctx, tree) + if !hasGitMarker(tree) { + return nil, nil + } + cmd := exec.Command("git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Ignored individual secrets still belong in the vault. + cmd.Dir = tree + raw, err := cmd.Output() if err != nil { - return nil, err + if exit, ok := err.(*exec.ExitError); ok && strings.Contains(string(exit.Stderr), "not a git repository") { + return nil, nil + } + return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) } var paths []string var directories []string diff --git a/internal/cellstore/ignored.go b/internal/cellstore/ignored.go index 3f7713b033..d06126b4d9 100644 --- a/internal/cellstore/ignored.go +++ b/internal/cellstore/ignored.go @@ -46,9 +46,15 @@ func gitIgnored(tree, path string) bool { func ignoredDirectories(tree string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - raw, err := ignoredListing(ctx, tree) + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Git names ignored directories without opening them. + cmd.Dir = tree + raw, err := cmd.Output() if err != nil { - return nil, err + var exit *exec.ExitError + if (errors.As(err, &exit) && strings.Contains(string(exit.Stderr), "not a git repository")) || !hasGitMarker(tree) { + return nil, nil + } + return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) } var candidates []string for _, name := range strings.Split(string(raw), "\x00") { @@ -80,32 +86,6 @@ func ignoredDirectories(tree string) ([]string, error) { return dirs, nil } -// ignoredListing asks about the project itself before listing its children. -// Git cannot list below an ignored ancestor, but can answer that the whole -// project is ignored. The cell is captured separately and keeps its words. -func ignoredListing(ctx context.Context, tree string) ([]byte, error) { - check := exec.CommandContext(ctx, "git", "--no-optional-locks", "check-ignore", "-q", "--", ".") //codeaf:plumbing An ignored project needs no listing of its children. - check.Dir = tree - _, err := check.Output() - if err == nil { - return []byte("./\x00"), nil - } - var exit *exec.ExitError - if !errors.As(err, &exit) || exit.ExitCode() != 1 { - if (exit != nil && strings.Contains(string(exit.Stderr), "not a git repository (or any")) || (errors.Is(err, exec.ErrNotFound) && !hasGitMarker(tree)) { - return nil, nil - } - return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) - } - cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Git names ignored directories without opening them. - cmd.Dir = tree - raw, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) - } - return raw, nil -} - // hasGitMarker keeps a failed Git call from licensing ignored files, while a // tree with no surrounding repository still works when Git is unavailable. func hasGitMarker(tree string) bool { diff --git a/internal/cellstore/ignored_workspace_test.go b/internal/cellstore/ignored_workspace_test.go deleted file mode 100644 index bb31408100..0000000000 --- a/internal/cellstore/ignored_workspace_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package cellstore - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/cell" - "github.com/Agent-Field/codeaf/internal/keys" -) - -func TestAChatInsideAnIgnoredFolderSavesItsWordsWithoutIgnoredFiles(t *testing.T) { - forEachTransport(t, func(t *testing.T) { - for _, layout := range []string{"folder ignored", "parent ignored", "nested repository", "workspace ceiling", "ancestor ceiling", "no repository", "broken repository"} { - t.Run(layout, func(t *testing.T) { - r := newRig(t) - repo := t.TempDir() - r.tree = filepath.Join(repo, "area", "work") - r.write("included.txt", "project words\n") - r.write("secret.txt", "CONFIDENTIAL_BOARD_MINUTES_W3\n") - if layout != "no repository" { - fixtureGit(t, repo, "init", "-q") - pattern := "area/work/\n" - if layout == "parent ignored" { - pattern = "area/\n" - } - if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(pattern), 0o600); err != nil { - t.Fatal(err) - } - } - ceiling := "" - if layout == "workspace ceiling" { - ceiling = r.tree - } else if layout == "ancestor ceiling" { - ceiling = filepath.Dir(r.tree) - } else if layout == "nested repository" { - fixtureGit(t, r.tree, "init", "-q") - r.write(".gitignore", "secret.txt\n") - } else if layout == "broken repository" { - r.write(".git", "gitdir: missing-directory\n") - } - t.Setenv(gitCeilingVar, ceiling) - if found := (keys.Scanner{}).Paths(r.tree, []string{"secret.txt"}); len(found) != 0 { - t.Fatalf("the marker must not match the credential scanner: %+v", found) - } - oracle := exec.Command("git", "check-ignore", "-q", "--", "secret.txt") //codeaf:plumbing Git decides whether the fixture's marker is ignored. - oracle.Dir = r.tree - ignored := oracle.Run() == nil - wantIgnored := layout != "ancestor ceiling" && layout != "no repository" && layout != "broken repository" - if ignored != wantIgnored { - t.Fatalf("Git ignored secret.txt = %v, want %v", ignored, wantIgnored) - } - transcript, err := r.cell.Path(cell.TranscriptPath) - if err != nil { - t.Fatal(err) - } - words := []byte("{\"type\":\"message\",\"role\":\"user\",\"content\":\"chat words always save\"}\n") - if err := os.WriteFile(transcript, words, 0o600); err != nil { - t.Fatal(err) - } - e := realEngine(t) - e.Workspace, e.Guard = r.tree, r.guard - sealed, err := e.Seal(t.Context(), r.cell, TurnInfo{}) - if layout == "broken repository" { - if err == nil || !strings.Contains(err.Error(), ignoredReadFailure) { - t.Fatalf("a genuine Git failure must refuse saving: %v", err) - } - return - } - if err != nil { - t.Fatal(err) - } - target := e.target(newCell(t)) - target.Tree, target.CellDir = t.TempDir(), t.TempDir() - if _, err := e.transport().Do(t.Context(), target, materializeOp{Head: sealed.Turn.ID}); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(target.CellDir, "transcript.jsonl")) - if err != nil || !bytes.Equal(raw, words) { - t.Fatalf("the saved chat lost its words: %q, %v", raw, err) - } - raw, err = os.ReadFile(filepath.Join(target.Tree, "secret.txt")) - if ignored { - if !os.IsNotExist(err) { - t.Fatalf("Git-ignored confidential words entered saved history: %q, %v", raw, err) - } - if hits := grepStore(t, e.StoreDir(r.cell), "CONFIDENTIAL_BOARD_MINUTES_W3"); len(hits) != 0 { - t.Fatalf("ignored confidential words entered saved objects: %v", hits) - } - } else if err != nil || string(raw) != "CONFIDENTIAL_BOARD_MINUTES_W3\n" { - t.Fatalf("Git did not ignore the ordinary project file: %q, %v", raw, err) - } - _, err = os.Stat(filepath.Join(target.Tree, "included.txt")) - whole := ignored && layout != "nested repository" - if (whole && !os.IsNotExist(err)) || (!whole && err != nil) { - t.Fatalf("the whole-project ignore answer was not respected: %v", err) - } - }) - } - }) -} diff --git a/internal/cellstore/withheld.go b/internal/cellstore/withheld.go index de14c1ae20..487a440ad5 100644 --- a/internal/cellstore/withheld.go +++ b/internal/cellstore/withheld.go @@ -2,7 +2,6 @@ package cellstore import ( "errors" - "fmt" "os" "path/filepath" "runtime" @@ -24,9 +23,7 @@ import ( // with no permissions, a folder it may not list) and those whose name this // machine's file system cannot keep beside another's (written when a chat is // taken here, never by a seal). -// A fifth block leaves project entries out when Git ignores the whole folder, -// so its ignore rules cannot also hide the separately composed conversation. -// The harness owns only those five blocks. The first three are rewritten whole +// The harness owns only those four blocks. The first three are rewritten whole // at every seal, so a path a person has cleaned of its secret, or whose // permissions now allow a read, goes back into the next snapshot; the held block // is kept exactly as it was read. @@ -37,7 +34,6 @@ const ( // unreadableTag and heldTag open the blocks of paths set apart one by one. unreadableTag = "# codeaf: left out because they cannot be read here (managed block)" heldTag = "# codeaf: left out because this machine's file system cannot keep both names (managed block)" - ignoredTag = "# codeaf: left out because Git ignores the whole project (managed block)" excludeWord = "exclude " ) @@ -46,12 +42,10 @@ var controlDirs = map[string]bool{".git": true, ".furrow": true, ".cell": true} // policyFile is a tree's .furrowpolicy split at the marker. type policyFile struct { - gitFenced bool - ignoredProject bool - ignoredProjectPaths []string - user []string // lines above the block, kept verbatim - withheld []string // paths the harness withholds because they hold secrets, sorted - rebuilt []string // install folders the harness leaves out, sorted + gitFenced bool + user []string // lines above the block, kept verbatim + withheld []string // paths the harness withholds because they hold secrets, sorted + rebuilt []string // install folders the harness leaves out, sorted // unreadable is the paths this machine cannot read, sorted; a seal recomputes it. unreadable []string // held is the paths whose name this machine cannot keep; only a take writes it. @@ -70,28 +64,6 @@ func readPolicy(tree, dir string) (policyFile, error) { if err != nil || dir == tree { return p, err } - p.ignoredDirs, err = ignoredDirectories(tree) - if err != nil { - return p, err - } - p.ignoredProject = inRules(p.ignoredDirs, ".") - p.ignoredProjectPaths = nil - if p.ignoredProject { - // Only list the project root's names; ignored children are never opened. - // Explicit exclusions let the engine retain the separately composed cell. - entries, err := os.ReadDir(tree) - if err != nil { - return p, err - } - for _, entry := range entries { - if !controlDirs[entry.Name()] && entry.Name() != policyName { - if strings.TrimSpace(entry.Name()) != entry.Name() || strings.ContainsAny(entry.Name(), "\r\n") { - return p, fmt.Errorf("%s: an ignored filename cannot be safely excluded", ignoredReadFailure) - } - p.ignoredProjectPaths = append(p.ignoredProjectPaths, entry.Name()) - } - } - } theirs, err := readPolicyAt(tree) p.outside = theirs.user return p, err @@ -121,8 +93,6 @@ func parsePolicy(text string) policyFile { block = &p.unreadable case heldTag: block = &p.held - case ignoredTag: - block = &p.ignoredProjectPaths default: *block = append(*block, managedLine(block == &p.user, line)) } @@ -185,7 +155,7 @@ func sorted(paths []string) []string { func (p policyFile) String() string { lines := make([]string, 0, len(p.user)+1) for _, line := range p.user { - if (!p.gitFenced && !p.ignoredProject) || strings.TrimSpace(line) != "gitignore" { + if !p.gitFenced || strings.TrimSpace(line) != "gitignore" { lines = append(lines, line) } } @@ -193,14 +163,13 @@ func (p policyFile) String() string { for _, line := range lines { hasIgnore = hasIgnore || strings.TrimSpace(line) == "gitignore" } - if !hasIgnore && !p.gitFenced && !p.ignoredProject { + if !hasIgnore && !p.gitFenced { lines = append(lines, "gitignore") } lines = appendBlock(lines, withheldTag, p.withheld) lines = appendBlock(lines, rebuiltTag, p.rebuilt) lines = appendBlock(lines, unreadableTag, p.unreadable) lines = appendBlock(lines, heldTag, p.held) - lines = appendBlock(lines, ignoredTag, p.ignoredProjectPaths) if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") { return "" } @@ -256,7 +225,7 @@ func (p policyFile) userRules() []string { // inRules is the engine's own test: a rule names a path or a subtree. func inRules(rules []string, rel string) bool { for _, rule := range rules { - if rule == "." || rel == rule || strings.HasPrefix(rel, rule+"/") { + if rel == rule || strings.HasPrefix(rel, rule+"/") { return true } } diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 3df7175441..8d1cb293d3 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -212,14 +212,6 @@ If the workspace's own `.furrowpolicy` contains `gitignore`, the native capture If an older build saved an empty chat and the workspace does not enable `gitignore` in its own `.furrowpolicy`, open the original chat in this build, send another message and close it before trying `continue here` again. -## Saving a chat in a Git-ignored project folder or inside an ignored parent - -A chat in an ignored folder can save, close and move to a paired computer. Its conversation still moves even when Git ignores the entire project folder. In that case the project's files stay on this computer, including ordinary ignored files such as `secret.txt`; the chat's own saved words and state travel separately. - -A repository inside that folder uses its own ignore rules. A ceiling equal to the project folder still allows Git to discover an enclosing repository. An ancestor ceiling that prevents discovery, or a folder with no repository, leaves ordinary project files eligible to move, subject to the usual secret screening and capture policy. - -If Git fails to answer, saving stops with `cannot read which folders Git ignores here` and the cause. It never admits ignored project files to make saving succeed. - ## This chat is not syncing — the sync service is not answering, working offline On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). diff --git a/internal/syncsetup/origin_test.go b/internal/syncsetup/origin_test.go index 46cdf75227..de15e17173 100644 --- a/internal/syncsetup/origin_test.go +++ b/internal/syncsetup/origin_test.go @@ -5,64 +5,14 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" "testing" "github.com/Agent-Field/codeaf/internal/cell" - "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/preflight" "github.com/Agent-Field/codeaf/internal/session" ) -func TestContinuingAChatInsideAnIgnoredDirectoryKeepsItsWordsAndLeavesItsFiles(t *testing.T) { - h := newTwoHomes(t) - repo := t.TempDir() - work := filepath.Join(repo, "ignored", "project") - mustNil(t, os.MkdirAll(work, 0o700)) - git := exec.Command("git", "init", "-q", repo) //codeaf:plumbing The enclosing repository ignores this project's parent. - if out, err := git.CombinedOutput(); err != nil { - t.Fatalf("prepare repository: %s: %v", out, err) - } - mustNil(t, os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("ignored/\n"), 0o600)) - marker := []byte("CONFIDENTIAL_BOARD_MINUTES_W3\n") - mustNil(t, os.WriteFile(filepath.Join(work, "secret.txt"), marker, 0o600)) - if found := (keys.Scanner{}).Paths(work, []string{"secret.txt"}); len(found) != 0 { - t.Fatalf("the marker must not match the credential scanner: %+v", found) - } - t.Setenv("GIT_CEILING_DIRECTORIES", "") - h.awayFromA() - h.work, h.engine.Workspace = work, work - h.backToA() - h.engine.Transport, h.engB.Transport = nil, nil - journal := transcriptOf(h.cell) - header, err := os.ReadFile(journal) - mustNil(t, err) - mustNil(t, os.WriteFile(journal, bytes.Replace(header, []byte(`"version":3`), []byte(`"version":1`), 1), 0o600)) - h.spokenFixture() - observer, err := preflight.OpenMachine(h.cell.Root, work) - mustNil(t, err) - a := h.openObserved(h.a, h.engine, h.cell, work, nameA, observer.Observer()) - a.mustSay("words from the ignored project") - h.durable(h.cell.ID, h.cell) - mustNil(t, a.drive.Close(t.Context())) - c := h.continuerB() - c.opt.BucketFor = func(origin string) string { return filepath.Join(h.rootsB, "copy-"+filepath.Base(origin)) } - got, err := c.Take(t.Context(), h.cell.ID) - mustNil(t, err) - words, err := os.ReadFile(transcriptOf(got.Taken.Cell)) - mustNil(t, err) - if !bytes.Contains(words, []byte("words from the ignored project")) || bytes.Contains(words, marker) { - t.Fatalf("the moved chat lost its words or captured confidential words: %s", words) - } - if _, err := os.Stat(filepath.Join(workspaceOf(got.Taken.Cell.Root), "secret.txt")); !os.IsNotExist(err) { - t.Fatalf("the ignored file moved to the other computer: %v", err) - } - b := h.openOn(h.b, h.engB, got.Taken.Cell, workspaceOf(got.Taken.Cell.Root), nameB) - b.mustSay("words added on the second computer") - mustNil(t, b.drive.Close(t.Context())) -} - func TestContinuingAChatInsideAGitDiscoveryBoundaryRestoresItsFilesInAnotherHome(t *testing.T) { fixture, err := os.MkdirTemp(".", "continue-ignored-") mustNil(t, err) From d604eba668206ada739815023e044155485fafa9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 9 Nov 2026 13:54:36 -0500 Subject: [PATCH 5/7] Count a blank-named staged file as work when a computer asks to join Deciding whether an opened chat's own repository is untouched read Git's file names newline-terminated and trimmed them, so a file named only with spaces, staged or committed and then deleted, printed a blank line that trimming discarded. The repository then passed as the empty one made at first open, and joining could replace that work. The names are now read NUL-terminated and any output counts as work; the startup repository prints nothing at all. Refs #2538 Co-Authored-By: Claude Opus 5.5 --- .../identity/opened_repository_work_test.go | 51 +++++++++++++++++++ internal/identity/pristine_judges.go | 9 +++- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 internal/identity/opened_repository_work_test.go diff --git a/internal/identity/opened_repository_work_test.go b/internal/identity/opened_repository_work_test.go new file mode 100644 index 0000000000..0c279b9726 --- /dev/null +++ b/internal/identity/opened_repository_work_test.go @@ -0,0 +1,51 @@ +package identity_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/identity/identitytest" +) + +// Staged or committed work in an owned chat's own repository keeps the home +// from joining, even when the file has gone from the folder and its name is +// nothing but spaces, which a trimmed listing used to read as no name at all. +func TestStagedOrCommittedWorkInAnOwnedRepositoryKeepsAHomeFromJoining(t *testing.T) { + for _, name := range []string{" "} { + for _, step := range []string{"staged", "committed"} { + t.Run(step+" "+map[bool]string{true: "a blank name", false: name}[name == " "], func(t *testing.T) { + home := t.TempDir() + place := identitytest.OpenAndQuit(t, home, true) + if ok, err := identity.Joinable(home); !ok || err != nil { + t.Fatalf("the untouched opened home must join first: %v, %v", ok, err) + } + work := place.Workspace + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"}, args...)...) + cmd.Dir = work + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + if err := os.WriteFile(filepath.Join(work, name), []byte("keep my work"), 0o600); err != nil { + t.Fatal(err) + } + git("add", "--", name) + if step == "committed" { + git("commit", "-q", "-m", "work") + } + // The file leaves the folder, so only Git still knows about it. + if err := os.Rename(filepath.Join(work, name), filepath.Join(t.TempDir(), "moved")); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); ok || err != nil { + t.Fatalf("%s work named %q must keep the home from joining: %v, %v", step, name, ok, err) + } + }) + } + } +} diff --git a/internal/identity/pristine_judges.go b/internal/identity/pristine_judges.go index 58bb857b59..39e6e4c4fa 100644 --- a/internal/identity/pristine_judges.go +++ b/internal/identity/pristine_judges.go @@ -98,14 +98,19 @@ func startupInventory(path string) (bool, error) { // unopenedRepository keeps even deleted or staged project work from being // mistaken for the empty repository made when an owned chat first opens. +// +// ANY NAME GIT PRINTS IS WORK. The names are read NUL-terminated and never +// trimmed: a file named only with spaces, staged and then deleted, printed a +// blank line that trimming discarded, and the repository passed as untouched. +// For the startup repository both listings print nothing at all. func unopenedRepository(path string) (bool, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - for _, args := range [][]string{{"log", "--all", "--format=", "--name-only"}, {"ls-files", "--cached"}} { + for _, args := range [][]string{{"log", "--all", "--format=", "--name-only", "-z"}, {"ls-files", "--cached", "-z"}} { cmd := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) //codeaf:plumbing An empty owned repository contains no saved or staged files. cmd.Dir = filepath.Dir(path) raw, err := cmd.Output() - if err != nil || len(bytes.TrimSpace(raw)) > 0 { + if err != nil || len(raw) > 0 { return true, err } } From 6eaee590e2adb1b2ad25d1399e9fc946fab250ad Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 9 Nov 2026 14:05:11 -0500 Subject: [PATCH 6/7] Stop the local relay rebuilding in a loop, and cap its heap The hosted relay's build hook wrote src/raft/core.wasm inside the folder wrangler watches, so rewriting it, even with the same bytes, could run the hook again; under disk load npm run dev rebuilt and reloaded in a loop, every reload of the eleven-megabyte module grew the Node process, and it aborted at about nine gigabytes, which on WSL also wrote a crash dump of that size. The Go build is reproducible, so the hook now builds beside the tree and moves the module into place only when its bytes differ; two runs in a row leave core.wasm untouched. The local wrapper also bounds Node's heap at 2 GB unless the caller sets its own limit. Fixes #2541 Co-Authored-By: Claude Opus 5.5 --- relay/hosted/test/local.sh | 8 ++++++++ scripts/raft-hosted-build.sh | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/relay/hosted/test/local.sh b/relay/hosted/test/local.sh index acf048db62..9aa91ff0ef 100644 --- a/relay/hosted/test/local.sh +++ b/relay/hosted/test/local.sh @@ -14,6 +14,14 @@ export WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true export CLOUDFLARE_CF_FETCH_ENABLED=false export WRANGLER_REGISTRY_PATH="$local_state/registry" export CI=1 +# A RUNAWAY LOCAL SERVER DIES SMALL. Wrangler's Node process grew to about nine +# gigabytes across repeated reloads before aborting, and on WSL each abort wrote +# a crash dump of that size to the Windows drive. A bounded heap ends such a run +# at a size nobody has to clean up; an explicit caller setting still wins. +case " ${NODE_OPTIONS:-} " in + *" --max-old-space-size="*) ;; + *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=2048" ;; +esac mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$local_state/logs" child= cleanup() { diff --git a/scripts/raft-hosted-build.sh b/scripts/raft-hosted-build.sh index 60e6a964f2..88363566df 100644 --- a/scripts/raft-hosted-build.sh +++ b/scripts/raft-hosted-build.sh @@ -1,6 +1,21 @@ #!/bin/sh # Wrangler invokes this from relay/hosted. The generated reactor is bundled # through a static import and is never an independently deployed service. +# +# AN UNCHANGED BUILD MUST NOT TOUCH core.wasm. Wrangler watches src/ and runs +# this hook again on any change there, so rewriting the file with the same +# bytes re-triggered the build, which rewrote it again: under disk load the +# local dev server rebuilt and reloaded in a loop, and every reload of this +# eleven-megabyte module grew the Node process until it aborted at about nine +# gigabytes. The Go build is reproducible, so the reactor is built beside the +# tree and moved into place only when its bytes differ. set -eu cd ../.. -sh scripts/raft-proof-footprint.sh env GOOS=wasip1 GOARCH=wasm GOMEMLIMIT=192MiB go build -p=1 -trimpath -buildmode=c-shared -ldflags='-s -w' -o relay/hosted/src/raft/core.wasm ./cmd/codeaf-raft-wasm +out=relay/hosted/src/raft/core.wasm +built=$(mktemp "${TMPDIR:-/tmp}/codeaf-core-wasm.XXXXXX") +trap 'rm -f "$built"' EXIT +sh scripts/raft-proof-footprint.sh env GOOS=wasip1 GOARCH=wasm GOMEMLIMIT=192MiB go build -p=1 -trimpath -buildmode=c-shared -ldflags='-s -w' -o "$built" ./cmd/codeaf-raft-wasm +if ! cmp -s "$built" "$out"; then + cp "$built" "$out.tmp" + mv -f "$out.tmp" "$out" +fi From 0f5903fc5c91b949a6275741e9bc88ca197f99ac Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 9 Nov 2026 14:05:21 -0500 Subject: [PATCH 7/7] Say in the #2540 entry what it now fixes Co-Authored-By: Claude Opus 5.5 --- .../2540-pair-fresh-and-ignored-workspace.md | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md b/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md index e7ff164757..6a5846c91e 100644 --- a/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md +++ b/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md @@ -1,17 +1,16 @@ --- kind: fixed -title: Empty chats can pair, and chats in Git-ignored project folders can save and move +title: A computer that only opened codeaf can pair, and the local relay stops rebuilding in a loop pr: 2540 -surface: [chat, engine, remote, docs] +surface: [chat, engine, docs] invalidates: - - "Opening and quitting an empty chat could prevent pairing without --replace; startup inventory and an empty owned working folder now allow code and link pairing while submitted work keeps its refusal." - - "A project inside a Git-ignored parent could fail to save or lose the conversation; Git's answer now excludes the project files while the chat's own words still save and move." + - "Opening codeaf once and quitting left an empty conversation that made codeaf pair refuse with 'this computer has local work'; a computer with nothing but that empty conversation now pairs by code or link without --replace, while a sent turn, saved memories, settings or any staged or committed file still refuse." + - "npm run dev for the hosted relay could rebuild and reload itself in a loop, because its build hook rewrote src/raft/core.wasm inside the watched folder, and its Node process grew to about 9 GB before aborting; an unchanged build now leaves core.wasm untouched and the local wrapper bounds Node's heap." --- -Both pairing roads accept a computer that only opened codeaf. A submitted chat -still produces the counted refusal, and saved memories and settings retain their -existing checks. +A computer that has only been started holds nothing to lose, so it joins +without being told to replace. Files staged or committed in its own repository, +including ones whose names are only spaces, still count as work. -When Git ignores the whole project, its entries stay out of saved history and -the separately saved conversation can move. Nested repositories and Git -discovery ceilings keep Git's own meaning. A genuine Git failure still stops -saving instead of admitting private files. +The local relay's build hook builds the reactor beside the tree and moves it +into place only when its bytes differ, and `npm run dev` caps Node's heap at +2 GB unless the caller sets its own limit.