diff --git a/cmd/codeaf-relay-admin/main.go b/cmd/codeaf-relay-admin/main.go new file mode 100644 index 0000000000..86e9e93376 --- /dev/null +++ b/cmd/codeaf-relay-admin/main.go @@ -0,0 +1,181 @@ +// codeaf-relay-admin is an operator tool and is deliberately excluded from releases. +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +const adminDomain = "codeaf/relay-admin/v1\n" +const adminPath = "/v1/admin/storage" + +var requestCode = regexp.MustCompile(`^id_[0-9a-f]{32}$`) + +type approval struct { + V int `json:"v"` + Host string `json:"host"` + Identity string `json:"identity"` + Storage string `json:"storage"` + At int64 `json:"at"` + Nonce string `json:"nonce"` +} + +func main() { + if err := run(context.Background(), os.Args[1:], os.Stdout, &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context, args []string, out io.Writer, hc *http.Client) error { + if len(args) == 0 { + return errors.New("usage: codeaf-relay-admin approve|revoke [--relay prod|staging|dev|] [--key ], or public-key [--key ]") + } + action, args := args[0], args[1:] + if action != "approve" && action != "revoke" && action != "public-key" { + return errors.New("unknown admin command") + } + code := "" + if action != "public-key" { + if len(args) == 0 || !requestCode.MatchString(args[0]) { + return errors.New("request code must be id_ followed by 32 lowercase hex digits") + } + code, args = args[0], args[1:] + } + flags := flag.NewFlagSet("codeaf-relay-admin", flag.ContinueOnError) + flags.SetOutput(io.Discard) + relay := flags.String("relay", "prod", "relay name or URL") + keyPath := flags.String("key", env.Get("CODEAF_RELAY_ADMIN_KEY"), "private seed file") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("unexpected admin command argument") + } + if *keyPath == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + *keyPath = filepath.Join(home, ".config", "codeaf-relay-admin", "admin.key") + } + key, err := readKey(*keyPath) + if err != nil { + return err + } + if action == "public-key" { + _, err = fmt.Fprintln(out, base64.RawURLEncoding.EncodeToString(key.Public().(ed25519.PublicKey))) + return err + } + base, err := relayURL(*relay) + if err != nil { + return err + } + nonce := make([]byte, 24) + if _, err = rand.Read(nonce); err != nil { + return err + } + storage := "on" + if action == "revoke" { + storage = "off" + } + raw, err := json.Marshal(approval{1, base.Host, code, storage, time.Now().UnixMilli(), base64.RawURLEncoding.EncodeToString(nonce)}) + if err != nil { + return err + } + signature := ed25519.Sign(key, append([]byte(adminDomain), raw...)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wireauth.Endpoint(base.String(), adminPath), bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("codeaf-admin-signature", base64.RawURLEncoding.EncodeToString(signature)) + resp, err := hc.Do(req) + if err != nil { + return errors.New("relay request failed: " + err.Error()) + } + defer resp.Body.Close() + answer, err := io.ReadAll(io.LimitReader(resp.Body, 4097)) + if err != nil { + return err + } + if len(answer) > 4096 { + return errors.New("relay answer is too large") + } + var reply struct { + Identity string `json:"identity"` + Storage string `json:"storage"` + Err string `json:"err"` + } + if json.Unmarshal(answer, &reply) != nil { + return fmt.Errorf("relay answered HTTP %d with an unreadable answer", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("relay refused: %s (HTTP %d)", reply.Err, resp.StatusCode) + } + if reply.Identity != code || reply.Storage != storage { + return errors.New("relay answer does not match the request") + } + _, err = fmt.Fprintf(out, "%s storage %s\n", reply.Identity, reply.Storage) + return err +} + +func relayURL(raw string) (*url.URL, error) { + switch raw { + case "prod": + raw = syncsetup.ProductionRelayURL + case "staging": + raw = syncsetup.StagingRelayURL + case "dev": + raw = syncsetup.DevRelayURL + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("relay must be prod, staging, dev or an HTTP URL") + } + return u, nil +} + +func readKey(path string) (ed25519.PrivateKey, error) { + file, err := os.Open(path) + if err != nil { + return nil, errors.New("could not open admin key file") + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { + return nil, errors.New("admin key file must be a regular file readable only by its owner") + } + raw, err := io.ReadAll(io.LimitReader(file, 128)) + if err != nil { + return nil, err + } + text := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r") + seed, err := base64.RawURLEncoding.Strict().DecodeString(text) + if err != nil || len(seed) != ed25519.SeedSize { + return nil, errors.New("admin key file must contain one unpadded base64url Ed25519 seed") + } + return ed25519.NewKeyFromSeed(seed), nil +} diff --git a/cmd/codeaf-relay-admin/main_test.go b/cmd/codeaf-relay-admin/main_test.go new file mode 100644 index 0000000000..ca417b7956 --- /dev/null +++ b/cmd/codeaf-relay-admin/main_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestStorageOptInAdminSignedRequestAndRefusals(t *testing.T) { + pub, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "test.key") + if err := os.WriteFile(path, []byte(base64.RawURLEncoding.EncodeToString(key.Seed())+"\n"), 0600); err != nil { + t.Fatal(err) + } + code := "id_" + strings.Repeat("b", 32) + calls, refused := 0, false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + raw, _ := io.ReadAll(r.Body) + signature, _ := base64.RawURLEncoding.DecodeString(r.Header.Get("codeaf-admin-signature")) + if !ed25519.Verify(pub, append([]byte(adminDomain), raw...), signature) { + t.Error("bad signature") + } + var body approval + if json.Unmarshal(raw, &body) != nil || body.Host != r.Host || body.Identity != code || body.Nonce == "" || time.Since(time.UnixMilli(body.At)).Abs() > time.Minute { + t.Errorf("bad body: %+v", body) + } + if r.URL.Path != "/fabric/v1/admin/storage" { + t.Errorf("lost prefix: %s", r.URL.Path) + } + if refused { + w.WriteHeader(409) + _, _ = w.Write([]byte(`{"err":"stale"}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"identity": code, "storage": body.Storage}) + })) + defer srv.Close() + for _, action := range []string{"approve", "revoke"} { + var out bytes.Buffer + if err := run(t.Context(), []string{action, code, "--key", path, "--relay", srv.URL + "/fabric"}, &out, srv.Client()); err != nil { + t.Fatal(err) + } + state := "on" + if action == "revoke" { + state = "off" + } + if out.String() != code+" storage "+state+"\n" { + t.Fatalf("answer: %q", out.String()) + } + } + refused = true + if err := run(t.Context(), []string{"approve", code, "--key", path, "--relay", srv.URL + "/fabric"}, io.Discard, srv.Client()); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("refusal: %v", err) + } + if err := run(t.Context(), []string{"approve", "bad", "--key", path, "--relay", srv.URL}, io.Discard, srv.Client()); err == nil || calls != 3 { + t.Fatal("invalid code sent a request") + } + var out bytes.Buffer + if err := run(t.Context(), []string{"public-key", "--key", path}, &out, srv.Client()); err != nil || strings.TrimSpace(out.String()) != base64.RawURLEncoding.EncodeToString(pub) { + t.Fatalf("public key: %v %q", err, out.String()) + } + _ = os.Chmod(path, 0640) + if _, err := readKey(path); err == nil { + t.Fatal("group-readable key accepted") + } + for name, want := range map[string]string{"prod": syncsetup.ProductionRelayURL, "staging": syncsetup.StagingRelayURL, "dev": syncsetup.DevRelayURL} { + got, err := relayURL(name) + if err != nil || got.String() != want { + t.Fatalf("relay %s: %v %v", name, got, err) + } + } +} diff --git a/cmd/codeaf/approvals.go b/cmd/codeaf/approvals.go index 00baeb1eb0..7970b9bed6 100644 --- a/cmd/codeaf/approvals.go +++ b/cmd/codeaf/approvals.go @@ -20,6 +20,7 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/reqsign" "github.com/Agent-Field/codeaf/internal/syncsetup" "github.com/Agent-Field/codeaf/internal/tui3" ) @@ -276,3 +277,21 @@ func (d *approvalsDoor) ownRow() tui3.DeviceRow { // SyncService names the local pairing service even when the chat runs elsewhere. func (d *approvalsDoor) SyncService() string { return syncsetup.Resolve(d.dir).URL } + +// RelayStorage reads this computer's account without creating keys or asking the network. +func (d *approvalsDoor) RelayStorage() (code, state string) { + relay := syncsetup.Resolve(d.dir) + if !relay.On() || identity.Solo(d.dir) { + return "", "" + } + id, err := identity.Load(d.dir) + if err != nil { + return "", "" + } + code = reqsign.IDOf(id.PublicKey()) + state = syncsetup.ReportedStorage(d.dir, relay.URL, code) + if state == "" { + return "", "" + } + return code, state +} diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index fd12a18115..70e2ffc4cf 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1453,6 +1453,14 @@ func openV3Agent(cfg session.Config, workspace string, open func(session.Config) return nil, cfg, "", errors.New(chatArrivingSentence) } cfg = v3Seated(v3Migrated(cfg)) + constructed := false + defer func() { + if !constructed { + if seals, ok := cfg.Seals.(interface{ Retire() }); ok { + seals.Retire() + } + } + }() // Restore the gate before construction, so restored work cannot start behind // the profile default. Return the launch config unchanged: a subsequent new // conversation must not inherit this one's saved override. @@ -1493,6 +1501,7 @@ func openV3Agent(cfg session.Config, workspace string, open func(session.Config) if savedEffort != "" { agent.SetConversationEffort(savedEffort) } + constructed = true return agent, cfg, "", nil } if !errors.Is(err, session.ErrSessionLocked) { diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 04eaddd840..d57f06e638 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -826,12 +826,15 @@ func v3SeatWith(place session.Place, say func(string), drives *driveBook) (execu machine, err := preflight.OpenMachine(c.Root, place.Workspace) watch := &cellstore.SealWatch{Say: say, SyncAddress: chatSyncService()} failed(watch, err) - drive := drives.driveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport) + drive := drives.driveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport, watch) if drive != nil { watch.OnTurnEnd = drive.Idle // the agent waits for the person: upload what is sealed now } seat, err := cellstore.SeatOver(class, c, place.Workspace, observerOf(machine), watch.Report, driveStore(drive), cellstore.WithNotices(watch.Note), cellstore.WithPipeline(), cellstore.WithWarmCapture()) failed(watch, err) + if err != nil { + watch.Retire() + } watch.OnTail = func() { sealTail(seat, drive) } // A fresh seat has no foreground turn in flight. Background tasks may be // the first input; their journal writes need a tail seal even before chat. @@ -874,6 +877,9 @@ func sealSeamOf(agent any, seals session.SealState) tui3.SealSeam { if seals != nil { seam = tui3.SealSeam{Failing: seals.Failing, Notice: seals.Take} } + if watch, ok := seals.(interface{ Listen(func()) func() }); ok { + seam.Listen = watch.Listen + } if door, ok := agent.(interface{ SealFailing() bool }); ok { seam.Failing = door.SealFailing } diff --git a/cmd/codeaf/chatv3_pair_test.go b/cmd/codeaf/chatv3_pair_test.go index b591a851a2..ec7b12a57b 100644 --- a/cmd/codeaf/chatv3_pair_test.go +++ b/cmd/codeaf/chatv3_pair_test.go @@ -95,7 +95,8 @@ func TestTeamPairedFollowsPairingAndSyncOff(t *testing.T) { if teamPaired() { t.Fatal("missing identity was paired") } - if _, err := identity.EnsureSolo(root); err != nil { + id, err := identity.EnsureSolo(root) + if err != nil { t.Fatal(err) } if teamPaired() { @@ -104,6 +105,16 @@ func TestTeamPairedFollowsPairingAndSyncOff(t *testing.T) { if err := identity.EndSolo(root); err != nil { t.Fatal(err) } + relay := syncsetup.Resolve(root) + if err := syncsetup.SaveStorage(root, relay.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + if teamPaired() { + t.Fatal("paired identity with storage off was team-paired") + } + if err := syncsetup.SaveStorage(root, relay.URL, id.ID(), "on"); err != nil { + t.Fatal(err) + } if !teamPaired() { t.Fatal("shared identity was unpaired") } diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index e51705a09c..e6406032fd 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -2,6 +2,7 @@ package main import ( "context" + "github.com/Agent-Field/codeaf/internal/syncsetup" "log" "os" @@ -49,6 +50,8 @@ var ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { + stopStorage := syncsetup.StartStorageRefresh(ctx, home.Dir(), false) + defer stopStorage() if options.Host == "" { if options.Notices == nil { options.Notices = surfaceNotices diff --git a/cmd/codeaf/chatv3_taskcomputers.go b/cmd/codeaf/chatv3_taskcomputers.go index 2e7712f137..be2916ad90 100644 --- a/cmd/codeaf/chatv3_taskcomputers.go +++ b/cmd/codeaf/chatv3_taskcomputers.go @@ -47,7 +47,7 @@ func v3TaskComputers(profile string) session.TaskComputers { engine := cellstore.EngineFor("") engine.DataRoot = filepath.Join(profile, "v3", "stores") engine.Guard.Home = profile - store := blobstore.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: time.Minute}) + store := syncsetup.GateStore(blobstore.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: time.Minute}), profile, route.URL, id.ID()) copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: route.URL, Identity: id, Device: device, Engine: engine, Store: store} return &taskplace.Computers{Directory: client, Copies: copies, Home: profile} } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index e90cef3dd9..18f054b9fb 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -42,6 +42,7 @@ package main // cannot work must still take a remote session. import ( + "context" "errors" "fmt" "io" @@ -155,6 +156,8 @@ func runRemoteEngine(args []string) error { return quietRefusal(remote.Refuse(os.Stdout, stale.reason)) } } + stopStorage := syncsetup.StartStorageRefresh(context.Background(), home.Dir()) + defer stopStorage() err := remote.Serve(os.Stdin, os.Stdout, remote.Options{ SyncService: chatSyncService(), Boot: func(hello remote.Hello) (*remote.Engine, error) { @@ -925,6 +928,8 @@ func placesWord(n int) string { // the machine is in exactly the state that was asked for — and this process was // started by another one that is about to dial the socket either way. func runEngineHost(workspaceFlag, sessionFlag string) error { + stopStorage := syncsetup.StartStorageRefresh(context.Background(), home.Dir()) + defer stopStorage() workspace, err := engineWorkspace(workspaceFlag) if err != nil { return err diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index fda68ce3ad..e50027612f 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -24,7 +24,8 @@ const quitSavingWord = "saving this chat to your other devices…" const quitStillSavingWord = "still saving — ctrl+c leaves it saved here" const quitStillLocalWord = "still saving on this computer — ctrl+c leaves the written turns here" const quitLegacySavingWord = "codeaf is still saving this chat in the background; unsaved turns stay on this computer." -const quitSavedHereWord = "saved here; it reaches your other devices when the sync service at %s answers" +const quitStorageOffWord = syncwords.SavedStorageOff +const quitSavedHereWord = syncwords.SavedHerePending // finalSaving keeps one failure until cleanup has returned the terminal. var finalSaving struct { @@ -176,7 +177,7 @@ func recordFinalSaveFailure(err error) { } if syncwords.UploadPending(err) { finalSaving.finishing = true - if finalSaving.failure == nil { + if finalSaving.failure == nil || syncwords.UploadPending(finalSaving.failure) && syncwords.SavedWithStorageOff(err) { finalSaving.failure = err } return @@ -265,8 +266,8 @@ func finishFinalSaving(ctx context.Context) (ok bool) { // finalSaveWords distinguishes durable local turns from a failed local write. func finalSaveWords(err error, finishing bool, address string) (string, bool) { - if syncwords.UploadPending(err) { - return fmt.Sprintf(quitSavedHereWord, syncwords.Host(address)), true + if line := syncwords.SavedHereWords(err, address); line != "" { + return line, true } if (err == nil && finishing) || errors.Is(err, session.ErrSavingUnanswered) { return "saving on this computer has not finished; its written turns stay here", false diff --git a/cmd/codeaf/serve_state.go b/cmd/codeaf/serve_state.go index 6d1e9e93da..2eb7f0e5d8 100644 --- a/cmd/codeaf/serve_state.go +++ b/cmd/codeaf/serve_state.go @@ -18,11 +18,11 @@ func startServeKeeping(ctx context.Context, service string) func() { } // serveKeeping is startServeKeeping with the decision made. With keep false -// it starts nothing and the join does nothing, so every serve that did not ask -// behaves exactly as it always has. +// only permission discovery runs; home-state and presence remain opt-in. func serveKeeping(ctx context.Context, service string, keep bool) func() { + stopStorage := syncsetup.StartStorageRefresh(ctx, home.Dir()) if !keep { - return func() {} + return stopStorage } joinState := startServeHomeState(ctx, home.Dir(), func(err error) { fmt.Fprintln(os.Stderr, "could not sync this machine's saved keys and settings:", err) @@ -34,6 +34,7 @@ func serveKeeping(ctx context.Context, service string, keep bool) func() { stopPresence() <-presenceDone joinState() + stopStorage() } } diff --git a/cmd/codeaf/serve_tasks.go b/cmd/codeaf/serve_tasks.go index b7e5003d14..3f673a0349 100644 --- a/cmd/codeaf/serve_tasks.go +++ b/cmd/codeaf/serve_tasks.go @@ -41,6 +41,7 @@ func serveTasks(ctx context.Context, service string) func() { } config.Say = func(line string) { fmt.Fprintln(os.Stdout, line) } ctx, cancel := context.WithCancel(ctx) + stopStorage := syncsetup.StartStorageRefresh(ctx, profile) done := make(chan struct{}) guard.Go("serve/tasks", func() { defer close(done) @@ -48,7 +49,7 @@ func serveTasks(ctx context.Context, service string) func() { fmt.Fprintln(os.Stderr, "could not take tasks from your paired computers:", err) } }) - return func() { cancel(); <-done } + return func() { cancel(); stopStorage(); <-done } } func serveTasksConfig(profile, service string) (taskdoor.Config, bool, error) { @@ -71,7 +72,7 @@ func serveTasksConfig(profile, service string) (taskdoor.Config, bool, error) { engine := cellstore.EngineFor("") engine.DataRoot = filepath.Join(profile, "v3", "stores") engine.Guard.Home = profile - store := blobstore.NewHTTP(service, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{}) + store := syncsetup.GateStore(blobstore.NewHTTP(service, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{}), profile, service, id.ID()) copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: service, Identity: id, Device: device, Engine: engine, Store: store} return taskdoor.Config{Service: service, Identity: id, Device: device, Home: profile, Root: filepath.Join(profile, "v3", "taskdoor"), Roster: func(ctx context.Context) (map[string]bool, error) { diff --git a/cmd/codeaf/storage_off_lifetime_test.go b/cmd/codeaf/storage_off_lifetime_test.go new file mode 100644 index 0000000000..27e111d1f4 --- /dev/null +++ b/cmd/codeaf/storage_off_lifetime_test.go @@ -0,0 +1,629 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +func TestSharedSealWatchLivesUntilBothConversationsClose(t *testing.T) { + for _, parentFirst := range []bool{true, false} { + t.Run(map[bool]string{true: "parent closes first", false: "child closes first"}[parentFirst], func(t *testing.T) { + cfg := v3Seated(storageOffNoticeConfig(t)) + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + parent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer parent.Close() + child, err := v3Errand(cfg, cfg.Workspace, "", true)(tui3.ErrandOrders{Dir: t.TempDir(), Workspace: cfg.Workspace}) + if err != nil { + t.Fatal(err) + } + defer child.Close() + first, last := tui3.Agent(parent), child + if !parentFirst { + first, last = child, parent + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("first close retired a shared watch: %d", got) + } + d.notices.say("remaining conversation still receives notices") + found := false + watch := cfg.Seals.(*cellstore.SealWatch) + for line := watch.Take(); line != ""; line = watch.Take() { + found = found || line == "remaining conversation still receives notices" + } + if !found { + t.Fatal("remaining owner lost its subscription") + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("repeated close released another owner: %d", got) + } + if err := last.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 0 { + t.Fatalf("last close retained %d watches", got) + } + }) + } +} + +func TestCancelledSurfaceDetachesItsSealListener(t *testing.T) { + cfg := v3Seated(storageOffNoticeConfig(t)) + startupFinished(syncDrives.renewalsOf(cfg.Place.ID())) + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + watch := cfg.Seals.(*cellstore.SealWatch) + seam := sealSeamOf(agent, cfg.Seals) + var active, wakes atomic.Int32 + listen := seam.Listen + seam.Listen = func(wake func()) func() { + active.Add(1) + stop := listen(func() { wakes.Add(1); wake() }) + return func() { stop(); active.Add(-1) } + } + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + screen := &syncOffFrameCapture{} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, SessionFile: cfg.SessionFile, Workspace: cfg.Workspace, Seal: seam, Input: input, Output: screen, Width: 120, Height: 35, Linear: true}) + }() + waitStorageOffBoundary(t, "surface listener", func() bool { return active.Load() == 1 && screen.Plain() != "" }) + cancel() + <-done + if active.Load() != 0 { + t.Fatal("cancelled surface retained its seal listener") + } + before := wakes.Load() + watch.Note("the watch outlives the window") + if wakes.Load() != before { + t.Fatal("retained watch rang a closed window") + } + found := false + for line := watch.Take(); line != ""; line = watch.Take() { + found = found || line == "the watch outlives the window" + } + if !found { + t.Fatal("surface cancellation retired the still-owned watch") + } +} + +type storageOffCountedFrames struct { + blobstore.Store + writes atomic.Int32 +} + +func (s *storageOffCountedFrames) PutFrame(ctx context.Context, b []byte) (blobstore.FrameID, error) { + s.writes.Add(1) + return s.Store.PutFrame(ctx, b) +} + +func waitStorageOffBoundary(t *testing.T, what string, ready func() bool) { + t.Helper() + until := time.Now().Add(5 * time.Second) + for !ready() { + if time.Now().After(until) { + t.Fatal("waiting for " + what) + } + time.Sleep(5 * time.Millisecond) + } +} + +func storageOffSavedChat(t *testing.T) (session.Config, cell.Cell) { + t.Helper() + cfg, c := spokenDoorChat(t) + // A real saved-chat location lets the ordinary Home reader find the reopened workspace. + root := filepath.Join(session.PlacesRoot(), "project", c.ID) + if err := os.MkdirAll(filepath.Dir(root), 0700); err != nil { + t.Fatal(err) + } + if err := os.Rename(c.Root, root); err != nil { + t.Fatal(err) + } + var err error + c, err = cell.OpenAt(root, c.ID) + if err != nil { + t.Fatal(err) + } + cfg.Place.Dir = root + cfg.SessionFile = cfg.Place.Transcript() + return cfg, c +} + +func TestStorageOffContinuedChatSendsAfterItsOwnEmptyHeadHoldExpires(t *testing.T) { + cfg, c := storageOffSavedChat(t) + t.Cleanup(cellstore.StopSpawned) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + dir := directory.NewMemory(func() time.Time { return time.UnixMilli(clock.Load()) }) + defer dir.Close() + store := &storageOffCountedFrames{Store: blobstore.NewMemory()} + auth := reqsign.AuthenticateAt(time.Now) + mux := http.NewServeMux() + mux.Handle("/v1/dir/", directory.Handler(auth, func(string) (directory.Directory, error) { return dir, nil })) + mux.Handle("/", blobstore.Handler(auth, func(string) (blobstore.Store, error) { return store, nil })) + srv := httptest.NewServer(mux) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + t.Fatalf("open sync: %v", err) + } + if _, err := dir.For("other-computer").Create(t.Context(), c.ID, directory.CellInit{}); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: cfg.Workspace}); err != nil { + t.Fatal(err) + } + eng := cellstore.EngineFor(cfg.Workspace) + if _, err := eng.Seal(t.Context(), c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + if err := cellstore.NotePublished(c, ""); err != nil { + t.Fatal(err) + } + taker := takeover{s.Continuer(eng, syncsetup.TakeOptions{RootFor: func(string) string { return c.Root }})} + continued, err := taker.Take(t.Context(), c.ID) + if err != nil || !continued.LocalOnly { + t.Fatalf("continue: %+v %v", continued, err) + } + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var request struct { + Messages []struct { + Role string + Content json.RawMessage + } + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + marker := "before-expiry" + for _, message := range request.Messages { + if message.Role == "user" && strings.Contains(string(message.Content), "after-expiry") { + marker = "after-expiry" + } + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"answer-%s\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", marker) + })) + defer model.Close() + cfg.BaseURL = model.URL + cfg = v3Seated(cfg) + drive := syncDrives.renewalsOf(c.ID) + startupFinished(drive) + if drive.started() != nil { + t.Fatal("storage-off seat started an uploading writer") + } + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + screen := &syncOffFrameCapture{} + t.Cleanup(func() { + if t.Failed() { + raw, _ := os.ReadFile(cfg.SessionFile) + t.Logf("surface: %s\ntranscript: %s", screen.Plain(), raw) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + open := func(workspace, transcript string) (tui3.Conversation, error) { + reopened, err := v3Reopen(cfg, transcript, workspace) + if err != nil { + return tui3.Conversation{}, err + } + reopened = v3Seated(reopened) + next, err := session.New(reopened) + if err == nil { + t.Cleanup(func() { _ = next.Close() }) + } + return tui3.Conversation{Agent: next, Workspace: reopened.Workspace, SessionFile: transcript, Resumed: true}, err + } + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, Workspace: cfg.Workspace, SessionFile: cfg.SessionFile, Machines: surfaceMachines{s}, Takeover: taker, Open: open, Input: input, Output: screen, Width: 160, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + waitStorageOffBoundary(t, "first frame", func() bool { return screen.Plain() != "" }) + send := func(marker string) { + t.Helper() + if _, err := io.WriteString(typed, marker+"\r"); err != nil { + t.Fatal(err) + } + waitStorageOffBoundary(t, "locally saved "+marker, func() bool { + if strings.Contains(screen.Plain(), "Continue this chat here?") { + t.Fatal("own empty-head chat asked to continue again") + } + raw, _ := os.ReadFile(cfg.SessionFile) + return strings.Contains(string(raw), `"role":"assistant","content":"answer-`+marker+`"`) + }) + } + send("before-expiry") + previous, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil { + t.Fatal(err) + } + clock.Add((directory.LeaseTTL + time.Second).Milliseconds()) + expired, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || expired.Cell.Head != "" || expired.Cell.Lease.Device != s.Device.ID() || expired.Cell.Lease.Expires > expired.Now { + t.Fatalf("not this computer's free empty-head chat: %+v %v", expired, err) + } + send("after-expiry") + waitStorageOffBoundary(t, "saved local turns", func() bool { + raw, _ := os.ReadFile(cfg.SessionFile) + head, _ := cellstore.Head(c) + return head != nil && head.Receipt.Transcript.End >= int64(len(raw)) + }) + raw, err := os.ReadFile(cfg.SessionFile) + if err != nil { + t.Fatal(err) + } + for _, words := range []string{"printf fixture", "answer-before-expiry", "answer-after-expiry"} { + if !strings.Contains(string(raw), words) { + t.Fatalf("local history lost %q", words) + } + } + latest, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || latest.Cell.Lease.Fence <= previous.Cell.Lease.Fence || latest.Cell.Lease.Device != s.Device.ID() { + t.Fatalf("send did not reacquire: %+v %v", latest, err) + } + if strings.Contains(screen.Plain(), "Continue this chat here?") { + t.Fatal("own expired chat asked to continue") + } + if store.writes.Load() != 0 { + t.Fatalf("store writes=%d", store.writes.Load()) + } +} + +func TestStorageOffChatKeepsSendingAfterItsOwnRecordedHeadHoldExpires(t *testing.T) { + testRecordedHeadReturn(t, "local turns ahead") +} + +func TestStorageOffChatWithMissingSavedTurnsDoesNotReturnQuietly(t *testing.T) { + testRecordedHeadReturn(t, "missing saved turns") +} + +func TestStorageOnChatWithDifferentHeadsStillAsksToContinue(t *testing.T) { + testRecordedHeadReturn(t, "storage on") +} + +func testRecordedHeadReturn(t *testing.T, mode string) { + t.Helper() + cfg, c := storageOffSavedChat(t) + t.Cleanup(cellstore.StopSpawned) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + dir := directory.NewMemory(func() time.Time { return time.UnixMilli(clock.Load()) }) + defer dir.Close() + store := &storageOffCountedFrames{Store: blobstore.NewMemory()} + auth := reqsign.AuthenticateAt(time.Now) + mux := http.NewServeMux() + mux.Handle("/v1/dir/", directory.Handler(auth, func(string) (directory.Directory, error) { return dir, nil })) + mux.Handle("/", blobstore.Handler(auth, func(string) (blobstore.Store, error) { return store, nil })) + srv := httptest.NewServer(mux) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + permission := func(state string) { + t.Helper() + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), state); err != nil { + t.Fatal(err) + } + } + permission("on") + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + t.Fatalf("open sync: %v", err) + } + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: cfg.Workspace}); err != nil { + t.Fatal(err) + } + eng := cellstore.EngineFor(cfg.Workspace) + if _, err := eng.Seal(t.Context(), c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + approved := v3Seated(cfg) + startupFinished(syncDrives.renewalsOf(c.ID)) + syncDrives.renewalsOf(c.ID).Idle() + syncDrives.closeAll() + approved.Seals.(*cellstore.SealWatch).Retire() + published, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || published.Cell.Head == "" || store.writes.Load() == 0 { + t.Fatalf("approved chat never published: %+v, writes=%d, %v", published, store.writes.Load(), err) + } + permission("off") + writesAtRevoke := store.writes.Load() + taker := takeover{s.Continuer(eng, syncsetup.TakeOptions{RootFor: func(string) string { return c.Root }})} + continued, err := taker.Take(t.Context(), c.ID) + if err != nil || !continued.LocalOnly { + t.Fatalf("continue after revoke: %+v %v", continued, err) + } + var turns atomic.Int32 + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var request struct { + Messages []struct { + Role string + Content json.RawMessage + } + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + marker := "" + for _, message := range request.Messages { + if message.Role == "user" { + for _, candidate := range []string{"local-one", "local-two", "before-expiry", "after-first-expiry", "after-second-expiry"} { + if strings.Contains(string(message.Content), candidate) { + marker = candidate + } + } + } + } + turns.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"answer-%s\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", marker) + })) + defer model.Close() + cfg.BaseURL = model.URL + localCfg := v3Seated(cfg) + startupFinished(syncDrives.renewalsOf(c.ID)) + local, err := session.New(localCfg) + if err != nil { + t.Fatal(err) + } + defer local.Close() + for _, words := range []string{"local-one", "local-two"} { + events, err := local.Submit(t.Context(), words) + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatal(event.Text) + } + } + } + if err := local.Close(); err != nil { + t.Fatal(err) + } + syncDrives.closeAll() + reopened, err := v3Reopen(cfg, cfg.SessionFile, cfg.Workspace) + if err != nil { + t.Fatal(err) + } + cfg = v3Seated(reopened) + startupFinished(syncDrives.renewalsOf(c.ID)) + if syncDrives.renewalsOf(c.ID).started() != nil { + t.Fatal("storage-off chat started an uploading writer") + } + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + screen := &syncOffFrameCapture{} + var opens atomic.Int32 + open := func(workspace, transcript string) (tui3.Conversation, error) { + opens.Add(1) + reopened, err := v3Reopen(cfg, transcript, workspace) + if err != nil { + return tui3.Conversation{}, err + } + reopened = v3Seated(reopened) + next, err := session.New(reopened) + if err == nil { + t.Cleanup(func() { _ = next.Close() }) + } + return tui3.Conversation{Agent: next, Workspace: reopened.Workspace, SessionFile: transcript, Resumed: true}, err + } + t.Cleanup(func() { + if t.Failed() { + raw, _ := os.ReadFile(cfg.SessionFile) + t.Logf("surface: %s\ntranscript: %s", screen.Plain(), raw) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + seam := sealSeamOf(agent, cfg.Seals) + var storageNotices atomic.Int32 + notice := seam.Notice + seam.Notice = func() string { + line := notice() + if line == chatlist.RelayStorageOff { + storageNotices.Add(1) + } + return line + } + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, Workspace: cfg.Workspace, SessionFile: cfg.SessionFile, Seal: seam, Machines: surfaceMachines{s}, Takeover: taker, Open: open, Input: input, Output: screen, Width: 200, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + shown := func() string { return strings.Join(strings.Fields(screen.Plain()), " ") } + waitStorageOffBoundary(t, "storage-off sentence", func() bool { return strings.Contains(shown(), chatlist.RelayStorageOff) }) + send := func(marker string) { + t.Helper() + if _, err := io.WriteString(typed, marker+"\r"); err != nil { + t.Fatal(err) + } + waitStorageOffBoundary(t, "saved "+marker, func() bool { + if strings.Contains(shown(), "Continue this chat here?") { + t.Fatal("own chat asked to continue again") + } + raw, _ := os.ReadFile(cfg.SessionFile) + head, _ := cellstore.Head(c) + return strings.Contains(string(raw), `"role":"assistant","content":"answer-`+marker+`"`) && head != nil && head.Receipt.Transcript.End >= int64(len(raw)) + }) + } + send("before-expiry") + before, err := os.ReadFile(cfg.SessionFile) + if err != nil { + t.Fatal(err) + } + for _, marker := range []string{"after-first-expiry", "after-second-expiry"} { + clock.Add((directory.LeaseTTL + time.Second).Milliseconds()) + if mode == "missing saved turns" { + // Another computer publishes a real child of the shared turn, without receiving our local turns. + other := cell.Cell{ID: c.ID, Root: filepath.Join(t.TempDir(), c.ID)} + otherEngine := eng + otherEngine.DataRoot = t.TempDir() + synced := otherEngine.Sync(cellstore.SyncKeys{CellKey: id.CellKey(), Dedup: id.DedupSecret()}, "fedcba9876543210") + fetch := cellsync.Fetcher{Engine: synced, Store: store.Store, Inbox: synced.Inbox} + if err := fetch.Fetch(t.Context(), other, published.Cell.Head); err != nil { + t.Fatal(err) + } + other, err = cell.OpenAt(other.Root, other.ID) + if err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(filepath.Join(other.Root, cell.TranscriptPath), os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, writeErr := io.WriteString(file, "{\"type\":\"message\",\"role\":\"user\",\"content\":\"another computer's turn\"}\n") + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + t.Fatalf("other turn: %v %v", writeErr, closeErr) + } + sealed, err := otherEngine.Seal(t.Context(), other, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + otherDir := dir.For("other-computer") + got, err := otherDir.Acquire(t.Context(), c.ID, directory.AcquireOpts{}) + if err != nil { + t.Fatal(err) + } + driving := cellsync.Driving{Cell: other, Fence: got.Cell.Lease.Fence, Head: got.Cell.Head, Frames: got.Cell.Frames} + publisher := cellsync.Publisher{Engine: synced, Store: store.Store, Dir: otherDir} + if err := publisher.Publish(t.Context(), &driving, sealed.Turn.ID, cellsync.PublishInfo{}); err != nil { + t.Fatal(err) + } + if err := otherDir.Release(t.Context(), c.ID, driving.Fence); err != nil { + t.Fatal(err) + } + // Returning the recorded hold here without fetching makes this an own free chat with missing history. + got, err = s.Dir.Acquire(t.Context(), c.ID, directory.AcquireOpts{}) + if err != nil { + t.Fatal(err) + } + if err := s.Dir.Release(t.Context(), c.ID, got.Cell.Lease.Fence); err != nil { + t.Fatal(err) + } + } + if mode == "storage on" { + // Stop background retries so this send, rather than an approval upload, observes the differing heads. + syncDrives.closeAll() + permission("on") + } + free, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || free.Cell.Head == "" || free.Cell.Lease.Device != s.Device.ID() || free.Cell.Lease.Expires > free.Now { + t.Fatalf("not this computer's free recorded chat: %+v %v", free, err) + } + extends, err := cellstore.Extends(c, free.Cell.Head) + if err != nil || extends != (mode != "missing saved turns") { + t.Fatalf("fixture ancestry=%v: %v", extends, err) + } + if mode != "local turns ahead" { + calls := turns.Load() + if _, err := io.WriteString(typed, marker+"\r"); err != nil { + t.Fatal(err) + } + waitStorageOffBoundary(t, "continue question or missing-turns refusal", func() bool { + return strings.Contains(shown(), "Continue this chat here?") || mode == "missing saved turns" && strings.Contains(shown(), "this computer is missing saved turns; your local turns stay here") + }) + latest, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || latest.Cell.Lease.Fence != free.Cell.Lease.Fence || turns.Load() != calls { + t.Fatalf("control acquired or sent: %+v calls=%d/%d %v", latest, turns.Load(), calls, err) + } + after, err := os.ReadFile(cfg.SessionFile) + if err != nil || string(after) != string(before) { + t.Fatalf("control changed local turns: %v", err) + } + break + } + send(marker) + latest, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || latest.Cell.Lease.Fence <= free.Cell.Lease.Fence || latest.Cell.Lease.Device != s.Device.ID() || latest.Cell.Head != published.Cell.Head { + t.Fatalf("send did not return without publishing: %+v %v", latest, err) + } + } + if opens.Load() != 0 { + t.Fatalf("chat reopened %d times", opens.Load()) + } + if got := storageNotices.Load(); got != 1 { + t.Fatalf("storage-off sentence shown %d times", got) + } + if store.writes.Load() != writesAtRevoke { + t.Fatalf("store writes after revoke=%d", store.writes.Load()-writesAtRevoke) + } + raw, err := os.ReadFile(cfg.SessionFile) + if err != nil { + t.Fatal(err) + } + for _, words := range []string{"printf fixture", "answer-local-one", "answer-local-two", "answer-before-expiry"} { + if !strings.Contains(string(raw), words) { + t.Fatalf("local history lost %q", words) + } + } +} diff --git a/cmd/codeaf/storage_off_notices_test.go b/cmd/codeaf/storage_off_notices_test.go new file mode 100644 index 0000000000..7970c0e8f5 --- /dev/null +++ b/cmd/codeaf/storage_off_notices_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// B4 crosses the engine/window protocol with the real seat watch and remote client. +func TestStorageNoticeReachesEngineWindow(t *testing.T) { + for _, mode := range []string{"refused start", "revoke", "no-host", "storage on", "self-hosted"} { + t.Run(mode, func(t *testing.T) { + cfg, c := spokenDoorChat(t) + t.Cleanup(cellstore.StopSpawned) + oldDesk := surfaceNotices + surfaceNotices = tui3.NewNotices() + defer func() { surfaceNotices = oldDesk }() + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + defer service.Close() + var refuse atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if refuse.Load() && r.Method == http.MethodPost && r.URL.Path == "/v1/store/frames" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"err":"storage_off"}`)) + return + } + service.Handler.ServeHTTP(w, r) + })) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(syncsetup.IntervalVar, "50") + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + if mode != "self-hosted" { + state := "on" + if mode == "refused start" || mode == "no-host" { + state = "off" + } + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), state); err != nil { + t.Fatal(err) + } + } + cfg = v3Seated(cfg) + // This fixture drives its seals explicitly and must not leave a trailing timer writing during cleanup. + cfg.Seals.(*cellstore.SealWatch).OnTail = nil + drive := syncDrives.renewalsOf(c.ID) + if drive == nil { + t.Fatal("seat has no live drive") + } + startupFinished(drive) + defer func() { _ = syncDrives.closeCell(c.ID) }() + seat := cfg.Seat + cfg.Seat = nil + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + var take func() string + if mode == "no-host" { + defer agent.Close() + if got := storageOffLocalNoticeCount(t, agent, cfg); got != 1 { + t.Fatalf("local window received notice %d times", got) + } + return + } else { + loop, err := remote.Loopback(remote.Hello{}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: agent, SessionFile: cfg.SessionFile}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + take = loop.Client.TakeNotice + } + if mode == "revoke" { + if drive.started() == nil { + t.Fatal("revoke fixture never started") + } + refuse.Store(true) + if err := toolCallOn(t, seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + drive.Idle() + } + count := 0 + deadline := time.After(3 * time.Second) + quiet := time.NewTimer(350 * time.Millisecond) + defer quiet.Stop() + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + want := mode != "storage on" && mode != "self-hosted" + waiting: + for { + for line := take(); line != ""; line = take() { + if line == chatlist.RelayStorageOff { + count++ + } + } + select { + case <-tick.C: + case <-quiet.C: + if !want || count > 0 { + break waiting + } + case <-deadline: + break waiting + } + } + if want && count != 1 || !want && count != 0 { + t.Fatalf("window received storage-off %d times", count) + } + }) + } +} + +// storageOffLocalNoticeCount gives both in-process notice roads to the real surface. +func storageOffLocalNoticeCount(t *testing.T, agent *session.Agent, cfg session.Config) int { + t.Helper() + input, writer := io.Pipe() + defer input.Close() + defer writer.Close() + screen := &syncOffFrameCapture{} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, SessionFile: cfg.SessionFile, Workspace: cfg.Place.Workspace, Seal: sealSeamOf(agent, cfg.Seals), Notices: surfaceNotices, Input: input, Output: screen, Width: 200, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + ticks := time.NewTicker(10 * time.Millisecond) + defer ticks.Stop() + for { + shown := strings.Join(strings.Fields(screen.Plain()), " ") + if strings.Contains(shown, chatlist.RelayStorageOff) { + return strings.Count(shown, chatlist.RelayStorageOff) + } + select { + case <-ticks.C: + case <-deadline.C: + t.Fatalf("local window missed storage notice: %s", shown) + return 0 + } + } +} + +type storageOffNoticeAgent struct { + quietAgent + watch *cellstore.SealWatch +} + +func (a *storageOffNoticeAgent) TakeSealNotice() string { return a.watch.Take() } + +// B4 gives each seat sharing a drive one notice over its own real window connection. +func TestSharedDriveNoticesEachWindow(t *testing.T) { + cfg, c := spokenDoorChat(t) + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + defer service.Close() + srv := httptest.NewServer(service.Handler) + defer srv.Close() + defer syncDrives.closeAll() + t.Setenv(syncsetup.URLVar, srv.URL) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + if err := syncsetup.SaveStorage(home.Dir(), syncsetup.Resolve(home.Dir()).URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + cfg = v3Seated(cfg) + d := syncDrives.renewalsOf(c.ID) + startupFinished(d) + _, _, second := v3SeatWith(cfg.Place, nil, syncDrives) + if syncDrives.renewalsOf(c.ID) != d { + t.Fatal("seats did not share a drive") + } + for _, watch := range []*cellstore.SealWatch{cfg.Seals.(*cellstore.SealWatch), second} { + loop, err := remote.Loopback(remote.Hello{}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: &storageOffNoticeAgent{watch: watch}}, nil + }}) + if err != nil { + t.Fatal(err) + } + func() { + defer loop.Close() + count := 0 + ticks := time.NewTicker(10 * time.Millisecond) + defer ticks.Stop() + deadline := time.NewTimer(350 * time.Millisecond) + defer deadline.Stop() + for { + for line := loop.Client.TakeNotice(); line != ""; line = loop.Client.TakeNotice() { + if line == chatlist.RelayStorageOff { + count++ + } + } + select { + case <-ticks.C: + case <-deadline.C: + if count != 1 { + t.Errorf("shared-drive window received %d notices", count) + } + return + } + } + }() + } + + // Approval must not replay the old refusal to a newly opened seat. + if err := syncsetup.SaveStorage(home.Dir(), syncsetup.Resolve(home.Dir()).URL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + startupFinished(d) + _, _, approved := v3SeatWith(cfg.Place, nil, syncDrives) + for line := approved.Take(); line != ""; line = approved.Take() { + if line == chatlist.RelayStorageOff { + t.Fatal("approved seat replayed an obsolete refusal") + } + } +} diff --git a/cmd/codeaf/storage_off_watches_test.go b/cmd/codeaf/storage_off_watches_test.go new file mode 100644 index 0000000000..d92ede04b8 --- /dev/null +++ b/cmd/codeaf/storage_off_watches_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "context" + "errors" + "io" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +func storageOffNoticeConfig(t *testing.T) session.Config { + t.Helper() + cfg, _ := spokenDoorChat(t) + svc := relayserve.New(relayserve.Config{Store: t.TempDir()}) + t.Cleanup(func() { _ = svc.Close() }) + srv := httptest.NewServer(svc.Handler) + t.Cleanup(srv.Close) + t.Setenv(syncsetup.URLVar, srv.URL) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + t.Cleanup(syncDrives.closeAll) + t.Cleanup(cellstore.StopSpawned) + return cfg +} + +// Item 4 exercises session retirement on retained in-process drives and ordinary engine cleanup. +func TestNoticeWatchesRetire(t *testing.T) { + for _, engine := range []bool{false, true} { + t.Run(map[bool]string{false: "in-process reopening", true: "engine cleanup"}[engine], func(t *testing.T) { + base := storageOffNoticeConfig(t) + for i := 0; i < 4; i++ { + cfg := v3Seated(base) + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + cfg.Seals.(*cellstore.SealWatch).OnTail = nil + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("reopen %d live watches=%d", i, got) + } + // Closing the real seated agent must release its subscription and pending captures. + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + // Clarification conversations inherit the parent's watch without owning it. + childCfg := cfg + childCfg.Seat, childCfg.Place, childCfg.SessionFile = nil, session.Place{}, "" + childCfg.Errand = true + child, err := session.New(childCfg) + if err != nil { + t.Fatal(err) + } + if err := child.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("child retired its parent's watch: %d", got) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + if engine { + if err := syncDrives.closeCell(cfg.Place.ID()); err != nil { + t.Fatal(err) + } + } + if got := storageOffWatchCount(d.notices); got != 0 { + t.Fatalf("closed session retained %d watches", got) + } + if engine && syncDrives.renewalsOf(cfg.Place.ID()) != nil { + t.Fatal("engine kept a closed drive") + } + } + }) + } +} + +func storageOffWatchCount(n *driveNotices) int { + n.mu.Lock() + defer n.mu.Unlock() + return len(n.watches) +} + +// Item 4 also retires a seated watch when construction fails before an agent exists. +func TestFailedConstructionRetiresNoticeWatch(t *testing.T) { + cfg := storageOffNoticeConfig(t) + _, seated, _, err := openV3Agent(cfg, cfg.Place.Workspace, func(session.Config) (*session.Agent, error) { return nil, errors.New("fixture construction failure") }) + // No agent owns this failed fixture's warm capture, so join it before removing its files. + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer cancel() + if err := executor.Drain(ctx, seated.Seat); err != nil { + t.Error(err) + } + }() + if err == nil { + t.Fatal("construction unexpectedly succeeded") + } + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + if got := storageOffWatchCount(d.notices); got != 0 { + t.Fatalf("failed construction retained %d watches", got) + } +} + +// Item 5 replays only an unresolved startup refusal, never announcements or recovered conditions. +func TestNoticeReplayIsCurrent(t *testing.T) { + for _, mode := range []string{"first run", "storage full recovers", "storage off remains"} { + t.Run(mode, func(t *testing.T) { + _ = storageOffNoticeConfig(t) + n := &driveNotices{} + if mode == "first run" { + n.say("first-run announcement") + } else { + entered := make(chan struct{}) + startErr := wireauth.ErrRelayFull + if mode == "storage off remains" { + startErr = wireauth.ErrStorageOff + } + l := newLiveDrive(func(func()) (*syncsetup.Drive, error) { <-entered; return nil, startErr }, n) + l.after = func(time.Duration, func()) func() bool { return func() bool { return true } } + close(entered) + startupFinished(l) + defer l.Close(t.Context()) + if mode == "storage full recovers" { + l.start = func() (*syncsetup.Drive, error) { return &syncsetup.Drive{}, nil } + l.retryAt = time.Time{} + l.try() + startupFinished(l) + // The empty fixture drive has no process to close. + l.drive = nil + } + } + watch := &cellstore.SealWatch{} + n.add(watch) + got := watch.Take() + want := "" + if mode == "storage off remains" { + want = chatlist.RelayStorageOff + } + if got != want || watch.Take() != "" { + t.Fatalf("replayed %q want %q, exactly once", got, want) + } + }) + } +} + +// Item 6 delivers a later notice to an idle in-process window through its bell, exactly once. +func TestIdleLocalWindowGetsNoticeAtOnce(t *testing.T) { + previousDesk := surfaceNotices + surfaceNotices = tui3.NewNotices() + defer func() { surfaceNotices = previousDesk }() + cfg := storageOffNoticeConfig(t) + cfg = v3Seated(cfg) + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + watch := cfg.Seals.(*cellstore.SealWatch) + watch.OnTail = nil + for watch.Take() != "" { + } + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + input, writer := io.Pipe() + defer input.Close() + defer writer.Close() + screen := &syncOffFrameCapture{} + seam := sealSeamOf(agent, cfg.Seals) + pushed := make(chan struct{}, 1) + // Observe the bell binding without requiring its new field in the before-fix tree. + listener := reflect.ValueOf(&seam).Elem().FieldByName("Listen") + if listener.IsValid() && !listener.IsNil() { + listen := listener.Interface().(func(func()) func()) + listener.Set(reflect.ValueOf(func(wake func()) func() { + return listen(func() { + select { + case pushed <- struct{}{}: + default: + } + wake() + }) + })) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, SessionFile: cfg.SessionFile, Workspace: cfg.Place.Workspace, Seal: seam, Notices: surfaceNotices, Input: input, Output: screen, Width: 200, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + deadline := time.Now().Add(3 * time.Second) + for screen.Plain() == "" && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + // The first frame has settled with no pending notice, key or three-second beat. + time.Sleep(50 * time.Millisecond) + select { + case <-pushed: + default: + } + line := "this chat moved to another computer" + d.notices.say(line) + select { + case <-pushed: + case <-time.After(500 * time.Millisecond): + t.Fatal("queued sentence did not ring the idle window's notice bell") + } + deadline = time.Now().Add(500 * time.Millisecond) + for !strings.Contains(screen.Plain(), line) && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + shown := strings.Join(strings.Fields(screen.Plain()), " ") + if !strings.Contains(shown, line) { + t.Fatalf("idle window missed push: %s", shown) + } + // Give an additional paint time to expose accidental duplicate consumers. + time.Sleep(100 * time.Millisecond) + shown = strings.Join(strings.Fields(screen.Plain()), " ") + if strings.Count(shown, line) != 1 || watch.Take() != "" { + t.Fatalf("idle sentence was not consumed exactly once: %s", shown) + } +} diff --git a/cmd/codeaf/storage_optin_test.go b/cmd/codeaf/storage_optin_test.go new file mode 100644 index 0000000000..8082f2c6ad --- /dev/null +++ b/cmd/codeaf/storage_optin_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestStorageOptInStatsWakesOpenChatAndRevokesLocally(t *testing.T) { + celltest.Available(t) + t.Setenv(home.EnvVar, t.TempDir()) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + var state atomic.Bool + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != "GET" || r.URL.Path != "/v1/store/stats" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + word := "off" + if state.Load() { + word = "on" + } + _, _ = w.Write([]byte(`{"storage":"` + word + `"}`)) + })) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + if _, err := startDrive(cell.Cell{ID: "open"}, cellstore.Engine{}, func(error) {}); !errors.Is(err, wireauth.ErrStorageOff) || requests.Load() != 0 { + t.Fatalf("off startup: %v requests=%d", err, requests.Load()) + } + old := syncDrives + var starts atomic.Int32 + l := &liveDrive{now: time.Now, changes: make(chan struct{}, 1), after: func(time.Duration, func()) func() bool { return func() bool { return true } }, start: func() (*syncsetup.Drive, error) { + starts.Add(1) + if syncsetup.StorageForHome(home.Dir()) != "on" { + return nil, wireauth.ErrStorageOff + } + return nil, nil + }} + syncDrives = &driveBook{drive: map[string]*liveDrive{"open": l}} + t.Cleanup(func() { syncDrives = old }) + l.try() + startupFinished(l) + if starts.Load() != 1 { + t.Fatal("local wait did not start") + } + state.Store(true) + counts, err := chatSyncStats(t.Context()) + if err != nil || counts.Storage != "on" { + t.Fatalf("approval refresh: %+v %v", counts, err) + } + startupFinished(l) + if starts.Load() != 2 || syncsetup.StorageForHome(home.Dir()) != "on" { + t.Fatal("approval did not wake open chat") + } + state.Store(false) + _ = syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off") + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + if syncsetup.StorageForHome(home.Dir()) != "off" || teamPaired() { + t.Fatal("revocation did not stop uploads") + } +} + +func TestStorageOptInSettingsOnlyPairedHosted(t *testing.T) { + dir := t.TempDir() + t.Setenv(syncsetup.URLVar, syncsetup.ProductionRelayURL) + id, err := identity.EnsureSolo(dir) + if err != nil { + t.Fatal(err) + } + door := newApprovalsDoor(dir) + if code, _ := door.RelayStorage(); code != "" { + t.Fatal("unpaired row present") + } + if err = identity.EndSolo(dir); err != nil { + t.Fatal(err) + } + if code, state := door.RelayStorage(); code != id.ID() || state != "off" { + t.Fatalf("hosted row: %s %s", code, state) + } + if err = syncsetup.SaveStorage(dir, syncsetup.ProductionRelayURL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + if _, state := door.RelayStorage(); state != "on" { + t.Fatal("approved row not on") + } + portHome := t.TempDir() + portID, err := identity.Ensure(portHome) + if err != nil { + t.Fatal(err) + } + portDoor := newApprovalsDoor(portHome) + for _, raw := range []string{syncsetup.ProductionRelayURL, syncsetup.StagingRelayURL, syncsetup.DevRelayURL} { + u, _ := url.Parse(raw) + u.Host = strings.ToUpper(u.Host) + ":443" + t.Setenv(syncsetup.URLVar, u.String()) + if code, state := portDoor.RelayStorage(); code != portID.ID() || state != "off" { + t.Fatalf("default-port row: %s %s %s", u, code, state) + } + } + + for _, relay := range []string{"http://own.test", "off"} { + t.Setenv(syncsetup.URLVar, relay) + if code, _ := door.RelayStorage(); code != "" { + t.Fatalf("inapplicable row present: %s", relay) + } + } +} + +func TestStorageOptInSettingsRelayAnswerDecides(t *testing.T) { + celltest.Available(t) + dir := t.TempDir() + t.Setenv(home.EnvVar, dir) + id, err := identity.Ensure(dir) + if err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + var absent atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + answer := `{"storage":"off"}` + if absent.Load() { + answer = `{"objects":0}` + } + _, _ = w.Write([]byte(answer)) + })) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + door := newApprovalsDoor(dir) + if code, _ := door.RelayStorage(); code != "" { + t.Fatal("unknown custom relay has row") + } + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + if code, state := door.RelayStorage(); code != id.ID() || state != "off" { + t.Fatalf("reported row: %s %s", code, state) + } + absent.Store(true) + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + if code, _ := door.RelayStorage(); code != "" { + t.Fatal("no-field answer retained row") + } + for range 3 { + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + } + if requests.Load() != 2 { + t.Fatalf("no-field requests: %d", requests.Load()) + } +} + +func TestStorageOptInQuitReceiptAfterRevocation(t *testing.T) { + armFinalSaving() + retainLocalDurability(func() bool { return true }) + before := os.Stdout + out, err := os.CreateTemp(t.TempDir(), "receipt") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stdout = out + t.Cleanup(func() { os.Stdout = before }) + recordFinalSaveFailure(syncwords.UploadFailure(context.DeadlineExceeded)) + recordFinalSaveFailure(syncwords.UploadFailure(wireauth.ErrStorageOff)) + if !finishFinalSaving(context.Background()) { + t.Fatal("local save changed exit status") + } + raw, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(raw)) != "saved here; sync storage is off for this account, so it stays on this computer" { + t.Fatalf("receipt: %s", raw) + } +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 8f56d4d5e2..9d414cb75a 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -10,9 +10,11 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // driveCloseWithin bounds the quit path's last publish and lease release, so a @@ -37,7 +39,7 @@ type driveBook struct { // A computer with no identity yet gets a drive side that starts by itself the // moment one is made (see liveDrive). A nil book answers nil, which is how a // door that never syncs asks. -func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(error)) *liveDrive { +func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(error), watches ...*cellstore.SealWatch) *liveDrive { if b == nil { return nil } @@ -48,6 +50,12 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er defer b.mu.Unlock() if d := b.drive[c.ID]; d != nil { if !d.superseded() { + if d.notices != nil { + if syncsetup.StorageForHome(home.Dir()) == "on" { + d.notices.clearStorageOff() + } + d.notices.register(watches...) + } return d } // Settle the displaced writer before the replacement reads its branch map @@ -64,7 +72,11 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er // back here) never drives again: the chat opened here needs a drive side of its own, or every // tool call in it would be refused as superseded. delete(b.drive, c.ID) - d := newLiveDrive(func(changed func()) (*syncsetup.Drive, error) { return startDrive(c, engine, report, changed) }) + notices := &driveNotices{} + notices.register(watches...) + d := newLiveDrive(func(changed func()) (*syncsetup.Drive, error) { + return startDriveNotifying(c, engine, report, notices.say, changed) + }, notices) d.transcript = (session.Place{Dir: c.Root}).Transcript() if d != nil { if b.drive == nil { @@ -78,6 +90,11 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er // startDrive opens the drive side of c. Local failures are reported here; // liveDrive owns refusal notices and waits across startup attempts. func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), changed ...func()) (*syncsetup.Drive, error) { + return startDriveNotifying(c, engine, report, surfaceNotices.Say, changed...) +} + +// startDriveNotifying keeps every drive sentence on the conversation's own notice road. +func startDriveNotifying(c cell.Cell, engine cellstore.Engine, report func(error), say func(string), changed ...func()) (*syncsetup.Drive, error) { s, ok, err := syncsetup.Open(home.Dir()) if errors.Is(err, syncsetup.ErrNoIdentity) { // Waiting for an identity is not a seal failing: the live drive starts by itself once one @@ -95,8 +112,11 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), change if syncsetup.Quiet(home.Dir()) { return nil, syncsetup.ErrQuiet // a fleet of one has nothing to sync with yet } + if !s.StorageAllowed() { + return nil, wireauth.ErrStorageOff + } if line, first := s.FirstRun(); first { - surfaceNotices.Say(line) + say(line) } var onChange func() if len(changed) > 0 { @@ -109,7 +129,7 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), change // list names the chat by it. Title: func() string { return chatTitle(c) }, // A line is said by the surface the moment it happens (tui3/notices.go). - OnNotice: surfaceNotices.Say, + OnNotice: say, }) if _, refused := cellsync.RefusalOf(err); refused { return nil, err @@ -228,10 +248,111 @@ func chatSyncStats(ctx context.Context) (blobstore.Counts, error) { if err != nil { return blobstore.Counts{}, err } - if store, ok := s.Store.(interface { - Stats(context.Context) (blobstore.Counts, error) - }); ok { - return store.Stats(ctx) + if !syncsetup.StorageNeedsRefresh(s.Home, s.Relay, s.Identity.ID()) { + return syncsetup.StorageCounts(s.Home, s.Relay, s.Identity.ID()), nil + } + before := s.StorageAllowed() + counts, err := s.RefreshStorage(ctx) + if err == nil && !before && s.StorageAllowed() { + syncDrives.storageOpened() + } + if syncsetup.StorageGated(s.Home, s.Relay, s.Identity.ID()) && counts.Storage != "on" { + counts.Storage = "off" + } + return counts, err +} + +// storageOpened wakes both running batchers and local startup waits for open chats. +func (b *driveBook) storageOpened() { + if b == nil { + return + } + for _, d := range b.openDrives() { + d.storageOpened() + } +} + +// openDrives copies the open drives under the lock, so waking them happens +// outside it. +func (b *driveBook) openDrives() []*liveDrive { + b.mu.Lock() + defer b.mu.Unlock() + drives := make([]*liveDrive, 0, len(b.drive)) + for _, d := range b.drive { + drives = append(drives, d) + } + return drives +} + +// driveNotices fans out to each seat sharing a writer, including an idle engine seat. +// Only a current startup refusal is replayed while no drive is running. +type driveNotices struct { + mu sync.Mutex + last string + watches map[*cellstore.SealWatch]bool +} + +func (n *driveNotices) register(watches ...*cellstore.SealWatch) { + for _, watch := range watches { + if watch != nil { + watch.OnRetire = n.add(watch) + } + } +} + +func (n *driveNotices) add(watches ...*cellstore.SealWatch) func() { + n.mu.Lock() + defer n.mu.Unlock() + if n.watches == nil { + n.watches = make(map[*cellstore.SealWatch]bool) + } + for _, watch := range watches { + if watch == nil || n.watches[watch] { + continue + } + n.watches[watch] = true + if n.last != "" { + watch.Note(n.last) + } + } + return func() { + n.mu.Lock() + defer n.mu.Unlock() + for _, watch := range watches { + delete(n.watches, watch) + } + } +} + +// refuse caches only startup conditions, never first-run or running-drive announcements. +func (n *driveNotices) refuse(line string) { + n.mu.Lock() + defer n.mu.Unlock() + n.last = line + for watch := range n.watches { + watch.Note(line) + } +} + +func (n *driveNotices) clear() { + n.mu.Lock() + defer n.mu.Unlock() + n.last = "" +} + +func (n *driveNotices) say(line string) { + n.mu.Lock() + defer n.mu.Unlock() + for watch := range n.watches { + watch.Note(line) + } +} + +// clearStorageOff keeps a later seat from replaying a refusal that approval ended. +func (n *driveNotices) clearStorageOff() { + n.mu.Lock() + defer n.mu.Unlock() + if n.last == chatlist.RelayStorageOff { + n.last = "" } - return blobstore.Counts{}, nil } diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 352c3304e9..3e1eee38e0 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -13,6 +13,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/syncsetup" "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // liveDrive is the drive side of one chat, started when it can be. A chat opens @@ -23,35 +24,41 @@ import ( // that time or capacity can cure also arms a timer, so pending turns upload even // when the person leaves the chat open without saving another turn. type liveDrive struct { - start func() (*syncsetup.Drive, error) - now func() time.Time - after func(time.Duration, func()) func() bool - say func(string) + start func() (*syncsetup.Drive, error) + now func() time.Time + after func(time.Duration, func()) func() bool + say func(string) + notices *driveNotices // transcript identifies the retained turns if this writer's final publish fails. transcript string - mu sync.Mutex - drive *syncsetup.Drive - waiting bool // local setup or a retryable failure still permits another attempt - ready chan struct{} - attempted bool - closed bool - retryAt time.Time - retryWait time.Duration - stopRetry func() bool - refusedSince time.Time - told error + mu sync.Mutex + drive *syncsetup.Drive + storageWaiting bool + waiting bool // local setup or a retryable failure still permits another attempt + ready chan struct{} + attempted bool + closed bool + retryAt time.Time + retryWait time.Duration + stopRetry func() bool + refusedSince time.Time + told error // changes coalesces local health updates for the host's cached surface state. changes chan struct{} } // newLiveDrive starts the drive side behind the first paint. Local startup // screening stays off the surface loop; saving and new tools wait for ownership. -func newLiveDrive(start func(func()) (*syncsetup.Drive, error)) *liveDrive { +func newLiveDrive(start func(func()) (*syncsetup.Drive, error), notices ...*driveNotices) *liveDrive { l := &liveDrive{ changes: make(chan struct{}, 1), now: time.Now, say: surfaceNotices.SayHomeOnce, after: func(d time.Duration, fn func()) func() bool { return time.AfterFunc(d, fn).Stop }, } + if len(notices) > 0 { + l.notices = notices[0] + l.say = l.notices.refuse + } l.start = func() (*syncsetup.Drive, error) { return start(l.renewalChanged) } l.try() return l @@ -63,7 +70,7 @@ func newLiveDrive(start func(func()) (*syncsetup.Drive, error)) *liveDrive { func (l *liveDrive) try() { l.mu.Lock() defer l.mu.Unlock() - if l.closed || l.drive != nil || l.ready != nil || (l.attempted && !l.waiting) || (l.waiting && !syncsetup.MayTalk(home.Dir())) { + if l.closed || l.storageWaiting || l.drive != nil || l.ready != nil || (l.attempted && !l.waiting) || (l.waiting && !syncsetup.MayTalk(home.Dir())) { return } // Seals and idle notifications cannot shorten the relay's requested wait. @@ -116,11 +123,22 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) if wait, retry := r.StartupWait(l.retryWait, cellsync.DefaultInterval, err); retry { l.waiting = true l.retryWait, l.retryAt = wait, now.Add(wait) - l.stopRetry = l.after(wait, l.try) + if errors.Is(err, wireauth.ErrStorageOff) { + l.storageWaiting = true + cancel := syncsetup.WaitStorage(home.Dir(), l.storageOpened) + l.stopRetry = func() bool { cancel(); return true } + } else { + l.stopRetry = l.after(wait, l.try) + } } } } if d != nil { + if l.notices != nil { + l.notices.clear() + } + l.told = nil + l.refusedSince = time.Time{} d.CatchUp() } l.renewalChanged() @@ -321,3 +339,29 @@ func (l *liveDrive) catchUpAfter(used *syncsetup.Drive) { l.drive.CatchUp() } } + +// storageOpened shortens only a capacity wait; it never replaces an active writer. +func (l *liveDrive) storageOpened() { + if l.notices != nil { + l.notices.clearStorageOff() + } + if d := l.endStorageWait(); d != nil { + d.StorageOpened() + } else { + l.try() + } +} + +// endStorageWait clears the storage wait and its retry timer under the lock and +// answers the running drive, if there is one, so waking it happens outside. +func (l *liveDrive) endStorageWait() *syncsetup.Drive { + l.mu.Lock() + defer l.mu.Unlock() + l.storageWaiting = false + if l.stopRetry != nil { + l.stopRetry() + l.stopRetry = nil + } + l.retryAt = time.Time{} + return l.drive +} diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 84dd806c0c..5049a65dc8 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -184,7 +184,8 @@ func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offli return nil, 0, false, directory.ErrUnreachable } rows, version, err := s.RowsForSend(ctx) - return rows, version, false, err + // Storage-off chats save locally without an uploading writer, even after their hold expires. + return rows, version, !s.StorageAllowed(), err } func (s surfaceMachines) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { @@ -318,7 +319,7 @@ func (t takeover) OpenBranch(ctx context.Context, id string) (tui3.Taken, error) } func surfaceTaken(got syncsetup.Continued, elapsed time.Duration) tui3.Taken { - return tui3.Taken{Fence: got.Taken.Driving.Fence, Head: got.Taken.Driving.Head, AlreadyHere: got.AlreadyHere, Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} + return tui3.Taken{Fence: got.Taken.Driving.Fence, Head: got.Taken.Driving.Head, AlreadyHere: got.AlreadyHere, LocalOnly: got.LocalOnly, Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} } // takeRootFor is where this machine keeps the chat with an id: the folder it diff --git a/cmd/codeaf/team_resume.go b/cmd/codeaf/team_resume.go index 12d71ea36f..5bf09605bd 100644 --- a/cmd/codeaf/team_resume.go +++ b/cmd/codeaf/team_resume.go @@ -92,4 +92,6 @@ func teamComputers(ctx context.Context, members []teams.Member) (map[string]stri } // teamPaired supplies the shared quiet gate to packages below relay setup. -func teamPaired() bool { return syncsetup.MayTalk(home.Dir()) } +func teamPaired() bool { + return syncsetup.MayTalk(home.Dir()) && syncsetup.StorageForHome(home.Dir()) == "on" +} diff --git a/docs/changes/unreleased/2509-relay-storage-opt-in.md b/docs/changes/unreleased/2509-relay-storage-opt-in.md new file mode 100644 index 0000000000..191a31f7fa --- /dev/null +++ b/docs/changes/unreleased/2509-relay-storage-opt-in.md @@ -0,0 +1,21 @@ +--- +kind: changed +title: Hosted sync storage is off until an account is approved +pr: 2509 +surface: [chat, engine, remote, docs] +invalidates: + - "Pairing two computers through the hosted relay made it store chats, files, saved keys, memories, teams and task copies automatically; it now stores nothing for an account until that account is approved, while pairing, the device list, presence and live --at keep working." + - "Every hosted relay write was accepted up to the account's 5 GiB limit; a write from an account that is not approved is now refused with 403 storage_off before anything is stored, and frames stored before this change stay readable." + - "There was no way to grant or withdraw hosted storage per account; codeaf-relay-admin approve and revoke send an operator-signed request to POST /v1/admin/storage, checked against CAF_ADMIN_KEYS, and CAF_LIMITS storageOpen opens storage for everyone." + - "Rotating an identity needed nothing from the hosted relay beyond the freeze; the freeze bytes are unchanged, and a relay that reports storage also takes a separate POST /v1/store/handoff from the freezing device so an approved account keeps storage." + - "On the default engine road, sync refusal sentences for storage full, removed, replaced, clock skew and displacement stayed in the engine process and never reached the window; they now reach every attached window of that chat." + - "Local turns under this device's own expired hold with an empty recorded head opened a viewer on reopening and asked to continue on the next Enter; both now quietly reacquire the chat, whether storage is on or off, and publish those turns once storage is on." +--- +The privacy terms for the hosted relay are not finished, so until they are it +keeps nobody's chats or files. A person asks for storage from Settings → +Devices → sync storage, which opens a pre-filled GitHub issue carrying their +request code, and an operator approves the account with `codeaf-relay-admin`. +The app learns of the approval by itself within about a minute of its next check +and starts uploading without a restart. While storage is off, `/status` says +`sync storage off`, chats keep saving on the computer, and the app makes no store +writes. A self-hosted relay is unaffected and needs no request. diff --git a/internal/blobstore/http.go b/internal/blobstore/http.go index 352bcab162..22c991c3ff 100644 --- a/internal/blobstore/http.go +++ b/internal/blobstore/http.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,9 +16,13 @@ import ( // HTTP is a Store that lives behind the store wire (contract §3). Every // request is signed, so the far side knows whose namespace it is. type HTTP struct { - base string - sign wireauth.Sign - hc *http.Client + // CanStore screens uploads locally while leaving the read wire available. + CanStore func() bool + // OnStorageOff remembers a refusal immediately for other local uploaders. + OnStorageOff func() + base string + sign wireauth.Sign + hc *http.Client // deadline sizes each request's time limit from its body; a test swaps it // to make a modest frame time out. @@ -35,8 +40,14 @@ func NewHTTP(base string, sign wireauth.Sign, hc *http.Client) *HTTP { // PutFrame implements Store. func (c *HTTP) PutFrame(ctx context.Context, frame []byte) (FrameID, error) { + if c.CanStore != nil && !c.CanStore() { + return "", wireauth.ErrStorageOff + } var out putAnswer if err := c.doJSON(ctx, http.MethodPost, pathFrames, frame, &out); err != nil { + if errors.Is(err, wireauth.ErrStorageOff) && c.OnStorageOff != nil { + c.OnStorageOff() + } return "", err } return out.Frame, nil @@ -139,6 +150,7 @@ func (c *HTTP) Stats(ctx context.Context) (Counts, error) { // Counts holds request counters and optional stored totals for one identity. // Nil totals distinguish an older or self-hosted relay from a reported zero. type Counts struct { + Storage string Puts, Gets, Has, BytesIn, BytesOut int64 StoredBytes, StoredObjects, FramesToday *int64 LimitBytes, LimitObjects, LimitFramesPerDay *int64 diff --git a/internal/blobstore/storage_optin_test.go b/internal/blobstore/storage_optin_test.go new file mode 100644 index 0000000000..3300e34e74 --- /dev/null +++ b/internal/blobstore/storage_optin_test.go @@ -0,0 +1,53 @@ +package blobstore + +import ( + "context" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/wireauth" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStorageOptInWireAndNoWriteRequest(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path == pathStats { + _, _ = w.Write([]byte(`{"storage":"off"}`)) + return + } + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"err":"storage_off"}`)) + })) + defer srv.Close() + c := NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client()) + if _, err := c.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("wire refusal: %v", err) + } + c.CanStore = func() bool { return false } + if _, err := c.PutFrame(context.Background(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 1 { + t.Fatalf("local gate: calls=%d err=%v", calls, err) + } + counts, err := c.Stats(t.Context()) + if err != nil || counts.Storage != "off" || calls != 2 { + t.Fatalf("stats while off: %+v %v", counts, err) + } + if errors.Is(wireauth.ErrStorageOff, ErrFull) || errors.Is(wireauth.ErrStorageOff, wireauth.ErrRelayFull) { + t.Fatal("off must remain distinct from full") + } +} + +func TestStorageOptInSelfHostedStatsOmitStorage(t *testing.T) { + h := Handler(func(*http.Request, []byte) (string, string, error) { return "id", "device", nil }, func(string) (Store, error) { return NewMemory(), nil }) + r := httptest.NewRecorder() + h.ServeHTTP(r, httptest.NewRequest("GET", pathStats, nil)) + var body map[string]any + if err := json.Unmarshal(r.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if _, present := body["storage"]; present { + t.Fatal("self-hosted stats sent storage state") + } +} diff --git a/internal/blobstore/wire.go b/internal/blobstore/wire.go index 6044fca6d5..b3389adf68 100644 --- a/internal/blobstore/wire.go +++ b/internal/blobstore/wire.go @@ -16,6 +16,7 @@ var wireErrors = []struct { status int err error }{ + {"storage_off", http.StatusForbidden, wireauth.ErrStorageOff}, {"bad_frame", http.StatusBadRequest, ErrBadFrame}, {"bad_rid", http.StatusBadRequest, ErrBadRID}, {"too_many", http.StatusBadRequest, ErrTooMany}, @@ -100,6 +101,7 @@ func LimitOf(err error) int64 { // statsBody is the answer to GET /v1/store/stats. type statsBody struct { + Storage string `json:"storage,omitempty"` Puts int64 `json:"puts"` Gets int64 `json:"gets"` Has int64 `json:"has"` diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index a2e22909a0..8b82d52860 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -36,6 +36,13 @@ type SealWatch struct { // Say, when set, receives each sentence as it happens. Say func(string) + // OnRetire releases seat-owned subscriptions after the session stops. + OnRetire func() + retired sync.Once + stopped bool + owners int + wake func() + listener uint64 // OnTurnEnd, when set, hears each time the agent finishes a turn. The door // sets it to the sync side's idle call. OnTurnEnd func() @@ -113,6 +120,77 @@ func (w *SealWatch) say(sentence string) { return } w.said = append(w.said, sentence) + if w.wake != nil { + w.wake() + } +} + +// Listen wakes a local surface without giving its sentences a second consuming road. +func (w *SealWatch) Listen(wake func()) func() { + w.mu.Lock() + defer w.mu.Unlock() + w.listener++ + serial := w.listener + w.wake = wake + if len(w.said) > 0 && wake != nil { + wake() + } + return func() { + w.mu.Lock() + defer w.mu.Unlock() + if w.listener == serial { + w.wake = nil + } + } +} + +// Adopt keeps subscriptions alive while a conversation still uses this watch. +func (w *SealWatch) Adopt() func() { + w.mu.Lock() + defer w.mu.Unlock() + if w.stopped { + return func() {} + } + w.owners++ + var released sync.Once + return func() { + released.Do(func() { + last := func() bool { + w.mu.Lock() + defer w.mu.Unlock() + w.owners-- + if w.owners != 0 { + return false + } + // Mark the last release before unlocking so a retired watch cannot be adopted again. + w.stopped = true + return true + }() + if last { + w.Retire() + } + }) + } +} + +// Retire ends this seat's sentence subscriptions once no conversation uses it. +// +// RETIRING NEVER TOUCHES THE TRAILING SEAL. The tail belongs to the writes, not +// to the subscriptions: a conversation still finishing its journal, or a Home +// errand opened later on the same seat, must seal what it wrote exactly as it +// did before retirement existed. Only the delivery of sentences ends here. +func (w *SealWatch) Retire() { + w.retired.Do(func() { + func() { + w.mu.Lock() + defer w.mu.Unlock() + w.stopped = true + w.wake = nil + }() + if w.OnRetire != nil { + w.OnRetire() + } + }) } // Waiting arms a newly constructed chat that is waiting for its first input. @@ -166,10 +244,13 @@ func (w *SealWatch) stopTail() { } func (w *SealWatch) sealTail() { - w.mu.Lock() - owed := w.idle && w.tail != nil - w.tail = nil - w.mu.Unlock() + owed := func() bool { + w.mu.Lock() + defer w.mu.Unlock() + owed := w.idle && w.tail != nil + w.tail = nil + return owed + }() if owed { w.OnTail() } diff --git a/internal/cellstore/sealwatch_test.go b/internal/cellstore/sealwatch_test.go index 3c731449d8..53bdb28e39 100644 --- a/internal/cellstore/sealwatch_test.go +++ b/internal/cellstore/sealwatch_test.go @@ -267,3 +267,18 @@ func TestSealWatchStartupTurnStillWaitsForPublish(t *testing.T) { t.Fatal("published startup turn did not recover") } } + +// A retired watch still seals what is written while the agent waits: retiring +// ends the delivery of sentences, never the trailing seal a later write is owed. +func TestARetiredSealWatchStillSealsALaterWrite(t *testing.T) { + sealed := make(chan struct{}, 1) + w := &SealWatch{Quiet: time.Millisecond, OnTail: func() { sealed <- struct{}{} }} + w.Waiting() + w.Retire() + w.Wrote() + select { + case <-sealed: + case <-time.After(5 * time.Second): + t.Fatal("a write after retirement was never sealed") + } +} diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index fae34273ff..7466d1ccbe 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -500,7 +500,7 @@ func (b *Batcher) publish(ctx context.Context, head string) error { b.adopt(d) b.setFlag(&b.published, true) b.tell(0) // a publish always carries the newest turn, so nothing is pending - b.recovered() + b.recovered(true) turns := b.durable(head) b.emit(Flush{Head: head, Turns: turns, Frames: len(ex.Frames), Objects: ex.Objects, Bytes: ex.Bytes}) return nil @@ -520,7 +520,7 @@ func (b *Batcher) beat(ctx context.Context) { switch { case err == nil: b.tell(pending) - b.recovered() + b.recovered(false) case errors.Is(err, directory.ErrFenceStale): b.lost(ctx, d) default: @@ -716,10 +716,14 @@ func (b *Batcher) now() time.Time { return time.Now() } -// recovered notes that the relay answered, so the next refusal is news again. -func (b *Batcher) recovered() { +// A directory heartbeat cannot end an upload-permission refusal episode. +// Only a successful upload proves that storage has recovered. +func (b *Batcher) recovered(upload bool) { b.mu.Lock() defer b.mu.Unlock() + if !upload && b.told == wireauth.ErrStorageOff.Error() { + return + } b.refusedSince, b.told = time.Time{}, "" } diff --git a/internal/cellsync/refusal.go b/internal/cellsync/refusal.go index f4975f6c22..4321e5d9f8 100644 --- a/internal/cellsync/refusal.go +++ b/internal/cellsync/refusal.go @@ -31,6 +31,7 @@ type Refusal struct { // within minutes stays quiet for a few of them, because a burst that ended // before anyone could act is noise; a longer refusal is said at once. var refusals = []Refusal{ + {Is: wireauth.ErrStorageOff, Say: said(chatlist.RelayStorageOff), Halts: true, RetryStartup: true}, // Both decoders preserve a general full match, so shared capacity must come first. {Is: wireauth.ErrRelayFull, Say: said(chatlist.RelayStorageFull), Halts: true, RetryStartup: true}, {Is: blobstore.ErrFull, Say: fullLine, Halts: true, RetryStartup: true}, @@ -73,15 +74,19 @@ func (r Refusal) StartupWait(prev, interval time.Duration, err error) (time.Dura } // next is the wait after a failed flush that followed a wait of prev. The relay's -// own Retry-After is a floor on any wait, and a halt outranks every other. +// own Retry-After is a floor except for storage permission, whose local gate +// must retry within a minute. Capacity halts outrank other waits. func (r Refusal) next(prev, interval time.Duration, err error) time.Duration { wait := min(2*max(prev, interval), max(MaxBackoff, interval)) if r.Flat { wait = interval } wait = max(wait, min(wireauth.After(err), MaxHalt)) - if r.Halts { + if r.Halts && !errors.Is(err, wireauth.ErrStorageOff) { wait = MaxHalt } + if errors.Is(err, wireauth.ErrStorageOff) { + wait = min(wait, MaxBackoff) + } return wait } diff --git a/internal/cellsync/storage_optin_test.go b/internal/cellsync/storage_optin_test.go new file mode 100644 index 0000000000..3375d3d8c6 --- /dev/null +++ b/internal/cellsync/storage_optin_test.go @@ -0,0 +1,66 @@ +package cellsync + +import ( + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/wireauth" + "testing" + "time" +) + +func TestStorageOptInRefusalHaltsAndSpeaksOnce(t *testing.T) { + r, ok := RefusalOf(wireauth.ErrStorageOff) + if !ok || !r.Halts || !r.RetryStartup || r.Say(nil) != chatlist.RelayStorageOff { + t.Fatalf("storage row: %+v", r) + } + if wait, retry := r.StartupWait(0, time.Second, wireauth.ErrStorageOff); !retry || wait > MaxBackoff { + t.Fatalf("startup wait: %v %v", wait, retry) + } + b := &Batcher{} + if !b.news(r) || b.news(r) { + t.Fatal("refusal repeated within one episode") + } + b.recovered(true) + if !b.news(r) { + t.Fatal("new episode lost") + } +} + +func TestStorageOptInMissedOffOnStillPublishesWithinCap(t *testing.T) { + r := newRig(t) + b := r.batcher() + head := r.seal(map[string]string{"a": "pending"}) + note(b, head) + r.store.set(wireauth.ErrStorageOff, nil) + wait := b.flush(t.Context()) + if wait > MaxBackoff { + t.Fatalf("storage-off wait %v exceeds %v", wait, MaxBackoff) + } + // Another process saves off and on between the waiter's samples. No edge + // notification is delivered to this batcher; its ordinary retry must suffice. + r.store.set(nil, nil) + if b.flush(t.Context()) != 0 || b.Pending() != 0 || r.head(cellID).Head != head { + t.Fatal("pending publication never recovered") + } +} + +func TestStorageOptInRetriesStayQuietAcrossSuccessfulHeartbeats(t *testing.T) { + r := newRig(t) + b := r.batcher() + notices := 0 + b.OnError = func(error) { notices++ } + note(b, r.seal(map[string]string{"a": "pending"})) + r.store.set(wireauth.ErrStorageOff, nil) + for i := 0; i < 5; i++ { + if wait := b.flush(t.Context()); wait > MaxBackoff { + t.Fatalf("wait %v", wait) + } + b.beat(t.Context()) + } + if notices != 1 { + t.Fatalf("refusal said %d times", notices) + } + row, _ := RefusalOf(wireauth.ErrStorageOff) + if wait, _ := row.StartupWait(0, 2*time.Minute, wireauth.ErrStorageOff); wait > MaxBackoff { + t.Fatalf("custom interval bypassed cap: %v", wait) + } +} diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index f89ffcf6c5..8a11e9f99c 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -15,16 +15,17 @@ const ( OfferContinue = "continue here" // MoveHere is the verb for a chat another device is running right now, and // ContinueVerb for one it let go of; the devices row offers them by name. - MoveHere = "Move here" - ContinueVerb = "Continue here" - LostRace = "another device continued this chat first" - NoIdentity = "this machine has no identity yet: codeaf identity import" - SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync service's address" - Unreachable = "the sync service is not answering" - ReadTimedOut = "the sync service did not answer in time" - SyncPaused = syncwords.UnknownOutage - SyncRestored = "this chat is syncing again" - ClockOff = "this computer's clock is off by more than 5 minutes" + MoveHere = "Move here" + ContinueVerb = "Continue here" + LostRace = "another device continued this chat first" + NoIdentity = "this machine has no identity yet: codeaf identity import" + RelayStorageOff = "sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices" + SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync service's address" + Unreachable = "the sync service is not answering" + ReadTimedOut = "the sync service did not answer in time" + SyncPaused = syncwords.UnknownOutage + SyncRestored = "this chat is syncing again" + ClockOff = "this computer's clock is off by more than 5 minutes" // What the relay's refusals say (one sentence each, in the order the // cellsync table lists them). None names a code or a number of requests: // each says what is true for the person and what, if anything, to do. diff --git a/internal/directory/rotation.go b/internal/directory/rotation.go index 07778b4b0f..0f2cc64e35 100644 --- a/internal/directory/rotation.go +++ b/internal/directory/rotation.go @@ -2,10 +2,14 @@ package directory import ( "context" + "encoding/hex" "errors" + "net/http" "time" + "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/wireauth" ) @@ -136,11 +140,54 @@ func retire(cur *Rotation, device string, req RotationReq, now int64, b GraceBou } // Gate is a Client's rotation calls as the three moves a rotation makes. -type Gate struct{ Client Client } +type Gate struct { + Client Client + // Store discovers the hosted handoff route without changing the rotation wire. + Store interface { + Stats(context.Context) (blobstore.Counts, error) + } + Source string +} // Freeze stops every write to the identity. func (g Gate) Freeze(ctx context.Context) error { return g.move(ctx, RotationReq{Op: OpFreeze}) } +// HandoffTo carries hosted storage permission after freezing and before uploads. +// It reports whether a binding was accepted; relays without the stats field +// receive no additional request and need no saved successor after cancellation. +func (g Gate) HandoffTo(ctx context.Context, successor identity.Identity) (bool, error) { + if g.Store == nil { + return false, nil + } + counts, err := g.Store.Stats(ctx) + if err != nil { + return false, err + } + if counts.Storage == "" { + return false, nil + } + client, ok := g.Client.(interface { + HandoffStorage(context.Context, string, identity.Identity) error + }) + if !ok { + return false, ErrTooOld + } + err = client.HandoffStorage(ctx, g.Source, successor) + return err == nil, err +} + +// HandoffStorage signs consent with the successor root; the HTTP request is +// still signed by a device of the source, just like other store requests. +func (h *HTTP) HandoffStorage(ctx context.Context, source string, successor identity.Identity) error { + proof := successor.Sign([]byte("codeaf/storage-handoff/v1\n" + source + "\n" + successor.ID())) + request := struct { + Successor string `json:"successor"` + SuccessorKey string `json:"successor_key"` + Proof string `json:"proof"` + }{successor.ID(), hex.EncodeToString(successor.PublicKey()), hex.EncodeToString(proof)} + return h.do(ctx, http.MethodPost, "/v1/store/handoff", request, nil) +} + // Thaw undoes a freeze. func (g Gate) Thaw(ctx context.Context) error { return g.move(ctx, RotationReq{Op: OpThaw}) } diff --git a/internal/directory/storage_handoff_test.go b/internal/directory/storage_handoff_test.go new file mode 100644 index 0000000000..8f91c25531 --- /dev/null +++ b/internal/directory/storage_handoff_test.go @@ -0,0 +1,107 @@ +package directory + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +func TestStorageOptInFreezeNegotiatesExactLegacyBytes(t *testing.T) { + id, err := identity.Mint() + if err != nil { + t.Fatal(err) + } + dev, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + next, err := identity.Mint() + if err != nil { + t.Fatal(err) + } + for _, state := range []string{"", "on", "off"} { + t.Run("state="+state, func(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.Path) + raw, _ := io.ReadAll(r.Body) + if _, _, err := reqsign.AuthenticateAt(time.Now)(r, raw); err != nil { + t.Errorf("device signature: %v", err) + } + switch r.URL.Path { + case "/fabric/v1/store/stats": + json.NewEncoder(w).Encode(blobstore.Counts{Storage: state}) + case "/fabric/v1/identity/rotation": + if string(raw) != `{"op":"freeze"}` { + t.Errorf("changed origin/dev freeze bytes: %q", raw) + } + // The restored production decoder rejects unknown rotation fields. + cert, _ := json.Marshal(dev.Cert) + proof := clusterstate.V1Request{Method: "POST", URI: "/v1/identity/rotation", At: "1000", IdentityKey: id.PublicKey(), Certificate: base64.RawURLEncoding.EncodeToString(cert), Body: raw} + proof.Signature = dev.Sign(clusterstate.V1SigningMessage(proof.Method, proof.URI, proof.At, proof.Body)) + if _, err := clusterstate.V1Command("test", 1000, proof); err != nil { + t.Errorf("origin/dev decoder: %v", err) + } + w.Write([]byte(`{}`)) + case "/fabric/v1/store/handoff": + var fields map[string]string + if err := json.Unmarshal(raw, &fields); err != nil { + t.Error(err) + return + } + pub, _ := hex.DecodeString(fields["successor_key"]) + sig, _ := hex.DecodeString(fields["proof"]) + message := []byte("codeaf/storage-handoff/v1\n" + id.ID() + "\n" + next.ID()) + if len(fields) != 3 || fields["successor"] != next.ID() || !reflect.DeepEqual(pub, []byte(next.PublicKey())) || !ed25519.Verify(next.PublicKey(), message, sig) { + t.Errorf("bad handoff: %s", raw) + } + w.Write([]byte(`{"successor":"` + next.ID() + `","storage":"on"}`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + defer server.Close() + sign := reqsign.SignFor(storageHandoffSigner{id, dev}, time.Now) + gate := Gate{Client: NewHTTP(server.URL+"/fabric", sign, server.Client()), Store: blobstore.NewHTTP(server.URL+"/fabric", sign, server.Client()), Source: id.ID()} + if err := gate.Freeze(context.Background()); err != nil { + t.Fatal(err) + } + accepted, err := gate.HandoffTo(context.Background(), next) + if err != nil { + t.Fatal(err) + } + if accepted != (state != "") { + t.Fatalf("handoff accepted=%v with storage=%q", accepted, state) + } + want := []string{"/fabric/v1/identity/rotation", "/fabric/v1/store/stats"} + if state != "" { + want = append(want, "/fabric/v1/store/handoff") + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls %v, want %v", calls, want) + } + }) + } +} + +type storageHandoffSigner struct { + id identity.Identity + dev identity.Dev +} + +func (s storageHandoffSigner) IdentityKey() ed25519.PublicKey { return s.id.PublicKey() } +func (s storageHandoffSigner) Cert() identity.Cert { return s.dev.Cert } +func (s storageHandoffSigner) Sign(msg []byte) []byte { return s.dev.Sign(msg) } diff --git a/internal/directory/storage_optin_test.go b/internal/directory/storage_optin_test.go new file mode 100644 index 0000000000..29fc250443 --- /dev/null +++ b/internal/directory/storage_optin_test.go @@ -0,0 +1,21 @@ +package directory + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/wireauth" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStorageOptInDirectoryWire(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"err":"storage_off"}`)) + })) + defer srv.Close() + _, err := NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client()).List(t.Context()) + if !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("directory refusal: %v", err) + } +} diff --git a/internal/directory/wire.go b/internal/directory/wire.go index 1af1314db5..8736c41b6d 100644 --- a/internal/directory/wire.go +++ b/internal/directory/wire.go @@ -19,6 +19,7 @@ type wireError struct { // wireErrors is the single place a sentinel and its code meet, so the handler // and the client cannot drift apart. Order matters only for errors.Is chains. var wireErrors = []wireError{ + {wireauth.ErrStorageOff, "storage_off", http.StatusForbidden}, {ErrUnreachable, "unreachable", http.StatusServiceUnavailable}, {wireauth.ErrUnavailable, "unreachable", http.StatusServiceUnavailable}, {clusterstate.ErrMigration, "unreachable", http.StatusServiceUnavailable}, diff --git a/internal/manual/chat/a-dev-box-on-your-own-railway.md b/internal/manual/chat/a-dev-box-on-your-own-railway.md index 1c589fea71..63a95c4458 100644 --- a/internal/manual/chat/a-dev-box-on-your-own-railway.md +++ b/internal/manual/chat/a-dev-box-on-your-own-railway.md @@ -60,6 +60,8 @@ removal of the one-time setup values. Then open `codeaf chat --at build-box`. ## How saved provider keys, settings and shared memories reach the box +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + The box starts `codeaf serve` with `CODEAF_SERVE_SYNC=1`, so it pulls the identity's saved provider keys, settings and shared memories at startup and every five seconds by default, the same interval an open terminal uses; without that variable `codeaf serve` @@ -150,6 +152,8 @@ to admit another computer yet; use the creating laptop. ## Send a task to my dev box +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + A dev box also takes tasks. It starts `codeaf serve --take-tasks` whenever its installed build lists that option, so a chat on your laptop can run one task on the box while the conversation and transcript stay on the laptop: diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index a6d2aa9c7e..9001d56a55 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -2,6 +2,8 @@ ## How do I add another computer — the Add another device card (add another machine), codeaf pair, a link to paste, and pairing a server with no browser +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Older chats move through an independent copy. The original files stay untouched and remain usable in older codeaf builds. @@ -39,6 +41,8 @@ The card goes away once you have two devices. After that, `/pair` or Settings ## Do older chats move after pairing? +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. ## Pairing a server with no browser — the link, token and check number @@ -101,7 +105,7 @@ On the approving screen the number reads `Check number 4821. Approve only if the 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. -When you open a link on a device that is already in (paste it, or type `/pair `), the screen says who is asking: +On an already paired device, paste a link or type `/pair ` to see who is asking: ``` A new device wants to pair. @@ -215,6 +219,8 @@ this device was removed by another of your devices, so this chat stays here only ## Can a removed computer rejoin — /devices, pairing again and retained local chats +Pairing again restores device access; sending new chats, keys or shared state through the hosted sync service needs sync storage approval (Settings → Devices → sync storage). + `/devices` on a removed computer says the same line in place of the list. A terminal refusal uses `this device was removed by another of your devices, so this chat stays here only — run codeaf pair to bring it back`. Home says `removed by another of your devices — pair again to sync`. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. It appears the moment the other device removes this one, even if codeaf has been open for days, in the open chat and on the home screen alike, and once only. Your chats on this device are safe and stay on it. They no longer follow your other devices. To bring the device back, run `codeaf pair` here, then approve the link it shows from a device that is still in (the steps above). If this computer has chats with submitted turns, run `codeaf pair --replace` instead. Its existing chats stay on this computer but no longer sync; shared memories and settings are cleared. Approve the new link from a computer still in your devices. A computer that has only opened codeaf, even with a provider connected, and whose chats contain only startup headers needs no `--replace`; saved knowledge, automations, tracked work and unknown local files still require the explicit replacement road. An active, already-paired computer is refused with the names of its other devices; `codeaf devices` lists them. ## Pair again in the chat — replace confirmation keeps local chats @@ -229,7 +235,7 @@ chats no longer sync under the replaced membership. 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. -These are the sentences a link pairing says when it stops, in the words the screen uses. Nothing was changed on either device when you read one of them. +These are the screen's sentences when link pairing stops. Nothing was changed on either device when you read one of them. - `that is not a codeaf pairing link or code; it looks like https://codeaf.agentfield.ai/p/k7m2q9xd#... or k7m2q9xd.`: what was pasted is neither shape. Paste the whole link or the whole `.` token. Links from your own sync service are accepted too. - `the link ran out before anyone approved it; run `codeaf pair` again for a new one`: a link is good for 10 minutes. @@ -244,6 +250,8 @@ Too many requests from one network are refused too: at most 10 new links in an h ## I quit on my laptop and opened my desktop after pairing +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + After a clean quit and final durable save, bare `codeaf` on another paired computer within 24 hours opens the released chat automatically. A sleeping or disconnected laptop still needs `alt+c` on Home. Local unsaved edits, another open local window, a named chat, `--host`, `--at` or `--session` prevent automatic continuation. A running chat never moves by itself. The arrival retains its `Moved from` banner and `not brought along:` facts; `esc` goes Home. Personal memory and stored model, display, approval and limit defaults follow the paired computers too. Limits are shared as values; spending counted against them stays per computer. Automations and machine-specific paths and settings stay local. diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 683f4067af..f4c7c44ece 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -50,6 +50,15 @@ Inside the paste editor, `ctrl+x` does the same thing. There is no recovery afte is discarded, though ordinary unsent draft recovery still applies to the rest of the message. +## Share a file with it — give codeaf a file to read + +To share a file with codeaf, attach it to your next message: `/attach ` (or `/upload +`, the same command) puts the file on the tray above the message box, and it goes with +the next thing you send. Dropping the file onto the terminal does the same. A bare `/attach` +opens a browser so you can find the file and look at it first. This is how you hand codeaf a +log, a CSV, a PDF or a screenshot; "Send a file with your message" below has the path rules +and the tray, and "Every refusal /attach can give you" has what it says when it cannot. + ## Send a file with your message — /attach, how do I share a file with codeaf, how do I send it a photo or an image `/attach ` puts an ordinary file — a log, a CSV, a PDF, a stack trace you saved — diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 0fafe3b58b..c73de1c873 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -29,11 +29,13 @@ restores the ordinary prompt. Combined stdout/stderr appears as plain text while the command runs, preserving line breaks and indentation. Wide lines wrap; terminal control sequences are removed for safe display. The command and output remain in history after reopening. The model waits for your next message before responding. -In a chat that can move between computers, file changes from `!` are included in +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). In a chat that can move between computers, file changes from `!` are included in its next saved turn, just like changes from the model's shell commands. A failed command keeps its output and exit status too. Shell paths, `@` names and slash words remain literal, not mentions or send tags. +## What can ! shell commands do — input, timeouts, approvals and workspace permissions + Commands receive no interactive input or terminal. Use non-interactive flags; editors, prompts and terminal apps are not supported. Each command starts a fresh shell in the workspace: `cd` and `export` apply only within that command. Large @@ -335,6 +337,8 @@ command). ## Pairing commands — /pair, /pair , /sync, /link and /laptop +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + | Command | Aliases | Argument | Effect | |---|---|---|---| | `/pair` | `/sync`, `/link`, `/laptop` | — | shows a code like `42-715-302`, valid 10 minutes, that gives your chats to another computer, then asks `y / n` when that computer types it | @@ -1958,7 +1962,7 @@ the Memories tab. Tasks controls delegated work; AI teams sets defaults for groups of AI chats. Permissions controls approvals. Spending holds limits. Connections holds accounts, keys, web search and remote connection settings. Devices lists your paired computers, renames this one, removes another, and starts -`/pair` from `Add another device`. +`/pair` from `Add another device`. A paired account on a relay that reports storage permission also has `sync storage`: when off, Enter opens a request and shows its code and link, including from Settings search. Self-hosted storage needs no request. Privacy holds history, drafts and data-sharing choices. Old saved keys and values are unchanged. Search accepts old labels and keys, @@ -2295,3 +2299,7 @@ nothing was left behind, `/setup` says so and does nothing. `/setup now` starts a **setup turn**: the agent installs the installable items and brings back what a move left behind, into user space. A new chat's commands already have the network and this computer's ordinary access; `/setup` is not needed to open the network or to install outside the workspace. Each setup call asks first unless approvals are open (`--yolo` or the allow posture), or the arrival card already approved that exact listed command. The setup approval floor still applies in a host-bound chat. Calls are recorded as external and the turn says `Setup` in `codeaf cell log`. Limits: a setup turn is never replayed on another machine (the agent does it again there), never attempts impossible items, and can be rewound like any turn. When it ends, codeaf looks at what is now on this machine and updates the record. Existing cells keep their declared class: an older sandboxed cell still has its folder limits and opens its network for the setup turn only. 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. A compatible furrow engine is required. Without a cell there is nothing to set up. + +## Why pairing commands do not turn on sync storage — request access in Devices + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. 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 31e3a21c35..208654afde 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -4,6 +4,8 @@ To continue on this machine, select the chat on home and choose `continue here`. ## Continue on another computer — continuing a chat on another computer, why can't I type in this chat, my message did not send and home opened with a question +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -43,6 +45,8 @@ taking a chat from Home restores that chat's own saved draft, or leaves its box ## Read a finished task's worker conversation after moving its chat +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Open `/task` and choose the finished task. Its saved worker conversation and steps remain readable with its outcome after moving the chat, even when the original working copy is no longer available. A finished task offers no restart action; typing `r` or `R` writes those letters in the box. ## What does leave it there do @@ -65,9 +69,19 @@ at five seconds without a chat-list answer, Home says `waiting for ; still these are Home's listing sentences. a message left unsent by a failed send check gets its own explanation: ` has not answered; your message is still in the box, press enter to send it`. +## Continue a chat on its own computer while sync storage is off + +Reading history already stored for your account needs no storage approval. With no local work to keep, `1 continue here` brings that recorded history here, even if this computer has no copy yet or its clean copy is older. + +After a live `--at` session, come back to the original computer and choose `1 continue here`. If its local history contains the last shared turn and any later local turns, or no turn was shared yet, the chat continues in place. Every local turn stays, the continue card goes away, and you can send messages and use tools immediately. Nothing is uploaded while storage is off. Quit and reopen normally; after approval, opening the chat on this same computer publishes the preserved turns even if its earlier hold expired. + +Keeping turns made here that another computer lacks waits for storage access only when they must be saved separately before replacing this copy. If those turns diverge from newer stored history, codeaf preserves them and says `cannot continue this chat here while sync storage is off because this computer is missing saved turns; your local turns stay here`. Your typed message stays in the box. Request access in Settings → Devices → sync storage before continuing this divergent copy. + ## Why does continue here say this chat was last held here — my only computer asks to continue -A chat this computer held last, whose hold expired with no other computer holding it, opens without a question: there is nothing to continue from. Enter sends, and turns this computer saved while it could not reach your other devices are sent to them when the chat opens. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. +A chat this computer held last can resume sending after its hold expires when nobody else holds it. While sync storage is off, Enter sends without a question if the local history contains every shared turn, including when later turns stayed only here or nothing was shared yet. The conversation stays open and the storage-off sentence is not repeated after idle time. After approval, opening the chat here sends its preserved turns to your other computers. + +With storage on, differing saved turns still raise the question. Missing shared turns also require a choice or show the existing refusal while storage is off; local turns stay here. The card still says `this chat was last held here; last saved turn 2h ago` when this window's own earlier move failed. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. @@ -75,6 +89,8 @@ If unpublished work needs to be kept separately, the number of kept turns counts ## What does held on tower mean — a row on home and the verb it offers +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Home lists chats from your other devices. A chat another device still holds says `held on `. The hold alone does not establish that work is running there. One that a device let go of, or that went off because its lid closed, says ` offline`. @@ -83,6 +99,8 @@ Press enter on either row to continue it here. The verb on the card depends on t ## Continue on this machine — Continue where you left off, alt+c and alt+x +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. If you open codeaf and a chat on another device was running or went off in the last 24 hours, and that device is offline now (its lid is closed) or has let go of the chat, home offers it first of all: @@ -104,6 +122,8 @@ To bring work from a device that is online, use the devices row: `alt+m bring wo ## What happens when the chat arrives — the banner Moved from tower, and what was not brought +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. When the chat opens on this device it starts with one line that says how long the move took: @@ -130,6 +150,8 @@ got it ## What was not brought along — folders, commands and tests on the arrival card +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + When a chat arrives on a paired computer, its arrival card can include these lines: Each line is left out when it has nothing to say. @@ -152,6 +174,8 @@ A new chat is host-bound and keeps that class when moved. Its commands run on th ## What folder does the moved chat work in — old absolute paths in earlier turns +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. A moved copy works in the workspace it arrived in. Before its next model request, the agent gets a session note naming the device it came from, the old folder and the new folder. Paths in earlier turns under the old folder now name the same relative paths under this workspace. The note is context for the agent, not a message you typed, and remains available after reopening or compacting. @@ -172,6 +196,8 @@ name. ## 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). + If this computer holds the chat and renewals have failed for longer than one renewal period, one dim line says `the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back`. @@ -184,10 +210,14 @@ only shows it` notice still applies. ## Reopen a kept copy after another computer takes the chat +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + When unsent turns were kept as a separate copy, reopening that copy checks who holds the copy itself. Another computer may still hold the original chat. The previous window finishes saving and stops syncing before the reopened copy starts its own sync, so a late save cannot change which copy it opens. Taking that copy elsewhere still stops new tool work in its older window. ## Continue here does nothing — continue here did nothing, the engine store is busy, or a chat is already continuing here +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. while a chat you explicitly chose is moving here, the card says `continuing here…`. another explicit continue during that move says `a chat is already continuing here; wait for it to open` on the card. an automatic startup move instead lets your explicit choice go next; see “my choice while an automatic chat is arriving”. @@ -196,6 +226,8 @@ every failure stays where you are looking: on Home's card with `1 retry` and `2 ## why continue here failed — retry or leave it there +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry confirms the holder and turn of ownership now displayed on that card. @@ -205,6 +237,9 @@ a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry - `could not continue this chat here`: the move failed. The row stays where it was; choose `retry` or try again. - A move does not fail because another chat on this computer is saving its secrets to the sync service at that moment. Its last step waits up to 30 seconds for that save to finish, then arrives. - `could not continue this chat here because the sync service is not answering; your words are still in the box`: the service went away during the move. Nothing was sent and the chat stays where it was; send again once the other machines are reachable. + +## Why can't I take an open chat from another computer — older relays and --host + - `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here` means the chat is open on another computer and the sync service is too old to take it from there safely. Everything else works, including continuing a chat the other computer has closed. Close the chat on that computer, then choose `continue here` again. - Under `codeaf chat --host`, `continue here` is not offered and a chat the remote host already holds never shows as held elsewhere. With no way to continue set up, an off row has no `continue here`: enter says why. @@ -245,6 +280,8 @@ need another upload before the move. ## I reopened an old local chat after the other computer let go — where are the newer turns +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Reopening an older copy does not replace newer history on your other devices, even if the other computer has closed the chat. This window only shows its local copy until you choose `continue here`. Submitting a message opens that question and keeps your words in the box. Continuing brings the newer history here before writing can resume. @@ -259,6 +296,8 @@ Turns already shared do not become kept edits. Usage, reconciled task cost, nami ## The chat is arriving on this computer — why it waits to open +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. While a chat is moving onto this computer, pressing enter on its row says `this chat is arriving on this computer; it opens by itself in a moment`. Nothing is wrong and nothing needs doing: the move takes a second or two, and the chat opens by itself when it ends, with the other computer's latest turns in it. Opening it earlier is refused on purpose, because a window opened halfway would show the chat as it was before the move and could not save your turns. @@ -267,38 +306,52 @@ When the holder is off (its lid is shut), the row reads ` offline` and y ## Can the chat on the other computer still open the job logs it mentions — logs, saved tool output, task journals and the task history after a move +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Yes. A chat's messages name files that sit beside the chat and not in your project: the log of a background job (`log at …/logs/jobs/2.log`), the full output of a tool result that was cut short (`full: logs/stubs/9c2f.txt`), and the journals of its tasks. They move with the chat. The other computer puts the logs and saved output back in the chat's own folder, so the same names open there. The task journals (each task's transcript, trajectory and saved output) are part of the chat itself, so they arrive whole inside it at the paths the chat reads them from. The first message it receives after the move says where they are now (`… that earlier messages name under were carried: they are under here`). A path in an old message still shows the old folder; use the new one. ## Job log and saved tool output limits after a move — task journals stay whole +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Three limits keep a move light, and they apply to job logs and saved tool output only. A single such file over 1 MiB stays behind, and so does whatever does not fit in 16 MiB together (the oldest go first). A task journal has no size limit and always moves whole. A file that looks like it holds a secret (a key, a token) stays behind too. Each one is named under `not brought along:` with the reason and `stayed on the machine that made it`; read it there by asking that machine, and never assume the other computer has it. Two things are made again on the other computer and are not carried: the project's task history (the list of finished tasks home and the tasks tool show), which is rebuilt from the chat's own record, and the git bookkeeping inside a task's working copy. The program's own files inside a task's working copy (the `.codeaf` folder, where a task's job logs sit) are not carried either, because they are not your work: they never show up on the other computer as a change you did not make. A file you made in the working copy does move. A task that finished on a kept branch keeps that branch in your project on the other computer, at the same commit. ## What moves with a chat — is my git history sent, branches, secrets in my repo +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Your repository's `.git`, including its history, branches and settings, moves with the chat to your own paired devices. A task's commits travel too, and its working-copy bookkeeping is rebuilt on arrival. A key in an old commit remains in that history: codeaf does not remove or rewrite it. Git history travels sealed end to end; the sync service sees only ciphertext, including when the history contains keys. The current working files are checked separately. A `.env` or source file that the guard recognises as holding a key is left out of the chat's file capture and carried through the encrypted vault. If you committed that secret earlier, its historical Git copy still moves with the repository. An untracked `.env` with no committed copy arrives only through the vault. +## How are secret files screened before saving or moving a chat — .env, ignored folders and changing files + Before each seal, codeaf asks which files will be captured and screens those candidates, including a full candidate list for a first reply or a capture that needs a full scan. If a file changes between screening and capture, saving plans and screens again. If repeated edits prevent a stable capture, it says `files kept changing while saving this chat; try again` and keeps the calls for a later save. A wholly git-ignored directory is neither entered nor screened unless it holds tracked files. An ignored file in an ordinary directory is still screened: root `.env`, `config/.env.local` and a private key in a `*.pem` file travel through the encrypted vault to paired computers. A withheld file stays out of local saved packs and the uploaded workspace capture. Tools still see a `.env` file's variables through the vault. An unreadable folder stays out until its permissions recover; its newly eligible files are then screened with a fresh capture plan. Restores and arrivals also screen before an automatic safety capture. ## Why does home appear while my chats are still syncing +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. The first screen draws while this computer publishes its device record, shares its saved keys and sends the first saved turn in the background. A slow sync service does not leave the terminal blank. New tool work waits for the initial ownership check; local startup saves are sent once that check finishes. ## Does every chat copy my whole project — disk space, shared saved files, older chats +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + New chats on this computer share one store of saved file contents. Opening four chats in the same project does not make four private copies of every unchanged file. Each chat keeps its own history, call log and transport scratch; restoring one does not move another chat's saved head. A chat whose older private store already exists keeps using that store in place, without migration. Nothing is deleted automatically. `codeaf cell gc` is an explicit command and removes only files the saved copy can restore. ## Why did dist or node_modules not move — gitignored files, build output, ignored folders +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Every chat's capture follows Git's ignore rules. Untracked ignored output stays on the computer that made it: it is neither stored in the local file history nor uploaded. Tracked files travel even if an ignore rule matches their name. A wholly ignored directory is not walked or screened. Lockfile rebuild folders still follow the existing rebuild rules. The arrival card names omitted paths from the saved summary, for example `not brought along: dist/ (git ignores them), node_modules/ (git ignores them)`. The summary is bounded and can name only some omitted groups. Ignored secret files in ordinary directories are still screened and travel through the key vault; an ignored `.env` inside a wholly ignored directory stays there with that directory. @@ -313,12 +366,14 @@ A command's result returns after its call log is written, while its file capture A save wait lasts at most 30 seconds. After the screen closes, a pending sync save says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. -If the turns are saved locally but not uploaded, the final sentence is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service’s host. That is a successful exit. Ctrl+c during this wait leaves promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`; that failure may include the local recovery path. +If the turns are saved locally but not uploaded, the final sentence is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service’s host. If that pending upload was refused because storage is off, the successful receipt is `saved here; sync storage is off for this account, so it stays on this computer`. That is a successful exit. Ctrl+c during this wait leaves promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`; that failure may include the local recovery path. If the last upload fails when you quit, for example because the connection dropped, this computer does not let the chat go. Your other computer keeps showing it as running here and does not open the older save by itself; the unsent turns stay on this computer until an upload succeeds. ## I pressed alt+m before the other chat appeared — still arriving, home on a second computer +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Device presence can arrive before its list of chats. If you press `alt+m` early, home says `chats are still arriving from your other computer; this opens when they are ready`. It keeps your request and opens the ordinary confirmation once the chat arrives; it never takes the chat without that confirmation. A recent chat left on another computer is offered when home opens, including one that computer closed cleanly. ## a new chat brings its full saved contents; a returning chat brings its changes @@ -341,6 +396,8 @@ A saved attachment reference keeps the source computer's path spelling with slas ## Can I rewind an old chat on my new laptop — where does the copy start +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Yes, after the older chat has a movable copy and you continue it on your paired laptop. Its first saved file state is the folder as it stood when the copy was made. Rewind can reach that first saved turn; file states from before the copy were never saved and cannot be reconstructed. The whole earlier conversation is still there to read. Recent older chats are prepared in the background after pairing. If you explicitly open an older chat whose copy is not ready, opening that conversation waits for its first folder snapshot to be saved. Large folders can take longer; Home's initial display does not wait for the background batch. @@ -349,6 +406,8 @@ The original older chat is untouched. A turn typed into the original by an older ## I quit on my laptop and opened my desktop — do I need alt+c +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + A clean quit lets go after the last save is durable. Opening bare `codeaf` on your other paired computer within 24 hours continues the released chat automatically, with the usual `Moved from` banner and `not brought along:` lines. `esc` goes Home. A sleeping laptop has not let go: Home offers the chat and `alt+c` continues it, asking only if local work needs a choice. If this computer is also coordinating, a clean terminal quit releases its idle chats and returns coordination to an anchor before stopping. With the anchors reachable, the other computer can continue immediately. Sleep, a crash or a lost connection still use the fallback described on the coordination and workers page. @@ -373,6 +432,8 @@ An older chat that worked in your home folder, or in a folder that holds it, is ## A save has not reached the other computer yet — upload failed or another device continued first +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + A saved chat reaches your other computers only after its encrypted objects have been uploaded and its new saved head has been accepted. If an upload fails, the other computer keeps seeing the previous saved head. Retry when the connection @@ -401,6 +462,8 @@ No. The worker saves its claim and an execution intent before invoking tools. A ## Why can an older computer list chats but not continue a chat that is still running? +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Older builds can still list, save, publish and release chats through a compatible service. A service using quorum-backed coordination refuses an older request that tries to take a live chat without naming its observed holder and ownership number. It uses the existing refusal, `another device continued this chat first`; an old build does not necessarily show an extra update hint. Quit on the computer holding the chat before continuing it elsewhere, or update codeaf on the computer trying to continue. A released or expired chat can still be taken. A conditional choice never silently changes to a newer holder. @@ -418,7 +481,7 @@ raises that question without running a turn or making a branch. A saved hold from an earlier process cannot bypass the question. A window whose writer still holds the chat sends with one Enter without asking the service first. -A new chat can send its first message normally. A sleeping computer can also resume sending when nobody else has taken the chat and its local saved turn is still current. That safe return happens without another question. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. +A new chat can send its first message normally. A sleeping computer can also resume sending when nobody else has taken the chat and its local saved turn is still current. While storage is off, a free chat last held here also resumes quietly if its local history contains every shared turn, even with newer turns saved only here. These returns keep the conversation open without another question. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. ## A move that failed partway — are my typed words safe, can the old copy still send @@ -460,3 +523,7 @@ presence; it does not guess which computers are online. ## Sync notices between older and current builds A current window understands the earlier outage and removed-computer notices too. It uses one outage line while local work continues, and points to `/pair` for recovery in a chat. A current engine keeps the earlier notice spelling for an older window, so that window's existing outage and removal handling still works. + +## Why can't I continue new turns from my offline computer — sync storage is off + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Existing saved content remains readable; turning storage off deletes nothing. The refusal is `sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices`. Once approval is read in the background, waiting chats start within a minute without restarting. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 71779e8f45..9d070f924c 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -450,7 +450,7 @@ profile or changes the requesting chat's model. ## Do my teams show on my other computer? -Team sharing is on by default for paired computers. The local switch is +Team sharing is on by default for paired computers and needs sync storage approval on the hosted service (Settings → Devices → sync storage). Use **Settings → General → share teams**. Sharing includes teams, members, handles, the manager and team settings, plus recent Traffic as read-only history. Team changes show within a few seconds, even with no chat open. The Teams dropdown, Teams page @@ -459,7 +459,7 @@ Both computers need to be running codeaf and able to reach the relay. An unpaired computer or a computer with sharing off keeps its teams to itself. Turning it off on either computer stops sharing between them: that computer stops sending and receiving teams and Traffic. What was already exchanged stays, -including teams and encrypted copies in the vault. Unpaired computers are unaffected. +including teams and encrypted copies in the vault. Open a member whose chat is on another paired computer to see `Continue this chat here?` on Home. Choose **continue here** to move the chat @@ -481,7 +481,7 @@ wait while this computer finishes sending an earlier change to the relay. ## Will deleting a team on my laptop delete it on my desktop? -Team sharing is on by default for paired computers. With **Settings → General → +Team sharing is on by default for paired computers and needs sync storage approval on the hosted service (Settings → Devices → sync storage). With **Settings → General → share teams** enabled on both computers, a deleted team stays deleted. A desktop that was offline catches up when it reconnects. Renaming its old copy while offline does not bring the team back. Deleting a team does not delete its conversations. @@ -495,6 +495,8 @@ consistently, and a removed member cannot remain the manager. ## Does Traffic sync between computers? +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Traffic sharing is on by default for paired computers through **Settings → General → share teams**. Turning it off on either computer stops sharing between them. Traffic from another computer shows its newest history, up to 32 lines per team and at most 16 KiB per team. Across all teams, each computer shares @@ -519,6 +521,8 @@ on the other computer's history. ## How do I turn on team sharing? +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + Team sharing is on by default for paired computers. If it was turned off, open **Settings → General → share teams** on that computer and choose **on** to resume. Sharing exchanges teams, members, manager choices and settings, and recent Traffic as read-only history @@ -532,7 +536,7 @@ Turn **share teams** to **off** to stop all team sharing immediately. Teams alre ## Stop sharing teams with my other computer -Team sharing is on by default for paired computers. Open **Settings → General → +Team sharing is on by default for paired computers and needs sync storage approval on the hosted service (Settings → Devices → sync storage). Open **Settings → General → share teams** on either computer and choose **off**. Turning it off on either computer stops sharing teams, members, manager choices and settings, and recent Traffic between them immediately. That computer stops sending and receiving teams and Traffic. Teams already received @@ -547,6 +551,8 @@ arrive within a few seconds; recent Traffic can take about 2 minutes. ## First team sharing: backup, duplicate names and global managers +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Paired computers exchange teams automatically while **Settings → General → share teams** is on, its default. An unpaired computer is unaffected. Before the first exchange, this computer keeps a copy of its teams as @@ -567,6 +573,8 @@ the original teams. Review manager choices in Teams. ## Does a shared team's spending cap cover both computers? +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + A team's spending cap counts spending on each computer separately. Sharing the team and its cap does not combine the usage meters. A member continued on the other computer is metered there, while members still running here count here. diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 64d7263df4..8ba90142c6 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -90,7 +90,11 @@ The sync connection carries presence hints and committed lease acknowledgments. ## Does syncing keep sending requests when nothing is happening? -A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round. Over ten idle minutes, caught-up open homes make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. Home reads its roster and backstop from the local applied directory. Its separate relay watch supplies advisory online presence, pinging every 10 seconds. A computer whose last socket closes is marked offline after a 15-second reconnect grace period; a silent connection is marked offline within 30 seconds. Home can then offer its recent chat before the 90-second lease expires. Continuing a chat and all ownership decisions still use fresh majority reads. A committed change wakes peer delivery and the next state round pulls the changed vault. If an older hosted relay has no learner door, the home keeps its existing five-second state polling. On the consensus directory, a group with no work or held chats can stop its periodic coordination timer after an idle barrier has reached every voter. The first operation wakes coordination and must obtain a fresh majority response. A saved leader address cannot answer that operation by itself. Losing the majority leaves syncing unavailable and keeps local work intact. +A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when sync storage is on. A relay that reports storage off gets one stats request at startup and every ten minutes; unknown hosted addresses use that same closed default. An unanswered custom address gets one startup request. An answer without storage keeps storage on and stops probing; known storage on makes no permission requests. On a relay with a learner door, over ten idle minutes, caught-up open homes with storage on make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. + +## How does an idle home notice offline computers and wake coordination? + +Home reads its roster and backstop from the local applied directory. Its separate relay watch supplies advisory online presence, pinging every 10 seconds. A computer whose last socket closes is marked offline after a 15-second reconnect grace period; a silent connection is marked offline within 30 seconds. Home can then offer its recent chat before the 90-second lease expires. Continuing a chat and all ownership decisions still use fresh majority reads. A committed change wakes peer delivery and the next state round pulls the changed vault. If an older hosted relay has no learner door, the home keeps its existing five-second state polling. On the consensus directory, a group with no work or held chats can stop its periodic coordination timer after an idle barrier has reached every voter. The first operation wakes coordination and must obtain a fresh majority response. A saved leader address cannot answer that operation by itself. Losing the majority leaves syncing unavailable and keeps local work intact. ## Hosted idle presence sweeps and sleeping connections @@ -212,3 +216,7 @@ Native and hosted watch connections carry committed receipts to clients that ask ## Cluster status or lead after this device was removed On a removed computer, `codeaf cluster status` and `codeaf cluster lead` fail with the same removal and pairing guidance as `codeaf devices`. This applies to the default relay and to your own relay. Pair that computer again with `codeaf pair` to restore access. A removed device is different from a cluster temporarily unable to coordinate. + +## Why shared state waits for sync storage — approval and serving computers + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Eligible serving homes start approval discovery after their first state round; quiet or removed homes do not start it. A stats refusal for a revoked or removed device stops polling. diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 9171c0aed6..6ebb8b8a3e 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -51,6 +51,8 @@ screen. Its heading is `Basic settings`, one line, with the keys line directly u ## I already use codeaf on a second computer — the code field on the first screen +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + 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. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -62,7 +64,7 @@ have a code from another device? use your own relay: codeaf pair --via ``` -On the computer that has your chats, type `/pair` to show a code like `42-715-302`. Type that code in the field. Compare the three words on both screens. The other computer asks `y / n`. On `y`, this computer has your chats. Read *Pairing your chats with a second computer* for the rest. +On the computer that has your chats, type `/pair` to show a code like `42-715-302`. Type that code in the field. Compare the three words on both screens. The other computer asks `y / n`. On `y`, this computer has your identity; chats copy here only when sync storage is enabled. Read *Pairing your chats with a second computer* for the rest. ## Skipping setup and reading its header diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 44f2ba57d6..0f9e664e7a 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -289,6 +289,8 @@ updates the tab and the row in Sessions together. ## A chat is running on my other computer — how does home show it, chats on my other machines, "held on tower", "tower offline", why does it say held on build-box offline, merge / discard +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -306,6 +308,8 @@ happened. The row says where the chat is only where that tells you something: ## Why a chat says held on build-box or build-box offline +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + For a chat on another paired computer, read the two words apart: `held on build-box` says that computer still holds the chat, including while it is idle, and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A chat on a computer that is `offline` can still be continued here; the card says how many turns may still be there. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows `untitled`, and a machine whose name will not open shows the first eight characters of its id. On a narrow frame a branch's sentence shortens to ` turns · `, and where @@ -336,6 +340,8 @@ same rows in a terminal (see "The cell verbs" in running-from-the-terminal). ## Continue a chat here that another machine left — "tower offline", takeover, "continue here", the chat is running on another computer +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -354,6 +360,8 @@ question takes the whole screen, with the sentence wrapped. ## Continue here with unsaved edits, another device continuing first, or an unreachable computer +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + When continuing a chat from another paired computer: - If you had unsaved edits to that chat here, they are not overwritten. They are kept as a @@ -365,6 +373,8 @@ When continuing a chat from another paired computer: ## Is my project folder changed when I continue a chat here — what comes with a continued chat, the files, binary files, file modes, the executable bit, the .env, where the copy lives, no git needed +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Continuing a chat brings all of it: the whole conversation, and every file the chat worked on, byte for byte: text and binary files, files that were never in a git repository, the executable bit, links. Git history, branches and settings travel too, sealed to your paired @@ -391,6 +401,8 @@ identity on this machine, no secrets are fetched and no file is written. ## Do saved provider keys and credentials follow me to another computer +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -409,6 +421,8 @@ is kept beside it as `credentials.json.damaged`. ## Do budgets, settings and model choices move between computers +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Your stored model picks, display and approval defaults, and spending-limit values follow your paired computers through the encrypted vault. Each setting merges independently; the later edit wins. Spending counted against a limit stays **per computer**, so a daily limit is not a combined fleet allowance. Local paths, caches, service addresses, automations and operator environment pins stay on their computer. Person and project memories also follow within a sync round, including lines edited by hand and memories from older chats. Concurrent edits of different lines survive; the later edit of the same line wins. Machine-scoped memories stay local. @@ -421,12 +435,16 @@ chat while you are still fetching it, this machine keeps exactly what it had. ## Is my git history sent — secrets in my repo, .git, committed keys, branches +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Yes. When you continue a chat on your own paired device, the repository's `.git` travels as it is, including its history, branches, settings and any keys in old commits. Task commits travel too. Git history is sealed end to end between your devices; the relay sees only ciphertext. codeaf does not screen, remove or rewrite committed secrets. The working-tree guard is separate: a `.env` or source file it recognises as holding a secret is excluded from the chat's file capture and sent only through the encrypted vault. The scanner reads the whole file, including keys past its first MiB. If the secret was committed, its Git copy still travels with the history. An untracked secret file with no committed copy arrives only through the vault. ## What a move carries and what it leaves behind — job logs, task history, kept branches, do spend and usage numbers sync between computers, memory, withheld folders +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + A chat that moves to another computer brings, from the relay and sealed on the way: - the whole conversation, and every file in its folder; @@ -436,12 +454,18 @@ A chat that moves to another computer brings, from the relay and sealed on the w - what the chat spent: the chat's share of the usage and spend figures is read again from its sealed record on the new computer, so its cost shows there and a rewind never drops it; - the memories that chat wrote, kept in the chat's own ledger and read back into the new computer's memory. +## What stays on this computer when a chat moves — task history, install folders, secrets and spending limits + +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + The project's task history (the list of finished tasks) and the git bookkeeping inside a task's working copy are not carried: they are made again on the other computer from the chat's own record. It leaves behind, and lists on the card under `not brought along:`: install folders a lockfile rebuilds (`node_modules`, `.venv`, `target`, `vendor`, `Pods`), files this computer cannot read, and one of two names that differ only in letter case or accent. A read-only folder arrives read-only. A secret file (`.env`) is not in the chat's files: it travels in the key vault. Stored limit values, model picks and personal memory follow separately through the encrypted vault. Spending against limits remains per computer; machine-scoped memory and local paths stay local. ## Does dist or other gitignored output move — ignored files and secret files +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Untracked Git-ignored output stays on the computer that made it and is named on the arrival card with `(git ignores them)`. It is neither captured locally nor uploaded. Tracked files still move. A wholly ignored directory is not walked or screened; an ignored secret file in an ordinary directory, such as `.env` or `config/.env.local`, is screened and still travels through the key vault to paired computers. ## What a moved chat left behind — set this machine up, why is node_modules missing, my dev server is not running after I moved, not brought along, rebuilt from the lockfile @@ -2719,6 +2743,8 @@ from a previous visit cannot replace the current visit's selection or draft. ## I quit on my laptop and opened my desktop — straight back into the chat +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + After a clean quit lets a paired computer's chat go, bare `codeaf` on another paired computer continues that chat automatically within 24 hours. You arrive with the `Moved from` banner and the usual `not brought along:` facts; `esc` goes Home. If the laptop only slept or lost its connection, Home offers its latest chat and `alt+c` continues immediately. When local unsaved edits need to be kept as a branch, the continuation card asks first. Nothing moves automatically while the other computer is running that chat, for a named chat or `--host`/`--at`/`--session`, when another local window holds a chat, or when local unsaved edits need a choice. If the move fails, Home stays open and says why. @@ -2738,3 +2764,7 @@ An empty new chat on one of your other devices stays off Home until it has a sav Turning sync off with `CODEAF_SYNC_URL=off` (or leaving sync off before pairing) means "do not talk to the sync service", never "forget my chats". Home still lists every chat on this computer — chats this version saved and chats from older versions alike — each once, and each opens and can be written. When an older chat was already copied into this version's store, Home lists and opens that copy even while sync is off, and the untouched original stays on disk beside it. Saved sync hold receipts from an earlier session stay dormant while sync is off, so sending a message in any local chat works without asking the sync service. + +## Why are chats and files still on this computer after pairing — sync storage + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Home can list paired devices while uploads wait; an offline computer cannot supply new turns that never reached the service. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 85db2510f6..ed6ceb7421 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3230,6 +3230,10 @@ A task handed to a program such as senior-dev cannot wait at all, because it sta moment it is approved: its `depends_on` may name only work that has already landed. A program's run that ended done may be named by any task; one still going may not. +## Choosing a task's model and tool-call checkpoints — propose_task model and max_steps + +These optional arguments choose the task's model and checkpoint size. Bad values return a refusal the model can correct. + **`model`** — which model this task runs on. Set only when you asked for a particular model or class of model for this work. Left out, the task runs on `task.model` if set, otherwise on whatever model the conversation is on at the moment of admission — settled once, then @@ -3242,6 +3246,8 @@ and only then a stop with `stopped: 200 steps and no finish`, the number being t checkpoint that was in force. A negative value answers `Invalid arguments: max_steps cannot be negative`. Zero or absent means the default. +## Which checks can propose_task run — quotes and shell characters + **`checks`** — the repeatable verification, described in *What propose_task needs from you* above. Each entry must be ONE rerunnable command, and one that even a permit-everything policy would still stop and ask about is refused outright. A character that joins, redirects @@ -3256,7 +3262,11 @@ that ends the line, refuses it. The refusal names the character and says what pa expands commands in "". Such a character may stand only inside a single-quoted argument, where it is text`. The checker's own shell reads a command the same way, so a check admitted here is one the checker can run, and what it must never run is stopped at -both. A check that leads with a directory change +both. + +## Where task checks run and how long a check can be — propose_task checks and divide_work + +The optional `checks` argument names rerunnable commands. A check that leads with a directory change (`cd && `) is refused like any other composition, and its refusal ends with the form that passes: `A check runs from the root of the task's own copy: leave the directory change out and name each file by its path`. It is never quietly repaired, because @@ -3267,18 +3277,22 @@ rerunnable command: this check is 1001 bytes and a check may be at most 1000`. T part declares is taken off all of them and given once to the task that divided them, which is the only one that can honestly make it after its parts are home. +## Why does a task say the call was refused — invalid arguments and repeated proposals + Proposals refused side by side in one reply are ONE attempt, and a refusal they share counts once: three refused together for one reason are not three repeats, and a refusal whose words changed is not the same failure again, so neither earns the note about repeating a call. -That refusal, and every `Invalid arguments:` sentence like it, is mail for the model, not +A check refusal, and every `Invalid arguments:` sentence like it, is mail for the model, not for you: the row in the conversation reads only `the call was refused`, the task's card reads `not started · the call was refused`, and the sentence itself — the field name, the quoted command, the repair — stays in the tool's own result, where the model reads it and fixes the call, and behind `ctrl+o` on that row for you if you ask. A command that genuinely ran and failed is the opposite case, and its row still opens with its own output. +## How many task calls can make no progress — propose_task no_progress and restarts + **`no_progress`** — how many tool calls in a row may teach nothing, ask nothing new, save nothing and leave nothing new in its working copy before the task is stopped as spinning. Default **6**. On the limit the report is `stopped: 6 steps without progress`, the landing turn runs, and what @@ -3289,7 +3303,7 @@ The same number is also **how many calls in a row may produce byte-identical con the second look is asked about it** — the repeat checkpoint above. That one stops nothing on its own: raising `no_progress` for work that is legitimately repetitive moves both. -Both step limits are recorded in the checkpoint, so they survive a restart along with the +Both `max_steps` and `no_progress` are recorded in the checkpoint, so they survive a restart along with the rest of the task. ## Does a long request lose requirements when work is checked or handed off? diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 1b0d6bc00d..23b9a31c2f 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -51,11 +51,13 @@ already explains that the chat stays on this computer until you pair it again. ## Quitting while the sync service is slow — waiting for a final save +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + The screen closes after at most one second while a slow close finishes. The launcher keeps its connection open for the final publish and release before it exits. A successful quit with nothing waiting stays quiet. While a sync save waits, the restored terminal says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without that confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. -The launcher waits up to 30 seconds for the final saving reply. If only upload is outstanding, the successful receipt is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service's host. Ctrl+c during this restored-terminal wait exits promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`. +The launcher waits up to 30 seconds for the final saving reply. If only upload is outstanding, the successful receipt is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service's host. If that pending upload was refused because storage is off, the successful receipt is `saved here; sync storage is off for this account, so it stays on this computer`. Ctrl+c during this restored-terminal wait exits promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`. ## One Enter sends — checking which computer holds this chat diff --git a/internal/manual/chat/lock-out-a-lost-computer.md b/internal/manual/chat/lock-out-a-lost-computer.md index ab80c027b8..99cb41d4ed 100644 --- a/internal/manual/chat/lock-out-a-lost-computer.md +++ b/internal/manual/chat/lock-out-a-lost-computer.md @@ -4,7 +4,7 @@ Stopping a computer with `codeaf devices revoke ` cuts it off from the relay. It does not undo what it holds. That computer has your whole identity. It can read everything it already has, and it can make itself a new device. -`codeaf identity rotate` is the real fix. It makes a replacement identity and moves your chats and your saved keys to it. The old identity is shut. The lost computer holds the old one, so it can read nothing of what you do from then on. +`codeaf identity rotate` is the real fix. It makes a replacement identity and moves your saved keys to it. With hosted sync storage off, rotation still finishes without uploading chats or resealed keys: chats stay on the computers that hold them. Previously stored frames remain under the old identity until grace-period erasure. The old identity is shut. The lost computer holds the old one, so it can read nothing of what you do from then on. Run it on a computer you keep, as soon as you know a computer is lost: @@ -24,8 +24,8 @@ usage: codeaf identity rotate [--grace 7d] [--yes] [--abandon] 2. It fetches the chats this computer does not hold yet. This can take a long time for big chats. You can still cancel. 3. It makes the new identity and writes it down on this computer, before it sends anything. 4. It shuts the old identity on the relay. Nobody can write to it any more, this computer included. Your other computers show that your chats are moving, and wait. -5. It seals every chat and your saved keys again under the new keys and puts them on the relay under the new identity. -6. It checks that the relay has everything. +5. With storage on, it seals every chat and your saved keys again and uploads them under the new identity. Approval follows only after proof from the new identity; a successor’s own storage setting stays in place. With hosted storage off, no resealing uploads are made; chats stay on your computers and the saved keys are resealed locally. +6. With storage on, it checks that the relay has everything; with storage off, it proceeds with local copies. 7. This computer switches to the new identity. This is the step after which there is no way back. 8. It asks the relay to delete the old identity after a grace period. The default is 7 days. Use `--grace` to change it: the relay takes 1 hour to 30 days. @@ -41,9 +41,9 @@ change these at their providers: OPENROUTER_API_KEY ## The rotation stopped half way — resume it or abandon it -If `codeaf identity rotate` stops half way, because the network dropped or the computer went to sleep, run it again. It finds what it had done and finishes. It never makes a second new identity. While a rotation is unfinished, the computer does not sync, and says `a rotation is unfinished on this computer; run codeaf identity rotate to finish it`. +If `codeaf identity rotate` stops half way, because the network dropped or the computer went to sleep, run it again. It finds what it had done and finishes. It never makes a second new identity. If saved replacement keys are damaged, keep `rotation-successor.json`; deleting it would lose the replacement already bound by the relay. While a rotation is unfinished, the computer does not sync, and says `a rotation is unfinished on this computer; run codeaf identity rotate to finish it`. -To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again and nothing has changed. After step 7 the only way is forward: it says `already switched to the new identity`. +To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again. Only after a hosted relay accepts the successor binding does this computer keep the replacement keys privately, outside the active rotation, for another rotation of the same identity on that same relay. Canceling before acceptance, using a relay without the storage capability, or using a self-hosted relay discards the candidate; the next rotation makes fresh keys. After step 7 the only way is forward: it says `already switched to the new identity`. If the computer that started the rotation is lost too, run `codeaf identity rotate --abandon` on another computer that still has the old identity. It opens the old identity again. @@ -89,9 +89,11 @@ A relay that does not know how to do this says `this relay cannot replace an ide A way to prove your identity with a written recovery phrase is not built. -## What it costs — every chat goes up again +## What rotation costs — uploads with storage on, local copies with storage off -A rotation uploads everything your chats hold, once, because the new identity starts empty. The plan it shows before you answer gives the number. A chat of 200 MB goes up as 200 MB. The transfer takes your network bandwidth and uses relay storage; the old and new copies count towards its limits during the grace period. There is no fixed price or duration promised by the command. +Hosted sync storage is off by default while privacy terms are being finished; request access in Settings → Devices → sync storage → enter to enable uploads. Pairing and live `--at` work while storage is off; your own sync service with `--store` needs no request. + +With hosted storage off, rotation completes with no store writes. Chats remain on your computers; any already-stored frames are left to the old identity’s grace-period erasure. With storage on, a rotation uploads everything your chats hold, once, because the new identity starts empty. The plan it shows before you answer gives the number. A chat of 200 MB goes up as 200 MB. The transfer takes your network bandwidth and uses sync storage; the old and new copies count towards its limits during the grace period. There is no fixed price or duration promised by the command. While the old identity is kept for the grace period, the relay holds both copies. diff --git a/internal/manual/chat/losing-every-device.md b/internal/manual/chat/losing-every-device.md index 2bff1d769f..bc2fdb07e1 100644 --- a/internal/manual/chat/losing-every-device.md +++ b/internal/manual/chat/losing-every-device.md @@ -2,17 +2,21 @@ ## I lost all my devices — can I get my chats back -**No. There is no recovery.** Your chats are sealed under keys that only your computers hold. The relay has only ciphertext. Nobody, including the operator of the relay, can open it or hand you a key. There is no account, no email link and no reset. +**No. There is no recovery.** Your chats are sealed under keys that only your computers hold. If storage was enabled and uploads finished, the relay has only ciphertext. With hosted storage off, your new chats never reached it. Nobody, including the operator of the relay, can open it or hand you a key. There is no account, no email link and no reset. If you still have one computer that holds your identity, pair a new one from it: `/pair`, or see "I got a new laptop". If you have **none**, the chats on the relay cannot be read by anybody. They stay there sealed until the relay is cleared. Only two things can save you before that day: - **An identity file.** `codeaf identity export` writes your identity under a passphrase, and `codeaf identity import` reads it on a new computer. Keep the file and the passphrase in a safe place, apart from the computer. -- **One computer that you keep.** A second computer that is paired is a backup. +- **One computer that you keep.** A second computer is a backup only for content that actually reached it; pairing alone makes no chat backup while hosted storage is off. ## Can I change my identity — identity rotate -If one trusted computer remains, **`codeaf identity rotate` works now**. It makes a replacement identity, seals your chats and saved keys under it, and retires the relay's old copy after the grace period. Pair the computers you keep again from that replacement. Read *Locking out a lost computer for good* for the steps and limits. +If one trusted computer remains, **`codeaf identity rotate` works now**. It makes a replacement identity and reseals saved keys locally. With hosted sync storage off, it finishes with no resealing uploads and chats stay on your computers; with storage on, it reseals and uploads chats and saved keys. It retires the relay's old copy after the grace period. Pair the computers you keep again from that replacement. Read *Locking out a lost computer for good* for the steps and limits. Rotation needs the old identity on a surviving computer. It cannot recover an identity when every copy, including any exported identity file and its passphrase, is lost. Revoking or rotating cannot take back chats and keys already downloaded by a lost computer; change exposed provider keys at their providers. + +## Why pairing alone is not a backup — sync storage and completed uploads + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Approval cannot recover turns that never left a lost computer. Keep a surviving computer with the content, or an exported identity and an accessible encrypted copy. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 5e15d2aa66..3dc1f66f58 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4335,6 +4335,8 @@ a chain ends after ten requests rather than waiting forever. ## Do my crew settings follow me to my other computer? +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + After pairing, crew seat model pins, allowed-model choices, the crew daily cap, per-task cap and free-route choice follow synced preferences within a few seconds, even with no chat open. Both computers need to be running codeaf and able to reach diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index d9997d2363..8c9e4e2fd6 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -584,3 +584,7 @@ An `--at` window uses the serving computer's chosen device name in its bar, connection errors, reconnecting notices and timeout messages. Its typed rendezvous name still selects the machine to connect to. If an older engine has not supplied a chosen name, the window uses the requested name. + +## Does live --at access need sync storage approval? + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Live access works on the other computer while it stays on; storage approval is needed to copy saved chats so they can open elsewhere when it is off. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index 3e4673a000..e57b9c674c 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -6,17 +6,25 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -A relay is the small service your computers sync through. There are two kinds. Both speak the same wire: the chat cannot tell them apart. +A relay is the small service your computers sync through. There are two kinds. Both speak the same wire. + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. - **A hosted relay** is run for you on a cloud account. You do nothing to run it. You give the chat its address. - **Your own relay** is the program `codeaf relay`. You run it on a computer you control. Either way the relay sees only ciphertext and the short list on "What can the relay see". -**Where sync goes by default.** Unset, each release channel syncs through its own hosted relay: stable releases and release candidates use `https://codeaf.agentfield.ai/fabric`, staging builds use `https://caf-fabric-staging.instrument-santosh.workers.dev`, and dev builds and local builds from source use `https://caf-fabric-dev.instrument-santosh.workers.dev`. Channels do not sync with each other: computers must run the same channel (or set the same `CODEAF_SYNC_URL`) to pair and share chats. A build whose `HostedRelayURL` is empty has no default, and then nothing is copied and pairing says `no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. +## Where sync goes by default — stable, staging and dev relay addresses + +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Unset, each release channel selects its own hosted relay: stable releases and release candidates use `https://codeaf.agentfield.ai/fabric`, staging builds use `https://caf-fabric-staging.instrument-santosh.workers.dev`, and dev builds and local builds from source use `https://caf-fabric-dev.instrument-santosh.workers.dev`. Channels do not sync with each other: computers must run the same channel (or set the same `CODEAF_SYNC_URL`) to pair and share chats. A build whose `HostedRelayURL` is empty has no default, and then nothing is copied and pairing says `no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. ## Point codeaf at a relay — CODEAF_SYNC_URL, url or off +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Set `CODEAF_SYNC_URL` in the environment of every computer that shares the chats. diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md index bb2023f62b..ae40a81b07 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -6,7 +6,7 @@ An older service cannot promise that taking a chat displaces only the computer y ## Is there a storage limit on the relay — how much can I sync -It depends on the relay. +Hosted uploads need sync storage approval (Settings → Devices → sync storage); these limits apply with storage on. **A hosted relay has limits.** The hosted relay in this repository sets, for each identity: @@ -16,17 +16,18 @@ It depends on the relay. - 2 chunks being sent at one moment; - 16 MiB for one chunk, and 1 MiB for any other message. -A chunk the relay already holds costs nothing again, so a computer that sends it twice is never refused for that. +With storage on, a chunk already held costs nothing again. Storage off refuses even a repeated put with `403 storage_off`; existing chunks remain readable. **Coordination storage has a separate limit on both relays.** Each identity starts with an 8 MiB allowance for directory and coordination records. A larger imported identity keeps its measured allowance. New growth beyond it is refused; existing records and saved chats are retained. A relay you run yourself also needs enough disk for encrypted chunks and all three coordination copies. Older directory requests keep their 1 MiB HTTP limit; staging a large request does not bypass retained storage capacity. ## How much sync space have I used -Open `/status` in a chat to see a dim `sync space` line, for example -`sync space 2.8 GiB of 5 GiB`. It shows how big your relay storage is and how +When hosted storage is off, `/status` shows one dim `sync storage off` line instead of sync space. The cached permission is refreshed in the background at launch and after each `/status`. + +With storage on, open `/status` in a chat to see a dim `sync space` line, for example +`sync space 2.8 GiB of 5 GiB`. It shows how big your sync storage is and how close you are to the relay limit: the amount stored, then the byte limit the -hosted relay reports. The same reading appears in `/status --json`. It is read when the chat opens and -again after each `/status`, so it can trail what the last few turns uploaded. +hosted relay reports. The same reading appears in `/status --json`. The permission refresher retains its last successful amount in this process. While hosted storage is on it makes no further permission requests, so this amount can trail new uploads. After a restart only the remembered on/off choice remains, and unknown usage draws no line. This counts everything stored for your identity on the hosted relay across all your computers and chats, not this chat alone. GiB and MiB are binary byte units, @@ -46,6 +47,12 @@ The hosted relay lets one network make **200 new identities in a day**. A networ Pairing has limits on both kinds of relay: 10 new codes in an hour for one network, and a relay holds at most 2,000 waiting pairings. The messages are on the pairing page, for example `too many pairings from this network; wait 7 min` and `sync is full right now; try again in a few minutes`. +## Sync storage is off — approval, retries and chats staying here + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + +**Sync storage is off** (`403`, code `storage_off`). The chat says `sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices`, once while it waits. No upload requests are made when this computer knows storage is off. A refresh that sees approval wakes sync automatically. This is separate from every full refusal; reads, pairing and live `--at` still work. Running uploads retry through the local gate on their normal backoff, capped at 60 seconds. One process-wide waiter starts chats waiting for storage within a minute of another process learning approval. Approval discovery runs at launch, after `/status`, and every ten minutes while a relay that reports storage is off (or a hosted address is unknown); serving computers use it too. It stops polling once storage is on. + ## What do I see when the relay says no **The chat says one plain sentence for each refusal.** The turns you save stay on this computer. The storage-full, too-many-identities and slow-down refusals below retry automatically while the chat stays open, including when sync could not start; saved turns upload when a retry succeeds. What differs is what the chat says and how it tries again: diff --git a/internal/manual/chat/relay-what-it-stores-and-sees.md b/internal/manual/chat/relay-what-it-stores-and-sees.md index e6f75d87c8..b7a7606501 100644 --- a/internal/manual/chat/relay-what-it-stores-and-sees.md +++ b/internal/manual/chat/relay-what-it-stores-and-sees.md @@ -1,20 +1,38 @@ # What the relay stores and what it can see -## What does the relay store — what is on the relay's disk +## Request access to sync storage — turn on sync storage, why don't my chats sync to my other computer -The relay holds two things for each identity, kept apart from every other identity: a **directory** (one record for each chat, one for each of your computers) and a **store** of chat content. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing does not turn it on. New chats, files and saved keys stay on this computer; the relay refuses even encrypted uploads. Existing stored frames stay readable and nothing is deleted. + +Pairing, seeing your devices and `--at` still work while the other computer is on. With storage off, new chat turns cannot be continued on another computer while the first is off, recovered from the relay after losing a computer, or sent as task copies. Teams sharing, synced keys, preferences and shared memories also wait. The relay still keeps device and directory coordination metadata, including existing chat records and leases; storage off does not erase those records. + +After pairing, open **Settings → Devices → sync storage**. When it says **off**, press **enter** (`enter request access`). This opens a pre-filled GitHub issue; the panel also shows the request code and link if no browser opens. The request code is your identity id (`id_` followed by 32 hex digits). Approval is tied to this identity id. It is safe to post publicly and grants nothing by itself. + +Turning sync storage off stops new uploads and leaves earlier frames readable. A self-hosted `codeaf relay --store ` keeps storage on and needs no request. Approved hosted accounts still have the limits on *Relay limits and refusals*. + +## How sync storage permission is discovered — custom addresses, startup and status + +The relay's answer decides, whatever its hostname. When it reports storage permission, Settings shows the sync storage row and `/status` shows the dim line `sync storage off` while off. A relay that answers without storage permission, including the self-hosted Go relay and older hosted Workers, keeps storage on and has no row or line. + +Before any answer, the three hosted addresses on *Hosted relay or your own relay* start closed; other addresses start open. A paired process probes an unanswered custom address once at startup. A successful answer is remembered for this account and relay, including when the field is absent. Sync off and unpaired homes make no requests. + +Eligible serving processes check after their first home-state round; chat windows check at launch and after `/status` in chat, and every ten minutes while a relay that reports storage is off (or a hosted address is still unknown). Storage on or an answer without the field stops requests. Once approval is read, waiting chats and uploads start within a minute without a restart. Opening a chat never waits for a relay reading. A revoked or removed device stops approval polling. + +## Does the relay store my chats — what is on the relay's disk + +The relay holds two things for each identity, kept apart from every other identity: a **directory** (one record for each chat, one for each of your computers) and a **store** of chat content. New content uploads need sync storage, which is off by default until approval (Settings → Devices → sync storage); directory records can remain while storage is off. The content is in the store as **encrypted chunks**. Each chunk is sealed under a key that only your own computers have. The relay cannot open a chunk. It keeps the chunks you send, and your saved keys too: they travel as one more sealed chunk, like a chat. Two things in the directory are sealed before they leave your computer: **the title of every chat** and **the name of every computer**. The relay stores them as sealed text and cannot read them. -There is no account on the relay. No email, no password, no name. Your identity is an id that starts with `id_`, made from your key. +There is no email or password sign-in on the relay. Your identity is an id that starts with `id_`, made from your key. ## What can the relay see — the metadata, my IP address, and what is not hidden from it **The relay cannot read:** your messages, your files, the titles of your chats, the names of your computers, your saved keys, the pairing code, the three words. -**The relay can see, and keeps or logs:** +**The relay can see the following metadata. Directory and coordination records can remain while storage is off; chunk metadata applies to content already stored or uploaded with storage on:** - your identity id, and the id of each computer you use (an id, never a name); - for each chat: its id, how big it is, when it last saved, which computer holds it, and the chat it was split from; @@ -25,13 +43,13 @@ There is no account on the relay. No email, no password, no name. Your identity - coordination metadata: cluster and member ids, roles, term and log position, learner progress, request sequence and digest; - worker routing: opaque assignment id and revision, selected chat and worker ids, process number, ownership number, status and times. -A relay you run yourself logs only the first 8 characters of the identity id, the kind of request, the status and two byte counts. It never logs a body. An operator of a relay you do not run can do more than that; you cannot check it. +The hosted relay has request logs and traces disabled. Rate counters retain network information for their fixed windows. A relay you run yourself logs only the first 8 characters of the identity id, the kind of request, the status and two byte counts. It never logs a body. An operator of a relay you do not run can do more than that; you cannot check it. The relay can also delete what it holds, or refuse to serve it. Keep a copy you care about on a computer of your own. ## Does the relay see my code, my prompts or my files -No. Turns, tool output and files are sealed on your computer before they are sent. The relay gets ciphertext and sizes. +Hosted storage is off until approved; no new chat or file chunks are kept while off. With storage on, turns, tool output and files are sealed on your computer before they are sent. The relay gets ciphertext and sizes. Your saved keys are sealed too, as said above. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index a60d40b1bb..51a4c2f37b 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -342,6 +342,8 @@ by default on Linux and macOS on every build. Native Windows is supported too; a ## codeaf pair — show a code, or use one, from the terminal +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + **`codeaf pair approve `** compares the four-digit check number and asks `y / n`: only `y` or `yes` approves, and `d`, Esc, Enter or anything else decline. Code pairing keeps its `y / n` question. **`codeaf pair`** on a new computer asks to join your devices and shows a link to approve from a computer that is already in (`codeaf pair approve `); **`codeaf pair --code`** shows a code that gives your chats to another computer. **`codeaf pair `** uses a code that another computer showed, for example `codeaf pair 42-715-302`. `CODEAF_SYNC_URL=` or `--via ` keeps the invitation on that service's own pairing page and includes `--via` in the token command; it never prints the hosted page link for a self-hosted invitation. `--via ` names the sync service to go through, and `--replace` keeps this computer’s old chat files locally while replacing shared keys and clearing shared memories, preferences and the shared key vault; provider keys in local settings remain. The page *Pairing your chats with a second computer* has the whole story. @@ -439,7 +441,7 @@ be read. A wrong passphrase and a damaged blob get the same refusal. computer you keep: a new root, every chat and your saved keys sealed again under it, and the old identity deleted by the relay after the grace period. It is how a lost computer is locked out for good. It asks first and says what it costs; `--abandon` stops a rotation that has not -switched yet. Read *Locking out a lost computer for good*. +switched yet. With hosted sync storage off it still completes, without resealing uploads: chats stay on your computers and the old stored frames wait for grace-period erasure. With storage on, successor proof carries approval unless the new identity already has its own setting. Read *Locking out a lost computer for good*. Each machine also has its own device key, made the first time and never exported. Your identity signs a small certificate for it, so two machines share one id but keep separate @@ -450,6 +452,8 @@ key the first time the identity is made on that machine. ## List your chats on every machine — codeaf cell list --all, and what its lines mean +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + **`codeaf cell list --all`** prints every chat you have on any of your machines, one line each: id, title, device, where it runs (`held on `, ` offline`, ` turns from : discard`, or `-` for one held here or let go), and how @@ -468,7 +472,9 @@ To use the new setting at once without touching the other window, run `codeaf ch Only a terminal that sets `CODEAF_SYNC_URL` itself is asking for a different relay. A window with the variable unset — `codeaf chat --host` reaching this computer over ssh, whose shell usually does not carry what you exported, or a terminal you never set it in — joins the running engine and its relay as it is. It restarts the engine only when the relay a pairing saved on this computer has changed since the engine started. -## Sync your chats between machines — CODEAF_SYNC_URL and CODEAF_SYNC_INTERVAL_MS +## Sync your chats between machines — what is CODEAF_SYNC_URL, and CODEAF_SYNC_INTERVAL_MS + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. 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. @@ -477,6 +483,8 @@ Older chats move through an independent copy. Once paired, codeaf prepares chats These environment settings are not rows in `/settings`. `CODEAF_SYNC_URL=off` stops relay traffic; new chats still keep their local cells. +## What is CODEAF_SYNC_URL — the sync service address or off + **`CODEAF_SYNC_URL`** is the relay's address, like `http://host:8787`, or the word `off`. Plain `http` is for trying it out inside an ssh tunnel; later the relay needs TLS. Unset, it is the relay a pairing saved on this computer, and with none saved it is codeaf's hosted @@ -489,6 +497,8 @@ to the relay until pairing; that identity alone does not connect your other comp ## How often chat turns sync — CODEAF_SYNC_INTERVAL_MS +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + After pairing, codeaf sends sealed chat turns to the relay configured by `CODEAF_SYNC_URL`. **`CODEAF_SYNC_INTERVAL_MS`** is how often unsaved turns are sent to the relay, in diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 1efffcb938..777a76303f 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -305,29 +305,28 @@ knowing even where you cannot answer it from this window. A **reading** window — one opened only to watch a task's page — is refused these keys on purpose, and says `this window is reading this conversation, not typing into it`. -## How do I work on the same conversation from two computers — another window, and another machine +## How do I work on the same conversation from two computers — open multiple terminals and have things just transfer + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. 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. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -These are two different things and they are easy to run together. - **Another window** is a second codeaf on the *same* machine holding a conversation you can see on home. Its row says `another window` in the right margin — and `another window · enter brings it here` while the cursor is on it. `enter` on it never opens a -second copy — one conversation, one writer — but it does something better: it **moves** the -conversation into this terminal. Two presses, the row says `coming here` while it is on its +second copy: it **moves** the conversation into this terminal, keeping one writer. Two presses, the row says `coming here` while it is on its way, and the other window lands on whatever else it was holding. *Continue a conversation -from another terminal*, on the home page, is the whole of it. (The old answer here was "go -to that terminal, or start a new conversation here", which is no longer the only way.) +from another terminal*, on the home page, is the whole of it. + +## Another machine with --host — attach to the conversation without copying it **Another machine** is `--host`. The conversation lives over there and your terminal *attaches* to it: what you type crosses the wire, the work runs on that machine, and the answer comes back. Close the lid and the turn keeps going if the far end is a session host; open a terminal somewhere else, attach to the same session, and you are back in it with the -gap replayed. That is what "it just transfers and works" actually is — attaching, not -copying, and the place a conversation lives never moves. +gap replayed. Attaching does not copy the conversation or change where it lives. **And the places are neither.** They are a listing of one machine's disk, and they follow the machine your session is on. Nothing about them opens a second window or moves a conversation. @@ -618,7 +617,7 @@ The task roster lists this far conversation's work. Its rows come from the far bytes to the far conversation's `attachments/` folder. Generated and viewed pictures take the reverse road automatically so their far bytes can be painted in this terminal. -## Reminders and watches over --host — they work, and they are that machine's automations +## Where does a reminder I set up over --host actually run — reminders, watches and that machine's automations `remind me at 6`, `tell me when CI on main goes red` and `every Monday post the standup` all work over `--host` and `--at`. The conversation over there proposes the automation, its diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 045710f1c6..2dbf5a1302 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -1005,3 +1005,11 @@ does not clear this chat's saving failure. If a local write fails, the error names the local transcript that keeps the unsaved turns. A sync outage alone leaves the turns saved here and does not produce a local-write failure. Keep that file: after storage becomes reachable again, the turns can be saved and published, or kept separately when you continue the chat. + +## Closing one open chat while sync storage is off + +Closing a chat with `/quit` or its close action can leave your other chats open. Only when an upload is refused because sync storage is off after a successful local save, codeaf says `saved here; sync storage is off for this account, so it stays on this computer`. The same words appear when quitting the app with that saving result. + +When local saving succeeds but another upload is still pending, the sentence is `saved here; it reaches your other devices when the sync service at
answers`. It names the service used by the chat you closed. + +If saving here fails, closing one chat still says `this chat could not be saved; its unsaved turns stay on this computer for the next start`. An unanswered save remains finishing in the background until its result arrives. Storage being off alone does not mean the local save failed. diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 48aeb73136..e1bf33124c 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -72,7 +72,7 @@ older version saved stays in the profile, read by nothing, and is never reported as an unknown setting. Internal model slots that this surface cannot change are omitted; supported model controls remain in Models. -## General — interface preferences and hints +## General — interface preferences, hints and share teams between paired computers **General** contains mouse interaction, completed tool details, tool icons, chat switching, the task sidebar and updates. There is no background reminders row: codeaf @@ -82,7 +82,7 @@ positive: on shows contextual tips, off hides them. Keyboard instructions and setting explanations stay visible either way. **Completed tool details** offers **collapsed** or **expanded** without changing the meaning of an existing profile. -**Settings → General → share teams** is on by default for paired computers. It +**Settings → General → share teams** is on by default for paired computers. On the hosted sync service sharing needs sync storage approval (Settings → Devices → sync storage). It shares teams, their members and settings, and recent Traffic as read-only history. The choice stays on each computer. Turning it off on either computer stops sharing between them: that computer stops sending and receiving teams and Traffic. What @@ -190,3 +190,9 @@ A compilation already running finishes with its original model. The compiler verifies the model reported by the provider and rejects substitutions; it never falls back to a different model. Existing notes stay intact when you change this setting. Knowledge capture must still be enabled from Memory for each project. + +## Devices — sync storage, off or on, and request access + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + +A paired account whose service reports storage permission has a **sync storage** row showing **on** or **off**. While **off**, press Enter (`enter request access`) to open a pre-filled GitHub issue. The panel also shows the public request code and link if no browser opens. The code is your identity id (`id_` and 32 hex digits), safe to post and granting nothing by itself. Choosing this row in Settings search clears the search and opens Devices. While **off**, Enter also requests access. While **on**, it opens Devices without offering or making a request. The row is absent with sync off, before pairing, or when the service answers without storage permission. A window already open when you pair checks for the answer within about a minute; an open Settings page then updates its row automatically. The first `/status` after pairing shows the cached dim line `sync storage off` while storage is off. diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index e513e6970a..4f1245c4ba 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -837,3 +837,11 @@ without printing it. A key saved by `codeaf connect openrouter` or `/settings` reaches a conversation when you next open it or switch models; `OPENROUTER_API_KEY` still wins. `your key was not accepted for this model` means the provider refused the key; doctor helps find the source to replace. + +## Chats on another computer and hosted sync storage + +Pairing and live `--at` access work while hosted sync storage is off. Hosted storage is off by default while its privacy terms are being finished, so chats and files stay on this computer until approval. After pairing, request access in Settings → Devices → sync storage → enter. The panel shows the public request code and link as well as opening a browser. Sync starts by itself after a background reading sees approval; `/status` refreshes it without holding up the chat. A self-hosted `codeaf relay --store ` needs no request. + +## Sync storage is off — a notice in an open chat + +A paired account with storage off sees `sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices` once when that chat first tries to sync. If access is withdrawn after syncing starts, the chat shows it once again. This appears in the window showing the chat, including an already-open window. Your chats keep saving on this computer. diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index cadc7495f3..a1b19293cf 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -2,6 +2,8 @@ ## Can a task run on my other computer +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Yes. Pair both computers with the same identity and run `codeaf serve --take-tasks` on the computer that will do the task. Both ends need Linux or macOS; Windows cannot send or receive these tasks yet. An ordinary open chat window does not take tasks. @@ -27,6 +29,8 @@ the conversation and task, once, and also stays on that computer's ledger. ## Run a task on my mac mini +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Pair your Mac mini and this computer, then run `codeaf serve --take-tasks` on the mini. Use its shown name, quoting names that contain spaces: @@ -142,6 +146,8 @@ setting names another computer, without a special placement sentence. ## codeaf serve --take-tasks takes tasks from paired computers +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Plain `codeaf serve` opens only the machine connection and does not take tasks. Run `codeaf serve --take-tasks` on the paired computer that should take tasks. It prints `takes tasks from your paired computers`. Pairing with the same identity is @@ -205,6 +211,8 @@ requires updating both ends before retrying. ## The task copy could not be sent +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + `the task copy changed while it was being sent; try again` keeps the copy here. Finish or pause the edits, then retry the task. `task copies need private storage and a relay blob store` means this chat's diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index a1c59bef8e..14c321c3b0 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -564,6 +564,8 @@ while it was closed. ## Team sharing and Traffic on another computer +Hosted sync storage approval is required for team sharing; storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + The traffic itself is kept in the profile of the machine the conversations run on, in `teams//traffic.jsonl`, one line per message, only ever added to. It holds only messages written on this computer. Team sharing is on by default for paired @@ -623,6 +625,8 @@ Review manager choices in Teams. ## Shared team spending caps and continued members +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Teams and their settings share automatically between paired computers by default; an unpaired computer is unaffected. A shared team's spending cap counts spending on each computer separately. A member continued on the other computer is metered diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 1c0ec7ae54..953c23356c 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -2,15 +2,17 @@ ## How do I use this on my laptop — pair a second computer with a code, connect another computer, and log in on a new machine +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. There is no account and no password to log in with. You pair: a code on one screen, typed on the other. -You learn one thing here: **a code shown on a screen you own, typed on the other computer.** +Use a code shown on a screen you own. -The computer that has your chats shows the code. The other computer types it. After that, both computers have the same chats. +The computer that has your chats shows the code. The other computer types it. After that, both computers share the identity; chats copy between them only when sync storage is on. This page is the code way. A new computer can also ask to join and be approved from a link, with a four-digit check: the page *Adding a computer to your devices, approving it, and removing it* tells it. @@ -32,11 +34,17 @@ Dashes and spaces in the code do not matter. `42-715-302`, `42 715 302` and `427 The code is good for 10 minutes. It works once. -## Sync my chats — how to sync between paired computers +## Sync my chats — get my chats on my other computer, chat syncing between paired computers + +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). 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 with `codeaf pair` or `/pair`, on Linux, macOS or native Windows. New chats save their turns locally and sync them to your paired -computers through the saved or hosted relay. `CODEAF_SYNC_URL=off` stops relay traffic. +Pair both computers first with `codeaf pair` or `/pair`, on Linux, macOS or native Windows. New chats save their turns locally and sync through the saved or hosted relay when storage is on. `CODEAF_SYNC_URL=off` stops relay traffic. + +## Do older chats sync after pairing — independent copies and untouched originals + +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Older chats become movable through independent copies after pairing; their original layout and files stay untouched for older builds. Recent chats are prepared in the background, and opening any other older chat prepares its copy. A never-paired home sends no relay requests. ## What does /laptop do — the laptop command pairs chats with my other computer; /pair, /sync and /link @@ -123,7 +131,9 @@ that code did not work, or it timed out; ask for a new one A code that ran out of time (10 minutes) says the same. Ask for a new code with `/pair` on the computer that has your chats. -Other things you can meet, each in one sentence: +## Why did pairing fail — wrong code shape, expired code, another device or network limits + +These are other code-pairing refusals, each in one sentence: A code with the wrong shape is refused before anything is sent: @@ -177,9 +187,11 @@ too many pairings from this network; wait 7 min ## What the pairing shares — the other computer holds everything this one holds +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. -**Pairing gives the other computer your whole identity.** Your chats, your saved keys and your sync are all part of it. The other computer now holds everything that this computer holds. +**Pairing gives the other computer your whole identity.** Your chats, your saved keys and your sync are all part of it. The other computer receives the identity keys; saved chats and vault content still need enabled sync storage to copy. It is not a view of your chats. It is a copy of you. Pair only a computer you own and trust. @@ -203,13 +215,15 @@ already paired; nothing changed ## I got a new laptop — move my chats to a new machine +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. 1. On the old computer, type `/pair`. 2. On the new computer, run `codeaf pair `, or type `/pair ` in the chat, or type the code on the first screen. 3. Compare the three words. Answer `y` on the old computer. -The new computer now has your chats. If both computers are on the same relay, chats sync between them. Read *Sync your chats between machines* in the terminal page for the sync settings. +The new computer now has your identity. If both computers are on the same relay and storage is enabled, chats sync between them. Read *Sync your chats between machines* in the terminal page for the sync settings. There is no phone app for continuing chats. A phone browser can open the `/p/` pairing-link page, but that page only shows the approval command and a link for a computer with codeaf installed; it does not turn the browser into a paired device. Use a supported desktop or server build to pair and continue chats. @@ -284,8 +298,8 @@ The first-run line about the hosted relay is said once, when chats are first cop 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. -Your chats live on your own computer first. They are also copied, sealed, -to a relay, so your other computers can open them. Which relay is one setting, +Your chats live on your own computer first. When sync storage is enabled they are also copied, sealed, +to the relay, so your other computers can open them. Which relay is one setting, `CODEAF_SYNC_URL`, and there is no sign-in and no account: - **Unset** uses this build's channel relay when it has one — `https://codeaf.agentfield.ai/fabric` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev` on staging builds, and `https://caf-fabric-dev.instrument-santosh.workers.dev` on dev builds and local builds from source — and says so once, in one @@ -301,13 +315,17 @@ to a relay, so your other computers can open them. Which relay is one setting, *Reaching a machine with a pairing code*). The hosted relay and your own speak the same way, so nothing else changes. +## Is my code private on the relay — encrypted chats, files and visible metadata + +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + **Is my code private?** Everything a chat holds, its turns and the files it saved, is sealed on your computer with a key only your own computers have, before it is sent. The relay keeps ciphertext: it cannot read your messages, your code, or even the names of your chats. It can see that an identity's devices talk to it, when, and how many bytes. Turning it off stops new copies going, and a relay you run is yours to empty. -## The relay — what it sees, your own relay, and turning pairing off +## The relay — what it sees, your own relay, and turning pairing off; sync is off, CODEAF_SYNC_URL=off, pairing needs a sync address 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. @@ -389,6 +407,8 @@ Moving chats and pairing are available by default on Linux, macOS and native Win ## Which door do I use — pairing, --at or --host +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. - **The same chats on both computers:** pair them with `/pair`. This page. @@ -399,6 +419,8 @@ Moving chats and pairing are available by default on Linux, macOS and native Win ## Moving chats on Windows — vault permissions, refused names, symlinks, hard links, WSL and every build +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. `CODEAF_SYNC_URL=off` stops relay traffic; local chats still keep their saved turns. @@ -433,6 +455,8 @@ and existing remote access still work. Use a build carrying the newer engine, or ## Does a CA certificate bundle stay out as a private key — cacert.pem +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + 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. A PEM certificate bundle containing only `BEGIN CERTIFICATE` blocks is kept in saved history and can move with the workspace. A `.pem` suffix alone does not make it a private key. Files containing PEM private-key headers (including encrypted keys and OpenSSH keys), PuTTY private keys, and PKCS#12 key containers are withheld; a PEM containing both certificates and a private key is withheld too. The same folder exclusions still apply: a certificate inside an install folder left behind for rebuilding is rebuilt with that folder. @@ -472,6 +496,12 @@ Once this computer has its own saved pairing keys, a sent turn, tracked work, a ## What happens to chats made with chat --once before the first pairing? +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + If this computer has no saved pairing keys yet — for example it has only ever run `codeaf chat --once` — those chats are kept when it first pairs without `--replace`, and pairing does not discard or hide them. They are not sent at the moment of pairing. The next time codeaf's window opens on this computer, each one is saved for syncing and appears on your other computers' Home, where it can be continued. A computer that already has its own saved pairing keys follows the local-work refusal and explicit `--replace` rules above. + +## Why pairing alone does not copy saved chats — sync storage access + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index a2ab016a89..5b7d767b72 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -647,10 +647,14 @@ one line on a failed row is the whole of what it says. ## Are my memories shared across computers? Does /remember work on my other computer — hand edited memories follow my paired computers, memory missing on the new laptop +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + Person and project memory follow your paired computers through the encrypted vault within one sync round, including hand-edited lines in the Memories place and lines written by older chats. No chat needs to be continued first. Concurrent edits of different lines both survive; for the same line the later edit wins, including forgetting a line. The service receives ciphertext only. Machine-scoped memories describe one computer and stay there. An unpaired home sends nothing. Pairing a computer that has its own memory merges those lines and preferences; explicit `--replace` replaces them. Chat-written memory still rides in each chat's own saved ledger as well. ## Memory in older local conversations and new moving chats +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + Memories use the local store on each computer. After pairing, an older conversation gets an independent movable copy with its earlier memory history sealed into `.cell/memories.jsonl`; the original is untouched. Person and project memory also sync independently through the encrypted vault. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 78c24860f3..8a22073e43 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4693,3 +4693,43 @@ func TestChatTeamSharingSafetyAnswers(t *testing.T) { }) } } + +func TestStorageOptInManualQuestions(t *testing.T) { + for _, question := range []string{ + "how do I turn on sync storage", + "why don't my chats sync to my other computer", + "request access to sync storage", + "does the relay store my chats", + } { + found := false + for _, section := range Chat().Search(question, DefaultResults) { + if section.Page == "relay-what-it-stores-and-sees" && strings.Contains(section.Body, "off by default") { + found = true + break + } + } + if !found { + t.Errorf("%q did not reach the hosted storage approval answer", question) + } + } +} + +func TestStorageOptInSettingsAndTeamsQuestions(t *testing.T) { + for _, probe := range []struct{ question, page, heading string }{ + {"Settings General share teams between paired computers", "settings", "General —"}, + {"Team sharing and Traffic on another computer", "team-manager", "Team sharing and Traffic"}, + {"Shared team spending caps and continued members", "team-manager", "Shared team spending caps"}, + } { + t.Run(probe.question, func(t *testing.T) { + for _, section := range Chat().Search(probe.question, DefaultResults) { + if section.Page == probe.page && strings.HasPrefix(section.Title, probe.heading) { + if !strings.Contains(section.Body, "sync storage approval") { + t.Fatalf("approval missing: %s", section.Body) + } + return + } + } + t.Fatalf("%q did not reach %s / %s", probe.question, probe.page, probe.heading) + }) + } +} diff --git a/internal/remote/client.go b/internal/remote/client.go index 399a42b6ef..8ad34d001b 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" "io" "os" "path/filepath" @@ -981,6 +982,9 @@ func (c *Client) deliver(frame Frame) { if frame.Error != "" { err := errors.New(frame.Error) if c.Welcome().LocalSavingReceipts && frame.SyncPending && frame.LocalSaved { + if frame.StorageOff { + err = errors.Join(wireauth.ErrStorageOff, err) + } err = syncwords.UploadFailure(err) } if frame.UnsavedTranscript != "" { diff --git a/internal/remote/server.go b/internal/remote/server.go index a94e8d0103..f9c5ead992 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -60,6 +60,7 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" "io" "path/filepath" "sort" @@ -2123,6 +2124,7 @@ func (s *server) dispatch(call Frame) { if err != nil { result.Payload, result.Error = nil, err.Error() result.SyncPending = result.LocalSaved && syncwords.UploadPending(err) + result.StorageOff = result.SyncPending && errors.Is(err, wireauth.ErrStorageOff) var unsaved *session.UnsavedChatError if errors.As(err, &unsaved) { result.UnsavedTranscript = unsaved.Transcript diff --git a/internal/remote/storage_optin_test.go b/internal/remote/storage_optin_test.go new file mode 100644 index 0000000000..70f5b4b43b --- /dev/null +++ b/internal/remote/storage_optin_test.go @@ -0,0 +1,25 @@ +package remote + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" + "testing" +) + +func TestStorageOptInRemoteSavingRetainsRefusal(t *testing.T) { + loop, err := Loopback(Hello{}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: &fakeAgent{}, SessionFile: "/chat/transcript.jsonl", FinishSaving: func(WrappedAgent) error { + return session.SavingError("/chat/transcript.jsonl", syncwords.UploadFailure(wireauth.ErrStorageOff)) + }}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + err = loop.Client.Agent().Close() + if !syncwords.UploadPending(err) || !errors.Is(err, wireauth.ErrStorageOff) || !loop.Client.Agent().LocalDurable() { + t.Fatalf("remote receipt lost storage refusal: %v", err) + } +} diff --git a/internal/remote/wire.go b/internal/remote/wire.go index cf9a3e2a33..8837261d6e 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -454,6 +454,7 @@ type Frame struct { LocalSaved bool `json:"localSaved,omitempty"` // SyncPending distinguishes an upload failure after the local close succeeded. SyncPending bool `json:"syncPending,omitempty"` + StorageOff bool `json:"storageOff,omitempty"` // UnsavedTranscript preserves a recovery location on a failed saving reply. UnsavedTranscript string `json:"unsavedTranscript,omitempty"` diff --git a/internal/rotate/journal.go b/internal/rotate/journal.go index 7c33a48319..3aa7064b7e 100644 --- a/internal/rotate/journal.go +++ b/internal/rotate/journal.go @@ -11,6 +11,8 @@ import ( // File is the journal under the codeaf home. const File = "rotation.json" +const successorFile = "rotation-successor.json" + // State is how far a rotation has got. Each state names what is already true. type State string @@ -33,17 +35,19 @@ type Item struct { // Journal is everything a crashed rotation needs. It holds secrets (the new // identity and both device keys) and is written 0600, atomically. type Journal struct { - V uint16 `json:"V"` - State State `json:"state"` - OldID string `json:"old_id"` - OldPublic string `json:"old_public"` // hex of the old signing public key: enough to verify the old device's cert - OldDevice json.RawMessage `json:"old_device"` // identity.Dev document, to ask the relay to retire - NewID string `json:"new_id"` - New json.RawMessage `json:"new"` // identity document - NewDevice json.RawMessage `json:"new_device"` // identity.Dev document - GraceMS int64 `json:"grace_ms"` - Items []Item `json:"items,omitempty"` - Vault bool `json:"vault,omitempty"` // the resealed vault is on the new relay + V uint16 `json:"V"` + State State `json:"state"` + OldID string `json:"old_id"` + OldPublic string `json:"old_public"` // hex of the old signing public key: enough to verify the old device's cert + OldDevice json.RawMessage `json:"old_device"` // identity.Dev document, to ask the relay to retire + NewID string `json:"new_id"` + New json.RawMessage `json:"new"` // identity document + NewDevice json.RawMessage `json:"new_device"` // identity.Dev document + GraceMS int64 `json:"grace_ms"` + Items []Item `json:"items,omitempty"` + LocalOnly bool `json:"local_only,omitempty"` + HandoffRelay string `json:"handoff_relay,omitempty"` // Retain canceled keys only for the hosted relay that accepted them. + Vault bool `json:"vault,omitempty"` // the resealed vault is on the new relay } func path(home string) string { return filepath.Join(home, File) } diff --git a/internal/rotate/plan.go b/internal/rotate/plan.go index a66145469e..d814101490 100644 --- a/internal/rotate/plan.go +++ b/internal/rotate/plan.go @@ -33,8 +33,18 @@ func (e Env) Plan(ctx context.Context) (Plan, error) { return Plan{}, err } p := Plan{Chats: len(l.Cells)} + upload := true + if e.StorageAllowed != nil { + var err error + upload, err = e.StorageAllowed(ctx, nil) + if err != nil { + return Plan{}, err + } + } for id, c := range l.Cells { - p.UpBytes += int64(c.Size) + if upload { + p.UpBytes += int64(c.Size) + } want, err := e.Engine(e.Identity).Want(ctx, e.Cell(id), c.Head) if err != nil { return Plan{}, err diff --git a/internal/rotate/rotate.go b/internal/rotate/rotate.go index d5e1bc2602..de0cc506b2 100644 --- a/internal/rotate/rotate.go +++ b/internal/rotate/rotate.go @@ -4,8 +4,11 @@ import ( "context" "crypto/ed25519" "encoding/hex" + "encoding/json" "errors" "fmt" + "os" + "path/filepath" "time" "github.com/Agent-Field/codeaf/internal/blobstore" @@ -64,6 +67,9 @@ type Env struct { Home string Identity identity.Identity // the identity on disk now Device identity.Dev // this machine's device under it + // HostedRelay is the normalized hosted URL, empty for other relays. Only an + // accepted binding on this service requires keeping a canceled successor. + HostedRelay string // Connect binds the relay to a signer: the old identity's device, then the // new one's. Connect func(reqsign.Signer) Relay @@ -75,8 +81,11 @@ type Env struct { Cell func(id string) cell.Cell // Record is this device's directory record under id. Record func(id identity.Identity) (directory.Device, error) - Grace time.Duration // 0 is DefaultGrace - Say func(string) // progress lines; nil says nothing + // StorageAllowed lets a closed account finish with local copies instead of + // asking verification to find content it was forbidden to upload. + StorageAllowed func(context.Context, blobstore.Store) (bool, error) + Grace time.Duration // 0 is DefaultGrace + Say func(string) // progress lines; nil says nothing // After is called once each step is durable, with its name; an error stops // the rotation there. Tests use it to crash at every point. After func(step string) error @@ -185,6 +194,30 @@ func (e Env) open(ctx context.Context) (*run, error) { // mint makes the new root, the device it certifies, and writes them down before // anything is sent. func (e Env) mint() (Journal, error) { + if raw, err := os.ReadFile(filepath.Join(e.Home, successorFile)); err == nil { + var j Journal + if json.Unmarshal(raw, &j) != nil { + return Journal{}, errors.New("rotate: saved successor keys are damaged; keep " + successorFile) + } + if j.OldID == e.Identity.ID() && e.HostedRelay != "" && j.HandoffRelay == e.HostedRelay { + if _, err := identity.Unmarshal(j.New); err != nil { + return Journal{}, err + } + oldDoc, err := e.Device.Marshal() + if err != nil { + return Journal{}, err + } + j.OldDevice = oldDoc + j.GraceMS = e.grace().Milliseconds() + if err := save(e.Home, j); err != nil { + return Journal{}, err + } + return j, os.Remove(filepath.Join(e.Home, successorFile)) + } + } else if !errors.Is(err, os.ErrNotExist) { + return Journal{}, err + } + id, err := identity.Mint() if err != nil { return Journal{}, err @@ -257,7 +290,8 @@ func (r *run) refusedFirst(err error) error { } // Abandon stops a rotation that has not switched: the old identity is thawed -// and the journal, which holds the unused new keys, is deleted. With no journal +// and keeps its successor keys only when a hosted relay accepted their binding. +// A later rotation of that source on the same relay must reuse them. With no journal // it thaws anyway, which is how another computer undoes a rotation whose // keeper lost its disk. func (e Env) Abandon(ctx context.Context) error { @@ -278,7 +312,17 @@ func (e Env) Abandon(ctx context.Context) error { if err := r.old.Gate.Thaw(ctx); err != nil { return err } - return remove(e.Home) + if r.j.HandoffRelay == "" { + return remove(e.Home) + } + r.j.State = Minted + r.j.Items = nil + r.j.Vault = false + r.j.LocalOnly = false + if err := save(e.Home, r.j); err != nil { + return err + } + return os.Rename(path(e.Home), filepath.Join(e.Home, successorFile)) } // vaultSyncer is the vault's syncer for the new relay. diff --git a/internal/rotate/steps.go b/internal/rotate/steps.go index 87be8557b8..31b1a1d55f 100644 --- a/internal/rotate/steps.go +++ b/internal/rotate/steps.go @@ -80,6 +80,30 @@ func (e Env) complete(ctx context.Context, from blobstore.Store, it Item) error // that does it. Each finished chat is written down, so a crash repeats at most // the chat that was in flight. func (r *run) seal(ctx context.Context) error { + // A frozen journal already holds the successor keys. The separate handoff + // retries safely after a crash without changing any coordination command. + if gate, ok := r.old.Gate.(interface { + HandoffTo(context.Context, identity.Identity) (bool, error) + }); ok { + accepted, err := gate.HandoffTo(ctx, r.newID) + if err != nil { + return err + } + if accepted && r.HostedRelay != "" { + r.j.HandoffRelay = r.HostedRelay + if err := save(r.Home, r.j); err != nil { + return err + } + } + } + if r.StorageAllowed != nil { + allowed, err := r.StorageAllowed(ctx, r.new.Store) + if err != nil { + return err + } + r.j.LocalOnly = !allowed + } + if len(r.j.Items) == 0 { l, err := r.old.Dir.List(ctx) if err != nil { @@ -90,6 +114,13 @@ func (r *run) seal(ctx context.Context) error { return err } } + if r.j.LocalOnly { + if err := r.registerDevice(ctx); err != nil { + return err + } + r.say("sync storage is off; chats stay on your computers") + return r.advance(Sealed) + } if err := r.registerDevice(ctx); err != nil { return err } @@ -152,7 +183,14 @@ func (r *run) record(ctx context.Context, it Item) error { Head: it.Head, Class: v.Cell.Class, Title: title, ParentCell: v.Cell.ParentCell, Size: v.Cell.Size, OrphanTurns: v.Cell.OrphanTurns, }) - if err != nil && !errors.Is(err, directory.ErrExists) { + if errors.Is(err, directory.ErrExists) { + held, acquireErr := r.new.Dir.Acquire(ctx, it.ID, directory.AcquireOpts{}) + if acquireErr != nil { + return acquireErr + } + _, err = r.new.Dir.Publish(ctx, it.ID, directory.Publish{Fence: held.Cell.Lease.Fence, OldHead: held.Cell.Head, Head: it.Head, Class: v.Cell.Class, Title: title, Size: v.Cell.Size}) + } + if err != nil { return err } if err := r.letGo(ctx, it.ID); err != nil { @@ -210,6 +248,9 @@ func (r *run) carryVault(ctx context.Context) error { // verify asks the new relay for what the journal says it holds: every chat at // the carried head, every head's remote id, and the vault. func (r *run) verify(ctx context.Context) error { + if r.j.LocalOnly { + return r.advance(Verified) + } l, err := r.new.Dir.List(ctx) if err != nil { return err diff --git a/internal/rotate/storage_optin_test.go b/internal/rotate/storage_optin_test.go new file mode 100644 index 0000000000..7372d8127b --- /dev/null +++ b/internal/rotate/storage_optin_test.go @@ -0,0 +1,355 @@ +package rotate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/reqsign" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +type storageCountWrites struct { + blobstore.Store + writes *int +} + +func (s storageCountWrites) PutFrame(ctx context.Context, b []byte) (blobstore.FrameID, error) { + *s.writes++ + return s.Store.PutFrame(ctx, b) +} +func TestStorageOptInRotationOffFinishesWithoutWrites(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "frozen"}[resume], func(t *testing.T) { + r := newRig(t) + r.putVault() + writes := 0 + connect := r.env.Connect + r.env.Connect = func(s reqsign.Signer) Relay { + side := connect(s) + side.Store = storageCountWrites{side.Store, &writes} + return side + } + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return false, nil } + if resume { + r.env.After = crashAt(string(Frozen)) + if _, err := r.env.Rotate(t.Context()); err != errCrash { + t.Fatalf("crash %v", err) + } + r.env.After = nil + } + result, err := r.env.Rotate(t.Context()) + if err != nil { + t.Fatal(err) + } + if result.Chats != 2 { + t.Fatalf("local chat count %d", result.Chats) + } + if writes != 0 || Pending(r.home) || r.relay.of(r.old.PublicKey()).state != "retired" { + t.Fatalf("writes=%d pending=%v", writes, Pending(r.home)) + } + id, err := identity.Load(r.home) + if err != nil || id.ID() != result.NewID { + t.Fatalf("identity %v", err) + } + vault, err := keys.Open(r.home) + if err != nil { + t.Fatal(err) + } + entry, err := vault.Get("p/KEY") + if err != nil || entry.Value != "secret" { + t.Fatalf("local vault %v", err) + } + // Local objects are shared by the engines before and after rotation. + for _, chat := range []string{chatA, chatB} { + head := listing(t, r.relay.of(r.old.PublicKey()), r.dev.ID()).Cells[chat].Head + if missing, err := r.engineFor(id).Want(t.Context(), r.cell(chat), head); err != nil || len(missing) > 0 { + t.Fatalf("local chat %s: missing %v, %v", chat, missing, err) + } + } + }) + } +} + +func TestStorageOptInCanceledRotationKeepsBoundSuccessor(t *testing.T) { + for _, mode := range []string{"self-hosted", "no-storage-field", "before-handoff", "refused-handoff", "accepted-same-relay", "accepted-other-relay"} { + t.Run(mode, func(t *testing.T) { + r := newRig(t) + if mode != "self-hosted" { + r.env.HostedRelay = "https://hosted.test/fabric" + connect := r.env.Connect + source := connect(signer{r.old.PublicKey(), r.dev}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/identity/rotation": + var req directory.RotationReq + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + t.Error(err) + return + } + var err error + switch req.Op { + case directory.OpFreeze: + err = source.Gate.Freeze(request.Context()) + case directory.OpThaw: + err = source.Gate.Thaw(request.Context()) + case directory.OpRetire: + err = source.Gate.Retire(request.Context(), 0) + default: + t.Errorf("unexpected rotation %s", req.Op) + } + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Write([]byte(`{}`)) + case "/v1/store/stats": + state := "off" + if mode == "no-storage-field" { + state = "" + } + json.NewEncoder(w).Encode(blobstore.Counts{Storage: state}) + case "/v1/store/handoff": + if mode == "refused-handoff" { + http.Error(w, `{"err":"unavailable"}`, 503) + return + } + w.Write([]byte(`{"storage":"off"}`)) + default: + t.Errorf("unexpected request %s", request.URL.Path) + } + })) + defer server.Close() + sign := func(*http.Request, []byte) {} + r.env.Connect = func(sig reqsign.Signer) Relay { + side := connect(sig) + if reqsign.IDOf(sig.IdentityKey()) == r.old.ID() { + side.Gate = directory.Gate{Client: directory.NewHTTP(server.URL, sign, server.Client()), Store: blobstore.NewHTTP(server.URL, sign, server.Client()), Source: r.old.ID()} + } + return side + } + } + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return false, nil } + stop := Sealed + if mode == "before-handoff" { + stop = Frozen + } + accepted := strings.HasPrefix(mode, "accepted-") + r.env.After = crashAt(string(stop)) + if _, err := r.env.Rotate(t.Context()); err == nil || (mode != "refused-handoff" && err != errCrash) { + t.Fatalf("stop: %v", err) + } + j, _, err := load(r.home) + if err != nil { + t.Fatal(err) + } + if err := r.env.Abandon(t.Context()); err != nil { + t.Fatal(err) + } + if Pending(r.home) { + t.Fatal("cancellation still blocks ordinary sync") + } + raw, err := os.ReadFile(filepath.Join(r.home, successorFile)) + if accepted { + if err != nil { + t.Fatal(err) + } + var saved Journal + if err := json.Unmarshal(raw, &saved); err != nil { + t.Fatal(err) + } + if saved.HandoffRelay != r.env.HostedRelay { + t.Fatalf("saved relay %q", saved.HandoffRelay) + } + info, err := os.Stat(filepath.Join(r.home, successorFile)) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("saved keys mode: %v %v", info, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unbound successor was retained: %v", err) + } + // An independent relay has no binding to this canceled successor. + if mode == "accepted-other-relay" { + second := &fakeRelay{clock: r.relay.clock, ns: map[string]*namespace{}} + r.env.Connect = second.connect + r.env.HostedRelay = "https://other.test/fabric" + } + // Stop after minting so even a deliberately refused relay can expose its candidate. + r.env.After = crashAt(string(Minted)) + if _, err := r.env.Rotate(t.Context()); err != errCrash { + t.Fatal(err) + } + next, _, err := load(r.home) + if err != nil { + t.Fatal(err) + } + reuse := mode == "accepted-same-relay" + if (next.NewID == j.NewID) != reuse { + t.Fatalf("successor reused=%v, want %v", next.NewID == j.NewID, reuse) + } + if reuse { + r.env.After = nil + result, err := r.env.Rotate(t.Context()) + if err != nil || result.NewID != j.NewID || Pending(r.home) { + t.Fatalf("same-relay completion: %+v %v", result, err) + } + } + }) + } +} + +func TestStorageOptInOffRotationPlanHasNoUploadCost(t *testing.T) { + r := newRig(t) + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return false, nil } + plan, err := r.env.Plan(t.Context()) + if err != nil { + t.Fatal(err) + } + if plan.Chats != 2 || plan.UpBytes != 0 { + t.Fatalf("off plan: %+v", plan) + } +} + +// A failed handoff leaves the Frozen journal resumable, without another freeze +// or a resealing write before the retry succeeds. +func TestStorageOptInRotationHandoffBeforeUploads(t *testing.T) { + for _, resume := range []bool{false, true} { + for _, state := range []string{"on", "off"} { + t.Run(fmt.Sprintf("resume=%v/storage=%s", resume, state), func(t *testing.T) { + r := newRig(t) + connect := r.env.Connect + source := connect(signer{r.old.PublicKey(), r.dev}) + freezes, handoffs, writes := 0, 0, 0 + refuse := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/identity/rotation": + var req directory.RotationReq + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + t.Error(err) + return + } + var err error + switch req.Op { + case directory.OpFreeze: + freezes++ + err = source.Gate.Freeze(request.Context()) + case directory.OpRetire: + err = source.Gate.Retire(request.Context(), 0) + default: + t.Errorf("unexpected rotation %s", req.Op) + } + if err != nil { + t.Error(err) + http.Error(w, err.Error(), 500) + return + } + w.Write([]byte(`{}`)) + case "/v1/store/stats": + json.NewEncoder(w).Encode(blobstore.Counts{Storage: state}) + case "/v1/store/handoff": + handoffs++ + if r.relay.of(r.old.PublicKey()).state != "frozen" { + t.Error("handoff before freeze") + } + if refuse { + http.Error(w, `{"err":"unavailable"}`, 503) + return + } + w.Write([]byte(`{"storage":"` + state + `"}`)) + default: + t.Errorf("unexpected request %s", request.URL.Path) + } + })) + defer server.Close() + sign := func(*http.Request, []byte) {} + r.env.Connect = func(sig reqsign.Signer) Relay { + side := connect(sig) + if reqsign.IDOf(sig.IdentityKey()) == r.old.ID() { + side.Gate = directory.Gate{Client: directory.NewHTTP(server.URL, sign, server.Client()), Store: blobstore.NewHTTP(server.URL, sign, server.Client()), Source: r.old.ID()} + } else { + side.Store = storageHandoffWrites{Store: side.Store, before: func() { + writes++ + if handoffs < 2 || refuse { + t.Error("upload before successful handoff") + } + }} + } + return side + } + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return state == "on", nil } + if resume { + r.env.After = crashAt(string(Frozen)) + if _, err := r.env.Rotate(t.Context()); !errors.Is(err, errCrash) { + t.Fatalf("freeze crash: %v", err) + } + if freezes != 1 || handoffs != 0 { + t.Fatalf("freeze=%d handoff=%d", freezes, handoffs) + } + r.env.After = nil + } + if _, err := r.env.Rotate(t.Context()); err == nil { + t.Fatal("handoff refusal was ignored") + } + if freezes != 1 || handoffs != 1 || writes != 0 { + t.Fatalf("before retry freeze=%d handoff=%d writes=%d", freezes, handoffs, writes) + } + refuse = false + if _, err := r.env.Rotate(t.Context()); err != nil { + t.Fatal(err) + } + if freezes != 1 || handoffs != 2 || Pending(r.home) { + t.Fatalf("freeze=%d handoff=%d pending=%v", freezes, handoffs, Pending(r.home)) + } + if (state == "on" && writes == 0) || (state == "off" && writes != 0) { + t.Fatalf("storage=%s writes=%d", state, writes) + } + }) + } + } +} + +type storageHandoffWrites struct { + blobstore.Store + before func() +} + +func (s storageHandoffWrites) PutFrame(ctx context.Context, b []byte) (blobstore.FrameID, error) { + s.before() + return s.Store.PutFrame(ctx, b) +} + +func TestReview2UnrelatedRelayRotationMintsFreshSuccessor(t *testing.T) { + r := newRig(t) + r.env.After = crashAt(string(Frozen)) + if _, err := r.env.Rotate(t.Context()); err != errCrash { + t.Fatal(err) + } + oldJournal, _, err := load(r.home) + if err != nil { + t.Fatal(err) + } + if err := r.env.Abandon(t.Context()); err != nil { + t.Fatal(err) + } + // A new self-hosted relay has no approval binding to the canceled successor. + second := &fakeRelay{clock: r.relay.clock, ns: map[string]*namespace{}} + r.env.Connect = second.connect + r.env.After = nil + result, err := r.env.Rotate(t.Context()) + if err != nil { + t.Fatal(err) + } + if result.NewID == oldJournal.NewID { + t.Fatal("an unrelated relay rotation reused the canceled rotation's complete private identity") + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index 65054f11b6..5bc83d9956 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -414,6 +414,10 @@ func newAgent(config Config, client Completer) (*Agent, error) { // AND THIS PROCESS SAYS IT HOLDS THIS CONVERSATION. It is how a lane knows // somebody is waiting on it, and how the memory pass finds the one room to // say its line in (livesessions.go). Close erases it. + // Every conversation using a watch owns its lifetime, including independently running errands. + if watch, ok := config.Seals.(interface{ Adopt() func() }); ok { + agent.releaseSeals = watch.Adopt() + } registerLiveSession(agent) // AND WHAT WAS ANSWERED WHILE NOBODY WAS HOME IS ANSWERED NOW. An answer left // on this conversation's doorstep rides the presence heartbeat @@ -2410,6 +2414,7 @@ func (f sessionCompleter) FallbackModels(model string) []string { // inside a tool must not hold the process open, and past the grace the journal // simply stops accepting writes rather than writing to a closed descriptor. func (a *Agent) Close() (saveErr error) { + defer a.releaseSealsAfterClose() defer func() { saveErr = SavingError(a.config.SessionFile, saveErr) }() // WHAT THE RECORD ALREADY HOLDS IS SETTLED BEFORE THE DOOR SHUTS, so an idle // session that read a landing in its last turn does not re-tell it tomorrow. diff --git a/internal/session/conversation_delete.go b/internal/session/conversation_delete.go index 64e5b6f3dc..e47d598f8a 100644 --- a/internal/session/conversation_delete.go +++ b/internal/session/conversation_delete.go @@ -76,11 +76,17 @@ func (a *Agent) finishCloseWhenStopped() { if file != nil { _ = file.Close() } + if a.releaseSeals != nil { + a.releaseSeals() + } return default: a.beltMu.Unlock() } guard.Go("finishing a conversation deletion stop", func() { + if a.releaseSeals != nil { + defer a.releaseSeals() + } <-done if file != nil { _ = file.Close() diff --git a/internal/session/sealnotice.go b/internal/session/sealnotice.go index 7ccf4d3173..632f36889e 100644 --- a/internal/session/sealnotice.go +++ b/internal/session/sealnotice.go @@ -51,3 +51,15 @@ func (a *Agent) noticeSealed() { hub.send(Event{Kind: EventNotice, Text: said, Told: true}) } } + +// Deferred journal completion still owns the watch after Close returns to the surface. +func (a *Agent) releaseSealsAfterClose() { + pending := func() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.closeFinalizer + }() + if !pending && a.releaseSeals != nil { + a.releaseSeals() + } +} diff --git a/internal/session/sealwatch_close_test.go b/internal/session/sealwatch_close_test.go new file mode 100644 index 0000000000..346055080a --- /dev/null +++ b/internal/session/sealwatch_close_test.go @@ -0,0 +1,48 @@ +package session + +import ( + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +func TestSealWatchOutlivesDeferredConversationClose(t *testing.T) { + retired := make(chan struct{}) + watch := &cellstore.SealWatch{OnRetire: func() { close(retired) }} + file := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(cfg *Config) { cfg.Seals = watch; cfg.SessionFile = file }) + done := make(chan struct{}) + agent.beltRun = &beltRun{over: done} + defer func() { + select { + case <-done: + default: + close(done) + } + agent.beltMu.Lock() + defer agent.beltMu.Unlock() + agent.beltRun = nil + }() + if err := agent.Close(); err != nil { + t.Fatal(err) + } + select { + case <-retired: + t.Fatal("watch retired before its workers and journal finished") + default: + } + if !InUse(file) { + t.Fatal("fixture did not retain its unfinished journal") + } + close(done) + select { + case <-retired: + case <-time.After(time.Second): + t.Fatal("completed local close retained its watch") + } + if InUse(file) { + t.Fatal("watch retired before the journal closed") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index fd0bd59e6a..b572c375d7 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3206,6 +3206,8 @@ type Agent struct { closingBeltDone <-chan struct{} deletionGrace time.Duration closeFinalizer bool + // releaseSeals gives back this conversation's share after its last local save. + releaseSeals func() // closeDone is closed by [Agent.Close] as its LAST act, and it is what makes // the close complete for everybody rather than only for whoever got there // first. diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 4490a3a70b..987fb02e2d 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -16,6 +16,7 @@ import ( "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/vaultsync" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // unknownDevice is said for the machine that took a chat when the directory @@ -77,6 +78,9 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt if Quiet(s.Home) { return nil, ErrQuiet } + if !s.StorageAllowed() { + return nil, wireauth.ErrStorageOff + } files, err := s.files(eng, c) if err != nil { return nil, err @@ -113,6 +117,8 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt defer close(d.done) var work sync.WaitGroup work.Go(func() { d.followOwnership(run) }) + stopStorage := OnStorageOpened(s.Home, d.StorageOpened) + defer stopStorage() _ = d.batcher.Run(run) work.Wait() }() @@ -175,6 +181,9 @@ func (d *Drive) note(sealed cellstore.Sealed) { // see. It is best effort and says nothing when it fails: the fingerprint is then // not recorded, so the next flush tries again, and a seal never waits on it. func (d *Drive) syncVault(ctx context.Context) { + if !d.sync.StorageAllowed() { + return + } err := d.sync.withVault(func(v vaultsync.Syncer) error { if err := v.Capture(); err != nil { return err @@ -277,6 +286,11 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri if err != nil { return drv, "", err } + // An empty head has no stored turns to require from this device's own + // expired hold; other devices retain the ordinary ancestry decision. + if view.Cell.Head == "" && view.Cell.Lease.Device == s.Device.ID() { + extends = true + } if !extends { return drv, nonemptyHolder(view.Cell.Lease.Device), nil } @@ -644,3 +658,10 @@ func (d *Drive) Close(ctx context.Context) error { }) return d.closeErr } + +// StorageOpened wakes capacity-halted publication without replacing ownership. +func (d *Drive) StorageOpened() { + if d.batcher != nil { + d.batcher.Freed() + } +} diff --git a/internal/syncsetup/rotate.go b/internal/syncsetup/rotate.go index 7924ec438f..c460d0fe7b 100644 --- a/internal/syncsetup/rotate.go +++ b/internal/syncsetup/rotate.go @@ -1,6 +1,7 @@ package syncsetup import ( + "context" "errors" "net/http" "os" @@ -35,9 +36,32 @@ func scratchDir(home string) string { return filepath.Join(home, "rotation-scrat // machine; a chat it does not know is fetched into a scratch folder. func (s *Sync) Rotation(eng cellstore.Engine, held func(id string) (cell.Cell, bool), name string, say func(string)) rotate.Env { eng.WorkspaceOf = func(c cell.Cell) string { return workspaceOf(c.Root) } + hostedRelay := "" + if IsHostedRelay(s.Relay) { + hostedRelay = normalizeStorageURL(s.Relay) + } return rotate.Env{ Home: s.Home, Identity: s.Identity, Device: s.Device, Say: say, - Connect: s.relayOf, + HostedRelay: hostedRelay, + Connect: s.relayOf, + StorageAllowed: func(ctx context.Context, successor blobstore.Store) (bool, error) { + if !IsHostedRelay(s.Relay) { + return true, nil + } + counts, err := s.Store.(interface { + Stats(context.Context) (blobstore.Counts, error) + }).Stats(ctx) + if err != nil { + return false, err + } + if counts.Storage == "off" || successor == nil { + return counts.Storage != "off", nil + } + next, err := successor.(interface { + Stats(context.Context) (blobstore.Counts, error) + }).Stats(ctx) + return err == nil && next.Storage != "off", err + }, Engine: func(id identity.Identity) cellsync.Engine { keys := cellstore.SyncKeys{CellKey: id.CellKey(), Dedup: id.DedupSecret()} return landing{Engine: eng.Sync(keys, cellstore.LedgerName(s.Relay, id.ID()))} @@ -60,7 +84,25 @@ func (s *Sync) Tidy() error { return os.RemoveAll(scratchDir(s.Home)) } func (s *Sync) relayOf(sig reqsign.Signer) rotate.Relay { sign := reqsign.SignFor(sig, time.Now) dir := directory.NewHTTP(s.Relay, sign, &http.Client{Timeout: requestTimeout}) - return rotate.Relay{Dir: dir, Store: blobstore.NewHTTP(s.Relay, sign, &http.Client{}), Gate: directory.Gate{Client: dir}} + store := GateStore(blobstore.NewHTTP(s.Relay, sign, &http.Client{}), s.Home, s.Relay, reqsign.IDOf(sig.IdentityKey())) + if IsHostedRelay(s.Relay) && reqsign.IDOf(sig.IdentityKey()) != s.Identity.ID() { + allowed := store.CanStore + legacy := false + store.CanStore = func() bool { + if legacy || allowed() { + return true + } + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + counts, err := store.Stats(ctx) + if err == nil && counts.Storage == "" { + legacy = true + return true + } + return err == nil && SaveStorage(s.Home, s.Relay, reqsign.IDOf(sig.IdentityKey()), counts.Storage) == nil && allowed() + } + } + return rotate.Relay{Dir: dir, Store: store, Gate: directory.Gate{Client: dir, Store: store, Source: reqsign.IDOf(sig.IdentityKey())}} } // deviceRecord is a device's directory record under id: its name sealed under diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index 3a84fe3992..984c37b25e 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -20,6 +20,12 @@ var stateMu sync.Mutex // its lifetime. Quiet, unpaired and removed homes end silently; other failures // retry after the sync interval and are reported once per distinct error. func RunHomeState(ctx context.Context, home string, report func(error)) { + var stopStorage func() + defer func() { + if stopStorage != nil { + stopStorage() + } + }() interval, err := flushInterval() if err != nil { interval = DefaultInterval @@ -53,6 +59,11 @@ func RunHomeState(ctx context.Context, home string, report func(error)) { if errors.Is(err, directory.ErrRevoked) || (current != nil && current.isRemoved()) { return } + // The first state round proves the device may still use this home. + // Starting earlier races a revoked device's one final request. + if stopStorage == nil && current != nil { + stopStorage = StartStorageRefresh(ctx, home) + } if err != nil && ctx.Err() == nil && !reported[err.Error()] { reported[err.Error()] = true if report != nil { @@ -96,7 +107,7 @@ func (s *Sync) SyncState(ctx context.Context) error { return err } fp, exists := keys.Fingerprint(s.Home) - if exists && fp != s.stateSent { + if exists && fp != s.stateSent && s.StorageAllowed() { if err := v.Push(ctx); err != nil { return err } @@ -112,7 +123,9 @@ func (s *Sync) SyncState(ctx context.Context) error { } s.stateVault, s.stateVaultKnown = rid, local } - s.stateSent, _ = keys.Fingerprint(s.Home) + if s.StorageAllowed() { + s.stateSent, _ = keys.Fingerprint(s.Home) + } return nil }) } diff --git a/internal/syncsetup/storage.go b/internal/syncsetup/storage.go new file mode 100644 index 0000000000..96cd6a2ac1 --- /dev/null +++ b/internal/syncsetup/storage.go @@ -0,0 +1,175 @@ +package syncsetup + +import ( + "encoding/json" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// StorageFile keeps the last permission beside the saved relay, bound to both +// account and service so pairing or switching relays cannot reuse an approval. +const StorageFile = "sync-storage" + +// StoragePollEvery notices a permission refreshed by another local process. +const StoragePollEvery = time.Minute + +// StorageRequestURL is the one public door for requesting hosted storage. +const StorageRequestURL = "https://github.com/Agent-Field/CodeAF/issues/new" + +type storageReading struct { + Relay string `json:"relay"` + Identity string `json:"identity"` + Storage string `json:"storage"` + ReportsStorage *bool `json:"reports_storage,omitempty"` +} + +// IsHostedRelay recognizes all hosted channels, including an explicitly saved URL. +func IsHostedRelay(raw string) bool { + u := storageURL(raw) + for _, host := range HostedLinkHosts() { + if strings.EqualFold(u.Host, host) { + return true + } + } + return false +} + +// storagePolicy distinguishes a cached answer without storage from no answer. +// A legacy on reading at a custom address could have come from the old +// no-field default; it needs one probe before claiming a permission row. +func storagePolicy(home, relay, identity string) (state string, gating, answered bool) { + raw, err := os.ReadFile(filepath.Join(home, StorageFile)) + var reading storageReading + if err == nil && json.Unmarshal(raw, &reading) == nil && + normalizeStorageURL(reading.Relay) == normalizeStorageURL(relay) && reading.Identity == identity { + switch reading.Storage { + case "on", "off": + if reading.ReportsStorage != nil { + if !*reading.ReportsStorage { + return "on", false, true + } + return reading.Storage, true, true + } + if reading.Storage == "off" || IsHostedRelay(relay) { + return reading.Storage, true, true + } + return "on", false, false + case "": + return "on", false, true + } + } + if IsHostedRelay(relay) { + return "off", true, false + } + return "on", false, false +} + +// StorageState reads only local state. Before an answer, hosted services are +// closed and other relays are open; afterwards the relay's answer decides. +func StorageState(home, relay, identity string) string { + state, _, _ := storagePolicy(home, relay, identity) + return state +} + +// StorageGated reports whether this relay exposes account storage permission. +func StorageGated(home, relay, identity string) bool { + _, gating, _ := storagePolicy(home, relay, identity) + return gating +} + +// ReportedStorage samples permission once for display, omitting services that do not gate storage. +func ReportedStorage(home, relay, identity string) string { + state, gating, _ := storagePolicy(home, relay, identity) + if !gating { + return "" + } + return state +} + +// SaveStorage remembers a successful stats answer atomically. An empty state +// records a relay without storage permission, so it need not be probed again. +func SaveStorage(home, relay, identity, state string) error { + reports := state != "" + if reports && state != "on" { + state = "off" + } + if err := os.MkdirAll(home, 0700); err != nil { + return err + } + raw, err := json.Marshal(storageReading{Relay: normalizeStorageURL(relay), Identity: identity, Storage: state, ReportsStorage: &reports}) + if err != nil { + return err + } + f, err := os.CreateTemp(home, ".sync-storage-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(append(raw, '\n')); err != nil { + f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + if err := os.Rename(f.Name(), filepath.Join(home, StorageFile)); err != nil { + return err + } + effective := state + if effective == "" { + effective = "on" + } + storageSaved(home, relay, identity, effective) + return nil +} + +// GateStore keeps every relay upload door on the same local permission check. +func GateStore(store *blobstore.HTTP, home, relay, identity string) *blobstore.HTTP { + store.CanStore = func() bool { return StorageState(home, relay, identity) == "on" } + store.OnStorageOff = func() { _ = SaveStorage(home, relay, identity, "off") } + return store +} + +func (s *Sync) StorageAllowed() bool { + return StorageState(s.Home, s.Relay, s.Identity.ID()) == "on" +} + +// StorageRequestLink includes only the public account identifier, never a key. +func StorageRequestLink(identity string) string { + query := url.Values{"title": {"sync storage access: " + identity}, "body": {"Please turn on sync storage for my account.\n\nRequest code: " + identity}} + return StorageRequestURL + "?" + query.Encode() +} + +// StorageForHome does not create an identity or make a network request. +func StorageForHome(home string) string { + relay := Resolve(home) + if !relay.On() { + return "" + } + id, err := identity.Load(home) + if err != nil { + return "" + } + return StorageState(home, relay.URL, id.ID()) +} + +// Default ports and host case identify the same service and permission file. +func storageURL(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil { + return &url.URL{} + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + if u.Scheme == "https" && u.Port() == "443" || u.Scheme == "http" && u.Port() == "80" { + u.Host = u.Hostname() + } + return u +} +func normalizeStorageURL(raw string) string { return strings.TrimRight(storageURL(raw).String(), "/") } diff --git a/internal/syncsetup/storage_batcher_test.go b/internal/syncsetup/storage_batcher_test.go new file mode 100644 index 0000000000..408ac80515 --- /dev/null +++ b/internal/syncsetup/storage_batcher_test.go @@ -0,0 +1,110 @@ +package syncsetup + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestStorageOptInRealBatcherMissedOffOnAndLocalGate(t *testing.T) { + home := t.TempDir() + const account = "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + var revoked atomic.Bool + revoked.Store(true) + var writes atomic.Int32 + memory := blobstore.NewMemory() + handler := blobstore.Handler(func(*http.Request, []byte) (string, string, error) { return account, "device", nil }, func(string) (blobstore.Store, error) { return memory, nil }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/store/frames" && r.Method == "POST" { + writes.Add(1) + if revoked.Load() { + w.WriteHeader(403) + w.Write([]byte(`{"err":"storage_off"}`)) + return + } + } + handler.ServeHTTP(w, r) + })) + defer srv.Close() + store := GateStore(blobstore.NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client()), home, ProductionRelayURL, account) + if err := SaveStorage(home, ProductionRelayURL, account, "on"); err != nil { + t.Fatal(err) + } + dir := directory.NewMemory(time.Now) + defer dir.Close() + client := dir.For("device") + c := cell.Cell{ID: "01J0000000000000000000000A", Root: t.TempDir()} + view, err := client.Create(t.Context(), c.ID, directory.CellInit{}) + if err != nil { + t.Fatal(err) + } + engine := cellsync.NewFakeEngine(t.TempDir()) + head := engine.Seal(c, map[string]string{"a": "pending"}) + pendingWait := make(chan time.Duration, 10) + retry := make(chan struct{}) + uploaded := make(chan struct{}, 1) + b := &cellsync.Batcher{Publisher: &cellsync.Publisher{Engine: engine, Store: store, Dir: client}, Driving: &cellsync.Driving{Cell: c, Fence: view.Cell.Lease.Fence}, Interval: 5 * time.Second, OnFlush: func(cellsync.Flush) { uploaded <- struct{}{} }} + b.Sleep = func(ctx context.Context, d time.Duration) error { + if d != directory.HeartbeatEvery { + pendingWait <- d + select { + case <-retry: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + <-ctx.Done() + return ctx.Err() + } + b.Note(cellstore.Turn{ID: head}) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { defer close(done); b.Run(ctx) }() + defer func() { cancel(); <-done }() + wait := <-pendingWait + if wait > cellsync.MaxBackoff { + t.Fatalf("halt %v", wait) + } + if StorageState(home, ProductionRelayURL, account) != "off" { + t.Fatal("wire revoke not saved") + } + // An ordinary retry while the local file is off makes no second put. + retry <- struct{}{} + wait = <-pendingWait + if writes.Load() != 1 || b.Pending() != 1 { + t.Fatal("closed local gate reached relay or lost pending turn") + } + // Another process replaces the file directly, without notifying this one. + // The whole off/on change occurred between permission-waiter samples. + revoked.Store(false) + raw := []byte(`{"relay":"` + ProductionRelayURL + `","identity":"` + account + `","storage":"on"}`) + if err := os.WriteFile(filepath.Join(home, StorageFile), raw, 0600); err != nil { + t.Fatal(err) + } + if wait > cellsync.MaxBackoff { + t.Fatalf("retry %v", wait) + } + retry <- struct{}{} + select { + case <-uploaded: + case <-time.After(5 * time.Second): + t.Fatal("missed edge stranded real publication") + } + if b.Pending() != 0 || writes.Load() != 2 { + t.Fatalf("pending=%d writes=%d", b.Pending(), writes.Load()) + } + if _, err := client.Cell(t.Context(), c.ID); err != nil { + t.Fatal(err) + } +} diff --git a/internal/syncsetup/storage_headless_test.go b/internal/syncsetup/storage_headless_test.go new file mode 100644 index 0000000000..a586946518 --- /dev/null +++ b/internal/syncsetup/storage_headless_test.go @@ -0,0 +1,93 @@ +package syncsetup + +import ( + "bytes" + "context" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/reqsign" + "io" + "net/http" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestStorageOptInFreshServingHomeDiscoversApproval(t *testing.T) { + celltest.Available(t) + server := relay(t) + first := machine(t, server.URL) + source, ok, err := Open(first) + if err != nil || !ok { + t.Fatal(err) + } + fresh := t.TempDir() + if err := identity.Install(fresh, source.Identity, source.Device); err != nil { + t.Fatal(err) + } + t.Setenv(URLVar, ProductionRelayURL) + t.Setenv(IntervalVar, "20") + _ = SaveStorage(first, ProductionRelayURL, source.Identity.ID(), "on") + if StorageForHome(fresh) != "off" { + t.Fatal("fresh home started open") + } + vault, err := keys.Open(fresh) + if err != nil { + t.Fatal(err) + } + if err := vault.Put("p/result", keys.Entry{Name: "result", Scope: "p", Value: "serving-home-upload"}); err != nil { + t.Fatal(err) + } + original := http.DefaultTransport + var stats, writes atomic.Int32 + approved := make(chan struct{}, 1) + http.DefaultTransport = storageTransport(func(r *http.Request) (*http.Response, error) { + if IsHostedRelay(r.URL.Scheme + "://" + r.URL.Host) { + var raw []byte + if r.Body != nil { + raw, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(raw)) + } + if _, _, err := reqsign.AuthenticateAt(time.Now)(r, raw); err != nil { + t.Errorf("unsigned request: %v", err) + } + if strings.HasSuffix(r.URL.Path, "/v1/store/stats") { + stats.Add(1) + select { + case approved <- struct{}{}: + default: + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"storage":"on"}`))}, nil + } + if strings.HasSuffix(r.URL.Path, "/v1/store/frames") { + writes.Add(1) + } + clone := r.Clone(r.Context()) + u, _ := url.Parse(server.URL) + clone.URL.Scheme = u.Scheme + clone.URL.Host = u.Host + clone.URL.Path = strings.TrimPrefix(clone.URL.Path, "/fabric") + clone.RequestURI = "" + reqsign.SignFor(deviceSigner{source.Identity, source.Device}, time.Now)(clone, raw) + return original.RoundTrip(clone) + } + return original.RoundTrip(r) + }) + t.Cleanup(func() { http.DefaultTransport = original }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { defer close(done); RunHomeState(ctx, fresh, func(err error) { t.Errorf("serving: %v", err) }) }() + defer func() { cancel(); <-done }() + select { + case <-approved: + case <-time.After(5 * time.Second): + t.Fatal("headless startup never read approval") + } + waitServeState(t, func() bool { return writes.Load() > 0 }) + if stats.Load() != 1 || StorageForHome(fresh) != "on" { + t.Fatalf("stats=%d permission=%s", stats.Load(), StorageForHome(fresh)) + } +} diff --git a/internal/syncsetup/storage_off_approve_test.go b/internal/syncsetup/storage_off_approve_test.go new file mode 100644 index 0000000000..02712c1678 --- /dev/null +++ b/internal/syncsetup/storage_off_approve_test.go @@ -0,0 +1,262 @@ +package syncsetup + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" +) + +// B5 permits stored-history reads when there is no local work to keep. +func TestStorageOffContinueReadsRecordedHistory(t *testing.T) { + for _, behind := range []bool{false, true} { + t.Run(map[bool]string{false: "absent root", true: "clean behind"}[behind], func(t *testing.T) { + h := newTwoHomes(t) + a := h.openA() + a.mustSay("first recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + if behind { + got, err := h.continuerB().Take(t.Context(), h.cell.ID) + mustNil(t, err) + mustNil(t, h.b.Dir.Release(t.Context(), h.cell.ID, got.Taken.Driving.Fence)) + a = h.openA() + a.mustSay("newer recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + } + want, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if behind { + local := cell.Cell{ID: h.cell.ID, Root: filepath.Join(h.rootsB, h.cell.ID)} + view, err := h.b.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + head, _, err := cellstore.Unpublished(local, view.Cell.Head) + mustNil(t, err) + dirty, err := h.continuerB().eng.Dirty(t.Context(), local) + mustNil(t, err) + if head != "" || dirty || headOf(t, local) == view.Cell.Head { + t.Fatal("fixture must be clean and behind the recorded history") + } + } + counted := &storageOffStore{Store: h.b.Store, off: true} + h.b.Store = counted + mustNil(t, SaveStorage(h.homeB, h.b.Relay, h.b.Identity.ID(), "off")) + got, err := h.continuerB().Take(t.Context(), h.cell.ID) + mustNil(t, err) + actual, err := os.ReadFile(transcriptOf(got.Taken.Cell)) + mustNil(t, err) + if !bytes.Equal(actual, want) || counted.writes != 0 { + t.Fatalf("history=%q want=%q store writes=%d", actual, want, counted.writes) + } + // Fetching history while off must not make the window wait for an uploader either. + storageOffRequireLocalReady(t, got) + }) + } +} + +// Continuation must preserve precisely the work the ordinary taker would keep. +func TestStorageOffContinuationUsesTheSameLocalWorkChecks(t *testing.T) { + for _, mode := range []string{"bookkeeping behind", "same length transcript edit", "workspace files only"} { + t.Run(mode, func(t *testing.T) { + h := newTwoHomes(t) + a := h.openA() + a.mustSay("first recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + got, err := h.continuerB().Take(t.Context(), h.cell.ID) + mustNil(t, err) + local := got.Taken.Cell + mustNil(t, h.b.Dir.Release(t.Context(), h.cell.ID, got.Taken.Driving.Fence)) + cont := h.continuerB() + before, err := os.ReadFile(transcriptOf(local)) + mustNil(t, err) + workspaceFile := filepath.Join(workspaceOf(local.Root), "kept.txt") + switch mode { + case "bookkeeping behind": + _, err = cont.eng.Seal(t.Context(), local, cellstore.TurnInfo{}) + mustNil(t, err) + a = h.openA() + a.mustSay("newer recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + before, err = os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + cont = h.continuerB() + case "same length transcript edit": + edited := bytes.Replace(before, []byte("first recorded turn"), []byte("local edited record"), 1) + if bytes.Equal(edited, before) || len(edited) != len(before) { + t.Fatal("fixture must edit a message without changing its length") + } + before = edited + mustNil(t, os.WriteFile(transcriptOf(local), before, 0600)) + case "workspace files only": + mustNil(t, os.WriteFile(workspaceFile, []byte("keep this work"), 0600)) + } + view, err := h.b.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + rawHead, _, err := cellstore.Unpublished(local, view.Cell.Head) + mustNil(t, err) + rawDirty, err := cont.eng.Dirty(t.Context(), local) + mustNil(t, err) + if mode == "bookkeeping behind" && rawHead == "" { + t.Fatal("fixture needs an unpublished bookkeeping seal") + } + if mode == "same length transcript edit" && (rawHead != "" || rawDirty) { + t.Fatal("fixture must evade the raw checks") + } + counted := &storageOffStore{Store: h.b.Store, off: true} + h.b.Store = counted + mustNil(t, SaveStorage(h.homeB, h.b.Relay, h.b.Identity.ID(), "off")) + got, takeErr := cont.Take(t.Context(), h.cell.ID) + actual, err := os.ReadFile(transcriptOf(local)) + mustNil(t, err) + if !bytes.Equal(actual, before) || counted.writes != 0 || got.Taken.Kept != "" { + t.Fatalf("history=%q want=%q writes=%d kept=%q", actual, before, counted.writes, got.Taken.Kept) + } + mustNil(t, takeErr) + if mode == "bookkeeping behind" { + if got.Taken.Driving.Head != view.Cell.Head || headOf(t, local) != view.Cell.Head { + t.Fatal("bookkeeping prevented the read-only fetch") + } + } else if got.Taken.Cell.Root != local.Root || got.Taken.Driving.Head != view.Cell.Head { + t.Fatal("local work did not continue in place") + } + if mode == "workspace files only" { + raw, err := os.ReadFile(workspaceFile) + mustNil(t, err) + if string(raw) != "keep this work" { + t.Fatalf("workspace work changed: %q", raw) + } + } + storageOffRequireLocalReady(t, got) + }) + } +} + +// B5 carries every preserved turn through quit, expiry, approval and publication. +func TestContinueOffThenApprovePublishes(t *testing.T) { + celltest.RequireEngine(t) + for _, empty := range []bool{true, false} { + t.Run(map[bool]string{true: "empty head", false: "extended head"}[empty], func(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + mustNil(t, err) + dev, err := identity.Device(home) + mustNil(t, err) + now := time.Now() + dir := directory.NewMemory(func() time.Time { return now }) + defer dir.Close() + store := &storageOffStore{Store: blobstore.NewMemory()} + s := &Sync{Home: home, Relay: "http://127.0.0.1:1", Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store, Ledger: "0123456789abcdef"} + t.Setenv(URLVar, s.Relay) + at, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + work := t.TempDir() + mustNil(t, session.SaveMeta(at.Root, session.Meta{ID: at.ID, Workspace: work, Created: now})) + eng := cellstore.EngineFor(work) + eng.DataRoot = t.TempDir() + t.Cleanup(cellstore.StopSpawned) + seal := func(words string) string { + appendTo(t, transcriptOf(at), `{"type":"message","role":"user","content":"`+words+`"}`+"\n") + got, err := eng.Seal(t.Context(), at, cellstore.TurnInfo{}) + mustNil(t, err) + return got.Turn.ID + } + base := "" + if !empty { + base = seal("recorded") + } + view, err := s.Dir.Create(t.Context(), at.ID, directory.CellInit{Head: base}) + mustNil(t, err) + mustNil(t, cellstore.NotePublished(at, base)) + seal("preserved one") + seal("preserved two") + mustNil(t, s.Dir.Release(t.Context(), at.ID, view.Cell.Lease.Fence)) + mustNil(t, SaveStorage(home, s.Relay, id.ID(), "off")) + store.off = true + continued, err := s.Continuer(eng, TakeOptions{RootFor: func(string) string { return at.Root }}).Take(t.Context(), at.ID) + mustNil(t, err) + preserved, err := os.ReadFile(transcriptOf(at)) + mustNil(t, err) + again, err := s.Continuer(eng, TakeOptions{RootFor: func(string) string { return at.Root }}).Take(t.Context(), at.ID) + mustNil(t, err) + if !again.AlreadyHere { + t.Fatal("current local hold was not reused") + } + storageOffRequireLocalReady(t, again) + if store.writes != 0 { + t.Fatalf("off writes=%d", store.writes) + } + // No drive runs while off, so quitting leaves the directory hold to expire. + now = now.Add(directory.LeaseTTL + time.Second) + store.off = false + mustNil(t, SaveStorage(home, s.Relay, id.ID(), "on")) + opened, err := cell.OpenAt(continued.Taken.Cell.Root, at.ID) + mustNil(t, err) + // A fresh owner reads the durable continuation result after the old process quits. + reopened := &Sync{Home: home, Relay: s.Relay, Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store, Ledger: s.Ledger} + drive, err := reopened.Drive(t.Context(), eng, opened, DriveOptions{}) + mustNil(t, err) + mustNil(t, drive.Close(t.Context())) + view, err = s.Dir.Cell(t.Context(), at.ID) + mustNil(t, err) + if view.Cell.Head != headOf(t, at) || store.writes == 0 { + t.Fatalf("approval left head=%s local=%s writes=%d", view.Cell.Head, headOf(t, at), store.writes) + } + restored := cell.Cell{ID: at.ID, Root: filepath.Join(t.TempDir(), at.ID)} + receiver := eng + receiver.DataRoot = t.TempDir() + synced := receiver.Sync(cellstore.SyncKeys{CellKey: id.CellKey(), Dedup: id.DedupSecret()}, "fedcba9876543210") + fetch := cellsync.Fetcher{Store: store.Store, Engine: synced, Inbox: synced.Inbox} + mustNil(t, fetch.Fetch(t.Context(), restored, view.Cell.Head)) + raw, err := os.ReadFile(transcriptOf(restored)) + mustNil(t, err) + if !bytes.Equal(raw, preserved) { + t.Fatalf("publication changed preserved history: got %s want %s", raw, preserved) + } + for _, words := range []string{"preserved one", "preserved two"} { + if !bytes.Contains(raw, []byte(words)) { + t.Fatalf("published history lacks %q: %s", words, raw) + } + } + }) + } +} + +func storageOffRequireLocalReady(t *testing.T, got Continued) { + t.Helper() + raw, err := json.Marshal(got) + mustNil(t, err) + var readiness struct{ LocalOnly bool } + mustNil(t, json.Unmarshal(raw, &readiness)) + if !readiness.LocalOnly { + t.Fatal("storage-off history waits for an uploading writer") + } +} + +// B5 does not grant another computer's expired empty-head chat to this writer. +func TestAnotherDevicesEmptyHeadStaysViewer(t *testing.T) { + r := newDriveRig(t) + r.spokenFixture() + _, err := r.b.Dir.Create(t.Context(), r.cell.ID, directory.CellInit{}) + mustNil(t, err) + r.clock.advance(directory.LeaseTTL + time.Second) + d, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer d.Close(t.Context()) + if d.Gate() == nil { + t.Fatal("another device's empty-head chat acquired a writer") + } +} diff --git a/internal/syncsetup/storage_off_continue_test.go b/internal/syncsetup/storage_off_continue_test.go new file mode 100644 index 0000000000..62a4da7ab0 --- /dev/null +++ b/internal/syncsetup/storage_off_continue_test.go @@ -0,0 +1,218 @@ +package syncsetup + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "reflect" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +type storageOffStore struct { + blobstore.Store + writes int + off bool +} + +func (s *storageOffStore) PutFrame(ctx context.Context, frame []byte) (blobstore.FrameID, error) { + s.writes++ + if s.off { + return "", wireauth.ErrStorageOff + } + return s.Store.PutFrame(ctx, frame) +} + +// B5 preserves the entire local root and acquires through the directory without store writes. +func TestStorageOffContinuePreservesLocalTurns(t *testing.T) { + celltest.RequireEngine(t) + for _, mode := range []string{"two turns above head", "empty recorded head", "diverged", "head moves at acquire"} { + t.Run(mode, func(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + mustNil(t, err) + dev, err := identity.Device(home) + mustNil(t, err) + dir := directory.NewMemory(time.Now) + defer dir.Close() + store := &storageOffStore{Store: blobstore.NewMemory(), off: true} + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store} + at, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + work := t.TempDir() + mustNil(t, session.SaveMeta(at.Root, session.Meta{ID: at.ID, Workspace: work, Created: time.Now()})) + eng := cellstore.EngineFor(work) + eng.DataRoot = t.TempDir() + t.Cleanup(cellstore.StopSpawned) + seal := func(words string) string { + appendTo(t, transcriptOf(at), `{"type":"message","role":"user","content":"`+words+`"}`+"\n") + sealed, err := eng.Seal(t.Context(), at, cellstore.TurnInfo{}) + mustNil(t, err) + return sealed.Turn.ID + } + base := seal("recorded turn") + if mode == "empty recorded head" { + base = "" + } + view, err := s.Dir.Create(t.Context(), at.ID, directory.CellInit{Head: base}) + mustNil(t, err) + mustNil(t, cellstore.NotePublished(at, base)) + seal("local one") + seal("local two") + if mode == "diverged" { + view, err = s.Dir.Publish(t.Context(), at.ID, directory.Publish{Fence: view.Cell.Lease.Fence, OldHead: base, Head: "missing-remote-turn"}) + mustNil(t, err) + } + mustNil(t, s.Dir.Release(t.Context(), at.ID, view.Cell.Lease.Fence)) + if mode == "head moves at acquire" { + s.Dir = storageOffMovingDirectory{Client: s.Dir} + } + before, err := os.ReadFile(transcriptOf(at)) + mustNil(t, err) + turns, err := cellstore.Turns(at) + mustNil(t, err) + cont := s.Continuer(eng, TakeOptions{RootFor: func(string) string { return at.Root }}) + got, err := cont.Take(t.Context(), at.ID) + refused := mode == "diverged" || mode == "head moves at acquire" + if refused { + if err == nil || err.Error() != "cannot continue this chat here while sync storage is off because this computer is missing saved turns; your local turns stay here" { + t.Fatalf("refusal: %v", err) + } + } else if err != nil { + t.Fatalf("continue: %v", err) + } + after, readErr := os.ReadFile(transcriptOf(at)) + mustNil(t, readErr) + afterTurns, readErr := cellstore.Turns(at) + mustNil(t, readErr) + if !bytes.Equal(before, after) || !reflect.DeepEqual(turns, afterTurns) || store.writes != 0 { + t.Fatalf("lost local history or wrote storage: turns=%d/%d writes=%d", len(turns), len(afterTurns), store.writes) + } + latest, err := s.Dir.Cell(t.Context(), at.ID) + mustNil(t, err) + if !refused && (got.Taken.Cell.Root != at.Root || latest.Cell.Lease.Device != dev.ID() || latest.Cell.Lease.Fence <= view.Cell.Lease.Fence || got.Taken.Driving.Fence != latest.Cell.Lease.Fence) { + t.Fatalf("directory lease not acquired: %+v", latest.Cell.Lease) + } + if refused && latest.Cell.Lease.Expires != 0 { + t.Fatal("refused continuation retained its new lease") + } + }) + } +} + +type storageOffMovingDirectory struct{ directory.Client } + +func (d storageOffMovingDirectory) Acquire(ctx context.Context, id string, opts directory.AcquireOpts) (directory.CellView, error) { + got, err := d.Client.Acquire(ctx, id, opts) + if err != nil { + return got, err + } + return d.Client.Publish(ctx, id, directory.Publish{Fence: got.Cell.Lease.Fence, OldHead: got.Cell.Head, Head: "new-remote-turn"}) +} + +// B5 keeps the storage-on branch upload and remote-head restore through the existing take path. +func TestStorageOnContinueStillKeepsAndFetches(t *testing.T) { + h := newTwoHomes(t) + a := h.openA() + a.mustSay("recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + base := headOf(t, h.cell) + appendTo(t, transcriptOf(h.cell), `{"type":"message","role":"user","content":"local extra"}`+"\n") + _, err := h.engine.Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + counted := &storageOffStore{Store: h.a.Store} + h.a.Store = counted + got, err := h.continuerA().Take(t.Context(), h.cell.ID) + mustNil(t, err) + if got.Taken.Kept == "" || got.Taken.Driving.Head != base || counted.writes == 0 { + t.Fatalf("on path changed: kept=%s head=%s writes=%d", got.Taken.Kept, got.Taken.Driving.Head, counted.writes) + } + raw, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if bytes.Contains(raw, []byte("local extra")) { + t.Fatal("root did not restore the recorded head") + } +} + +// B3 discovers a mid-session pairing once and leaves never-paired homes silent. +func TestStorageRefreshDiscoversMidSessionPairing(t *testing.T) { + celltest.Available(t) + for _, solo := range []bool{false, true} { + for _, initial := range []bool{false, true} { + name := "missing identity/" + if solo { + name = "solo identity/" + } + if initial { + name += "engine" + } else { + name += "window" + } + t.Run(name, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/v1/store/stats" { + t.Errorf("unexpected request %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"storage":"on"}`)) + })) + defer server.Close() + t.Setenv(URLVar, server.URL) + home := t.TempDir() + if solo { + _, err := identity.EnsureSolo(home) + mustNil(t, err) + } + p := storageProcessFor(home) + timers := make(chan func(), 8) + p.after = func(_ time.Duration, fn func()) func() bool { timers <- fn; return func() bool { return true } } + stop := StartStorageRefresh(t.Context(), home, initial) + defer stop() + next := func() func() { + select { + case fn := <-timers: + return fn + case <-time.After(3 * time.Second): + t.Fatal("pairing observation stopped") + return nil + } + } + first := next() + if requests.Load() != 0 { + t.Fatal("never-paired home made a request") + } + first() + second := next() + if requests.Load() != 0 { + t.Fatal("never-paired home made a request on its next check") + } + id, err := identity.Ensure(home) + mustNil(t, err) + if solo { + mustNil(t, identity.EndSolo(home)) + } + second() + p.check(true) + again := StartStorageRefresh(t.Context(), home, false) + defer again() + if requests.Load() != 1 || StorageState(home, server.URL, id.ID()) != "on" { + t.Fatalf("pairing requests=%d state=%s", requests.Load(), StorageState(home, server.URL, id.ID())) + } + }) + } + } +} diff --git a/internal/syncsetup/storage_optin_test.go b/internal/syncsetup/storage_optin_test.go new file mode 100644 index 0000000000..52ac532f38 --- /dev/null +++ b/internal/syncsetup/storage_optin_test.go @@ -0,0 +1,226 @@ +package syncsetup + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/wireauth" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type storageTransport func(*http.Request) (*http.Response, error) + +func (f storageTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestStorageOptInCacheAndUploadDoors(t *testing.T) { + home := t.TempDir() + const id = "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + for _, relay := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + if !IsHostedRelay(relay) || StorageState(home, relay, id) != "off" { + t.Fatalf("unknown hosted state open: %s", relay) + } + } + if IsHostedRelay("https://codeaf.agentfield.ai.evil.test/fabric") || StorageState(home, "http://own.test", id) != "on" { + t.Fatal("self-hosted policy changed") + } + calls := 0 + hc := &http.Client{Transport: storageTransport(func(r *http.Request) (*http.Response, error) { + calls++ + body := `{"frame":"frame","objects":1}` + if strings.HasSuffix(r.URL.Path, "/has") { + body = `{"have":[false]}` + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })} + c := GateStore(blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc), home, ProductionRelayURL, id) + if _, err := c.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 0 { + t.Fatalf("write while off: %v calls=%d", err, calls) + } + if _, err := c.Has(t.Context(), nil); err != nil || calls != 1 { + t.Fatalf("read while off: %v calls=%d", err, calls) + } + if err := SaveStorage(home, ProductionRelayURL, id, "on"); err != nil { + t.Fatal(err) + } + if _, err := c.PutFrame(t.Context(), nil); err != nil || calls != 2 { + t.Fatalf("approved write: %v calls=%d", err, calls) + } + if StorageState(home, DevRelayURL, id) != "off" || StorageState(home, ProductionRelayURL, "id_b") != "off" { + t.Fatal("permission crossed account or relay") + } + if err := SaveStorage(home, ProductionRelayURL, id, "off"); err != nil { + t.Fatal(err) + } + if _, err := c.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 2 { + t.Fatal("revoked store made a request") + } + _ = SaveStorage(home, ProductionRelayURL, id, "") + if StorageState(home, ProductionRelayURL, id) != "on" { + t.Fatal("no-field hosted answer must enable legacy storage") + } + _ = os.WriteFile(filepath.Join(home, StorageFile), []byte("{"), 0600) + if StorageState(home, ProductionRelayURL, id) != "off" { + t.Fatal("damaged cache enabled storage") + } +} + +func TestStorageOptInDriveStopsBeforeAnyNetworkOrEngineWork(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id} + _, err = s.Drive(t.Context(), cellstore.Engine{}, cell.Cell{ID: "local"}, DriveOptions{}) + if !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("drive started: %v", err) + } +} + +func TestStorageOptInWireRefusalClosesAllLocalUploaders(t *testing.T) { + const relay = "http://127.0.0.1:18831" + home := t.TempDir() + id := "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if err := SaveStorage(home, relay, id, "on"); err != nil { + t.Fatal(err) + } + calls := 0 + hc := &http.Client{Transport: storageTransport(func(*http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 403, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"err":"storage_off"}`))}, nil + })} + first := GateStore(blobstore.NewHTTP(relay, func(*http.Request, []byte) {}, hc), home, relay, id) + second := GateStore(blobstore.NewHTTP(relay, func(*http.Request, []byte) {}, hc), home, relay, id) + _, _ = first.PutFrame(t.Context(), nil) + if _, err := second.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 1 { + t.Fatalf("refusal was not remembered: %v requests=%d", err, calls) + } +} + +func TestStorageOptInVaultEditsWaitAndUploadAfterApproval(t *testing.T) { + home := t.TempDir() + t.Setenv(URLVar, ProductionRelayURL) + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + dev, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + vault, err := keys.Open(home) + if err != nil { + t.Fatal(err) + } + if err = vault.Put("project/pending", keys.Entry{Name: "pending", Value: "throwaway-test-value", Scope: "project"}); err != nil { + t.Fatal(err) + } + dir := directory.NewMemory(time.Now) + defer dir.Close() + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Device: dev, Store: blobstore.NewMemory(), Dir: dir.For(dev.ID())} + if err = s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + listing, err := s.Dir.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if listing.Identity.Vault != "" || s.stateSent != "" { + t.Fatal("pending vault was treated as sent while off") + } + if err = SaveStorage(home, ProductionRelayURL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + if err = s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + listing, err = s.Dir.List(t.Context()) + if err != nil || listing.Identity.Vault == "" { + t.Fatalf("pending vault did not upload after approval: %+v %v", listing.Identity, err) + } +} + +func TestStorageOptInDefaultPortsAndCase(t *testing.T) { + home := t.TempDir() + for _, relay := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + u, _ := url.Parse(relay) + u.Host = strings.ToUpper(u.Host) + ":443" + if !IsHostedRelay(u.String()) || StorageState(home, u.String(), "account") != "off" { + t.Fatalf("unknown default-port hosted relay is open: %s", u) + } + if err := SaveStorage(home, relay, "account", "on"); err != nil { + t.Fatal(err) + } + if StorageState(home, u.String(), "account") != "on" { + t.Fatalf("cache binding differs for %s", u) + } + } + if !IsHostedRelay("http://CODEAF.AGENTFIELD.AI:80/fabric") || IsHostedRelay("https://codeaf.agentfield.ai:444/fabric") { + t.Fatal("port normalization") + } +} + +func TestStorageOptInRotationBindingRelay(t *testing.T) { + for _, relay := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + raw := strings.Replace(relay, "https://", "HTTPS://", 1) + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + u.Host = strings.ToUpper(u.Host) + ":443" + u.Path += "/" + s := &Sync{Relay: u.String()} + env := s.Rotation(cellstore.Engine{}, nil, "", nil) + if env.HostedRelay != relay { + t.Fatalf("binding relay %q, want %q", env.HostedRelay, relay) + } + } + for _, relay := range []string{"http://own.test:8787", "https://own.test"} { + s := &Sync{Relay: relay} + if env := s.Rotation(cellstore.Engine{}, nil, "", nil); env.HostedRelay != "" { + t.Fatalf("self-hosted binding %q", env.HostedRelay) + } + } +} + +func TestStorageOptInLegacyCustomOnCacheDoesNotInventCapability(t *testing.T) { + home := t.TempDir() + const relay = "http://127.0.0.1:18831" + const id = "id_account" + raw := `{"relay":"http://127.0.0.1:18831","identity":"id_account","storage":"on"}` + if err := os.WriteFile(filepath.Join(home, StorageFile), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if StorageGated(home, relay, id) || StorageState(home, relay, id) != "on" || !StorageNeedsRefresh(home, relay, id) { + t.Fatal("legacy on cache cannot prove a custom relay reported storage") + } + if err := SaveStorage(home, relay, id, "on"); err != nil { + t.Fatal(err) + } + if !StorageGated(home, relay, id) || StorageNeedsRefresh(home, relay, id) { + t.Fatal("explicit on answer must be gating and idle") + } +} + +func TestStorageOptInRequestLinkUsesPublicWording(t *testing.T) { + const id = "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + link, err := url.Parse(StorageRequestLink(id)) + if err != nil { + t.Fatal(err) + } + query := link.Query() + if query.Get("title") != "sync storage access: "+id || query.Get("body") != "Please turn on sync storage for my account.\n\nRequest code: "+id { + t.Fatalf("request wording: %v", query) + } +} diff --git a/internal/syncsetup/storage_reads_linux_test.go b/internal/syncsetup/storage_reads_linux_test.go new file mode 100644 index 0000000000..273fedcbf4 --- /dev/null +++ b/internal/syncsetup/storage_reads_linux_test.go @@ -0,0 +1,153 @@ +package syncsetup + +import ( + "context" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "golang.org/x/sys/unix" +) + +// File opens observe the complete production sample, including reads inside +// dependencies; seam counters alone cannot detect duplicated quiet checks. +func watchStorageReads(t *testing.T, home string) func() map[string]int { + t.Helper() + fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { unix.Close(fd) }) + // Close events keep consecutive opens of the same file from coalescing. + if _, err := unix.InotifyAddWatch(fd, home, unix.IN_OPEN|unix.IN_CLOSE_NOWRITE); err != nil { + t.Fatal(err) + } + return func() map[string]int { + t.Helper() + counts := map[string]int{} + buf := make([]byte, 64<<10) + for { + n, err := unix.Read(fd, buf) + if err == unix.EAGAIN { + return counts + } + if err != nil { + t.Fatal(err) + } + for at := 0; at < n; { + mask := binary.NativeEndian.Uint32(buf[at+4 : at+8]) + size := int(binary.NativeEndian.Uint32(buf[at+12 : at+16])) + name := strings.TrimRight(string(buf[at+unix.SizeofInotifyEvent:at+unix.SizeofInotifyEvent+size]), "\x00") + if mask&unix.IN_Q_OVERFLOW != 0 { + t.Fatal("file-read measurement overflowed") + } + if mask&unix.IN_OPEN != 0 { + counts[name]++ + } + at += unix.SizeofInotifyEvent + size + } + } + } +} + +func TestStorageOptInSharedWaiterWork(t *testing.T) { + for _, override := range []bool{false, true} { + for _, n := range []int{1, 10, 50} { + for _, mode := range []string{"on", "off", "custom-off", "self-hosted"} { + t.Run(fmt.Sprintf("env=%v/%s/%d", override, mode, n), func(t *testing.T) { + home := t.TempDir() + relay := ProductionRelayURL + if mode == "self-hosted" || mode == "custom-off" { + relay = "http://own.test" + } + t.Setenv(URLVar, "") + if override { + t.Setenv(URLVar, relay) + } else { + if err := os.Unsetenv(URLVar); err != nil { + t.Fatal(err) + } + if err := SaveRelayURL(home, relay); err != nil { + t.Fatal(err) + } + } + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + state := mode + if state == "self-hosted" { + state = "" + } + if mode == "custom-off" { + state = "off" + } + if err := SaveStorage(home, relay, id.ID(), state); err != nil { + t.Fatal(err) + } + timers, requests := 0, 0 + reads := watchStorageReads(t, home) + var next func() + p := storageProcessFor(home) + p.fetch = func(context.Context) error { requests++; return nil } + p.after = func(wait time.Duration, fn func()) func() bool { + if wait > StoragePollEvery { + t.Fatalf("wait %v", wait) + } + timers++ + next = fn + return func() bool { next = nil; return true } + } + woke := 0 + if mode == "off" || mode == "custom-off" { + for i := 0; i < n; i++ { + p.waiting[uint64(i)] = func() { woke++ } + } + } + p.check(false) + // Discard setup reads, then observe every production file open during + // one minute of steady-state waiting, including nested quiet checks. + reads() + timers, requests = 0, 0 + if next != nil { + fn := next + next = nil + fn() + } + wantReads, wantTimers := map[string]int{}, 0 + if mode == "off" || mode == "custom-off" { + wantReads[identity.File], wantReads[StorageFile] = 1, 1 + if !override { + wantReads[SavedURLFile] = 1 + } + wantTimers = 1 + } + actual := reads() + if !reflect.DeepEqual(actual, wantReads) || timers != wantTimers || requests != 0 { + t.Fatalf("reads=%v want=%v timers=%d requests=%d", actual, wantReads, timers, requests) + } + t.Logf("env=%v chats=%d state=%s file-reads/min=%v timer-arms/min=%d relay-requests=%d", override, n, mode, actual, timers, requests) + if mode == "off" || mode == "custom-off" { + // A raw file change models another process sharing this home. + raw := fmt.Sprintf(`{"relay":%q,"identity":%q,"storage":"on","reports_storage":true}`, relay, id.ID()) + if err := os.WriteFile(filepath.Join(home, StorageFile), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + fn := next + fn() + if woke != n || next != nil { + t.Fatalf("wake=%d timer=%v", woke, next != nil) + } + } + }) + } + } + } + +} diff --git a/internal/syncsetup/storage_refresh.go b/internal/syncsetup/storage_refresh.go new file mode 100644 index 0000000000..a4a6c43082 --- /dev/null +++ b/internal/syncsetup/storage_refresh.go @@ -0,0 +1,393 @@ +package syncsetup + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +const StorageRefreshEvery = 10 * time.Minute + +// One coordinator owns both approval discovery and startup waits in a process. +// Running batchers use their own bounded retry and never poll permission files. +var storageProcesses = struct { + sync.Mutex + homes map[string]*storageProcess +}{homes: make(map[string]*storageProcess)} + +type storageProcess struct { + mu sync.Mutex + home string + users int + next uint64 + waiting map[uint64]func() + opened map[uint64]func() + stop func() bool + // ctx belongs to the current lifetime of users; the last user to leave + // cancels it, so a request in flight cannot outlive everyone who asked. + ctx context.Context + cancel context.CancelFunc + // inflight is open while a check runs and closed when it ends, so the + // last user can wait for it instead of returning while it still writes. + inflight chan struct{} + lastRefresh time.Time + active bool + watchSetup bool + dirty bool + refused *Sync + counts blobstore.Counts + countsRelay, countsIdentity string + probeRelay, probeIdentity string + // These seams count actual scheduled work without relying on elapsed time. + after func(time.Duration, func()) func() bool + read func() (string, string, string) + fetch func(context.Context) error + now func() time.Time +} + +func storageProcessFor(home string) *storageProcess { + storageProcesses.Lock() + defer storageProcesses.Unlock() + p := storageProcesses.homes[home] + if p == nil { + ctx := context.Background() + p = &storageProcess{home: home, ctx: ctx, waiting: make(map[uint64]func()), opened: make(map[uint64]func()), now: time.Now} + p.after = func(d time.Duration, f func()) func() bool { return time.AfterFunc(d, f).Stop } + p.read = func() (string, string, string) { + relay := Resolve(home) + if !relay.On() || identity.Solo(home) { + return "", "", "" + } + id, err := identity.Load(home) + if err != nil { + return "", "", "" + } + state, gating, answered := storagePolicy(home, relay.URL, id.ID()) + if !answered && !gating { + state = "" + } + return relay.URL, id.ID(), state + } + p.fetch = func(ctx context.Context) error { + s, ok, err := Open(home) + if err != nil || !ok { + return err + } + _, err = s.RefreshStorage(ctx) + return err + } + storageProcesses.homes[home] = p + } + return p +} + +// StartStorageRefresh shares one asynchronous signed reading at startup and +// every ten minutes while a gating relay is unknown or off. Other relays get +// one discovery request until an answer establishes their storage policy. +// Callers join its lifetime independently; chat opening never waits for a network answer. +func StartStorageRefresh(ctx context.Context, home string, initial ...bool) func() { + p := storageProcessFor(home) + p.mu.Lock() + p.users++ + p.watchSetup = true + first := p.users == 1 + if first && (p.ctx == nil || p.ctx.Err() != nil || p.cancel == nil) { + p.ctx, p.cancel = context.WithCancel(context.Background()) + } + p.mu.Unlock() + if first { + refresh := len(initial) == 0 || initial[0] + go p.check(refresh) + } + var once sync.Once + stop := func() { + once.Do(func() { + p.mu.Lock() + p.users-- + var running chan struct{} + if p.users == 0 && len(p.waiting) == 0 { + p.disarm() + if p.cancel != nil { + p.cancel() + } + running = p.inflight + } + p.mu.Unlock() + // A check that already started may still be saving its answer into + // the home; the caller must not return while that write can land. + if running != nil { + <-running + } + }) + } + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + stop() + case <-done: + } + }() + var joined sync.Once + return func() { stop(); joined.Do(func() { close(done) }) } +} + +// RefreshStorage retains a failed reading and persists only successful stats. +func (s *Sync) RefreshStorage(ctx context.Context) (blobstore.Counts, error) { + store, ok := s.Store.(interface { + Stats(context.Context) (blobstore.Counts, error) + }) + if !ok { + return blobstore.Counts{}, nil + } + p := storageProcessFor(s.Home) + p.mu.Lock() + p.probeRelay, p.probeIdentity = normalizeStorageURL(s.Relay), s.Identity.ID() + p.mu.Unlock() + counts, err := store.Stats(ctx) + if p != nil { + p.mu.Lock() + p.lastRefresh = p.now() + if errors.Is(err, wireauth.ErrRevoked) || errors.Is(err, wireauth.ErrGone) { + p.refused = s + p.disarm() + } + if err == nil { + p.counts = counts + p.countsRelay, p.countsIdentity = s.Relay, s.Identity.ID() + } + p.mu.Unlock() + } + if err == nil { + err = SaveStorage(s.Home, s.Relay, s.Identity.ID(), counts.Storage) + } + return counts, err +} + +// WaitStorage registers a stopped chat on one shared local sampling timer. +func WaitStorage(home string, opened func()) func() { + p := storageProcessFor(home) + p.mu.Lock() + p.next++ + id := p.next + p.waiting[id] = opened + first := len(p.waiting) == 1 + p.mu.Unlock() + if first { + go p.check(false) + } + return func() { + p.mu.Lock() + _, had := p.waiting[id] + delete(p.waiting, id) + empty := had && len(p.waiting) == 0 + if empty { + p.disarm() + } + users := p.users + p.mu.Unlock() + if empty && users > 0 { + go p.check(false) + } + } +} + +// OnStorageOpened wakes an existing batcher immediately after an in-process +// refresh, without giving it a timer or a separate permission-file reader. +func OnStorageOpened(home string, opened func()) func() { + p := storageProcessFor(home) + p.mu.Lock() + p.next++ + id := p.next + p.opened[id] = opened + p.mu.Unlock() + return func() { p.mu.Lock(); delete(p.opened, id); p.mu.Unlock() } +} + +func (p *storageProcess) disarm() { + if p.stop != nil { + p.stop() + p.stop = nil + } +} + +func (p *storageProcess) check(refresh bool) { + p.mu.Lock() + // Checks start on their own goroutines, so one can begin after the last + // user and waiter left. It then has nobody to answer and must not touch + // the home, or it outlives the lifetime that started it. + if p.users == 0 && len(p.waiting) == 0 { + p.mu.Unlock() + return + } + if p.active { + p.dirty = true + p.mu.Unlock() + return + } + p.active = true + p.inflight = make(chan struct{}) + requests := p.ctx + p.disarm() + p.mu.Unlock() + relay, id, state := p.read() + p.mu.Lock() + refused := p.refused + p.mu.Unlock() + removed := false + if refused != nil { + // Only a different pairing can reopen a refused lifetime. This extra + // lookup happens after refusal, keeping ordinary waiter samples small. + current, ok, err := Open(p.home) + removed = err != nil || !ok || (current.Relay == refused.Relay && + current.Identity.ID() == refused.Identity.ID() && current.Device.ID() == refused.Device.ID()) + if !removed { + p.mu.Lock() + if p.refused == refused { + p.refused = nil + p.probeRelay, p.probeIdentity = "", "" + } + p.mu.Unlock() + } + } + p.mu.Lock() + ask := !removed && (state == "off" || state == "" && (p.probeRelay != normalizeStorageURL(relay) || p.probeIdentity != id)) + if relay != "" && ask && refresh { + p.probeRelay, p.probeIdentity = normalizeStorageURL(relay), id + } + p.mu.Unlock() + if relay != "" && ask && refresh { + ctx, cancel := context.WithTimeout(requests, requestTimeout) + err := p.fetch(ctx) + cancel() + removed = errors.Is(err, wireauth.ErrRevoked) || errors.Is(err, wireauth.ErrGone) + p.mu.Lock() + p.lastRefresh = p.now() + p.mu.Unlock() + relay, _, state = p.read() + } + p.mu.Lock() + p.active = false + close(p.inflight) + p.inflight = nil + removed = removed || p.refused != nil + again := p.dirty && !removed + p.dirty = false + var callbacks []func() + if !removed && state == "on" { + for id, fn := range p.waiting { + callbacks = append(callbacks, fn) + delete(p.waiting, id) + } + } + // A removed device cannot regain access by polling for storage approval. + // Before pairing, sample local setup only; the first eligible sample asks once. + watchingPairing := p.watchSetup && p.users > 0 && relay == "" && Resolve(p.home).On() + if !removed && (watchingPairing || relay != "" && state == "off") && (p.users > 0 || len(p.waiting) > 0) { + wait := StorageRefreshEvery + if p.users > 0 && !p.lastRefresh.IsZero() { + wait = max(time.Millisecond, StorageRefreshEvery-p.now().Sub(p.lastRefresh)) + } + if watchingPairing || len(p.waiting) > 0 { + wait = min(wait, StoragePollEvery) + } + p.stop = p.after(wait, func() { + p.mu.Lock() + refresh := p.users > 0 && p.now().Sub(p.lastRefresh) >= StorageRefreshEvery + p.mu.Unlock() + p.check(refresh) + }) + } + p.mu.Unlock() + for _, fn := range callbacks { + fn() + } + if again { + p.check(false) + } +} + +func storageSaved(home, relay, id, state string) { + storageProcesses.Lock() + p := storageProcesses.homes[home] + storageProcesses.Unlock() + if p == nil { + return + } + // The cache may temporarily hold a rotation successor, so callbacks use the + // current home's binding instead of trusting a different identity's reading. + p.mu.Lock() + active := p.active + if active && state == "off" { + p.dirty = true + } + p.mu.Unlock() + if state == "on" { + current, account, reading := p.read() + if normalizeStorageURL(current) != normalizeStorageURL(relay) || account != id || reading != "on" { + return + } + p.mu.Lock() + p.disarm() + callbacks := make([]func(), 0, len(p.waiting)+len(p.opened)) + for key, fn := range p.waiting { + callbacks = append(callbacks, fn) + delete(p.waiting, key) + } + for _, fn := range p.opened { + callbacks = append(callbacks, fn) + } + p.mu.Unlock() + for _, fn := range callbacks { + go fn() + } + } else if !active { + go p.check(false) + } +} + +// StorageCounts retains the last in-process usage reading while approval is on. +// Permission persistence deliberately stores no usage counters. +func StorageCounts(home, relay, identity string) blobstore.Counts { + storageProcesses.Lock() + p := storageProcesses.homes[home] + storageProcesses.Unlock() + if p == nil { + return blobstore.Counts{Storage: "on"} + } + p.mu.Lock() + defer p.mu.Unlock() + counts := p.counts + if normalizeStorageURL(p.countsRelay) != normalizeStorageURL(relay) || p.countsIdentity != identity { + counts = blobstore.Counts{} + } + counts.Storage = "on" + return counts +} + +// StorageNeedsRefresh keeps startup and /status on the same request policy. +// It reserves an unanswered custom relay probe atomically across callers. +// An unanswered custom relay is probed at most once in this process, even if +// that request fails. Hosted unknown/off permissions retain approval discovery. +func StorageNeedsRefresh(home, relay, id string) bool { + state, gating, answered := storagePolicy(home, relay, id) + if state == "off" { + return true + } + if answered || gating { + return false + } + p := storageProcessFor(home) + p.mu.Lock() + defer p.mu.Unlock() + if p.probeRelay == normalizeStorageURL(relay) && p.probeIdentity == id { + return false + } + p.probeRelay, p.probeIdentity = normalizeStorageURL(relay), id + return true +} diff --git a/internal/syncsetup/storage_refresh_test.go b/internal/syncsetup/storage_refresh_test.go new file mode 100644 index 0000000000..96889327cf --- /dev/null +++ b/internal/syncsetup/storage_refresh_test.go @@ -0,0 +1,354 @@ +package syncsetup + +import ( + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestStorageOptInFailedRefreshPreservesCache(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + for _, state := range []string{"off", "on"} { + if err := SaveStorage(home, ProductionRelayURL, id.ID(), state); err != nil { + t.Fatal(err) + } + hc := &http.Client{Transport: storageTransport(func(*http.Request) (*http.Response, error) { return nil, errors.New("offline fixture") })} + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Store: blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc)} + if _, err := s.RefreshStorage(t.Context()); err == nil || StorageState(home, ProductionRelayURL, id.ID()) != state { + t.Fatal("failed refresh changed permission") + } + } +} +func TestStorageOptInRefresherCadenceAndApplicability(t *testing.T) { + for _, mode := range []string{"off", "on", "self-hosted", "sync-off"} { + t.Run(mode, func(t *testing.T) { + requests := 0 + var next func() + now := time.Unix(100, 0) + p := &storageProcess{ctx: t.Context(), users: 1, waiting: map[uint64]func(){}, opened: map[uint64]func(){}, now: func() time.Time { return now }} + p.read = func() (string, string, string) { + if mode == "self-hosted" || mode == "sync-off" { + return "", "", "" + } + return ProductionRelayURL, "id", mode + } + p.fetch = func(context.Context) error { requests++; return nil } + p.after = func(wait time.Duration, fn func()) func() bool { + if wait != StorageRefreshEvery { + t.Fatalf("cadence %v", wait) + } + next = fn + return func() bool { next = nil; return true } + } + p.check(true) + if mode == "off" { + if requests != 1 || next == nil { + t.Fatal("missing startup refresh") + } + now = now.Add(StorageRefreshEvery) + next() + if requests != 2 { + t.Fatal("missing periodic refresh") + } + } else if requests != 0 || next != nil { + t.Fatalf("inapplicable work requests=%d timer=%v", requests, next != nil) + } + }) + } +} + +func TestStorageOptInSignedRefreshUpdatesWaitersAndRunningDrives(t *testing.T) { + home := t.TempDir() + t.Setenv(URLVar, ProductionRelayURL) + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + p := storageProcessFor(home) + opened := make(chan string, 2) + cancelRunning := OnStorageOpened(home, func() { opened <- "running" }) + defer cancelRunning() + // Register under the coordinator lock so this test controls the initial + // local sample and the signed answer, rather than the scheduler. + p.mu.Lock() + p.waiting[1] = func() { opened <- "waiting" } + p.mu.Unlock() + hc := &http.Client{Transport: storageTransport(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("Codeaf-Sig") == "" { + t.Error("unsigned stats") + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"storage":"on"}`))}, nil + })} + dev, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Device: dev, Store: blobstore.NewHTTP(ProductionRelayURL, reqsign.SignFor(deviceSigner{id, dev}, time.Now), hc)} + if _, err := s.RefreshStorage(t.Context()); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + select { + case <-opened: + case <-time.After(5 * time.Second): + t.Fatal("refresh failed to wake") + } + } +} + +// Exercise the real signed stats wire at an arbitrary relay address. +func TestStorageOptInRelayAnswerDecides(t *testing.T) { + celltest.Available(t) + for _, answer := range []string{`{"storage":"off"}`, `{"storage":"on"}`, `{"objects":0}`} { + t.Run(answer, func(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/v1/store/stats" || r.Header.Get("Codeaf-Sig") == "" { + t.Errorf("unexpected stats: %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(answer)) + })) + defer srv.Close() + t.Setenv(URLVar, srv.URL) + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + if StorageState(home, srv.URL, id.ID()) != "on" { + t.Fatal("unknown custom relay must start open") + } + p := storageProcessFor(home) + p.users = 1 + var next func() + p.after = func(_ time.Duration, fn func()) func() bool { + next = fn + return func() bool { next = nil; return true } + } + defer p.disarm() + p.check(true) + if requests.Load() != 1 { + t.Fatalf("startup requests=%d want 1", requests.Load()) + } + if strings.Contains(answer, `"off"`) { + if StorageState(home, srv.URL, id.ID()) != "off" || next == nil { + t.Fatal("relay's off answer must close gate and continue refresh") + } + } else { + if StorageState(home, srv.URL, id.ID()) != "on" || next != nil { + t.Fatal("no-field answer must leave gate open without a timer") + } + p.check(true) + if requests.Load() != 1 { + t.Fatalf("repeated no-field probe: %d", requests.Load()) + } + // A new process reading the persisted answer also remains idle. + fresh := &storageProcess{ctx: t.Context(), users: 1, read: p.read, fetch: p.fetch, after: p.after, now: time.Now} + fresh.check(true) + if requests.Load() != 1 { + t.Fatal("no-field answer was not cached") + } + } + }) + } +} + +func TestStorageOptInUnansweredCustomProbeFailure(t *testing.T) { + celltest.Available(t) + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + t.Setenv(URLVar, srv.URL) + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + p := storageProcessFor(home) + p.users = 1 + p.after = func(time.Duration, func()) func() bool { + t.Error("failed custom probe armed a timer") + return func() bool { return true } + } + for range 3 { + p.check(true) + } + if requests.Load() != 1 || StorageState(home, srv.URL, id.ID()) != "on" || StorageNeedsRefresh(home, srv.URL, id.ID()) { + t.Fatalf("failed discovery requests=%d state=%s", requests.Load(), StorageState(home, srv.URL, id.ID())) + } +} + +func TestStorageOptInDiscoveryQuietHomes(t *testing.T) { + for _, mode := range []string{"sync-off", "unpaired", "no-identity"} { + t.Run(mode, func(t *testing.T) { + home := t.TempDir() + t.Setenv(URLVar, "http://127.0.0.1:1") + if mode == "sync-off" { + t.Setenv(URLVar, "off") + if _, err := identity.Ensure(home); err != nil { + t.Fatal(err) + } + } + if mode == "unpaired" { + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + } + p := storageProcessFor(home) + p.users = 1 + p.fetch = func(context.Context) error { t.Error("quiet home requested stats"); return nil } + p.after = func(time.Duration, func()) func() bool { + t.Error("quiet home armed timer") + return func() bool { return true } + } + p.check(true) + }) + } +} + +func TestStorageOptInCustomProbeSharedByCallers(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + const relay = "http://127.0.0.1:18831" + for i := 0; i < 50; i++ { + want := i == 0 + if got := StorageNeedsRefresh(home, relay, id.ID()); got != want { + t.Fatalf("caller %d requests=%v want=%v", i, got, want) + } + } +} + +// A terminal stats refusal must end scheduled work, including local waiter sampling. +func TestStorageOptInRefresherStopsAfterDeviceRemoval(t *testing.T) { + celltest.Available(t) + for _, refusal := range []struct { + code string + status int + storage string + }{ + {"revoked", http.StatusUnauthorized, "off"}, + {"gone", http.StatusGone, "off"}, + {"revoked", http.StatusUnauthorized, "unknown"}, + {"gone", http.StatusGone, "unknown"}, + } { + t.Run(refusal.code+"/"+refusal.storage, func(t *testing.T) { + var requests atomic.Int32 + var approved atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/v1/store/stats" || r.Header.Get("Codeaf-Sig") == "" { + t.Errorf("unexpected stats request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + if approved.Load() { + _, _ = io.WriteString(w, `{"storage":"on"}`) + return + } + w.WriteHeader(refusal.status) + _, _ = io.WriteString(w, `{"err":"`+refusal.code+`"}`) + })) + defer srv.Close() + home := machine(t, srv.URL) + id, err := identity.Load(home) + if err != nil { + t.Fatal(err) + } + if refusal.storage == "off" { + if err := SaveStorage(home, srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + } + p := storageProcessFor(home) + p.users = 1 + p.waiting[1] = func() { t.Error("removed device woke a waiting chat") } + var next func() + p.after = func(_ time.Duration, fn func()) func() bool { + next = fn + return func() bool { next = nil; return true } + } + defer p.disarm() + p.check(true) + if requests.Load() != 1 || next != nil { + t.Fatalf("removed device: requests=%d timer=%v", requests.Load(), next != nil) + } + // Another waiter or caller cannot restart the refused lifetime. + p.check(false) + p.check(true) + if requests.Load() != 1 || next != nil { + t.Fatalf("refusal restarted: requests=%d timer=%v", requests.Load(), next != nil) + } + // Pairing again supplies a different device key and a new eligible lifetime. + delete(p.waiting, 1) + dev, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + if err := identity.Install(home, id, dev); err != nil { + t.Fatal(err) + } + approved.Store(true) + p.check(true) + if requests.Load() != 2 || StorageState(home, srv.URL, id.ID()) != "on" || next != nil { + t.Fatalf("new pairing did not discover approval: requests=%d timer=%v", requests.Load(), next != nil) + } + }) + } +} + +// THE LAST USER TO LEAVE CANCELS THE CHECK IN FLIGHT AND WAITS FOR IT. A +// serving loop that has returned must not leave a request behind that later +// writes its answer into the home, which is what a temporary home's cleanup +// caught as "directory not empty". +func TestStorageOptInLastStopCancelsAndJoinsTheCheckInFlight(t *testing.T) { + home := t.TempDir() + p := storageProcessFor(home) + p.read = func() (string, string, string) { + return "https://relay.example", "id_0123456789abcdef0123456789abcdef", "off" + } + p.after = func(time.Duration, func()) func() bool { return func() bool { return true } } + started, finished := make(chan struct{}), make(chan struct{}) + p.fetch = func(ctx context.Context) error { + close(started) + <-ctx.Done() + time.Sleep(20 * time.Millisecond) // the answer is still being saved after the cancel + close(finished) + return ctx.Err() + } + stop := StartStorageRefresh(context.Background(), home) + <-started + stop() + select { + case <-finished: + default: + t.Fatal("the last stop returned while its check was still running") + } + again := StartStorageRefresh(context.Background(), home, false) + defer again() + p.mu.Lock() + live := p.ctx.Err() == nil + p.mu.Unlock() + if !live { + t.Fatal("a new lifetime kept the cancelled context") + } +} diff --git a/internal/syncsetup/syncsetup.go b/internal/syncsetup/syncsetup.go index d2062b82e2..bc3656f3bc 100644 --- a/internal/syncsetup/syncsetup.go +++ b/internal/syncsetup/syncsetup.go @@ -204,7 +204,7 @@ func buildAt(home, base string, id identity.Identity, dev identity.Dev, interval Interval: interval, } s.Dir = directory.NewHTTP(base, sign, s.ClusterHTTP(&http.Client{Timeout: requestTimeout})) - s.Store = blobstore.NewHTTP(base, sign, s.ClusterHTTP(&http.Client{})) + s.Store = GateStore(blobstore.NewHTTP(base, sign, s.ClusterHTTP(&http.Client{})), home, base, id.ID()) return s } diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 16aa688fb7..0628af1cfc 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -26,6 +26,7 @@ import ( "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/preflight" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/Agent-Field/codeaf/internal/taskcopy" "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/vaultsync" @@ -59,6 +60,8 @@ type Continued struct { Taken handoff.Taken // AlreadyHere keeps an active local session attached when no move was needed. AlreadyHere bool + // LocalOnly permits immediate local work without waiting for an uploading writer. + LocalOnly bool // KeptTurns is how many turns the kept branch holds, 0 when nothing was kept. KeptTurns uint32 // Device is this machine's name. @@ -214,7 +217,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err := c.sync.recordSendLease(opened.ID, sendLease{Cell: id, Fence: lease.Fence}); err != nil { return Continued{}, err } - return Continued{AlreadyHere: true, Device: c.opt.DeviceName, Taken: handoff.Taken{ + return Continued{AlreadyHere: true, LocalOnly: !c.sync.StorageAllowed(), Device: c.opt.DeviceName, Taken: handoff.Taken{ Cell: opened, Driving: cellsync.Driving{Cell: opened, Fence: lease.Fence, Head: view.Cell.Head, Frames: view.Cell.Frames}, }}, nil } @@ -230,9 +233,41 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if lease.Device == c.sync.Device.ID() { from = "" } - retired := handoff.Trace(c.opt.OnPhase, "retirement") - c.retireHolder(ctx, id) - retired() + storageOff := !c.sync.StorageAllowed() + // An own free chat can return without replacing its open local conversation. + keepOpen := storageOff && lease.Device == c.sync.Device.ID() && lease.Expires <= view.Now + retire := func() { + defer handoff.Trace(c.opt.OnPhase, "retirement")() + c.retireHolder(ctx, id) + } + if !keepOpen { + retire() + } + localWork := engineLocal{eng: c.eng, ctx: ctx} + if storageOff { + local := cell.Cell{ID: id, Root: root} + if _, err := os.Stat(root); err == nil { + head, _, err := localWork.Unpublished(local, view.Cell.Head) + if err != nil { + return Continued{}, err + } + dirty, err := localWork.Dirty(ctx, local) + if err != nil { + return Continued{}, err + } + // An own free chat whose shared turn is here can keep its live reader; + // an absent or clean older copy still reads the recorded history normally. + if head != "" || dirty || keepOpen && known { + return c.takeLocalStorageOff(ctx, id, root, seen) + } + } else if !errors.Is(err, os.ErrNotExist) { + return Continued{}, err + } + } + if keepOpen { + // A read-only fetch still replaces files, so it must retire their live reader. + retire() + } sc := c.sync.scope(id) defer func() { defer handoff.Trace(c.opt.OnPhase, "filing")() @@ -243,6 +278,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx intendedPrimary = project } taker := c.taker(ctx, sc) + taker.Local = localWork preflight := func(_ context.Context, staged cell.Cell) error { place := session.Place{Dir: staged.Root, Workspace: intendedPrimary} plan, err := session.TaskWorkspaceTransferPlan(place) @@ -353,7 +389,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx } } } - out = Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} + out = Continued{LocalOnly: storageOff, Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} if taken.Kept != "" { out.KeptTurns = c.orphanTurns(ctx, taken.Kept) } @@ -735,7 +771,7 @@ func (s *Sync) withVaultWithin(ctx context.Context, within time.Duration, use fu } sc := s.scope(cellstats.VaultScope) defer sc.Settle() - return use(vaultsync.Syncer{Store: sc.Store, Dir: s.Dir, Vault: v, CellKeyID: s.Identity.CellKeyID(), + return use(vaultsync.Syncer{StorageOn: s.StorageAllowed, Store: sc.Store, Dir: s.Dir, Vault: v, CellKeyID: s.Identity.CellKeyID(), Carry: append(s.carried(), extra...)}) } @@ -871,3 +907,54 @@ func (c *Continuer) ReleaseUnused(ctx context.Context, id string, fence uint64) } } } + +// takeLocalStorageOff acquires through the directory without uploading or restoring over local turns. +func (c *Continuer) takeLocalStorageOff(ctx context.Context, id, root string, seen *directory.LeaseExpectation) (Continued, error) { + at, err := cell.OpenAt(root, id) + if err != nil { + return Continued{}, syncwords.ErrContinueStorageOff + } + contains := func(head string) error { + if _, _, err := cellstore.Unpublished(at, head); err != nil { + return err + } + // An empty record has no remote turns to require. Unpublished conservatively + // retains divergent work too, so only ancestry can permit an in-place claim. + if head == "" { + return nil + } + extends, err := cellstore.Extends(at, head) + if err != nil { + return err + } + if !extends { + return syncwords.ErrContinueStorageOff + } + return nil + } + view, err := c.sync.Dir.Cell(ctx, id) + if err != nil { + return Continued{}, err + } + if view.Cell.Lease.Device != seen.Device || view.Cell.Lease.Fence != seen.Fence { + return Continued{}, directory.ErrLeaseHeld + } + if err := contains(view.Cell.Head); err != nil { + return Continued{}, err + } + got, err := c.sync.Dir.Acquire(ctx, id, directory.AcquireOpts{RequireConditional: true, Force: view.Cell.Lease.Expires > view.Now, Expect: seen}) + if err != nil { + return Continued{}, err + } + fail := func(err error) (Continued, error) { + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, got.Cell.Lease.Fence)) + } + // Publishing can race the acquire; validate its actual head before recording ownership. + if err := contains(got.Cell.Head); err != nil { + return fail(err) + } + if err := c.sync.recordSendLease(id, sendLease{Cell: id, Fence: got.Cell.Lease.Fence}); err != nil { + return fail(err) + } + return Continued{LocalOnly: true, Device: c.opt.DeviceName, Taken: handoff.Taken{Cell: at, Driving: cellsync.Driving{Cell: at, Fence: got.Cell.Lease.Fence, Head: got.Cell.Head, Frames: got.Cell.Frames}}}, nil +} diff --git a/internal/syncwords/errors.go b/internal/syncwords/errors.go index b7eb49cb7b..477136ad8a 100644 --- a/internal/syncwords/errors.go +++ b/internal/syncwords/errors.go @@ -3,6 +3,7 @@ package syncwords import ( "context" "errors" + "fmt" "net" "net/url" "strings" @@ -114,3 +115,19 @@ func IsStruggling(err error) bool { var response interface{ HTTPStatus() int } return errors.Is(err, wireauth.ErrUnavailable) || errors.Is(err, clusterctl.ErrUnavailable) || errors.Is(err, relay.ErrStruggling) || errors.As(err, &response) && response.HTTPStatus() >= 500 } + +// SavedHereWords needs positive local durability before describing a pending upload. +func SavedHereWords(err error, address string) string { + if !UploadPending(err) { + return "" + } + if SavedWithStorageOff(err) { + return SavedStorageOff + } + return fmt.Sprintf(SavedHerePending, Host(address)) +} + +// SavedWithStorageOff requires a positive local receipt so a mixed local failure stays a failure. +func SavedWithStorageOff(err error) bool { + return UploadPending(err) && errors.Is(err, wireauth.ErrStorageOff) +} diff --git a/internal/syncwords/words.go b/internal/syncwords/words.go index 4a17fc4553..cc861362f5 100644 --- a/internal/syncwords/words.go +++ b/internal/syncwords/words.go @@ -2,6 +2,7 @@ package syncwords import ( + "errors" "fmt" "net/url" "strings" @@ -96,3 +97,12 @@ func Asking(address string) string { return "asking the sync service at " + Host func IsOutage(text string) bool { return text == UnknownOutage || text == LegacyOutage || strings.HasPrefix(text, outagePrefix) && strings.HasSuffix(text, outageSuffix) } + +// SavedHerePending names the same pending upload at app quit and individual close. +const SavedHerePending = "saved here; it reaches your other devices when the sync service at %s answers" + +// SavedStorageOff is shared by app quit and the close of an individual chat. +const SavedStorageOff = "saved here; sync storage is off for this account, so it stays on this computer" + +// ErrContinueStorageOff preserves local turns when the recorded history is missing here. +var ErrContinueStorageOff = errors.New("cannot continue this chat here while sync storage is off because this computer is missing saved turns; your local turns stay here") diff --git a/internal/taskdoor/storage_optin_test.go b/internal/taskdoor/storage_optin_test.go new file mode 100644 index 0000000000..f09982beda --- /dev/null +++ b/internal/taskdoor/storage_optin_test.go @@ -0,0 +1,93 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +type storageStatsTransport func(*http.Request) (*http.Response, error) + +func (f storageStatsTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// This acceptance test uses the existing independent-copy and child fixtures, +// including their disposable Git history, isolated from the caller's checkout. +func TestStorageOptInServingTaskResultAfterApproval(t *testing.T) { + celltest.Available(t) + c := newChildRig(t) + first, fresh := t.TempDir(), t.TempDir() + id, dev := c.copies.Identity, c.copies.Device + if err := identity.Install(first, id, dev); err != nil { + t.Fatal(err) + } + if err := identity.Install(fresh, id, dev); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, syncsetup.ProductionRelayURL) + if err := syncsetup.SaveStorage(first, syncsetup.ProductionRelayURL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + if syncsetup.StorageForHome(fresh) != "off" { + t.Fatal("fresh home reused first home permission") + } + var stats, writes atomic.Int32 + original := http.DefaultTransport + underlying := blobstore.NewHTTP(c.copies.Service, func(*http.Request, []byte) {}, &http.Client{Transport: storageStatsTransport(func(r *http.Request) (*http.Response, error) { + if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/v1/store/frames") { + writes.Add(1) + } + return original.RoundTrip(r) + })}) + // The production HTTP gate used by serving copies shares the fresh home's + // reading; the frame wire still reaches the existing real loopback store. + c.copies.Store = syncsetup.GateStore(underlying, fresh, syncsetup.ProductionRelayURL, id.ID()) + http.DefaultTransport = storageStatsTransport(func(r *http.Request) (*http.Response, error) { + if syncsetup.IsHostedRelay(r.URL.Scheme + "://" + r.URL.Host) { + if r.Method != "GET" || !strings.HasSuffix(r.URL.Path, "/v1/store/stats") { + t.Fatalf("unexpected hosted request %s", r.URL) + } + if _, _, err := reqsign.AuthenticateAt(time.Now)(r, nil); err != nil { + t.Errorf("stats signature: %v", err) + } + stats.Add(1) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"storage":"on"}`))}, nil + } + return original.RoundTrip(r) + }) + defer func() { http.DefaultTransport = original }() + // This is the same process-wide lifetime serve and --take-tasks start. + stopRefresh := syncsetup.StartStorageRefresh(t.Context(), fresh) + defer stopRefresh() + deadline := time.After(5 * time.Second) + for syncsetup.StorageForHome(fresh) != "on" { + select { + case <-deadline: + t.Fatal("serving process did not discover approval") + default: + time.Sleep(time.Millisecond) + } + } + stop := c.r.serve(c.config(t, false)) + defer stop() + conn := c.start(t) + defer conn.Close() + if err := os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600); err != nil { + t.Fatal(err) + } + _, _, done := childFinish(t, c.r.dial(), c.id) + if done.Outcome != "done" || done.Result == nil || writes.Load() == 0 || stats.Load() != 1 { + t.Fatalf("result=%+v stats=%d writes=%d", done, stats.Load(), writes.Load()) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index f6d60927b7..e0cafb3f84 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2156,7 +2156,8 @@ type app struct { link LinkSeam // seal is what the door can tell this surface about the sealing of the // conversation's calls (sealseam.go). Its zero value draws nothing. - seal SealSeam + seal SealSeam + stopSealNotice func() // newsSilenceSaid is whether this window has already said that its engine // sends no status-line news (hostlink.go's [app.sayNewsSilence]). It is said // once per window, because it is a fact about a machine and not about a turn. @@ -2397,7 +2398,8 @@ type app struct { // sheet is the settings panel (settings.go): the FIRST fullscreen thing this // surface drew, and the only overlay that is modal for the pointer as well // as for the keyboard. Closed, it costs the frame nothing. - sheet sheet + sheet sheet + storageRead storageSampling // deck is the phone tier's status sheet (statusdeck.go): the SECOND // fullscreen thing, and the only one that exists at one size class only — // under sixty columns the status row is a two-row deck, and this is where @@ -3491,6 +3493,7 @@ func newApp(ctx context.Context, opts Options) *app { opts.Notices = NewNotices() } a.useNotices(opts.Notices) + a.useSeal(opts.Seal) return a } @@ -3871,12 +3874,8 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case conversationClosedMsg: a.conversationClosing = false - if errors.Is(msg.err, session.ErrSavingUnanswered) { - a.note(closeStillFinishingWord) - } else if msg.err != nil { - a.note(closeSaveFailedWord) - } else if msg.finishing { - a.note(closeStillFinishingWord) + if line := closeSaveWords(msg.err, msg.finishing, msg.address); line != "" { + a.note(line) } a.touch() return a, nil @@ -5946,11 +5945,28 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { a.toldNote(msg.line) return a, nil + case storageAccountMsg: + if msg.serial <= a.storageRead.applied || msg.serial != a.storageRead.pending { + return a, nil + } + a.storageRead.pending = 0 + a.storageRead.applied = msg.serial + a.storageRead.code, a.storageRead.state = msg.code, msg.state + if a.at(pageSettings) && (a.sheet.storageCode != msg.code || a.sheet.storageState != msg.state) { + a.sheet.storageCode, a.sheet.storageState = msg.code, msg.state + a.sheet.build() + a.touch() + } + return a, nil + case syncSpaceMsg: // The reading is the identity's, not one chat's, so it is kept whichever // chat is on screen. It is only ever read by the next /status. + if msg.counts.Storage == "" { + msg.counts.Storage = a.syncSpace.Storage + } a.syncSpace = msg.counts - return a, nil + return a, a.sampleStorageAccount(true) case spelledMsg: // The expansion, or nothing at all — a failure, a timeout and a model that @@ -8871,7 +8887,7 @@ func (a *app) takeUp(conv Conversation, whole bool) { a.history = conv.History // AND THE SEALS ARE THIS CONVERSATION'S OWN, cleared by a zero for the same // reason: one chat's failed seal is not another's (sealseam.go). - a.seal = conv.Seal + a.useSeal(conv.Seal) a.saveApproval = conv.SaveApproval a.saveBashApproval = conv.SaveBashApproval a.applyApprovals = conv.ApplyApprovals diff --git a/internal/tui3/conversation_close.go b/internal/tui3/conversation_close.go index 93aeab0dd6..e71c2cf5aa 100644 --- a/internal/tui3/conversation_close.go +++ b/internal/tui3/conversation_close.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // conversationCloseWithin bounds how long the surface waits for engine cleanup. @@ -18,6 +19,7 @@ const closeStillFinishingWord = "this chat is still finishing in the background" type conversationClosedMsg struct { err error finishing bool + address string } // quitAfterClosing restores the terminal even when cleanup is still running. @@ -40,7 +42,7 @@ func quitAfterClosing(work func(), started func(<-chan struct{})) tea.Cmd { // Closing releases the keyboard after a bounded wait. The close method has no // cancellation door, so it can finish later without holding the update loop. -func closeConversationLater(agent Agent, door session.StopDoor, report func(error), started func(<-chan struct{}), lateFailure func()) tea.Cmd { +func closeConversationLater(agent Agent, door session.StopDoor, report func(error), started func(<-chan struct{}), lateFailure func(error), address string) tea.Cmd { done, finished := make(chan error, 1), make(chan struct{}) var mu sync.Mutex late := false @@ -58,7 +60,7 @@ func closeConversationLater(agent Agent, door session.StopDoor, report func(erro notify := late && err != nil && !errors.Is(err, session.ErrSavingUnanswered) && lateFailure != nil mu.Unlock() if notify { - lateFailure() + lateFailure(err) } }() return func() tea.Msg { @@ -66,17 +68,32 @@ func closeConversationLater(agent Agent, door session.StopDoor, report func(erro defer timer.Stop() select { case err := <-done: - return conversationClosedMsg{err: err} + return conversationClosedMsg{err: err, address: address} case <-timer.C: mu.Lock() defer mu.Unlock() select { case err := <-done: - return conversationClosedMsg{err: err} + return conversationClosedMsg{err: err, address: address} default: late = true } - return conversationClosedMsg{finishing: true} + return conversationClosedMsg{finishing: true, address: address} } } } + +// closeSaveWords uses the same local receipt as app quit before claiming turns are saved here. +func closeSaveWords(err error, finishing bool, address string) string { + if line := syncwords.SavedHereWords(err, address); line != "" { + return line + } + switch { + case errors.Is(err, session.ErrSavingUnanswered), err == nil && finishing: + return closeStillFinishingWord + case err != nil: + return closeSaveFailedWord + default: + return "" + } +} diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 94ac86042f..a0a7830135 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -46,9 +46,11 @@ type Taken struct { Head string // AlreadyHere preserves the attached session when this device already holds it. AlreadyHere bool - Kept string - KeptTurns uint32 - Device string + // LocalOnly allows local sending while account storage is off. + LocalOnly bool + Kept string + KeptTurns uint32 + Device string // TaskCopies names the tasks whose working copies came along with the chat. TaskCopies []string // Resume is what the chat left behind on the other machine that this one @@ -872,7 +874,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { if previous.failed || (previous.fence != 0 && previous.fence != msg.taken.Fence) { msg.taken.AlreadyHere = false } - a.machineRead.sendHolds[msg.row.Cell] = sendHold{fence: msg.taken.Fence, awaitingWriter: !msg.taken.AlreadyHere} + a.machineRead.sendHolds[msg.row.Cell] = sendHold{fence: msg.taken.Fence, awaitingWriter: !msg.taken.AlreadyHere && !msg.taken.LocalOnly} rows := append([]chatlist.Row(nil), a.machineRead.rows...) for i := range rows { if rows[i].Cell == msg.row.Cell { @@ -882,6 +884,12 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } } a.machineRead.rows = rows + if msg.send != nil && msg.row.Mine && msg.taken.LocalOnly && msg.fromFile == a.file && msg.taken.Transcript == a.file { + // The local conversation was kept in place, so reopening would repeat its startup notices. + a.restoreTakeDraft(msg) + a.finishHomeTake(false) + return msg.send() + } a.machinesRetaken = a.machinesAsking a.considerResume() a.home.others = a.othersNow() diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 364491e286..582a491dd2 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -16,6 +16,7 @@ import ( "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // ── A WINDOW ON A CHAT ANOTHER MACHINE NOW HOLDS ──────────────────────────── @@ -61,6 +62,7 @@ type ownershipReadMsg struct { fence uint64 proof bool local bool + extends bool // notHeld prevents a saved receipt from reviving a displaced writer offline. notHeld bool writerStopped bool @@ -188,9 +190,13 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { } for _, row := range rows { if row.Cell == remote && (row.Status == chatlist.Idle || row.Status == chatlist.Off) { - if head, _ := cellstore.Head(cell.Cell{ID: id, Root: session.DirOf(file)}); head != nil { + at := cell.Cell{ID: id, Root: session.DirOf(file)} + if head, _ := cellstore.Head(at); head != nil { msg.head = head.Turn.ID } + if msg.local && row.Mine && row.Head != "" { + msg.extends, _ = cellstore.Extends(at, row.Head) + } break } } @@ -254,7 +260,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { } // resumeOwnedSend spends only the submitted gesture after its fresh ownership -// reading. A free chat can resume silently only from the same saved turn. +// reading. A storage-off return can include later local turns when every saved turn is here. func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if msg.writerDisplaced { return nil @@ -307,7 +313,10 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { } return msg.resume() } - if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && row.Head != "" && row.Head == msg.head { + // Local-only work can be ahead of the last shared turn. Missing turns + // still need a choice, and the conditional take checks ancestry again. + localHead := row.Head != "" && row.Head == msg.head || row.Mine && (row.Head == "" || msg.local && msg.extends) + if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && localHead { if _, ok := a.taker.(conditionalTaker); ok { draft := a.mainComposer() return a.takeRemoteState(homeTakenMsg{row: row, draft: &draft, send: msg.resume}) @@ -421,6 +430,8 @@ func (a *app) failedTake(msg homeTakenMsg) string { // safety reading has not started a take and must not cancel one already running. func (a *app) continueFailure(msg homeTakenMsg) string { switch { + case errors.Is(msg.err, syncwords.ErrContinueStorageOff): + return syncwords.ErrContinueStorageOff.Error() case errors.Is(msg.err, directory.ErrLeaseHeld): return lostRaceWord case errors.Is(msg.err, directory.ErrUnreachable): diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index a6d372b04c..2b0aef99f1 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1272,6 +1272,7 @@ func (a *app) closeFront() (tea.Cmd, bool) { // arrived, and "you closed this conversation" and "another window took it" are // two different sentences to be owed (internal/session's stopcause.go). func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { + address := a.syncService name := a.sessionName() if name == "" { name = a.place @@ -1284,9 +1285,9 @@ func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { a.conversationClosing = true a.retainSavingDurability([]Agent{leaving}) desk := a.outsideDesk - return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted, func() { - desk.Say(name + ": " + closeSaveFailedWord) - })), true + return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted, func(err error) { + desk.Say(name + ": " + closeSaveWords(err, false, address)) + }, address)), true } // stepBackFront is the same act for a window that is NOT ending anything: another @@ -1397,6 +1398,10 @@ func (a *app) leaveEverything() { // leavingWork captures surface state before any engine work leaves the loop. // The returned work owns only the captured agents and thread-safe callbacks. func (a *app) leavingWork() func() { + if a.stopSealNotice != nil { + a.stopSealNotice() + a.stopSealNotice = nil + } agents := make([]Agent, 0, len(a.behind)+1) if a.agent != nil { agents = append(agents, a.agent) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 368e723810..107e01d090 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -36,19 +36,50 @@ func (placeSettings) word() string { return "settings" } func (placeSettings) open(a *app) tea.Cmd { a.raiseSettings() - // THE CLOCK IS ARMED FOR THE BAR AND NOT FOR THIS PANEL. Nothing here is - // read from disk on a beat — the rows are this machine's settings and they - // change when somebody changes them — but the tab bar's numbers are - // recomputed on that beat, and a room that armed no clock stopped the whole - // bar counting while it was up ([placeSettings.tick]). - // + // The place clock samples local sync setup, the pairing identity, the solo + // marker and the saved storage permission only on Devices or in Settings + // search. It derives the public account code from that identity, so pairing + // updates the visible row without recurring account discovery on unrelated + // tabs. The same beat keeps the navigation bar's counts current; other + // settings rows change only when their settings change. + // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A // local launch asks for nothing. return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices()) } -// tick re-reads nothing and keeps the beat: see the note over [placeSettings.open]. -func (placeSettings) tick(a *app, now time.Time) (bool, tea.Cmd) { return true, nil } +// tick refreshes only the account reading this page currently displays. +func (placeSettings) tick(a *app, now time.Time) (bool, tea.Cmd) { + return true, a.sampleStorageAccount(true) +} + +type storageAccountMsg struct { + code, state string + serial uint64 +} + +// storageSampling survives page changes so a pending read cannot overlap a new visit. +type storageSampling struct { + next, pending, applied uint64 + code, state string +} + +func (a *app) sampleStorageAccount(refresh bool) tea.Cmd { + door, ok := a.approvals.(interface{ RelayStorage() (string, string) }) + if !ok || !a.at(pageSettings) || a.cellsUnavailable != "" || + (!a.sheet.onDevices() && !a.sheet.searching()) || a.storageRead.pending != 0 || + (!refresh && a.sheet.storageSampled) { + return nil + } + a.sheet.storageSampled = true + a.storageRead.next++ + serial := a.storageRead.next + a.storageRead.pending = serial + return func() tea.Msg { + code, state := door.RelayStorage() + return storageAccountMsg{code: code, state: state, serial: serial} + } +} func (placeSettings) close(a *app) { a.dropSettings() } @@ -173,6 +204,9 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { detailRoom := 0 if s.edit == nil && s.conn.entry == nil { detailRoom = min(6, max(0, room-3)) + if s.storageRequest { + detailRoom = min(len(settingsDetails(a, width))+1, max(0, room-3)) + } } room -= detailRoom body, owner := s.listLines(width, room, pal, a.hoveredSheetRow()) @@ -409,7 +443,7 @@ func settingsDetails(a *app, width int) []string { if a.sheet.onDevices() && !a.sheet.searching() { frameW, _ := a.size() lines := a.sheet.devicesDetails(frameW, width) - if len(lines) > 5 { + if len(lines) > 5 && !a.sheet.storageRequest { lines = lines[:5] lines[4] = fit(lines[4]+glyphMore, max(1, width-4)) } @@ -431,7 +465,7 @@ func settingsDetails(a *app, width int) []string { } } lines = append(lines, wrap(about, max(1, width-4))...) - if len(lines) > 5 { + if len(lines) > 5 && !a.sheet.storageRequest { lines = lines[:5] lines[4] = fit(lines[4]+glyphMore, max(1, width-4)) } diff --git a/internal/tui3/sealseam.go b/internal/tui3/sealseam.go index 7d08609744..ff5c628b89 100644 --- a/internal/tui3/sealseam.go +++ b/internal/tui3/sealseam.go @@ -26,6 +26,20 @@ type SealSeam struct { // Notice hands over the next sentence worth saying, and "" when there is // none. It drains, so [app.takeSealNotice] is its only caller. Notice func() string + // Listen binds the existing notice bell; only Notice consumes the queue. + Listen func(func()) func() +} + +// useSeal detaches the previous window listener when the front conversation changes. +func (a *app) useSeal(seam SealSeam) { + if a.stopSealNotice != nil { + a.stopSealNotice() + a.stopSealNotice = nil + } + a.seal = seam + if seam.Listen != nil { + a.stopSealNotice = seam.Listen(a.noticeBell.ring) + } } // sealSegment is the status-line segment: a mark and two words while sealing @@ -44,7 +58,7 @@ func (a *app) takeSealNotice() { if a.seal.Notice == nil { return } - if said := a.seal.Notice(); said != "" { + for said := a.seal.Notice(); said != ""; said = a.seal.Notice() { a.note(said) } } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 836a6cc356..90513a04ae 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1011,6 +1011,10 @@ type sheet struct { // hosts a [deviceCard] over the surface's own [Approvals] door and clock // (settings_devices.go). cellsUnavailable string + storageCode string + storageState string + storageSampled bool + storageRequest bool approvals Approvals dev *deviceCard now func() time.Time @@ -1336,6 +1340,7 @@ func (a *app) raiseSettings() { routing: a.routing, today: a.todayReading(), } + a.sheet.storageCode, a.sheet.storageState = a.storageRead.code, a.storageRead.state a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() // WHOSE ROWS, SAID FOR THE TAB ON SHOW. Over --host the note is @@ -2387,7 +2392,7 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { if s.dev != nil && s.dev.loaded { s.syncDeviceCursor() - if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.storage && !item.dev.unavailable { return a.revoke(s.dev), true } } @@ -2399,7 +2404,7 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { if s.dev != nil && s.dev.loaded { s.syncDeviceCursor() - if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.storage && !item.dev.unavailable { s.dev.startNaming() } } @@ -2529,6 +2534,9 @@ func (a *app) activate() tea.Cmd { } s.syncDeviceCursor() } + if item.dev.storage { + return a.requestRelayStorage() + } return nil } if item.conn != nil { @@ -2897,6 +2905,7 @@ func (a *app) applySetting(item sheetItem, raw string) bool { } else { a.sheet.msg = "" } + a.sheet.storageCode, a.sheet.storageState = a.storageRead.code, a.storageRead.state a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() }) @@ -2906,6 +2915,7 @@ func (a *app) applySetting(item sheetItem, raw string) bool { } a.sheet.msg = note a.sheet.savedKey = item.row.Key + a.sheet.storageCode, a.sheet.storageState = a.storageRead.code, a.storageRead.state a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() return true @@ -4068,6 +4078,11 @@ func (s *sheet) keysLine() string { switch { case item.memoryDoor, item.crewDoor: action = "open" + case item.dev != nil && item.dev.storage: + if s.storageState != "off" { + return "↑↓ results · esc clear search" + } + action = "request access" case item.dev != nil && item.dev.add: action = "add" case item.dev != nil: diff --git a/internal/tui3/settings_devices.go b/internal/tui3/settings_devices.go index 3541ec4ffc..a8eecb6c02 100644 --- a/internal/tui3/settings_devices.go +++ b/internal/tui3/settings_devices.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/fuzzy" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // ── THE DEVICES TAB ON THE SETTINGS SHEET ─────────────────────────────────── @@ -27,7 +28,8 @@ import ( const ( // tabDevices is the category title on the settings bar, placed between // Connections and Privacy. - tabDevices = "Devices" + tabDevices = "Devices" + storageLabel = "sync storage" // devicesAddKey is the key legend clause for the row that starts pairing // another computer. devicesAddKey = "enter add" @@ -38,6 +40,7 @@ const ( type deviceSheetRow struct { index int id string + storage bool add bool unavailable bool } @@ -82,21 +85,28 @@ func (s *sheet) devNow() time.Time { return timeNow() } -// buildDevices populates the Devices tab when no search query is active. When -// devices cannot work here, or while the initial directory read is still in -// flight or failed, no rows are emitted — [sheet.listLines] draws the single -// explanatory sentence instead, never an empty list and never a working-looking -// add row. +// buildDevices keeps the local storage request available while a directory +// read waits or fails; device actions still require a loaded device list. func (s *sheet) buildDevices() { - if s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + if s.devicesUnavailable() != "" { s.cursor = 0 return } + if s.dev == nil || !s.dev.loaded { + if s.storageCode != "" { + s.items = append(s.items, sheetItem{dev: &deviceSheetRow{storage: true}}) + } + s.cursor = s.clampCursor(s.cursor) + return + } for i, d := range s.dev.rowsOf { s.items = append(s.items, sheetItem{ dev: &deviceSheetRow{index: i, id: d.ID}, }) } + if s.storageCode != "" { + s.items = append(s.items, sheetItem{dev: &deviceSheetRow{storage: true}}) + } s.items = append(s.items, sheetItem{ dev: &deviceSheetRow{add: true}, }) @@ -126,7 +136,7 @@ func (s *sheet) syncDeviceCursor() { return } item, ok := s.current() - if !ok || item.dev == nil || item.dev.add || item.dev.unavailable { + if !ok || item.dev == nil || item.dev.add || item.dev.storage || item.dev.unavailable { s.dev.cursor = len(s.dev.rowsOf) return } @@ -136,7 +146,8 @@ func (s *sheet) syncDeviceCursor() { // ensureSheetDevices starts the initial directory read and joins the change // watch the first time a visit lands on the Devices tab. Other categories never // pay a network ask they did not look at. -func (a *app) ensureSheetDevices() tea.Cmd { +func (a *app) ensureSheetDevices() (cmd tea.Cmd) { + defer func() { cmd = tea.Batch(cmd, a.sampleStorageAccount(false)) }() if !a.at(pageSettings) || !a.sheet.onDevices() { return nil } @@ -259,6 +270,12 @@ func (s *sheet) appendDevicesSearch(terms []fuzzy.Term) int { }) } } + if s.storageCode != "" { + if score, ok, at, size := s.matchHits([]string{storageLabel, "request access", tabDevices}, terms); ok { + best = max(best, score) + matched = append(matched, devHit{item: sheetItem{dev: &deviceSheetRow{storage: true}, hitAt: at, hitLen: size}, score: score}) + } + } if score, ok, hitAt, hitLen := s.matchHits(addDeviceSearchWords, terms); ok { best = max(best, score) matched = append(matched, devHit{ @@ -285,6 +302,9 @@ func (s *sheet) devRowLines(row *deviceSheetRow, hit []int, selected, hovered bo if row.unavailable { return overlayLines(s.devicesUnavailable(), "", selected, false, hovered, width, pal) } + if row.storage { + return overlayLinesHit(storageLabel, s.storageState, hit, selected, false, hovered, width, pal) + } if row.add { return overlayLinesHit(addDeviceWord, "", hit, selected, false, hovered, width, pal) } @@ -339,12 +359,22 @@ func (s *sheet) devicesNote(pal palette, width int) ([]string, bool) { // foot rule (or a concurrent refresh error standing alongside a completed // removal, so neither fact erases the other). func (s *sheet) devicesDetails(frameWidth, bodyWidth int) []string { - if !s.onDevices() || s.searching() || s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + if !s.onDevices() || s.searching() || s.devicesUnavailable() != "" { return nil } ruleRoom := max(1, frameWidth-placeNoteRuleFrame) wrapRoom := max(1, bodyWidth-4) var lines []string + if s.storageRequest && s.storageCode != "" { + lines = append(lines, wrap("request code "+s.storageCode, wrapRoom)...) + target := syncsetup.StorageRequestLink(s.storageCode) + for _, line := range wrap(target, wrapRoom) { + lines = append(lines, linkify(line, target)) + } + } + if s.dev == nil { + return lines + } if s.dev.line != "" && ansi.StringWidth(s.dev.line) > ruleRoom { lines = append(lines, wrap(s.dev.line, wrapRoom)...) } @@ -376,7 +406,11 @@ func (s *sheet) devicesKeysLine() string { out = append(out, devicesMoveKey) } if item, ok := s.current(); ok && item.dev != nil && !item.dev.unavailable { - if item.dev.add { + if item.dev.storage { + if s.storageState == "off" { + out = append(out, "enter request access") + } + } else if item.dev.add { out = append(out, devicesAddKey) } else { out = append(out, devicesNameKey) @@ -390,3 +424,25 @@ func (s *sheet) devicesKeysLine() string { out = append(out, "←→ categories", devicesCloseKey) return strings.Join(out, " · ") } + +func (a *app) relayStorageAccount() (string, string) { + if a.cellsUnavailable != "" { + return "", "" + } + if door, ok := a.approvals.(interface{ RelayStorage() (string, string) }); ok { + return door.RelayStorage() + } + return "", "" +} + +func (a *app) requestRelayStorage() tea.Cmd { + if a.sheet.storageCode == "" || a.sheet.storageState != "off" { + return nil + } + a.sheet.storageRequest = true + link := syncsetup.StorageRequestLink(a.sheet.storageCode) + open := processOpener + return func() tea.Msg { _ = open(link); return storageRequestOpenedMsg{} } +} + +type storageRequestOpenedMsg struct{} diff --git a/internal/tui3/statusnote.go b/internal/tui3/statusnote.go index 0c67acb818..c76903a1bd 100644 --- a/internal/tui3/statusnote.go +++ b/internal/tui3/statusnote.go @@ -117,7 +117,9 @@ func (a *app) statusItems() []deckItem { } items = append(items, item) } - if word := a.syncSpaceWord(); word != "" { + if a.relayStorageOff() { + items = append(items, deckItem{label: storageLabel, value: "off"}) + } else if word := a.syncSpaceWord(); word != "" { items = append(items, deckItem{label: "sync space", value: word}) } // phone lane: `keeping watch` used to be added HERE and nowhere else, which @@ -206,6 +208,9 @@ func (a *app) statusFacts(text string) []string { facts := columnFacts(text, false) crew := a.crewWord() space := a.syncSpaceWord() + if a.relayStorageOff() { + space = "off" + } if crew == "" && space == "" { return facts } diff --git a/internal/tui3/storage_off_settings_test.go b/internal/tui3/storage_off_settings_test.go new file mode 100644 index 0000000000..20e26522d4 --- /dev/null +++ b/internal/tui3/storage_off_settings_test.go @@ -0,0 +1,297 @@ +package tui3 + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +type storageOffTaker struct { + continuer *syncsetup.Continuer + on func(syncsetup.Continued) +} + +func (f storageOffTaker) Take(ctx context.Context, id string) (Taken, error) { + got, err := f.continuer.Take(ctx, id) + if err == nil && f.on != nil { + f.on(got) + } + // Carry outcome flags without requiring new declarations in the before-fix tree. + raw, _ := json.Marshal(got) + var taken Taken + _ = json.Unmarshal(raw, &taken) + taken.Fence, taken.Head = got.Taken.Driving.Fence, got.Taken.Driving.Head + taken.Transcript = filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath) + return taken, err +} + +type storageOffReadOnlyStore struct { + blobstore.Store + writes int +} + +func (s *storageOffReadOnlyStore) PutFrame(context.Context, []byte) (blobstore.FrameID, error) { + s.writes++ + return "", errors.New("unexpected store write") +} + +// B5 uses the real continuation card, reopened agent and Enter road to save the next turn. +func TestContinueCardSendsAndSavesWhileStorageOff(t *testing.T) { + celltest.RequireEngine(t) + lab := newHomeLab(t) + work := lab.workspace("project") + at, err := cell.CreateIn(lab.project("project"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + file := lab.session("project", at.ID, "Local chat", work, time.Now()) + canonical := session.Place{Dir: at.Root}.Transcript() + if err := os.Rename(file, canonical); err != nil { + t.Fatal(err) + } + file = canonical + appendFile := func(words string) { + f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(`{"type":"message","role":"user","content":"` + words + `"}` + "\n") + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + } + eng := cellstore.EngineFor(work) + eng.DataRoot = t.TempDir() + t.Cleanup(cellstore.StopSpawned) + for _, words := range []string{"preserved one", "preserved two"} { + appendFile(words) + if _, err := eng.Seal(t.Context(), at, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + } + if err := cellstore.NotePublished(at, ""); err != nil { + t.Fatal(err) + } + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + dev, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + dir := directory.NewMemory(time.Now) + defer dir.Close() + view, err := dir.For("other-computer").Create(t.Context(), at.ID, directory.CellInit{}) + if err != nil { + t.Fatal(err) + } + store := &storageOffReadOnlyStore{Store: blobstore.NewMemory()} + s := &syncsetup.Sync{Home: home, Relay: syncsetup.ProductionRelayURL, Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store} + a := lab.app(file) + t.Cleanup(a.leaveEverything) + a.width, a.height = 200, 70 + a.open = func(workspace, transcript string) (Conversation, error) { + seat, err := cellstore.SeatOver(executor.HostBound, at, workspace, nil, nil, func(cellstore.Engine) cellstore.Store { return eng }) + if err != nil { + return Conversation{}, err + } + agent, err := session.New(session.Config{Workspace: workspace, Place: session.Place{Dir: at.Root, Workspace: workspace}, SessionFile: transcript, Seat: seat, Model: "stub/chat", APIKey: "stub", BaseURL: "http://127.0.0.1:1"}) + return Conversation{Agent: agent, Workspace: workspace, SessionFile: transcript, Resumed: true}, err + } + drain(t, a, a.openHome()) + src := &continuationListing{row: chatlist.Row{Cell: at.ID, Title: "Local chat", Device: "other-computer", DeviceID: "other-computer", Fence: view.Cell.Lease.Fence, Status: chatlist.Running}} + a.machines = src + a.taker = storageOffTaker{ + continuer: s.Continuer(eng, syncsetup.TakeOptions{RootFor: func(string) string { return at.Root }}), + on: func(got syncsetup.Continued) { + src.change(func(row *chatlist.Row) { + row.Mine, row.Status, row.DeviceID, row.Fence = true, chatlist.Here, dev.ID(), got.Taken.Driving.Fence + }) + }, + } + // A previous displaced writer must not make the local continuation wait again. + a.machineRead.sendHolds = map[string]sendHold{at.ID: {fence: view.Cell.Lease.Fence, failed: true}} + drain(t, a, a.askMachines()) + standOn(t, a, at.ID) + confirm(t, a) + if a.at(pageHome) || a.file != file { + t.Fatalf("continuation did not reopen the conversation: %s", plain(frame(a))) + } + if a.machineRead.sendHolds[at.ID].awaitingWriter { + t.Fatal("local continuation waits for an uploading writer") + } + if strings.Contains(plain(frame(a)), "continue here") || strings.Contains(plain(frame(a)), "held on other-computer") { + t.Fatal("continuation card remained") + } + typeLine(t, a, "! printf next-local-turn") + raw, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for _, words := range []string{"preserved one", "preserved two", "next-local-turn"} { + if !strings.Contains(string(raw), words) { + t.Fatalf("saved conversation lacks %q: %s", words, raw) + } + } + turns, err := cellstore.Turns(at) + if err != nil || len(turns) < 3 || store.writes != 0 { + t.Fatalf("turns=%d writes=%d err=%v", len(turns), store.writes, err) + } +} + +type storageOffSampleDoor struct { + *storageApprovals + reads atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (d *storageOffSampleDoor) RelayStorage() (string, string) { + d.reads.Add(1) + if d.entered != nil { + d.entered <- struct{}{} + <-d.release + } + return d.storageApprovals.RelayStorage() +} + +// Item 7 bounds account reads to visible consumers, with one in flight and ordered answers. +func TestSettingsSamplesOnlyVisibleStorage(t *testing.T) { + door := &storageOffSampleDoor{storageApprovals: &storageApprovals{fakeApprovals: devicesFake(), code: "id_account", state: "off"}} + a := newTestApp(&fakeAgent{model: "m"}) + a.approvals = door + a.openSettings() + for i := 0; i < 4; i++ { + _, cmd := (placeSettings{}).tick(a, time.Now()) + if cmd != nil { + a.Update(cmd()) + } + } + if got := door.reads.Load(); got != 0 { + t.Fatalf("unrelated Settings tab read the account %d times", got) + } + for i, tab := range settingTabs { + if tab == tabDevices { + a.sheet.tab = i + } + } + door.entered, door.release = make(chan struct{}, 1), make(chan struct{}) + _, cmd := (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("Devices did not read storage") + } + answer := make(chan any, 1) + go func() { answer <- cmd() }() + <-door.entered + for i := 0; i < 4; i++ { + if _, cmd := (placeSettings{}).tick(a, time.Now()); cmd != nil { + t.Fatal("overlapping account read") + } + } + close(door.release) + old := <-answer + a.Update(old) + door.entered, door.release = nil, nil + door.state = "on" + _, cmd = (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("next beat did not refresh") + } + a.Update(cmd()) + a.Update(old) + if a.sheet.storageState != "on" || door.reads.Load() != 2 { + t.Fatalf("state=%s reads=%d", a.sheet.storageState, door.reads.Load()) + } + a.sheet.tab = 0 + a.sheet.query.insert("sync storage") + _, cmd = (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("Settings search did not refresh") + } + a.Update(cmd()) +} + +// Item 8 crosses a real remote client with an older saving reply that omits StorageOff. +func TestCloseOlderRemoteSavingReceipt(t *testing.T) { + for _, receipt := range []bool{true, false} { + t.Run(map[bool]string{true: "positive local receipt", false: "no durability receipt"}[receipt], func(t *testing.T) { + front, back := remote.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer back.Close() + dec, enc := json.NewDecoder(back), json.NewEncoder(back) + var hello remote.Frame + if dec.Decode(&hello) != nil { + return + } + welcome, _ := json.Marshal(remote.Welcome{Version: remote.Version, LocalSavingReceipts: receipt, SessionFile: "/chat/transcript.jsonl"}) + if enc.Encode(remote.Frame{Kind: "welcome", Payload: welcome}) != nil { + return + } + for { + var call remote.Frame + if dec.Decode(&call) != nil { + return + } + if call.Method != remote.MethodClose { + if enc.Encode(remote.Frame{Kind: "result", ID: call.ID, Payload: json.RawMessage("null")}) != nil { + return + } + continue + } + // This map deliberately has no storageOff key, as on the compatible peer. + _ = enc.Encode(map[string]any{"kind": "result", "id": call.ID, "error": "upload pending", "localSaved": receipt, "syncPending": receipt}) + return + } + }() + client, err := remote.Dial(front, "older-peer", remote.Hello{}) + if err != nil { + t.Fatal(err) + } + defer func() { front.Close(); <-done }() + a := newTestApp(client.Agent()) + t.Cleanup(a.leaveEverything) + a.file = "/chat/transcript.jsonl" + a.syncService = "http://127.0.0.1:8901" + emptyMachine(a) + a.stow(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/remaining/transcript.jsonl"}, &aside{}) + cmd, more := a.closeFront() + if !more { + t.Fatal("remaining chat missing") + } + spend(t, a, cmd) + shown := strings.Join(strings.Fields(plain(frame(a))), " ") + want := closeSaveFailedWord + if receipt { + want = "saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers" + } + if !strings.Contains(shown, want) { + t.Fatalf("receipt=%v frame=%s", receipt, shown) + } + }) + } +} diff --git a/internal/tui3/storage_off_window_test.go b/internal/tui3/storage_off_window_test.go new file mode 100644 index 0000000000..2944afd3ea --- /dev/null +++ b/internal/tui3/storage_off_window_test.go @@ -0,0 +1,157 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// B1 requires the first status frame after pairing to use the cached permission. +func TestFirstStatusAfterPairing(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + door := &storageApprovals{fakeApprovals: devicesFake(), state: "off"} + a.approvals = door + requests := 0 + a.syncStats = func(context.Context) (blobstore.Counts, error) { requests++; return blobstore.Counts{}, nil } + door.code = "id_account" + cmd := a.runStatusCommand("") + shown := plain(frame(a)) + if !strings.Contains(strings.Join(strings.Fields(shown), " "), "sync storage off") || requests != 0 || cmd == nil { + t.Fatalf("first status frame, requests=%d: %s", requests, shown) + } +} + +// B2 offers the request action in search only while storage is off. +func TestStorageSearchActionMatchesPermission(t *testing.T) { + for _, state := range []string{"off", "on"} { + t.Run(state, func(t *testing.T) { + door := &storageApprovals{fakeApprovals: devicesFake(), code: "id_account", state: state} + a, r := openSettingsDevices(t, door, time.Now()) + r.press("ctrl+u") + r.typeText("sync storage") + found := false + for i, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = i + found = true + break + } + } + if !found { + t.Fatal("storage search result missing") + } + shown := plain(frame(a)) + if strings.Contains(shown, "enter request access") != (state == "off") { + t.Fatalf("search footer: %s", shown) + } + if state == "on" { + old := processOpener + processOpener = func(string) error { t.Error("approved row opened a request"); return nil } + defer func() { processOpener = old }() + r.press("enter") + if a.sheet.storageRequest { + t.Fatal("approved row requested storage") + } + } + }) + } +} + +type storageOffCloseAgent struct { + *fakeAgent + err error +} + +func (a *storageOffCloseAgent) Close() error { return a.err } + +// B6 distinguishes a durable local save from a failed save when another chat remains open. +func TestCloseStorageOffReceipt(t *testing.T) { + for _, tc := range []struct { + name string + err error + want string + }{ + {"storage off", syncwords.UploadFailure(wireauth.ErrStorageOff), "saved here; sync storage is off for this account, so it stays on this computer"}, + {"local failure", errors.New("disk full"), closeSaveFailedWord}, + {"mixed failure", errors.Join(errors.New("disk full"), syncwords.UploadFailure(wireauth.ErrStorageOff)), closeSaveFailedWord}, + } { + t.Run(tc.name, func(t *testing.T) { + a := newTestApp(&storageOffCloseAgent{fakeAgent: &fakeAgent{model: "m"}, err: tc.err}) + t.Cleanup(a.leaveEverything) + a.file = "/tmp/closing/transcript.jsonl" + emptyMachine(a) + a.stow(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/remaining/transcript.jsonl"}, &aside{}) + if a.openCount() != 2 { + t.Fatal("fixture must have exactly two chats") + } + cmd, more := a.closeFront() + if !more { + t.Fatal("no remaining chat") + } + spend(t, a, cmd) + shown := strings.Join(strings.Fields(plain(frame(a))), " ") + if !strings.Contains(shown, tc.want) || tc.name == "storage off" && strings.Contains(shown, closeSaveFailedWord) { + t.Fatalf("close frame: %s", shown) + } + }) + } +} + +// B3 keeps an already-open Settings frame current after the background answer arrives. +func TestOpenSettingsNoticesPairingAnswer(t *testing.T) { + door := &storageApprovals{fakeApprovals: devicesFake()} + a, _ := openSettingsDevices(t, door, time.Now()) + if strings.Contains(plain(frame(a)), "sync storage") { + t.Fatal("unpaired row shown") + } + door.code, door.state = "id_account", "off" + _, cmd := (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("Settings never sampled the account answer") + } + a.Update(cmd()) + shown := plain(frame(a)) + if !strings.Contains(shown, "sync storage") || a.sheet.storageState != "off" { + t.Fatalf("paired row missing: %s", shown) + } +} + +type storageOffLateCloseAgent struct{ *blockedCloseAgent } + +func (a *storageOffLateCloseAgent) Close() error { + _ = a.blockedCloseAgent.Close() + return syncwords.UploadFailure(wireauth.ErrStorageOff) +} + +// B6 reports the same saved-here receipt when an individual close finishes after the wait. +func TestLateCloseStorageOffReceipt(t *testing.T) { + agent := &storageOffLateCloseAgent{&blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{})}} + a := newTestApp(agent) + t.Cleanup(a.leaveEverything) + a.file, a.title = "/tmp/closing/transcript.jsonl", "saved local chat" + emptyMachine(a) + a.stow(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/remaining/transcript.jsonl"}, &aside{}) + if a.openCount() != 2 { + t.Fatal("fixture must have exactly two chats") + } + finished := make(chan struct{}) + a.closingStarted = func(done <-chan struct{}) { go func() { <-done; close(finished) }() } + cmd, more := a.closeFront() + if !more { + t.Fatal("no remaining chat") + } + spend(t, a, cmd) + close(agent.release) + <-finished + a.Update(noticesMsg{}) + shown := strings.Join(strings.Fields(plain(frame(a))), " ") + if !strings.Contains(shown, "saved here; sync storage is off for this account, so it stays on this computer") || strings.Contains(shown, closeSaveFailedWord) { + t.Fatalf("late receipt: %s", shown) + } +} diff --git a/internal/tui3/storage_optin_test.go b/internal/tui3/storage_optin_test.go new file mode 100644 index 0000000000..10cfb1cc3c --- /dev/null +++ b/internal/tui3/storage_optin_test.go @@ -0,0 +1,156 @@ +package tui3 + +import ( + tea "charm.land/bubbletea/v2" + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "net/url" + "strings" + "testing" + "time" +) + +type storageApprovals struct { + *fakeApprovals + code, state string +} + +func (d *storageApprovals) RelayStorage() (string, string) { return d.code, d.state } + +func TestStorageOptInDevicesRequestAndStatus(t *testing.T) { + code := "id_" + strings.Repeat("a", 32) + door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} + a, r := openSettingsDevices(t, door, time.Now()) + a.syncService = "http://127.0.0.1:18831" + found := false + for at, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = at + found = true + break + } + } + if !found || !strings.Contains(a.sheet.devicesKeysLine(), "enter request access") { + t.Fatalf("missing storage request row: %s", plain(frame(a))) + } + before := processOpener + var opened string + processOpener = func(link string) error { opened = link; return errors.New("no browser in test") } + t.Cleanup(func() { processOpener = before }) + r.press("enter") + r.until("storage request opener", func() bool { return opened != "" }) + parsed, err := url.Parse(opened) + if err != nil || !strings.HasPrefix(opened, syncsetup.StorageRequestURL+"?") || parsed.Query().Get("title") != "sync storage access: "+code || parsed.Query().Get("body") != "Please turn on sync storage for my account.\n\nRequest code: "+code { + t.Fatalf("request link: %s %v", opened, err) + } + details := plain(frame(a)) + if !strings.Contains(details, code) || !strings.Contains(strings.Join(strings.Fields(details), ""), syncsetup.StorageRequestLink(code)) { + t.Fatalf("no-browser instructions: %s", details) + } + a.syncSpace = blobstore.Counts{Storage: "off"} + text := a.statusText() + if !strings.Contains(text, "sync storage") || !strings.Contains(text, "off") || strings.Contains(text, "sync space") { + t.Fatalf("off status: %s", text) + } + for _, fact := range a.statusFacts(text) { + if fact == "off" { + t.Fatal("off value is bright") + } + } + door.state = "on" + a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() + if strings.Contains(a.sheet.devicesKeysLine(), "request access") { + t.Fatal("enabled storage still asks for access") + } + a.syncSpace = blobstore.Counts{Storage: "on"} + if strings.Contains(a.statusText(), "sync storage") { + t.Fatal("enabled status has off line") + } +} + +func (d *storageApprovals) SyncService() string { return syncsetup.ProductionRelayURL } + +func TestStorageOptInStatsAfterPairingWithoutRestart(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + door := &storageApprovals{fakeApprovals: devicesFake(), state: "off"} + a.approvals = door + calls := 0 + a.syncStats = func(context.Context) (blobstore.Counts, error) { calls++; return blobstore.Counts{Storage: "on"}, nil } + if cmd := a.refreshSyncSpace(); cmd != nil || calls != 0 { + t.Fatal("unpaired surface asked for storage") + } + door.code = "id_" + strings.Repeat("a", 32) + cmd := a.refreshSyncSpace() + if cmd == nil || calls != 0 { + t.Fatal("pairing did not enable the background refresh") + } + a.Update(cmd()) + if calls != 1 || a.syncSpace.Storage != "on" { + t.Fatal("approval not seen without restart") + } +} + +func TestStorageOptInSearchRequestShowsFallbackFrame(t *testing.T) { + code := "id_" + strings.Repeat("b", 32) + door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} + a, r := openSettingsDevices(t, door, time.Now()) + a.syncService = "http://127.0.0.1:18831" + old := processOpener + processOpener = func(string) error { return errors.New("no browser") } + t.Cleanup(func() { processOpener = old }) + r.press("ctrl+u") + r.typeText("sync storage") + for i, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = i + break + } + } + r.press("enter") + shown := plain(frame(a)) + if a.sheet.searching() || !a.sheet.onDevices() || !strings.Contains(shown, code) || !strings.Contains(strings.Join(strings.Fields(shown), ""), syncsetup.StorageRequestLink(code)) { + t.Fatalf("search fallback frame: %s", shown) + } +} + +func TestStorageOptInRequestFallbackNarrowPendingDevicesFrame(t *testing.T) { + code := "id_" + strings.Repeat("c", 32) + door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} + a, r := openSettingsDevices(t, door, time.Now()) + a.syncService = "http://127.0.0.1:18831" + a.Update(tea.WindowSizeMsg{Width: 56, Height: 40}) + for i, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = i + break + } + } + before := processOpener + processOpener = func(string) error { return errors.New("browser missing") } + defer func() { processOpener = before }() + // A device-list failure must not hide the locally available request. + a.sheet.dev.readErr = errors.New("device list unavailable") + r.press("enter") + shown := strings.Join(strings.Fields(plain(frame(a))), "") + if !strings.Contains(shown, code) || !strings.Contains(shown, syncsetup.StorageRequestLink(code)) { + t.Fatalf("narrow request frame: %s", plain(frame(a))) + } +} + +func TestStorageOptInStatusPrefersCurrentPermission(t *testing.T) { + door := &storageApprovals{fakeApprovals: devicesFake(), code: "id_account", state: "off"} + a := newTestApp(&fakeAgent{model: "m"}) + a.approvals = door + a.syncService = "http://127.0.0.1:18831" + a.syncSpace = blobstore.Counts{Storage: "on"} + if !strings.Contains(a.statusText(), "sync storage") { + t.Fatal("cached refusal hidden by earlier usage answer") + } + door.state = "on" + a.syncSpace.Storage = "off" + if strings.Contains(a.statusText(), "sync storage") { + t.Fatal("cached approval hidden by earlier usage answer") + } +} diff --git a/internal/tui3/syncspace.go b/internal/tui3/syncspace.go index 96994dd9a6..a3e284766c 100644 --- a/internal/tui3/syncspace.go +++ b/internal/tui3/syncspace.go @@ -25,14 +25,23 @@ type syncSpaceMsg struct { // laptop wait on every press; the stored amount changes slowly, so a reading // taken at launch or at the previous /status is close enough. func (a *app) runStatusCommand(rest string) tea.Cmd { + refresh := a.refreshSyncSpace() a.showStatus(rest == "--json") - return a.refreshSyncSpace() + return refresh } // refreshSyncSpace is the one background reading, or nil when sync is off or no // relay can report it. It is a command, so it never runs on the update loop or // while a frame is painted. func (a *app) refreshSyncSpace() tea.Cmd { + // Pairing can happen after this surface opened with no sync service. + if a.syncService == "" { + if code, _ := a.relayStorageAccount(); code != "" { + if door, ok := a.approvals.(interface{ SyncService() string }); ok { + a.syncService = door.SyncService() + } + } + } if a.syncService == "" || a.syncService == "off" || a.syncStats == nil { a.syncSpace = blobstore.Counts{} return nil @@ -61,8 +70,21 @@ func (a *app) showStatus(json bool) { // syncSpaceWord reads only the last answer, so rendering cannot contact a relay. func (a *app) syncSpaceWord() string { used, limit := a.syncSpace.StoredBytes, a.syncSpace.LimitBytes - if a.syncService == "" || a.syncService == "off" || used == nil || limit == nil || *used <= 0 || *limit <= 0 { + if a.syncService == "" || a.syncService == "off" || a.relayStorageOff() || used == nil || limit == nil || *used <= 0 || *limit <= 0 { return "" } return chatlist.BytesSize(*used) + " of " + chatlist.BytesSize(*limit) } + +// Failed readings retain only the previous permission, never stale usage totals. +func (a *app) relayStorageOff() bool { + if a.syncService == "" || a.syncService == "off" { + return false + } + // Another local process or a refused upload may have refreshed permission + // after the last usage reading was painted. + if _, state := a.relayStorageAccount(); state != "" { + return state == "off" + } + return a.syncSpace.Storage == "off" +} diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index 592781042b..f178423f63 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -592,7 +592,7 @@ func (a *app) takeOver() tea.Cmd { a.dropParked() a.writeDraftsNow(a.leavingDraft()) a.retainSavingDurability([]Agent{a.agent}) - return closeConversationLater(a.agent, session.StopByTakeover, a.saveFailure, a.closingStarted, nil) + return closeConversationLater(a.agent, session.StopByTakeover, a.saveFailure, a.closingStarted, nil, a.syncService) } a.dropParked() if a.draftFile != "" { diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 02c8bbb604..106510e1c2 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -1345,6 +1345,7 @@ func Run(ctx context.Context, opts Options) error { defer listenForNews(surface.news)() defer surface.news.close() defer surface.noticeBell.close() + defer func() { surface.useSeal(SealSeam{}) }() defer surface.leaving.close() // AND THE DOOR LINE ENDS WITH THE WINDOW, after what is already in it has // been asked (offloop.go): a person's last keystroke before they close a diff --git a/internal/vaultsync/storage_optin_test.go b/internal/vaultsync/storage_optin_test.go new file mode 100644 index 0000000000..3eef48cecb --- /dev/null +++ b/internal/vaultsync/storage_optin_test.go @@ -0,0 +1,17 @@ +package vaultsync + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/wireauth" + "testing" +) + +func TestStorageOptInVaultDoesNotStartUploadRound(t *testing.T) { + s := Syncer{StorageOn: func() bool { return false }} + if err := s.Push(t.Context()); !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("push: %v", err) + } + if err := s.PushSealed(t.Context(), []byte("sealed"), ""); !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("push sealed: %v", err) + } +} diff --git a/internal/vaultsync/syncer.go b/internal/vaultsync/syncer.go index 3f913dad5c..1122839f65 100644 --- a/internal/vaultsync/syncer.go +++ b/internal/vaultsync/syncer.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // magic leads every vault object; the store refuses any object without a known @@ -43,6 +44,8 @@ var ErrTampered = errors.New("vaultsync: the vault object does not match its id // Syncer moves one identity's vault between this device and the store. The // vault object is the sealed vault.enc, so the store never sees a secret. type Syncer struct { + // StorageOn prevents an upload round while the last relay answer is closed. + StorageOn func() bool Store blobstore.Store Dir directory.Client Vault VaultFile @@ -59,6 +62,9 @@ type Syncer struct { // third device pushed in between, pulls again and pushes once more; a second // loss is returned, since looping would only hide a busy directory. func (s Syncer) Push(ctx context.Context) error { + if s.StorageOn != nil && !s.StorageOn() { + return wireauth.ErrStorageOff + } if err := s.Capture(); err != nil { return err } @@ -91,6 +97,9 @@ func (s Syncer) publish(ctx context.Context, old string) error { // new identity's relay with the vault resealed for it, which no local file // holds yet. func (s Syncer) PushSealed(ctx context.Context, enc []byte, old string) error { + if s.StorageOn != nil && !s.StorageOn() { + return wireauth.ErrStorageOff + } obj := append(bytes.Clone(magic), enc...) rid := ridOf(obj) if err := s.put(ctx, rid, obj); err != nil || rid == old { diff --git a/internal/wireauth/wireauth.go b/internal/wireauth/wireauth.go index fb6575a85e..a4862ab3e6 100644 --- a/internal/wireauth/wireauth.go +++ b/internal/wireauth/wireauth.go @@ -73,6 +73,9 @@ var ErrRateLimited = errors.New("wireauth: rate limited") // time, but by hours and not seconds. var ErrTooManyIdentities = errors.New("wireauth: too many new identities") +// ErrStorageOff means the relay has not enabled storage for this account. +var ErrStorageOff = errors.New("wireauth: sync storage off") + // ErrRelayFull identifies shared storage capacity on both wires. Each decoder // also retains its own general full error so existing callers keep matching it. var ErrRelayFull = errors.New("wireauth: relay storage full") diff --git a/relay/hosted/README.md b/relay/hosted/README.md index d32434f7a6..4e344b14b8 100644 --- a/relay/hosted/README.md +++ b/relay/hosted/README.md @@ -291,3 +291,28 @@ metrics and update prompts off, no dependency on a writable home. Use that wrapp local Wrangler commands too. The project `.npmrc` keeps npm's own installation cache and logs below the ignored `.wrangler/npm-cache` directory, so `npm ci && npm test` also works with a read-only home. `npm ci` installs only the lockfile's dependencies. + +## Hosted storage approvals + +Hosted R2 writes are closed by default, including dev and staging, while the privacy terms are being finished. Pairing, presence, directory coordination, live tunnels and existing frame reads remain available. Account approval is stored separately from newcomer admission. A self-hosted Go relay does not use this policy. + +Build the operator-only command with `go build -o /tmp/codeaf-relay-admin ./cmd/codeaf-relay-admin` from the repository root; it is not packaged in releases or installers. An operator supplies their own Ed25519 seed file, one unpadded base64url 32-byte seed per file with owner-only permissions. The default is `~/.config/codeaf-relay-admin/admin.key`; `CODEAF_RELAY_ADMIN_KEY` or `--key` overrides it. Never place a private key in this repository. + +```sh +codeaf-relay-admin public-key --key +codeaf-relay-admin approve id_<32-hex> --relay prod --key +codeaf-relay-admin revoke id_<32-hex> --relay staging --key +``` + +`--relay` defaults to `prod`; `staging`, `dev` and an explicit HTTP URL are also accepted. URL prefixes such as `/fabric` are preserved. The command signs the exact JSON body after `codeaf/relay-admin/v1\n` and posts to `/v1/admin/storage`. The front Worker verifies an Ed25519 signature from `CAF_ADMIN_KEYS`, the arrived host and a ten-minute clock window. The identity object rejects replayed or reordered timestamps with `409 stale`. Revocation stops future writes without deleting existing frames. + +`CAF_ADMIN_KEYS` contains comma-separated unpadded base64url raw public keys; an empty value permits no approvals. The committed public key in `[vars]` is retained by every channel configuration. `CAF_LIMITS.storageOpen` accepts `true` or `1` to open storage for everyone later; no deployment configuration enables it by default. + + +## Storage handoff during rotation + +Relays advertise handoff support through the `storage` field in signed store stats. The client always sends the original freeze body, then posts a device-signed `POST /v1/store/handoff` as the source identity before resealing uploads. Its JSON body is `{"successor":"id_…","successor_key":"","proof":""}`. The successor identity key signs `codeaf/storage-handoff/v1\n\n`. + +The hosted object checks its committed rotation view: a source that is neither frozen nor retired receives `409 not_rotating`. WebCrypto verifies the successor's key-derived ID and proof. The first accepted successor is permanently bound in the source's local `meta`, surviving thaw and object restart. A different successor receives `409 conflict`; the same successor may retry. This does not change coordination commands, records or the shared evaluator. + +Only an `on` approval can be inherited, only into a successor with no approval record, and only while storage is closed globally. The response is `200 {"successor":…, "storage":"on"|"off"}` with the successor's resulting state. A frozen rotation journal repeats this handoff before uploads; `--abandon` keeps its bound successor keys for reuse. Storage-off rotation completes locally with no store writes. An older relay receives no handoff request. diff --git a/relay/hosted/src/front.js b/relay/hosted/src/front.js index 9e7ba58ee5..9275dfb30d 100644 --- a/relay/hosted/src/front.js +++ b/relay/hosted/src/front.js @@ -2,6 +2,7 @@ // Durable Object classes (which only load inside the Workers runtime). import { peerPath,peerLimits } from './raft/peers.js'; import { stripBase } from './base.js'; +import { serveStorageAdmin } from './storage.js'; import { clusterRequestLimit } from './raft/ingress.js'; import { sharedRuntime } from './raft/actor.js'; import { checkDeclared,readBody } from './body.js'; @@ -35,6 +36,7 @@ export function dispatch(outer, env) { const request = stripBase(outer, env); if (!request) throw notFound(); const { pathname } = new URL(request.url); + if (pathname === '/v1/admin/storage') return serveStorageAdmin(request, env); if (pathname.startsWith('/v1/link/')) return serveLink(request, env); if (pathname.startsWith('/v1/pair')) return servePair(request, env); if (isMachineDoor(pathname)) return serveMachineDoor(request, env); diff --git a/relay/hosted/src/identity.js b/relay/hosted/src/identity.js index 38ccea43fd..a99b4b11df 100644 --- a/relay/hosted/src/identity.js +++ b/relay/hosted/src/identity.js @@ -8,6 +8,8 @@ import { HostedIdentity } from './raft/hosted.js'; import { clusterIngress,clusterRequestLimit } from './raft/ingress.js'; import { sharedRuntime } from './raft/actor.js'; import { identityOf } from './verify.js'; +import { Meta } from './meta.js'; +import { StorageAccess } from './storage.js'; import { b64,unb64 } from './raft/storage.js'; import { fromBase64Url } from './codec.js'; import { signedUri } from './base.js'; @@ -21,7 +23,7 @@ import { policyOf } from './rules.js'; import { Tenant, HAS_WAIT_MS } from './tenant.js'; import { finishClose, Watchers } from './watch.js'; import { checkCert, checkRequest } from './verify.js'; -import { gone, ipOf, rateLimited, respond, unwrap, Wire } from './wire.js'; +import { gone, guarded, ipOf, rateLimited, respond, unwrap, Wire } from './wire.js'; const SIGNED = { identity: 'codeaf-identity', cert: 'codeaf-cert', time: 'codeaf-time', sig: 'codeaf-sig' }; const signedHeaders = (request) => Object.fromEntries(Object.entries(SIGNED).map(([k, h]) => [k, request.headers.get(h)])); @@ -51,6 +53,21 @@ export class IdentityDO extends DurableObject { this.deviceRate = new RateLimit(this.limits.requestsPerMinutePerDevice); } + // Approval creates only its own small record; first device contact still pays admission. + setStorage(approval) { + return guarded(() => { + new StorageAccess(new Meta(this.ctx.storage.sql), this.limits).set(approval); + }); + } + + inheritStorage(approval) { + return guarded(() => { + const storage = new StorageAccess(new Meta(this.ctx.storage.sql), this.limits); + if (approval) storage.set(approval, true); + return storage.state(); + }); + } + fetch(request) { return respond(() => this.#serve(request)); } diff --git a/relay/hosted/src/limits.js b/relay/hosted/src/limits.js index 81836b401c..c794b3dc88 100644 --- a/relay/hosted/src/limits.js +++ b/relay/hosted/src/limits.js @@ -18,6 +18,8 @@ export const PRESENCE = { maxBeat: 60, defaultBeat: 30, windowFactor: 2.5 }; export const windowMs = (beat) => beat * 1000 * PRESENCE.windowFactor; export const DEFAULTS = { + // Hosted storage remains closed until an account is approved or the operator opens it. + storageOpen: false, // One identity. storeBytes: 5 * GiB, // Maximum stored bytes per identity. storeObjects: 5_000_000, // the index is rows in the identity's SQLite, so this is a backstop; the byte cap rules @@ -68,7 +70,7 @@ export const DEFAULTS = { export function limitsOf(env) { const table = { ...DEFAULTS, ...JSON.parse(env.CAF_LIMITS ?? '{}') }; // A device may come back to its code until the code's life ends, so the default quarantine is one life. - return { ...table, plateQuarantineMs: table.plateQuarantineMs ?? table.pairTtlMs }; + return { ...table, storageOpen: [true, 1, 'true', '1'].includes(table.storageOpen), plateQuarantineMs: table.plateQuarantineMs ?? table.pairTtlMs }; } /** diff --git a/relay/hosted/src/raft/hosted.js b/relay/hosted/src/raft/hosted.js index 6563d06bb0..367d0298f3 100644 --- a/relay/hosted/src/raft/hosted.js +++ b/relay/hosted/src/raft/hosted.js @@ -1,3 +1,4 @@ +import { checkStorageHandoff } from '../storage.js'; import { Flight } from '../flight.js'; import { PersonalForward } from './forward.js'; import { PersonalPeers } from './peers.js'; @@ -831,7 +832,24 @@ export class HostedIdentity { entry.error=new Wire(code,code==='revoked'?401:410);entry.reject(entry.error); } } + // Handoff uses the same committed rotation view as GET, but its binding and + // approval belong only to this hosted object's meta, never to Raft commands. + async handoff(c,fresh){ + await this.ensure(fromBase64Url(c.request.headers.get('codeaf-identity'))); + const authority=await this.authorize(c.device,c.request.headers.get('codeaf-cert'),false,fresh); + checkTime(c.request.headers.get('codeaf-time'),this.readNow(authority.app.now)); + const rotation=authority.identity.rotation; + if(!['frozen','retired'].includes(rotation?.state)||rotation.by!==c.device)throw new Wire('not_rotating',409); + const successor=await checkStorageHandoff(this.tenant.identity,c.body); + await this.finishRead(authority.barrier,null); + this.tenant.storage.bindSuccessor(successor); + await this.ctx.storage.sync(); + const approval=this.tenant.limits.storageOpen?null:this.tenant.storage.approval(); + const storage=unwrap(await this.env.IDENTITY.get(this.env.IDENTITY.idFromName(successor)).inheritStorage(approval?.storage==='on'?approval:null)); + return json({successor,storage}); + } async dispatch(c,route){ + if(new URL(c.request.url).pathname==='/v1/store/handoff')return this.operation(()=>this.retryRead(fresh=>this.handoff(c,fresh))); if(!new URL(c.request.url).pathname.startsWith('/v1/store/'))return this.operation(()=>c.request.method==='GET'?this.retryRead(fresh=>this.serve(c,route,fresh)):this.serve(c,route)); const cert=c.request.headers.get('codeaf-cert'); const ticket=await this.operation(()=>this.retryRead(async fresh=>{ diff --git a/relay/hosted/src/routes.js b/relay/hosted/src/routes.js index 0a5f0fff0b..c40216c22a 100644 --- a/relay/hosted/src/routes.js +++ b/relay/hosted/src/routes.js @@ -93,6 +93,8 @@ const STORE_WRITE = { ...STORE, write: true }; const DIR_WRITE = { ...DIR, write: true }; const ROUTES = [ + // HostedIdentity serves this against committed read authority outside Raft. + ['POST', /^\/v1\/store\/handoff$/, { ...STORE, limit: 4096 }, () => { throw notFound(); }], ['POST', /^\/v1\/store\/frames$/, { ...STORE_WRITE, limit: MAX_FRAME, frames: true }, async (c) => json(await c.tenant.putFrame(c.body))], ['GET', /^\/v1\/store\/objects\/([^/]+)$/, STORE, async (c, [rid]) => { if (!isRid(rid)) throw new Wire('bad_rid', 400); @@ -102,7 +104,7 @@ const ROUTES = [ ['GET', /^\/v1\/store\/frames\/([^/]+)$/, STORE, frameOf], ['POST', /^\/v1\/store\/locate$/, STORE, async (c) => json({ at: await c.tenant.locate(asRids(object(c.body))) })], ['POST', /^\/v1\/store\/has$/, STORE, async (c) => json({ have: await c.tenant.has(asRids(object(c.body))) })], - ['GET', /^\/v1\/store\/stats$/, STORE, (c) => json({ ...c.tenant.stats.snapshot(), ...c.tenant.quota.snapshot(c.tenant.clock()) })], + ['GET', /^\/v1\/store\/stats$/, STORE, (c) => json({ storage: c.tenant.storage.state(), ...c.tenant.stats.snapshot(), ...c.tenant.quota.snapshot(c.tenant.clock()) })], ['GET', /^\/v1\/dir\/list$/, DIR, (c) => withVersion(json(c.tenant.dir.list()), c.tenant.dir.version)], ['GET', /^\/v1\/dir\/watch$/, DIR_WRITE, (c) => watching(c)], // C7: the client reads this promise before it can displace a holder. diff --git a/relay/hosted/src/storage.js b/relay/hosted/src/storage.js new file mode 100644 index 0000000000..942d68e936 --- /dev/null +++ b/relay/hosted/src/storage.js @@ -0,0 +1,91 @@ +// Storage permission is separate from directory authority, so disabling uploads +// leaves pairing, live connections and previously stored frames available. +import { Wire, json, unwrap } from './wire.js'; +import { readBody } from './body.js'; +import { hex, sha256, fromBase64Url } from './codec.js'; + +export const ADMIN_DOMAIN = 'codeaf/relay-admin/v1\n'; +export const ADMIN_WINDOW_MS = 10 * 60_000; +export const isIdentity = value => typeof value === 'string' && /^id_[0-9a-f]{32}$/.test(value); +const enc = new TextEncoder(); +const approvalKey = 'storage-approval'; + +export class StorageAccess { + constructor(meta, limits) { this.meta = meta; this.limits = limits; } + approval() { return this.meta.get(approvalKey); } + state() { return this.limits.storageOpen || this.approval()?.storage === 'on' ? 'on' : 'off'; } + assert() { if (this.state() !== 'on') throw new Wire('storage_off', 403); } + bindSuccessor(successor) { + const old = this.meta.get('storage-successor'); + if (old && old !== successor) throw new Wire('conflict', 409); + if (!old) this.meta.set('storage-successor', successor); + } + set(approval, inherited = false) { + const old = this.approval(); + if (inherited && (this.limits.storageOpen || approval.storage !== 'on' || old)) return; + if (old && approval.at <= old.at) { + if (inherited) return; + throw new Wire('stale', 409); + } + this.meta.set(approvalKey, { storage: approval.storage, at: approval.at }); + } +} + +// The successor proves its own key and binds consent to this source identity. +export async function checkStorageHandoff(source, raw) { + let body; + try { body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); } + catch { throw new Wire('bad_request', 400); } + if (!body || !isIdentity(body.successor) || body.successor === source || + typeof body.successor_key !== 'string' || !/^[0-9a-f]{64}$/.test(body.successor_key) || + typeof body.proof !== 'string' || !/^[0-9a-f]{128}$/.test(body.proof)) throw new Wire('bad_request', 400); + const fromHex = text => Uint8Array.from(text.match(/../g), h => parseInt(h, 16)); + const bytes = fromHex(body.successor_key); + if ('id_' + hex((await sha256(bytes)).subarray(0, 16)) !== body.successor) throw new Wire('bad_request', 400); + let accepted = false; + try { + const key = await crypto.subtle.importKey('raw', bytes, 'Ed25519', false, ['verify']); + accepted = await crypto.subtle.verify('Ed25519', key, fromHex(body.proof), + enc.encode(`codeaf/storage-handoff/v1\n${source}\n${body.successor}`)); + } catch { /* An invalid successor key cannot authorize a handoff. */ } + if (!accepted) throw new Wire('bad_request', 400); + return body.successor; +} + +// Verify the exact bytes before crossing the identity boundary. Neither a +// device certificate nor newcomer admission participates in operator approval. +export async function serveStorageAdmin(request, env, clock = Date.now) { + if (request.method !== 'POST') throw new Wire('not_found', 404); + let raw; + try { raw = await readBody(request, 4096, 'bad_request'); } + catch { throw new Wire('bad_request', 400); } + let body; + try { body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); } + catch { throw new Wire('bad_request', 400); } + if (!body || body.v !== 1 || !isIdentity(body.identity) || + !['on', 'off'].includes(body.storage) || typeof body.host !== 'string' || + !Number.isSafeInteger(body.at) || typeof body.nonce !== 'string' || !body.nonce.trim() || body.nonce.length > 256) { + throw new Wire('bad_request', 400); + } + const signatureText = request.headers.get('codeaf-admin-signature') ?? ''; + const signature = /^[A-Za-z0-9_-]+$/.test(signatureText) && fromBase64Url(signatureText); + const message = new Uint8Array(enc.encode(ADMIN_DOMAIN).length + raw.length); + message.set(enc.encode(ADMIN_DOMAIN)); message.set(raw, enc.encode(ADMIN_DOMAIN).length); + let accepted = false; + if (signature?.length === 64) for (const text of (env.CAF_ADMIN_KEYS ?? '').split(',')) { + const keyText = text.trim(); + if (!/^[A-Za-z0-9_-]{43}$/.test(keyText)) continue; + const bytes = fromBase64Url(keyText); + if (bytes?.length !== 32) continue; + try { + const key = await crypto.subtle.importKey('raw', bytes, 'Ed25519', false, ['verify']); + if (await crypto.subtle.verify('Ed25519', key, signature, message)) { accepted = true; break; } + } catch { /* An invalid configured key cannot authorize an approval. */ } + } + if (!accepted || body.host !== new URL(request.url).host || Math.abs(clock() - body.at) > ADMIN_WINDOW_MS) { + throw new Wire('unauthorized', 401); + } + const target = env.IDENTITY.get(env.IDENTITY.idFromName(body.identity)); + unwrap(await target.setStorage(body)); + return json({ identity: body.identity, storage: body.storage }); +} diff --git a/relay/hosted/src/store.js b/relay/hosted/src/store.js index 614cf5b050..a777b761af 100644 --- a/relay/hosted/src/store.js +++ b/relay/hosted/src/store.js @@ -11,6 +11,7 @@ import { decode } from './frame.js'; import { hex, sha256 } from './codec.js'; import { Serial } from './serial.js'; +export class StorageOff extends Error {} export class Conflict extends Error {} export class Damaged extends Error {} /** Unsatisfiable is a ranged get whose offset begins past the frame: the caller turns it into a refusal. */ @@ -43,7 +44,8 @@ export async function sealFrame(bytes) { export class R2Store { #writes = new Serial(); - constructor(bucket, sql, identity) { + constructor(bucket, sql, identity, assertStorage = () => { throw new StorageOff(); }) { + this.assertStorage = assertStorage; this.bucket = bucket; this.sql = sql; this.prefix = `${identity}/f/`; @@ -59,9 +61,11 @@ export class R2Store { */ put(frame, admit = () => {}) { return this.#writes.run(async () => { + this.assertStorage(); if (this.#indexed(frame.id)) return false; await this.#refuseConflicts(frame.objects); await admit(); + this.assertStorage(); await this.bucket.put(this.prefix + frame.id, frame.bytes); this.#index(frame); return true; diff --git a/relay/hosted/src/tenant.js b/relay/hosted/src/tenant.js index f527b8cf73..d59c9e8f13 100644 --- a/relay/hosted/src/tenant.js +++ b/relay/hosted/src/tenant.js @@ -4,6 +4,7 @@ import { LegacySource } from './raft/legacy.js'; import { Directory } from './directory.js'; import { Quota } from './limits.js'; import { Meta } from './meta.js'; +import { StorageAccess } from './storage.js'; import { Stats } from './stats.js'; import { encode } from './frame.js'; import { rotateBy, rotationView } from './rotation.js'; @@ -34,8 +35,9 @@ export class Tenant { this.watchers = watchers; this.dir = new Directory(sql, identity, clock, policy, watchers); this.quota = new Quota(sql, limits); - this.store = new R2Store(bucket, sql, identity); this.meta = new Meta(sql); + this.storage = new StorageAccess(this.meta, limits); + this.store = new R2Store(bucket, sql, identity, () => this.storage.assert()); this.stats = new Stats(this.meta, () => arm(clock() + limits.statsFlushMs)); } @@ -48,6 +50,7 @@ export class Tenant { * the store already holds costs the identity nothing, so a client that resends is never refused for it. */ async putFrame(bytes) { + this.storage.assert(); this.stats.add({ puts: 1, bytes_in: bytes.length }); const frame = await sealFrame(bytes); const cost = { size: frame.size, objects: frame.objects.length }; diff --git a/relay/hosted/src/wire.js b/relay/hosted/src/wire.js index a49ee408d9..a67360ca77 100644 --- a/relay/hosted/src/wire.js +++ b/relay/hosted/src/wire.js @@ -5,7 +5,7 @@ import { Refusal } from './verify.js'; import { BadFrame } from './frame.js'; import { RuleError } from './rules.js'; -import { Conflict, Damaged } from './store.js'; +import { Conflict, Damaged, StorageOff } from './store.js'; import { networkOf } from './network.js'; export class Wire extends Error { @@ -48,6 +48,7 @@ export function wireOf(e) { if (e instanceof Wire) return e; if (e instanceof Refusal) return new Wire(e.kind, 401); if (e instanceof BadFrame) return new Wire('bad_frame', 400); + if (e instanceof StorageOff) return new Wire('storage_off', 403); if (e instanceof Conflict) return new Wire('conflict', 409); if (e instanceof Damaged) return new Wire('damaged', 500); if (e instanceof RuleError) return new Wire(e.code, RULE_STATUS[e.code] ?? 500); diff --git a/relay/hosted/test/e2e.sh b/relay/hosted/test/e2e.sh index a8645e8616..ca3aea00af 100644 --- a/relay/hosted/test/e2e.sh +++ b/relay/hosted/test/e2e.sh @@ -23,6 +23,16 @@ JS start() { # port, extra wrangler args port=$1; shift + # Existing storage scenarios use an open service; targeted storage tests prove the closed default. + remaining=$# + while [ "$remaining" -gt 0 ]; do + argument=$1; shift + case "$argument" in + CAF_LIMITS:*) argument="CAF_LIMITS:$(node -e 'console.log(JSON.stringify({...JSON.parse(process.argv[1]), storageOpen:true}))' "${argument#CAF_LIMITS:}")" ;; + esac + set -- "$@" "$argument" + remaining=$((remaining - 1)) + done persist="$work/$port" if [ "$port" = "${scenario_port:-}" ]; then persist=$scenario_state; fi : >"$work/$port.log" diff --git a/relay/hosted/test/global.test.js b/relay/hosted/test/global.test.js index 8b4afa15b6..a0cd3ec22c 100644 --- a/relay/hosted/test/global.test.js +++ b/relay/hosted/test/global.test.js @@ -11,7 +11,7 @@ import { frame, rid, openBucket } from './helpers.js'; const empty = { list: async () => ({ objects: [], truncated: false }) }; const refusal = (code, status) => (e) => e.code === code && e.status === status; -const limits = (overrides) => ({ ...DEFAULTS, ...overrides }); +const limits = (overrides) => ({ ...DEFAULTS, storageOpen: true, ...overrides }); test('deployment defaults allow admission and storage above the former global limits', async () => { const sql = memorySql(); diff --git a/relay/hosted/test/raft/directory.test.js b/relay/hosted/test/raft/directory.test.js index 39b1484ac7..2f71b805e7 100644 --- a/relay/hosted/test/raft/directory.test.js +++ b/relay/hosted/test/raft/directory.test.js @@ -84,7 +84,7 @@ test('R1 R2 R4 R14 R16 R17 R21 hosted facade uses committed Go policy and native }, cluster); const stored=new Map(); let failPut=true; const bucket={async put(id,b) {if(failPut) throw new Error('blob write failed');stored.set(id,b.slice());},async get(id,opts) {const b=stored.get(id);if(!b)return null;const offset=opts?.range?.offset??0,length=opts?.range?.length??b.length;return {arrayBuffer:async()=>b.slice(offset,offset+length).buffer};}}; - const sqlHost=new Host();hostStores.push(sqlHost);const store=new R2Store(bucket,sqlHost.sql,cluster); + const sqlHost=new Host();hostStores.push(sqlHost);const store=new R2Store(bucket, sqlHost.sql, cluster, () => {}); const rid='1'.repeat(64), f=await sealFrame(encodeFrame('0'.repeat(32),[{rid,bytes:enc.encode('AGEO\x01sealed-object')}])); const receipt={device:'a',head:'sealed-one',head_rid:rid};let durable=0; await assert.rejects(store.put(f),/blob write failed/); diff --git a/relay/hosted/test/raft/effects.test.js b/relay/hosted/test/raft/effects.test.js index 50f019ea26..024b6e4071 100644 --- a/relay/hosted/test/raft/effects.test.js +++ b/relay/hosted/test/raft/effects.test.js @@ -14,7 +14,7 @@ function bucket() { } test('R21 an upload receipt waits for blob completion, indexing and the storage durability boundary',async()=>{ - const b=bucket(),store=new R2Store(b,memorySql(),'one'),f=await frame();let release,started; + const b=bucket(),store=new R2Store(b, memorySql(), 'one', () => {}),f=await frame();let release,started; const wait=new Promise(r=>{release=r;}),entered=new Promise(r=>{started=r;});const put=b.put; b.put=async(...args)=>{started();await wait;return put(...args);}; const writing=store.put(f);await entered;let ready=false,synced=false; @@ -26,7 +26,7 @@ test('R21 an upload receipt waits for blob completion, indexing and the storage }); test('R16 R21 a freeze or revoke during external I/O leaves bytes and returns no upload success',async()=>{ - const b=bucket(),store=new R2Store(b,memorySql(),'one'),f=await frame();let calls=0; + const b=bucket(),store=new R2Store(b, memorySql(), 'one', () => {}),f=await frame();let calls=0; const guarded=new RaftBlobTenant(store,async()=>{if(++calls===2)throw Error('revoked');},async()=>{}); await assert.rejects(guarded.putFrame(f),/revoked/); assert(b.data.has('one/f/'+f.id));assert.deepEqual(await store.get(rid),enc.encode('AGEO\x01sealed')); @@ -56,7 +56,7 @@ test('R17 expired public delivery records a terminal outcome and never invents a test('R21 an interrupted R2 index has no completion marker and a repeated immutable upload repairs it',async()=>{ const sql=memorySql(),original=sql.exec.bind(sql);let fail=true; sql.exec=(q,...args)=>{if(fail&&q.startsWith('INSERT OR IGNORE INTO objs')){fail=false;throw Error('index failed');}return original(q,...args);}; - const store=new R2Store(bucket(),sql,'one'),f=await frame(); + const store=new R2Store(bucket(),sql,'one',()=>{}),f=await frame(); await assert.rejects(store.put(f),/index failed/);assert.equal(await store.put(f),true); await store.checkReferences(rid,[f.id],async()=>{});assert.equal(await store.put(f),false); }); diff --git a/relay/hosted/test/raft/hosted-fixture.js b/relay/hosted/test/raft/hosted-fixture.js index 34d22ad1c6..02cb91af50 100644 --- a/relay/hosted/test/raft/hosted-fixture.js +++ b/relay/hosted/test/raft/hosted-fixture.js @@ -10,7 +10,7 @@ import { join } from 'node:path'; import { generateKeyPairSync, createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'; const sha=b=>createHash('sha256').update(b).digest('hex'); -export async function hostedFixture({fixture=false,base='',go=false,rootLabel,now,entryPoint}={}) { +export async function hostedFixture({fixture=false,base='',go=false,rootLabel,now,entryPoint,storageOpen=true,adminKeys='',limits={}}={}) { const prefix=prefixOf({BASE_PATH:base}); const out=process.env.CODEAF_RAFT_PROOF_DIR;assert(out,'build the native/WASI proof first'); const dir=await mkdtemp(join(tmpdir(),'p17b-')); @@ -47,7 +47,7 @@ export async function hostedFixture({fixture=false,base='',go=false,rootLabel,no }; const start=async(cut)=>{ if(mf)await mf.dispose();await compile(cut); - const worker={name:'hosted-proof',modulesRoot:dir,modules:[{type:'ESModule',path:join(dir,'worker.js')},{type:'CompiledWasm',path:join(dir,'core.wasm')}],compatibilityDate:'2026-09-01',durableObjects:Object.fromEntries(Object.entries({IDENTITY:'IdentityDO',RAFT_MEMBERS:'RaftMemberDO',NEWCOMERS:'NewcomerGate',PAIR_GATE:'PairGate',MAILBOX:'Mailbox',LINK_GATE:'LinkGate',LINK_REQUEST:'LinkRequest'}).map(([binding,className])=>[binding,{className,useSQLite:true}])),r2Buckets:['FRAMES'],bindings:{BASE_PATH:base,LEASE_POLICY:'amended',CAF_LIMITS:JSON.stringify({requestsPerMinute:100000,requestsPerMinutePerDevice:100000,minGraceMs:2000})},outboundService:()=>{throw Error('H4 outbound network forbidden');}}; + const worker={name:'hosted-proof',modulesRoot:dir,modules:[{type:'ESModule',path:join(dir,'worker.js')},{type:'CompiledWasm',path:join(dir,'core.wasm')}],compatibilityDate:'2026-09-01',durableObjects:Object.fromEntries(Object.entries({IDENTITY:'IdentityDO',RAFT_MEMBERS:'RaftMemberDO',NEWCOMERS:'NewcomerGate',PAIR_GATE:'PairGate',MAILBOX:'Mailbox',LINK_GATE:'LinkGate',LINK_REQUEST:'LinkRequest',MACHINE:'MachineDO'}).map(([binding,className])=>[binding,{className,useSQLite:true}])),r2Buckets:['FRAMES'],bindings:{BASE_PATH:base,LEASE_POLICY:'amended',CAF_ADMIN_KEYS:adminKeys,CAF_LIMITS:JSON.stringify({storageOpen,requestsPerMinute:100000,requestsPerMinutePerDevice:100000,minGraceMs:2000,...limits})},outboundService:()=>{throw Error('H4 outbound network forbidden');}}; mf=new Miniflare(convertV4MiniflareOptions({log:diagnostics,handleStructuredLogs:entry=>diagnostics.capture(entry.level,entry.message),host:'127.0.0.1',inspectorPort:0,resourcePersistencePath:join(dir,'persist'),telemetry:{enabled:false},cf:false,workers:[worker]})); await mf.ready;return mf; }; diff --git a/relay/hosted/test/raft/storage-live-worker.js b/relay/hosted/test/raft/storage-live-worker.js new file mode 100644 index 0000000000..16d59f0ecb --- /dev/null +++ b/relay/hosted/test/raft/storage-live-worker.js @@ -0,0 +1,31 @@ +import relay from '../../src/raft-worker.js'; +export * from '../../src/raft-worker.js'; +import { nameFor } from '../../src/engine/names.js'; +import { PROTOCOL, HEADER_NAME, HEADER_KEY, OP, frame as carrierFrame, readFrame, registrationProof } from '../../src/engine/carrier.js'; +const check=(ok,word)=>{if(!ok)throw Error(word)}; +const equal=(a,b)=>check(a===b,`expected ${b}, received ${a}`); +const same=(a,b)=>equal(JSON.stringify([...a]),JSON.stringify([...b])); +async function live(env,ctx){ + let engine,surface; + const receive=ws=>{const queue=[],waiting=[];ws.binaryType='arraybuffer';ws.addEventListener('message',async event=>{const raw=typeof event.data.arrayBuffer==='function'?await event.data.arrayBuffer():event.data;const value=new Uint8Array(raw);if(waiting.length)waiting.shift()(value);else queue.push(value)});ws.accept();return ()=>queue.length?Promise.resolve(queue.shift()):new Promise(resolve=>waiting.push(resolve))}; + try { + const pair=await crypto.subtle.generateKey('X25519',true,['deriveBits']); + const key=new Uint8Array(await crypto.subtle.exportKey('raw',pair.publicKey)),name=await nameFor(key); + const upgrade={upgrade:'websocket','sec-websocket-protocol':PROTOCOL}; + const registered=await call('http://local/v1/engine',{headers:{...upgrade,[HEADER_NAME]:name,[HEADER_KEY]:btoa(String.fromCharCode(...key))}}); + equal(registered.status,101);engine=registered.webSocket;const fromEngine=receive(engine); + const challenge=await fromEngine();equal(challenge.length,64); + const ephemeral=await crypto.subtle.importKey('raw',challenge.subarray(0,32),'X25519',false,[]); + const shared=new Uint8Array(await crypto.subtle.deriveBits({name:'X25519',public:ephemeral},pair.privateKey,256)); + engine.send(await registrationProof(shared,challenge.subarray(32),name,key));same([...await fromEngine()],[1,0]); + const dial=await call('http://local/v1/dial/'+name,{headers:upgrade});equal(dial.status,101);surface=dial.webSocket;const fromSurface=receive(surface); + const opened=readFrame(await fromEngine());equal(opened.op,OP.open); + const payload=Uint8Array.from([0,255,19,47]);surface.send(payload); + const sent=readFrame(await fromEngine());equal(sent.op,OP.data);same(sent.payload,payload); + engine.send(carrierFrame(OP.data,opened.stream,Uint8Array.from([91,0,255])));same(await fromSurface(),Uint8Array.from([91,0,255])); + + return Response.json({registered:true,surface_to_engine:[...sent.payload],engine_to_surface:[91,0,255]}); + }finally{try{surface?.close()}catch{}try{engine?.close()}catch{}} + function call(url,options){return relay.fetch(new Request(url,options),env,ctx)} +} +export default {fetch(request,env,ctx){return new URL(request.url).pathname==='/fixture/storage-live'?live(env,ctx):relay.fetch(request,env,ctx)}}; diff --git a/relay/hosted/test/raft/storage.test.js b/relay/hosted/test/raft/storage.test.js new file mode 100644 index 0000000000..bb4372c968 --- /dev/null +++ b/relay/hosted/test/raft/storage.test.js @@ -0,0 +1,218 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign, createHash } from 'node:crypto'; +import { hostedFixture } from './hosted-fixture.js'; +import { frame, rid } from '../helpers.js'; +import { ADMIN_DOMAIN } from '../../src/storage.js'; + +test('R1 R3 R4 R6 R8 R9 production Worker approval, cold replay protection, rotation and revocation', {timeout:60000}, async () => { + const keys=generateKeyPairSync('ed25519'), pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({base:'/fabric',storageOpen:false,adminKeys:pub}), c=h.client(); + let at=Date.now(); + const admin=async(storage,identity=h.identity,stamp=++at)=>{ + const raw=JSON.stringify({v:1,host:'local',identity,storage,at:stamp,nonce:'test-random-'+stamp}); + const signature=sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url'); + const r=await h.mf.dispatchFetch('http://local/fabric/v1/admin/storage',{method:'POST',headers:{'codeaf-admin-signature':signature},body:raw}); + return {status:r.status,body:await r.json()}; + }; + try { + await h.start(); + // This approval arrives before the namespace has ever authenticated a device. + const approval=await admin('on');assert.equal(approval.status,200,JSON.stringify(approval)); + const stamp=at; + assert.equal((await admin('off',h.identity,stamp)).status,409); + let stats=await h.call(c,'GET','/v1/store/stats');assert.equal(stats.status,200,JSON.stringify(stats));assert.equal(stats.body.storage,'on'); + const f=frame([rid(41)]); + assert.equal((await h.call(c,'POST','/v1/store/frames',f.bytes)).status,200); + assert.equal((await admin('off')).status,200); + stats=await h.call(c,'GET','/v1/store/stats');const used=stats.body.stored_bytes;assert(used>0); + assert.deepEqual(await h.call(c,'POST','/v1/store/frames',frame([rid(42)]).bytes),{status:403,body:{err:'storage_off'}}); + assert.equal((await h.call(c,'GET','/v1/store/stats')).body.stored_bytes,used); + assert.equal((await h.request(c,'GET','/v1/store/objects/'+rid(41))).status,200); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.start(); + assert.equal((await admin('on',h.identity,stamp)).status,409); + assert.equal((await h.call(c,'GET','/v1/store/stats')).body.storage,'off'); + assert.equal((await admin('on')).status,200); + const nextRoot=generateKeyPairSync('ed25519'),nextDevice=generateKeyPairSync('ed25519'); + const rootBytes=nextRoot.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const deviceBytes=nextDevice.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const digest=raw=>createHash('sha256').update(raw).digest('hex'); + const successor='id_'+digest(rootBytes).slice(0,32); + const cert={V:1,device:deviceBytes.toString('hex'),created:1000,sig:''}; + cert.sig=sign(null,Buffer.from('codeaf/device-cert/v1\n'+JSON.stringify(cert)),nextRoot.privateKey).toString('hex'); + const nextRequest=async(method,path,raw='')=>{ + const stamp=String(Date.now()),uri='/fabric'+path; + return h.mf.dispatchFetch('http://local'+uri,{method,headers:{ + 'Codeaf-Identity':rootBytes.toString('base64url'),'Codeaf-Cert':Buffer.from(JSON.stringify(cert)).toString('base64url'),'Codeaf-Time':stamp, + 'Codeaf-Sig':sign(null,Buffer.from('codeaf-req-v1\n'+method+'\n'+uri+'\n'+stamp+'\n'+digest(raw)),nextDevice.privateKey).toString('base64url')},...(method==='POST'?{body:raw}:{})}); + }; + const handoff={successor,successor_key:rootBytes.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),nextRoot.privateKey).toString('hex')}; + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + assert.equal((await h.call(c,'POST','/v1/store/handoff',{successor})).status,400); + assert.equal((await h.call(c,'POST','/v1/store/handoff',{...handoff,proof:''})).status,400); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + assert.equal((await (await nextRequest('GET','/v1/store/stats')).json()).storage,'off'); + assert.equal((await nextRequest('POST','/v1/store/frames',frame([rid(43)]).bytes)).status,403); + const freeze=await h.call(c,'POST','/v1/store/handoff',handoff); + assert.deepEqual(freeze,{status:200,body:{successor,storage:'on'}}); + assert.equal((await (await nextRequest('GET','/v1/store/stats')).json()).storage,'on'); + assert.equal((await nextRequest('POST','/v1/store/frames',frame([rid(43)]).bytes)).status,200); + // A newer direct revocation at the successor outranks inherited approval. + assert.equal((await admin('off',successor)).status,200); + const retry=await h.call(c,'POST','/v1/store/handoff',handoff); + assert.deepEqual(retry,{status:200,body:{successor,storage:'off'}}); + assert.equal((await admin('on',successor,stamp)).status,409); + assert.equal((await (await nextRequest('GET','/v1/store/stats')).json()).storage,'off'); + const otherRoot=generateKeyPairSync('ed25519'),otherKey=otherRoot.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const other='id_'+digest(otherKey).slice(0,32); + const second={successor:other,successor_key:otherKey.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+other),otherRoot.privateKey).toString('hex')}; + await h.start(); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'thaw'})).status,200); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',second),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + const refused=await h.call(c,'POST','/v1/store/handoff',second); + assert.deepEqual(refused,{status:409,body:{err:'conflict'}}); + // If inheritance had reached the second identity, this older direct + // command would be refused as stale instead of succeeding. + assert.equal((await admin('off',other,stamp)).status,200); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'retire'})).status,200); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:200,body:{successor,storage:'off'}}); + const statsOff=await admin('off');assert.equal(statsOff.status,200); + } finally {await h.close();} +}); + +test('S2 first device after preapproval still pays IP and global newcomer budgets', {timeout:60000}, async () => { + for(const limits of [{newIdentitiesPerIpPerDay:0},{globalNewIdentitiesPerDay:0}]) { + const keys=generateKeyPairSync('ed25519'),pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({storageOpen:false,adminKeys:pub,limits}); + try { + await h.start(); + const raw=JSON.stringify({v:1,host:'local',identity:h.identity,storage:'on',at:Date.now(),nonce:'preapprove'}); + const r=await h.mf.dispatchFetch('http://local/v1/admin/storage',{method:'POST',body:raw,headers:{'codeaf-admin-signature':sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url')}}); + assert.equal(r.status,200,await r.text()); + const first=await h.call(h.client(),'GET','/v1/store/stats'); + assert.equal(first.status,429,JSON.stringify(first)); + assert.equal(first.body.err,limits.newIdentitiesPerIpPerDay===0?'too_many_identities':'relay_admission_full'); + } finally { await h.close(); } + } +}); + +test('B1 off does not travel and a successor key must match its ID and source-bound proof', {timeout:60000}, async () => { + const keys=generateKeyPairSync('ed25519'),pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({storageOpen:false,adminKeys:pub}); + const at=Date.now(); + const admin=async(storage,identity,stamp)=>{ + const raw=JSON.stringify({v:1,host:'local',identity,storage,at:stamp,nonce:'handoff'}); + return h.mf.dispatchFetch('http://local/v1/admin/storage',{method:'POST',body:raw,headers:{'codeaf-admin-signature':sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url')}}); + }; + try { + await h.start();const c=h.client(); + assert.equal((await admin('off',h.identity,at+10)).status,200); + const root=generateKeyPairSync('ed25519'),rawKey=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(rawKey).digest('hex').slice(0,32); + const handoff={successor,successor_key:rawKey.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + assert.equal((await h.mf.dispatchFetch('http://local/v1/store/handoff',{method:'POST',body:JSON.stringify(handoff)})).status,401); + for(const body of [null,[],{successor},{...handoff,successor_key:''},{...handoff,successor:h.identity},{...handoff,successor:'id_'+'e'.repeat(32)}]) { + assert.equal((await h.call(c,'POST','/v1/store/handoff',body)).status,400,JSON.stringify(body)); + } + assert.equal((await h.call(c,'POST','/v1/store/handoff',{...handoff,proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\nwrong\n'+successor),root.privateKey).toString('hex')})).status,400); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:200,body:{successor,storage:'off'}}); + // An inherited off at at+10 would make this older approval stale. + assert.equal((await admin('on',successor,at)).status,200); + assert.equal((await h.call(c,'POST','/v1/store/handoff',handoff)).status,200); + assert.equal((await admin('off',successor,at+1)).status,200); + }finally{await h.close()} +}); + +test('S2 real pairing mailbox exchange and machine tunnel bytes while storage is off', {timeout:60000}, async () => { + const h=await hostedFixture({storageOpen:false,entryPoint:'test/raft/storage-live-worker.js'}); + try{ + await h.start();const client=h.client(); + assert.equal((await h.call(client,'GET','/v1/store/stats')).body.storage,'off'); + const pairKey=Buffer.alloc(16,1).toString('base64url'),joinKey=Buffer.alloc(16,2).toString('base64url'); + const created=await h.mf.dispatchFetch('http://local/v1/pair',{method:'POST',headers:{'codeaf-pair-key':pairKey}}); + assert.equal(created.status,201);const {nameplate}=await created.json(); + const post=async(side,key,bytes)=>h.mf.dispatchFetch('http://local/v1/pair/'+nameplate+'/'+side,{method:'POST',headers:{'codeaf-pair-key':key},body:bytes}); + assert.equal((await post('a',pairKey,'sealed identity grant')).status,201); + assert.equal((await post('b',joinKey,'paired acknowledgement')).status,201); + for(const [side,expected]of [['a','sealed identity grant'],['b','paired acknowledgement']]){ + const read=await h.mf.dispatchFetch('http://local/v1/pair/'+nameplate+'/'+side+'?after=0');assert.equal(Buffer.from((await read.json()).msgs[0],'base64').toString(),expected); + } + assert.equal((await h.mf.dispatchFetch('http://local/v1/pair/'+nameplate,{method:'DELETE',headers:{'codeaf-pair-key':pairKey}})).status,204); + const carried=await h.mf.dispatchFetch('http://local/fixture/storage-live');assert.equal(carried.status,200,await carried.clone().text()); + assert.deepEqual(await carried.json(),{registered:true,surface_to_engine:[0,255,19,47],engine_to_surface:[91,0,255]}); + const stats=(await h.call(client,'GET','/v1/store/stats')).body;assert.equal(stats.storage,'off');assert.equal(stats.stored_bytes,0); + assert.equal((await h.call(client,'POST','/v1/admin/storage',{v:1,host:'local',identity:h.identity,storage:'on',at:Date.now(),nonce:'device-only'})).status,401); + const bypass=await h.api(client,{action:'setStorage',storage:'on'});assert(bypass.status>=400,JSON.stringify(bypass)); + assert.equal((await h.call(client,'GET','/v1/store/stats')).body.storage,'off'); + }finally{await h.close()} +}); + +test('R8 globally open storage binds the successor without copying an approval record', {timeout:60000}, async () => { + const keys=generateKeyPairSync('ed25519'),pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({storageOpen:true,adminKeys:pub}); + const at=Date.now(); + const admin=async(storage,identity,stamp)=>{ + const raw=JSON.stringify({v:1,host:'local',identity,storage,at:stamp,nonce:'globally-open'}); + return h.mf.dispatchFetch('http://local/v1/admin/storage',{method:'POST',body:raw,headers:{'codeaf-admin-signature':sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url')}}); + }; + try { + await h.start();const c=h.client(); + assert.equal((await admin('on',h.identity,at+10)).status,200); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + const makeHandoff=()=>{ + const root=generateKeyPairSync('ed25519'),rawKey=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(rawKey).digest('hex').slice(0,32); + return {successor,successor_key:rawKey.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + }; + const handoff=makeHandoff(); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:200,body:{successor:handoff.successor,storage:'on'}}); + // An inherited on at at+10 would make this earlier direct off stale. + assert.equal((await admin('off',handoff.successor,at)).status,200); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',makeHandoff()),{status:409,body:{err:'conflict'}}); + } finally { await h.close(); } +}); + +test('R8 a handoff from an unfrozen identity is refused without binding a successor', {timeout:60000}, async () => { + const h=await hostedFixture({storageOpen:false}); + const makeHandoff=()=>{ + const root=generateKeyPairSync('ed25519'),key=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(key).digest('hex').slice(0,32); + return {successor,successor_key:key.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + }; + try { + await h.start();const c=h.client(); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',makeHandoff()),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + const accepted=makeHandoff(); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',accepted),{status:200,body:{successor:accepted.successor,storage:'off'}}); + } finally {await h.close();} +}); + + +test('R1 only the device that froze the identity can bind its hosted successor', {timeout:60000}, async () => { + const h=await hostedFixture({storageOpen:false}); + const makeHandoff=()=>{ + const root=generateKeyPairSync('ed25519'),key=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(key).digest('hex').slice(0,32); + return {successor,successor_key:key.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + }; + try { + await h.start();const keeper=h.client(),other=h.client(); + for(const device of [keeper,other])assert.equal((await h.call(device,'GET','/v1/store/stats')).body.storage,'off'); + const frozen=await h.call(keeper,'POST','/v1/identity/rotation',{op:'freeze'}); + assert.equal(frozen.status,200);assert.equal(frozen.body.rotation.by,keeper.device); + const competing=makeHandoff(); + assert.deepEqual(await h.call(other,'POST','/v1/store/handoff',competing),{status:409,body:{err:'not_rotating'}}); + // A durable competing binding would make the keeper's different successor conflict. + await h.start();const accepted=makeHandoff(); + assert.deepEqual(await h.call(keeper,'POST','/v1/store/handoff',accepted),{status:200,body:{successor:accepted.successor,storage:'off'}}); + assert.deepEqual(await h.call(other,'POST','/v1/store/handoff',competing),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(keeper,'POST','/v1/identity/rotation',{op:'retire'})).status,200); + assert.deepEqual(await h.call(other,'POST','/v1/store/handoff',accepted),{status:409,body:{err:'not_rotating'}}); + assert.deepEqual(await h.call(keeper,'POST','/v1/store/handoff',accepted),{status:200,body:{successor:accepted.successor,storage:'off'}}); + } finally {await h.close();} +}); diff --git a/relay/hosted/test/storage.test.js b/relay/hosted/test/storage.test.js new file mode 100644 index 0000000000..6c8b50ab8f --- /dev/null +++ b/relay/hosted/test/storage.test.js @@ -0,0 +1,191 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import { dispatch } from '../src/front.js'; +import { StorageAccess, ADMIN_DOMAIN, ADMIN_WINDOW_MS } from '../src/storage.js'; +import { limitsOf } from '../src/limits.js'; +import { Meta } from '../src/meta.js'; +import { Tenant } from '../src/tenant.js'; +import { RaftBlobTenant } from '../src/raft/effects.js'; +import { sealFrame } from '../src/store.js'; +import { matchRoute } from '../src/routes.js'; +import { PROTOCOL, HEADER_NAME } from '../src/engine/carrier.js'; +import { Flight } from '../src/flight.js'; +import { NOBODY } from '../src/watch.js'; +import { policyOf } from '../src/rules.js'; +import { respond, guarded } from '../src/wire.js'; +import { memorySql } from './sql.js'; +import { frame, rid } from './helpers.js'; + +const account = 'id_' + 'a'.repeat(32); +const refusal = (code, status) => e => e.code === code && e.status === status; +function tenant() { + const data = new Map(), sql = memorySql(); let puts = 0, reserves = 0; + const bucket = { async put(key, value) { puts++; data.set(key, value.slice()); }, async get(key, options) { + const bytes = data.get(key); if (!bytes) return null; + const range = options?.range, off = range?.offset ?? 0; + return { size: bytes.length, body: bytes, arrayBuffer: async () => bytes.slice(off, off + (range?.length ?? bytes.length)).buffer }; + }}; + const t = new Tenant({ identity: account, sql, bucket, clock: () => 1, limits: limitsOf({}), policy: policyOf(), + flight: new Flight(), arm: () => {}, watchers: NOBODY, reserve: async () => { reserves++; } }); + return { t, sql, data, puts: () => puts, reserves: () => reserves }; +} +const call = (t, method, path, body) => { + const route = matchRoute(method, path); + return respond(() => route.handler({ tenant: t, body, device: 'device', request: new Request('https://relay.test' + path, { method }) }, route.args)); +}; + +test('R1 R2 both frame doors refuse before R2 and every quota or global reservation change', async () => { + const r = tenant(), bytes = frame([rid(1)]).bytes; + const before = r.t.quota.snapshot(1); + const response = await call(r.t, 'POST', '/v1/store/frames', bytes); + assert.equal(response.status, 403); assert.deepEqual(await response.json(), { err: 'storage_off' }); + const raft = new RaftBlobTenant(r.t.store, async () => {}, async () => {}); + let admits = 0; + await assert.rejects(raft.putFrame(await sealFrame(bytes), () => { admits++; }), refusal('storage_off', 403)); + assert.equal(r.puts(), 0); assert.equal(r.reserves(), 0); assert.equal(admits, 0); + assert.deepEqual(r.t.quota.snapshot(1), before); + assert.deepEqual(r.sql.exec('SELECT * FROM frames').toArray(), []); +}); + +test('R3 R4 R9 approval and revocation preserve reads, directory writes and stats', async () => { + const r = tenant(), f = frame([rid(2)]); + assert.equal((await (await call(r.t, 'GET', '/v1/store/stats')).json()).storage, 'off'); + r.t.storage.set({ storage: 'on', at: 1 }); + assert.equal((await call(r.t, 'POST', '/v1/store/frames', f.bytes)).status, 200); + assert.equal((await (await call(r.t, 'GET', '/v1/store/stats')).json()).storage, 'on'); + r.t.storage.set({ storage: 'off', at: 2 }); + assert.equal((await call(r.t, 'POST', '/v1/store/frames', f.bytes)).status, 403); + assert.deepEqual(await r.t.getObject(rid(2)), f.objs[0]); + assert.deepEqual(await r.t.has([rid(2)]), [true]); + assert.equal(Object.keys(await r.t.locate([rid(2)])).length, 1); + assert.equal((await r.t.getFrame((await sealFrame(f.bytes)).id)).size, f.bytes.length); + r.t.dir.create('chat', { head: 'h', class: 'chat' }, 'device'); + assert.equal(r.t.dir.cell('chat').cell.head, 'h'); + assert.equal((await (await call(r.t, 'GET', '/v1/store/stats')).json()).storage, 'off'); +}); + +test('R5 storageOpen accepts true/1 only and default is closed in every deployment', async () => { + for (const value of [undefined, false, 0, 'false', '0', 'yes']) assert.equal(limitsOf({ CAF_LIMITS: JSON.stringify({ storageOpen: value }) }).storageOpen, false); + for (const value of [true, 1, 'true', '1']) { + const r = tenant(); r.t.storage.limits = limitsOf({ CAF_LIMITS: JSON.stringify({ storageOpen: value }) }); + assert.equal(r.t.storage.state(), 'on'); + await r.t.putFrame(frame([rid(3)]).bytes); assert.equal(r.puts(), 1); + } + const config = readFileSync(new URL('../wrangler.toml', import.meta.url), 'utf8'); + assert(config.includes('CAF_ADMIN_KEYS = "aTuSKJXUX8oeetqpxdDIqyB7tHfetea0dmlHJzEfreQ"')); + assert(!/storageOpen\s*[":= ]+\s*(true|1)/.test(config)); + const generator = readFileSync(new URL('../../../scripts/relay-wrangler-config.sh', import.meta.url), 'utf8'); + assert(generator.includes('print $0')); + for (const channel of ['stable', 'staging', 'dev']) { + const generated = execFileSync('bash', [new URL('../../../scripts/relay-wrangler-config.sh', import.meta.url).pathname, channel], { env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: 'test-account' }, encoding: 'utf8' }); + assert(generated.includes('CAF_ADMIN_KEYS = "aTuSKJXUX8oeetqpxdDIqyB7tHfetea0dmlHJzEfreQ"')); + assert(!/storageOpen/.test(generated)); + } +}); + +function admin() { + const keys = generateKeyPairSync('ed25519'), pub = keys.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('base64url'); + const records = new Map(); let admissions = 0; + const env = { BASE_PATH: '/fabric', CAF_ADMIN_KEYS: pub, IDENTITY: { + idFromName(id) { return id; }, get(id) { return { setStorage: body => guarded(() => { + let state = records.get(id); + if (!state) { state = new StorageAccess(new Meta(memorySql()), limitsOf({})); records.set(id, state); } + state.set(body); + }) }; }, + }, NEWCOMERS: { get() { admissions++; throw Error('approval must not admit'); } } }; + const now = Date.now(); + const body = { v: 1, host: 'relay.test', identity: account, storage: 'on', at: now, nonce: 'random-test-nonce' }; + const send = async (changes = {}, options = {}) => { + const raw = options.raw ?? JSON.stringify({ ...body, ...changes }); + const sig = options.signature ?? sign(null, Buffer.concat([Buffer.from(ADMIN_DOMAIN), Buffer.from(raw)]), keys.privateKey).toString('base64url'); + return respond(() => dispatch(new Request('https://relay.test/fabric/v1/admin/storage', { method: 'POST', headers: { 'codeaf-admin-signature': sig }, body: raw }), env)); + }; + return { env, records, body, signature: (raw, label=ADMIN_DOMAIN) => sign(null,Buffer.from(label+raw),keys.privateKey).toString('base64url'), send, admissions: () => admissions }; +} + +test('R6 R7 signed front-door approval works before first device contact; stale and reordered commands fail', async () => { + const a = admin(); + const response = await a.send(); assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { identity: account, storage: 'on' }); + assert.equal(a.admissions(), 0); + assert.equal((await a.send()).status, 409); + assert.equal((await a.send({ at: a.body.at - 1, storage: 'off' })).status, 409); + assert.equal((await a.send({ at: a.body.at + 1, storage: 'off' })).status, 200); + assert.equal(a.records.get(account).state(), 'off'); +}); + +test('R6 R7 signature, exact bytes, host, time window, shape and missing keys are enforced', async () => { + const a = admin(); + for (const changes of [{ host: 'other.test' }, { at: a.body.at - ADMIN_WINDOW_MS - 1000 }, { at: a.body.at + ADMIN_WINDOW_MS + 1000 }]) assert.equal((await a.send(changes)).status, 401); + for (const changes of [{ v: 2 }, { identity: 'bad' }, { storage: true }, { nonce: '' }, { at: '1' }]) assert.equal((await a.send(changes)).status, 400); + assert.equal((await a.send({}, { raw: '{' })).status, 400); + assert.equal((await a.send({}, { signature: 'bad' })).status, 401); + const unknown = admin(); a.env.CAF_ADMIN_KEYS = unknown.env.CAF_ADMIN_KEYS; + assert.equal((await a.send()).status, 401); + a.env.CAF_ADMIN_KEYS = ''; assert.equal((await a.send()).status, 401); + assert.equal(a.records.size, 0); +}); + +test('R1 structural guard keeps all production R2 puts inside the gated writer', () => { + const root = new URL('../src/', import.meta.url), paths = []; + const visit = dir => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = new URL(entry.name, dir); if (entry.isDirectory()) visit(new URL(entry.name + '/', dir)); else if (entry.name.endsWith('.js')) paths.push(path); } }; + visit(root); + const puts = paths.flatMap(path => [...readFileSync(path, 'utf8').matchAll(/\b(?:bucket|FRAMES)\s*(?:\.\s*put|\[\s*['"]put['"]\s*\])\s*\(/g)].map(() => path.pathname)); + assert.deepEqual(puts, [new URL('../src/store.js', import.meta.url).pathname]); + const constructors = paths.filter(path => /new R2Store\(/.test(readFileSync(path, 'utf8'))).map(path => path.pathname); + assert.deepEqual(constructors, [new URL('../src/tenant.js', import.meta.url).pathname]); + const tenantSource = readFileSync(new URL('../src/tenant.js', import.meta.url), 'utf8'); + assert.match(tenantSource, /new R2Store\(bucket, sql, identity, \(\) => this\.storage\.assert\(\)\)/); + const store = readFileSync(new URL('../src/store.js', import.meta.url), 'utf8'); + assert.match(store, /this\.assertStorage\(\);\s*await this\.bucket\.put/); + assert.match(store, /this\.assertStorage\(\);\s*if \(this\.#indexed/); +}); + + +test('R3 pairing and machine tunnel ingress remain available with storage off', async () => { + let pairs=0, machines=0; + const gate={create:async()=>{pairs++;return {ok:{nameplate:42}};}}; + const env={CAF_LIMITS:'{"storageOpen":false}',PAIR_GATE:{idFromName:x=>x,get:()=>gate}, + MACHINE:{idFromName:x=>x,get:()=>({fetch:async()=>{machines++;return new Response('live',{status:200});}})}, + MACHINE_DIAL_LIMIT:{limit:async()=>({success:true})},MACHINE_REGISTER_LIMIT:{limit:async()=>({success:true})}, + FRAMES:new Proxy({}, {get(){throw Error('live access touched R2');}}), + IDENTITY:new Proxy({}, {get(){throw Error('live access consulted storage approval');}})}; + let response=await respond(()=>dispatch(new Request('https://relay.test/v1/pair',{method:'POST',headers:{'codeaf-pair-key':Buffer.alloc(16,1).toString('base64url')}}),env)); + assert.equal(response.status,201);assert.equal(pairs,1); + for(const path of ['/v1/engine','/v1/dial/amber-forest-42']) { + response=await respond(()=>dispatch(new Request('https://relay.test'+path,{headers:{upgrade:'websocket','sec-websocket-protocol':PROTOCOL,[HEADER_NAME]:'amber-forest-42'}}),env)); + assert.equal(response.status,200,await response.text()); + } + assert.equal(machines,2); +}); + +test('S2 a fixed valid signature refuses changed bytes, whitespace and a missing domain label', async () => { + const a=admin(), raw=JSON.stringify(a.body), signature=a.signature(raw); + for(const changed of [raw.replace('random-test-nonce','random-test-noncf'),raw+' ']) assert.equal((await a.send({}, {raw:changed,signature})).status,401); + assert.equal((await a.send({}, {raw,signature:a.signature(raw,'')})).status,401); + assert.equal(a.records.size,0); + delete a.env.CAF_ADMIN_KEYS; + assert.equal((await a.send()).status,401); + const other=admin(); a.env.CAF_ADMIN_KEYS=other.env.CAF_ADMIN_KEYS+', '+admin().env.CAF_ADMIN_KEYS; + assert.equal((await a.send()).status,401); + a.env.CAF_ADMIN_KEYS+=', '+other.env.CAF_ADMIN_KEYS; + const b=admin(); b.env.CAF_ADMIN_KEYS=other.env.CAF_ADMIN_KEYS+', '+b.env.CAF_ADMIN_KEYS; + assert.equal((await b.send()).status,200); +}); + +test('B1 inheritance copies only on into an empty successor and never replaces its own record', () => { + for(const state of ['on','off']) { + const access=new StorageAccess(new Meta(memorySql()), limitsOf({})); + access.set({storage:state,at:1}); + access.set({storage:'on',at:5},true); + assert.deepEqual(access.approval(),{storage:state,at:1}); + } + const empty=new StorageAccess(new Meta(memorySql()),limitsOf({})); + empty.set({storage:'off',at:10},true); assert.equal(empty.approval(),undefined); + empty.set({storage:'on',at:11},true); assert.equal(empty.state(),'on'); + const open=new StorageAccess(new Meta(memorySql()),{storageOpen:true}); + open.set({storage:'on',at:12},true); assert.equal(open.approval(),undefined); +}); diff --git a/relay/hosted/test/store.test.js b/relay/hosted/test/store.test.js index 34286792c6..35b014c98c 100644 --- a/relay/hosted/test/store.test.js +++ b/relay/hosted/test/store.test.js @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; import { Conflict, R2Store, sealFrame } from '../src/store.js'; import { Tenant } from '../src/tenant.js'; import { matchRoute } from '../src/routes.js'; -import { DEFAULTS } from '../src/limits.js'; +import { DEFAULTS as CLOSED_DEFAULTS } from '../src/limits.js'; +const DEFAULTS = { ...CLOSED_DEFAULTS, storageOpen: true }; import { policyOf } from '../src/rules.js'; import { Flight } from '../src/flight.js'; import { Wire } from '../src/wire.js'; @@ -33,7 +34,7 @@ before(async () => { env = await openBucket(); }); after(() => env.close()); // One identity's state is its SQLite and its R2 prefix; a "cold" store is a new object over the same two. -const open = (identity, sql = memorySql()) => ({ sql, store: new R2Store(env.bucket, sql, identity) }); +const open = (identity, sql = memorySql()) => ({ sql, store: new R2Store(env.bucket, sql, identity, () => {}) }); const put = async (store, f, admit) => store.put(await sealFrame(f.bytes), admit); test('stats adds durable stored totals and active limits without changing request counters or writing on a read', async () => { @@ -59,14 +60,14 @@ test('stats adds durable stored totals and active limits without changing reques const caps = { limit_bytes: 10_000, limit_objects: 100, limit_frames_per_day: 10 }; queries.length = 0; const stats = async (tenant = cold) => (await runRoute('GET', '/v1/store/stats', tenant)).json(); - assert.deepEqual(await stats(), { ...counters, ...used, ...caps }); + assert.deepEqual(await stats(), { storage: 'on', ...counters, ...used, ...caps }); assert.deepEqual(counters, { puts: 3, gets: 1, has: 1, bytes_in: first.bytes.length * 2 + second.bytes.length, bytes_out: first.objs[0].length }); - assert.deepEqual(await stats(tenantOf('id_stats_other')), { puts: 0, gets: 0, has: 0, bytes_in: 0, bytes_out: 0, + assert.deepEqual(await stats(tenantOf('id_stats_other')), { storage: 'on', puts: 0, gets: 0, has: 0, bytes_in: 0, bytes_out: 0, stored_bytes: 0, stored_objects: 0, frames_today: 0, limit_bytes: DEFAULTS.storeBytes, limit_objects: DEFAULTS.storeObjects, limit_frames_per_day: DEFAULTS.framesPerDay }); now = 86_400_000; - assert.deepEqual(await stats(), { ...counters, ...used, frames_today: 0, ...caps }); + assert.deepEqual(await stats(), { storage: 'on', ...counters, ...used, frames_today: 0, ...caps }); assert.deepEqual(sql.exec('SELECT day, frames, bytes, objects FROM usage').one(), { day: 0, frames: 2, bytes: used.stored_bytes, objects: 3, }); @@ -185,7 +186,7 @@ test('getMany costs one R2 read per frame touched, not one per object', async () const { sql, store } = open('id_many_cost'); await put(store, frame([rid(1), rid(2), rid(3), rid(4)])); const counted = countingBucket(env.bucket); - const cold = new R2Store(counted.bucket, sql, 'id_many_cost'); + const cold = new R2Store(counted.bucket, sql, 'id_many_cost', () => {}); await cold.getMany([rid(4), rid(1), rid(3), rid(2)]); assert.equal(counted.calls.get, 1); }); @@ -274,7 +275,7 @@ test('R21 upload references become confirmable only after local R2 and index com async put(...args) { if (fail) throw new Error('injected R2 failure'); return env.bucket.put(...args); }, get(...args) { return env.bucket.get(...args); }, }; - const sql = memorySql(), store = new R2Store(bucket, sql, 'id_r21'); + const sql = memorySql(), store = new R2Store(bucket, sql, 'id_r21', () => {}); const f = frame([rid(21), rid(22)]), sealed = await sealFrame(f.bytes); await assert.rejects(store.put(sealed), /injected R2 failure/); await assert.rejects(store.checkReferences(rid(21), [sealed.id], async () => {})); @@ -283,7 +284,7 @@ test('R21 upload references become confirmable only after local R2 and index com let completed = false; await store.checkReferences(rid(21), [sealed.id], async () => { completed = true; }); assert(completed); - const cold = new R2Store(bucket, sql, 'id_r21'); + const cold = new R2Store(bucket, sql, 'id_r21', () => {}); await cold.checkReferences(rid(21), [], async () => {}); assert.deepEqual(await cold.get(rid(22)), f.objs[1]); }); diff --git a/relay/hosted/wrangler.toml b/relay/hosted/wrangler.toml index 0a3f6fa9d3..48b6dfb963 100644 --- a/relay/hosted/wrangler.toml +++ b/relay/hosted/wrangler.toml @@ -54,6 +54,7 @@ new_sqlite_classes = ["LinkGate", "LinkRequest"] command = "sh ../../scripts/raft-hosted-build.sh" [vars] +CAF_ADMIN_KEYS = "aTuSKJXUX8oeetqpxdDIqyB7tHfetea0dmlHJzEfreQ" # "amended": 90-second leases, renewed by publish; "stage1": 30-second leases. LEASE_POLICY = "amended" diff --git a/scripts/relay-conformance.sh b/scripts/relay-conformance.sh index 64de4d66d7..78847e23bc 100755 --- a/scripts/relay-conformance.sh +++ b/scripts/relay-conformance.sh @@ -168,9 +168,12 @@ OPEN='{"newIdentitiesPerIpPerDay":1000000,"minGraceMs":2000,"sweepPageSize":2,"r SMALL='{"newIdentitiesPerIpPerDay":1000000,"pairMaxBoxes":2}' EXPIRY='{"newIdentitiesPerIpPerDay":1000000,"pairTtlMs":4000}' wrangler() { # port, persist dir, limits json + # These existing conformance cases exercise enabled storage; opt-in has its own closed-default proofs. + local storage_limits + storage_limits=$(node -e 'console.log(JSON.stringify({...JSON.parse(process.argv[1]), storageOpen:true}))' "$3") start_relay "http://127.0.0.1:$1/" "$work/worker-$1.log" \ sh "$hosted/test/local.sh" "$hosted/node_modules/.bin/wrangler" dev --local -c "${CONFORMANCE_WORKER_CONFIG:-$hosted/wrangler.toml}" --name "codeaf-conformance-$1" --inspector-port 0 --ip 127.0.0.1 --port "$1" --persist-to "$2" \ - --var "CAF_LIMITS:$3" --var TRUST_PROXY:1 + --var "CAF_LIMITS:$storage_limits" --var TRUST_PROXY:1 } # Track 2: relayconf through the Worker.