diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 8a4f04a476..0f05496e07 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -299,6 +299,7 @@ func runServe(args []string) error { // out instead of leaving the relay to notice. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + defer startServeKeeping(ctx, service)() host := &pair.Host{ Service: service, Device: device, diff --git a/cmd/codeaf/devbox.go b/cmd/codeaf/devbox.go new file mode 100644 index 0000000000..340dff1931 --- /dev/null +++ b/cmd/codeaf/devbox.go @@ -0,0 +1,332 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/devbox" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func runDevbox(args []string) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return devboxCommand(ctx, args, newDevboxFlow()) +} + +func devboxCommand(ctx context.Context, args []string, flow devbox.Flow) error { + if len(args) == 0 { + return wrongCall("usage: codeaf devbox up | status [] | down ") + } + if askedForHelp(args[:1]) { + return commandHelp("devbox") + } + action := args[0] + if action != "up" && action != "status" && action != "down" { + return wrongCall("usage: codeaf devbox up | status [] | down ") + } + var o devbox.Options + flags := devboxFlags("devbox "+action, action == "up", &o) + if err := parseCommandFlags(flags, reorder(flags, args[1:])); err != nil { + return err + } + if flags.NArg() > 1 || (action != "status" && flags.NArg() != 1) { + return wrongCall("usage: codeaf devbox " + action + " ") + } + if flags.NArg() == 1 { + o.Name = flags.Arg(0) + } + if action == "up" { + o.Version = devboxVersion(buildinfo.Revision(), o.Channel) + } + err := flow.Run(ctx, action, o) + if err != nil && (err.Error() == devbox.TokenNeeded || err.Error() == devbox.TerraformNeeded) { + fmt.Fprintln(usageErr, err) + return exitStatus(1) + } + return err +} + +// The root row stays compact; this page carries the full action and flag reference. +const devboxUsage = ` codeaf devbox up [--yes] [--dir D] [--region R] + [--workspace W] [--channel C] + codeaf devbox down [--yes] [--dir D] + codeaf devbox status [] [--dir D]` + +func devboxFlags(name string, up bool, o *devbox.Options) *flag.FlagSet { + flags := commandFlags(name) + flags.StringVar(&o.Dir, "dir", "", "folder holding this box's Terraform; default is /devbox/") + flags.BoolVar(&o.Yes, "yes", false, "skip the create or destroy confirmation") + if up { + flags.StringVar(&o.Region, "region", "", "the region the box runs in; empty picks one for you") + flags.StringVar(&o.Workspace, "workspace", "", "the workspace on your account, by name or id; needed when you have several") + flags.StringVar(&o.Channel, "channel", devboxChannel(buildinfo.Revision()), "stable, staging or dev; follows this binary, with dev for a source build") + } + return flags +} + +func devboxChannel(revision string) string { + switch channel := buildinfo.Channel(revision); channel { + case "stable", "staging", "dev": + return channel + case "rc": + return "stable" + default: + return "dev" + } +} + +func devboxVersion(revision, channel string) string { + if buildinfo.Channel(revision) == channel { + if _, _, err := devbox.Channel(channel); err == nil { + return revision + } + } + return "" +} + +func newDevboxFlow() devbox.Flow { + door := newPairDoor(os.Stdin, os.Stdout) + return devbox.Flow{ + Home: home.Dir(), In: os.Stdin, Out: os.Stdout, Err: os.Stderr, Interactive: stdinIsTerminal(os.Stdin), StdoutTerminal: stdinIsTerminal(os.Stdout), + Unavailable: func() string { + if reason := cell.UnavailableReason(); reason != "" { + return reason + } + _, err := door.mailbox("") + if err != nil { + return err.Error() + } + return "" + }, + Prepare: func(ctx context.Context, name string) (devbox.Offer, error) { return prepareDevbox(door, name) }, + Pair: func(ctx context.Context, name string, ready func(devbox.Offer)) error { + return offerDevbox(ctx, door, name, ready) + }, + Devices: devboxDevices, + Probe: probeDevbox, + Remove: devboxRemoval(openChatsKind, pair.MachineBook()), + ShareState: shareDevboxState, + } +} + +// devboxShareWithin bounds the one home-state round up runs for a new box, so +// a slow relay cannot hold the command after the box already answers. +const devboxShareWithin = 30 * time.Second + +// shareDevboxState runs the round an open terminal runs: it pushes this +// computer's saved keys and settings to the identity the box just joined, and +// the box's serve pulls them on its next round. +func shareDevboxState(ctx context.Context) error { + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + return err + } + ctx, cancel := context.WithTimeout(ctx, devboxShareWithin) + defer cancel() + return s.SyncState(ctx) +} + +func devboxDevices(ctx context.Context) ([]devbox.Device, error) { + known, err := pair.Machines() + if err != nil { + return nil, err + } + remembered := make([]devbox.Device, 0, len(known)) + for _, one := range known { + name := one.Name + if one.Alias != "" { + name = one.Alias + } + remembered = append(remembered, devbox.Device{Name: name}) + } + if syncsetup.Quiet(home.Dir()) { + return remembered, nil + } + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil { + return nil, err + } + if !ok { + return remembered, nil + } + kind := chatsKindOf(s.Dir, s.Identity.CellKey(), s.Device.ID(), s.Relay) + ctx, cancel := context.WithTimeout(ctx, listRequestWithin) + defer cancel() + listing, err := kind.dir.List(ctx) + if err != nil { + return nil, kind.explain(err) + } + presence, presenceErr := kind.dir.Presence(ctx) + result := make([]devbox.Device, 0, len(listing.Devices)) + for id, record := range listing.Devices { + name, err := directory.OpenName(kind.key, record.Name) + if err != nil { + if record.Revoked { + continue + } + return nil, errors.New("could not read a device name from this identity's list") + } + d := devbox.Device{ID: id, Name: name, Removed: record.Revoked} + seen := record.LastSeen + if presenceErr == nil { + if p, ok := presence.Devices[id]; ok { + online := p.Online + d.Online = &online + seen = p.LastSeen + } + } + if seen > 0 { + d.LastSeen = time.UnixMilli(seen) + } + result = append(result, d) + } + return append(result, remembered...), nil +} + +// Pin before offering the first code so even a lost acknowledgement can be probed. +func prepareDevbox(door pairDoor, name string) (devbox.Offer, error) { + route, err := door.mailbox("") + if err != nil { + return devbox.Offer{}, err + } + client, err := pair.ThisDevice(pair.OpenKeeper()) + if err != nil { + return devbox.Offer{}, err + } + key, devices, known, err := pair.DevboxRemote(name, route.URL, client, pair.ThisMachineLabel()) + if err != nil { + return devbox.Offer{}, err + } + if err := pair.MachineBook().Remember(known); err != nil { + return devbox.Offer{}, err + } + return devbox.Offer{Via: route.URL, RemoteKey: base64.StdEncoding.EncodeToString(key), RemoteDevices: base64.StdEncoding.EncodeToString(devices)}, nil +} + +// Select once, ignoring revoked names; a saved id continues to identify a renamed box. +func devboxRemoval(open func() (chatsKind, bool), book *pair.Book) func(context.Context, string, string) (bool, error) { + return func(ctx context.Context, name, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + kind, ok := open() + if !ok { + return false, errors.New("could not open this identity's device list") + } + rows, err := kind.rows() + if err != nil { + return false, err + } + var selected *chatsRow + if id != "" { + for _, row := range rows { + if !row.Stopped && row.id == id { + copy := row + selected = © + break + } + } + } + if selected == nil { + for _, row := range rows { + if !row.Stopped && strings.EqualFold(row.Name, name) { + if selected != nil { + return false, fmt.Errorf("more than one active device is called %q", name) + } + copy := row + selected = © + } + } + } + if selected != nil { + if _, err := kind.revoke(*selected); err != nil { + return false, err + } + } + if err := book.ForgetAlias(name); err != nil { + return false, err + } + return selected != nil, nil + } +} + +func offerDevbox(ctx context.Context, door pairDoor, name string, ready func(devbox.Offer)) error { + route, err := door.mailbox("") + if err != nil { + return err + } + grant, err := door.grant(route) + if err != nil { + return err + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + ui := devboxOfferUI{name: name, offer: devbox.Offer{Via: route.URL}, ready: ready, cancel: cancel} + label, err := pair.Offer(ctx, route, grant, &ui) + if err != nil { + if errors.Is(err, pair.ErrDidNotFinish) || errors.Is(err, context.DeadlineExceeded) { + return devbox.ErrJoinInterrupted + } + return err + } + if label != name { + return fmt.Errorf("a different device joined: %s", label) + } + door.recordOwnDeviceOrSay(ctx, route) + return nil +} + +// The command authorizes this one named box to receive the full identity. +// A burned code cancels rather than silently changing the code already in a plan. +type devboxOfferUI struct { + name string + offer devbox.Offer + ready func(devbox.Offer) + cancel context.CancelFunc + shown bool +} + +func (u *devboxOfferUI) Show(code *pair.Code, _ string) { + if !u.shown { + u.shown = true + u.offer.Code = code.Shown() + u.ready(u.offer) + } +} +func (u *devboxOfferUI) Burned() { u.cancel() } +func (u *devboxOfferUI) Ask(ctx context.Context, label, _ string) bool { + return ctx.Err() == nil && label == u.name +} + +// The Noise handshake proves the configured key answered, including on released boxes without a watch. +func probeDevbox(ctx context.Context, name string) bool { + book := pair.MachineBook() + known, found, err := book.Machine(name) + if err != nil || !found || known.Alias != name { + return false + } + client, err := pair.ThisDevice(pair.OpenKeeper()) + if err != nil { + return false + } + tunnel, err := (pair.Reach{Name: name, Device: client, Machines: book, Label: pair.ThisMachineLabel()}).Open(ctx) + if err != nil { + return false + } + _ = tunnel.Close() + return true +} diff --git a/cmd/codeaf/devbox_remove_test.go b/cmd/codeaf/devbox_remove_test.go new file mode 100644 index 0000000000..9bd3b10cca --- /dev/null +++ b/cmd/codeaf/devbox_remove_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devbox" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// R5.6: the production removal wiring revokes by saved id across name reuse and renames. +func TestDevboxR56UpDownNameReuseRenameAndMissingDevice(t *testing.T) { + for _, mode := range []string{"reuse", "renamed", "no-match"} { + t.Run(mode, func(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("CODEAF_HOME", stateHome) + dir := filepath.Join(t.TempDir(), "box") + memory := directory.NewMemory(time.Now) + key := bytes.Repeat([]byte{7}, 32) + kind := chatsKindOf(memory.For(thisComputer), key, thisComputer, "https://relay.example") + var out bytes.Buffer + var reachable atomic.Bool + joined := 0 + f := devbox.Flow{Home: stateHome, Out: &out, Getenv: func(string) string { return "fixture-token" }, LookPath: func(string) (string, error) { return "fake-terraform", nil }, Workspaces: func(context.Context, string) ([]devbox.Workspace, error) { + return []devbox.Workspace{{ID: "workspace"}}, nil + }, Settle: func(context.Context, string, string, string) error { return nil }, Probe: func(context.Context, string) bool { return reachable.Load() }, + Prepare: func(_ context.Context, name string) (devbox.Offer, error) { + err := pair.MachineBook().Remember(pair.Known{Name: "box-remote", Alias: name, Key: "test-key", Service: "https://relay.example"}) + return devbox.Offer{Via: "https://relay.example", RemoteKey: "fixture-key", RemoteDevices: "fixture-devices"}, err + }, + Devices: func(context.Context) ([]devbox.Device, error) { + rows, err := kind.rows() + var devices []devbox.Device + for _, r := range rows { + devices = append(devices, devbox.Device{ID: r.id, Name: r.Name, Removed: r.Stopped}) + } + return devices, err + }, + Remove: devboxRemoval(func() (chatsKind, bool) { return kind, true }, pair.MachineBook()), + } + f.Pair = func(ctx context.Context, name string, ready func(devbox.Offer)) error { + joined++ + id := fmt.Sprintf("dev_%032d", joined) + sealed, err := directory.SealName(kind.key, name) + if err != nil { + return err + } + if err := memory.For(id).PutDevice(ctx, id, directory.Device{V: 1, Name: sealed}); err != nil { + return err + } + ready(devbox.Offer{Code: "42-123-456", Via: "https://relay.example"}) + <-ctx.Done() + return ctx.Err() + } + f.RunTerraform = func(_ context.Context, _, folder string, args, env []string, stdout, stderr io.Writer) error { + switch args[0] { + case "output": + _, err := io.WriteString(stdout, `{"service_id":{"value":"service"},"environment_id":{"value":"environment"}}`) + return err + case "show": + _, err := io.WriteString(stdout, `{"resource_changes":[]}`) + return err + case "apply": + if strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + reachable.Store(true) + } + return os.WriteFile(filepath.Join(folder, "terraform.tfstate"), []byte(`{"resources":[{"type":"railway_project","instances":[{}]}]}`), 0o600) + case "destroy": + reachable.Store(false) + return os.WriteFile(filepath.Join(folder, "terraform.tfstate"), []byte(`{"resources":[]}`), 0o600) + } + return nil + } + rounds := 1 + if mode == "reuse" { + rounds = 2 + } + for n := 1; n <= rounds; n++ { + opts := devbox.Options{Name: "box", Dir: dir, Yes: true} + if err := f.Run(t.Context(), "up", opts); err != nil { + t.Fatal(err) + } + expected := fmt.Sprintf("dev_%032d", n) + raw, err := os.ReadFile(filepath.Join(dir, "codeaf.devbox.json")) + if err != nil || !strings.Contains(string(raw), expected) { + t.Fatalf("R5.6 missing joined id: %s %v", raw, err) + } + info, err := os.Stat(filepath.Join(dir, "codeaf.devbox.json")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatal("R5.6 receipt is not 0600") + } + if mode == "renamed" { + sealed, err := directory.SealName(kind.key, "new-box-name") + if err != nil { + t.Fatal(err) + } + if err := memory.For(expected).PutDevice(t.Context(), expected, directory.Device{V: 1, Name: sealed}); err != nil { + t.Fatal(err) + } + } + // The second removal also proves the active-name fallback ignores old revoked rows. + if mode == "reuse" && n == 2 { + if err := os.Remove(filepath.Join(dir, "codeaf.devbox.json")); err != nil { + t.Fatal(err) + } + } + if mode == "no-match" { + if err := kind.dir.Revoke(t.Context(), expected); err != nil { + t.Fatal(err) + } + } + if err := f.Run(t.Context(), "down", opts); err != nil { + t.Fatalf("R5.6 down %d: %v", n, err) + } + listing, err := kind.dir.List(t.Context()) + if err != nil || !listing.Devices[expected].Revoked { + t.Fatalf("R5.6 did not revoke %s: %v", expected, err) + } + if _, found, err := pair.MachineBook().Machine("box"); err != nil || found { + t.Fatal("R5.6 alias retained") + } + } + if mode == "no-match" && !strings.Contains(out.String(), "No active device matches box; no device was removed.") { + t.Fatalf("R5.6 no-match hidden: %s", &out) + } + }) + } +} diff --git a/cmd/codeaf/devbox_test.go b/cmd/codeaf/devbox_test.go new file mode 100644 index 0000000000..1bc7a30416 --- /dev/null +++ b/cmd/codeaf/devbox_test.go @@ -0,0 +1,410 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devbox" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/pairbox" +) + +// R6.1: only a release on the chosen stable, staging or dev channel pins the first install. +func TestDevboxR61FirstInstallVersionMatchesChannel(t *testing.T) { + for _, row := range []struct{ name, revision, channel, want string }{ + {"stable", "v1.2.3", "stable", "v1.2.3"}, + {"staging", "staging-20261030-aaaaaaaaaaaa", "staging", "staging-20261030-aaaaaaaaaaaa"}, + {"dev", "dev-20261030-aaaaaaaaaaaa", "dev", "dev-20261030-aaaaaaaaaaaa"}, + {"stable-to-staging", "v1.2.3", "staging", ""}, + {"stable-to-dev", "v1.2.3", "dev", ""}, + {"staging-to-stable", "staging-20261030-aaaaaaaaaaaa", "stable", ""}, + {"staging-to-dev", "staging-20261030-aaaaaaaaaaaa", "dev", ""}, + {"dev-to-stable", "dev-20261030-aaaaaaaaaaaa", "stable", ""}, + {"dev-to-staging", "dev-20261030-aaaaaaaaaaaa", "staging", ""}, + {"release-candidate", "v1.2.3-rc.1", "stable", ""}, + {"rc-is-not-offered", "v1.2.3-rc.1", "rc", ""}, + {"source-commit", "abcdef12", "dev", ""}, + {"source-unstamped", "", "dev", ""}, + {"source-word", "dev", "dev", ""}, + {"other", "custom-build", "other", ""}, + } { + t.Run(row.name, func(t *testing.T) { + if got := devboxVersion(row.revision, row.channel); got != row.want { + t.Fatalf("R6.1 version = %q, want %q", got, row.want) + } + }) + } +} + +// Contracts 1 and 7: each command exposes its actual flags without touching a provider. +func TestDevboxHelpAndChannelDefaults(t *testing.T) { + for _, action := range []string{"up", "status", "down"} { + t.Run(action, func(t *testing.T) { + out, errs := captureUsage(t) + err := devboxCommand(context.Background(), []string{action, "--help"}, devbox.Flow{}) + if exitCodeOf(err) != 0 || errs.Len() != 0 { + t.Fatalf("help: %v %s", err, errs) + } + for _, flag := range []string{"codeaf devbox " + action, "--dir", "--yes"} { + if !strings.Contains(out.String(), flag) { + t.Fatalf("missing %s: %s", flag, out) + } + } + if (action == "up") != strings.Contains(out.String(), "--channel") { + t.Fatalf("wrong flags: %s", out) + } + }) + } + for _, row := range []struct{ revision, want string }{{"source", "dev"}, {"dev-20261001-aaaaaaaaaaaa", "dev"}, {"staging-20261001-aaaaaaaaaaaa", "staging"}, {"v1.2.3", "stable"}, {"v1.2.3-rc.1", "stable"}} { + if got := devboxChannel(row.revision); got != row.want { + t.Errorf("%s: %s != %s", row.revision, got, row.want) + } + } +} + +// Contract 3: only the requested name may receive the unattended identity offer. +func TestDevboxOfferDoesNotPrintItsCodeOrAdmitAnotherName(t *testing.T) { + var code, via string + cancelled := false + ui := devboxOfferUI{name: "build-box", offer: devbox.Offer{Via: "https://relay.example"}, ready: func(o devbox.Offer) { code, via = o.Code, o.Via }, cancel: func() { cancelled = true }} + fresh, err := pair.NewCode(time.Now()) + if err != nil { + t.Fatal(err) + } + ui.Show(fresh.WithPlate("42"), "") + if code != "42-"+strings.ReplaceAll(fresh.Shown(), " ", "-") || via != "https://relay.example" { + t.Fatalf("ready %q %q", code, via) + } + if !ui.Ask(context.Background(), "build-box", "") || ui.Ask(context.Background(), "stranger", "") { + t.Fatal("wrong admission") + } + ui.Burned() + if !cancelled { + t.Fatal("burned code was left live") + } +} + +// Contract 6: an idle serving computer refreshes its own directory record until stopped. +func TestDevboxServeRefreshesItsOwnRecord(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ticks := make(chan time.Time) + calls := make(chan struct{}, 4) + done := make(chan struct{}) + go func() { + defer close(done) + refreshServeRecord(ctx, ticks, func(context.Context) error { calls <- struct{}{}; return nil }) + }() + await := func() { + t.Helper() + select { + case <-calls: + case <-time.After(time.Second): + t.Fatal("no refresh") + } + } + await() + ticks <- time.Now() + await() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("refresh survived cancellation") + } + if len(calls) != 0 { + t.Fatal("refresh after stop") + } +} + +// Contract 1: the command prints exactly one prerequisite sentence and exits nonzero. +func TestDevboxPrerequisiteSentenceIsPrintedOnce(t *testing.T) { + for _, action := range []string{"up", "down"} { + out, errs := captureUsage(t) + f := devbox.Flow{Getenv: func(string) string { return "" }, LookPath: func(string) (string, error) { t.Fatal("terraform before token"); return "", nil }} + err := devboxCommand(context.Background(), []string{action, "box"}, f) + if exitCodeOf(err) == 0 || out.Len() != 0 || errs.String() != devbox.TokenNeeded+"\n" { + t.Fatalf("exit %v, stdout %q, stderr %q", err, out.String(), errs.String()) + } + } +} + +// R5.2: serving shares one gated follower across refreshes and closes it synchronously. +func TestDevboxR52ServePresenceWatchLifetimeAndBoundedRefresh(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ticks := make(chan time.Time, 32) + refreshes := make(chan struct{}, 32) + stream := &devboxPresenceStream{closed: make(chan struct{}), readerDone: make(chan struct{}), started: make(chan struct{})} + var dials atomic.Int32 + dial := func(context.Context) (dirwatch.Stream, error) { dials.Add(1); return stream, nil } + done := make(chan struct{}) + go func() { + defer close(done) + servePresence(ctx, dirwatch.Follow(t.Name(), dial), ticks, func(context.Context) error { refreshes <- struct{}{}; return nil }) + }() + await := func(ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(time.Second): + t.Fatal("presence did not advance") + } + } + await(refreshes) + await(stream.started) + // A second follower must share this process's socket, including while records refresh. + other := dirwatch.Follow(t.Name(), dial) + defer other.Close() + for range 20 { + ticks <- time.Now() + await(refreshes) + } + select { + case <-refreshes: + t.Fatal("refresh spins without a tick") + default: + } + cancel() + await(done) + select { + case <-stream.closed: + t.Fatal("closed a shared watch") + default: + } + other.Close() + await(stream.closed) + await(stream.readerDone) + if dials.Load() != 1 { + t.Fatalf("watch opened %d times", dials.Load()) + } + + // Buffered ticks cannot create workers while the current refresh is blocked. + blockedCtx, stop := context.WithCancel(context.Background()) + defer stop() + var calls atomic.Int32 + entered := make(chan struct{}) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + refreshServeRecord(blockedCtx, ticks, func(ctx context.Context) error { + if calls.Add(1) == 1 { + close(entered) + } + <-ctx.Done() + return ctx.Err() + }) + }() + await(entered) + for range 20 { + ticks <- time.Now() + } + stop() + await(stopped) + if calls.Load() != 1 { + t.Fatalf("refresh workers survived cancellation: %d", calls.Load()) + } + // A closed ticker also ends the loop instead of spinning. + for len(ticks) > 0 { + <-ticks + } + close(ticks) + refreshServeRecord(context.Background(), ticks, func(context.Context) error { + if calls.Add(1) > 3 { + t.Fatal("closed ticker spins") + } + return nil + }) +} + +type devboxPresenceStream struct{ closed, readerDone, started chan struct{} } + +func (s *devboxPresenceStream) Next(ctx context.Context) (dirwatch.Frame, error) { + defer close(s.readerDone) + close(s.started) + <-ctx.Done() + return dirwatch.Frame{}, ctx.Err() +} +func (*devboxPresenceStream) Ping(context.Context) error { return nil } +func (*devboxPresenceStream) Vouching() bool { return false } +func (s *devboxPresenceStream) Close() { close(s.closed) } + +// R5.3: remote configuration and its alias are prepared before offering an unchanged identity grant. +func TestDevboxR53OfferKeepsRemoteConfigurationOutsideIdentityGrant(t *testing.T) { + for _, mode := range []string{"joined", "other-name", "cancelled"} { + t.Run(mode, func(t *testing.T) { + laptopHome := t.TempDir() + t.Setenv("CODEAF_HOME", laptopHome) + id, err := identity.Ensure(laptopHome) + if err != nil { + t.Fatal(err) + } + route := pair.Mailbox{URL: "https://relay.example", Host: "relay.example", Box: pairbox.NewMemory(pairbox.DefaultLimits, nil)} + door := pairDoor{term: newTerminal(strings.NewReader(""), io.Discard), mailbox: func(string) (pair.Mailbox, error) { return route, nil }, grant: func(pair.Mailbox) (pair.Grant, error) { return pair.Grant{Identity: id, SyncURL: route.URL}, nil }} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + remote, err := prepareDevbox(door, "build-box") + if err != nil { + t.Fatal(err) + } + ready := make(chan devbox.Offer, 1) + done := make(chan error, 1) + go func() { done <- offerDevbox(ctx, door, "build-box", func(o devbox.Offer) { ready <- o }) }() + var offer devbox.Offer + select { + case offer = <-ready: + case err := <-done: + t.Fatalf("offer ended: %v", err) + case <-ctx.Done(): + t.Fatal("no offer") + } + if offer.Code == "" || offer.Via != route.URL { + t.Fatal("missing identity offer") + } + for _, blob := range []string{remote.RemoteKey, remote.RemoteDevices} { + if raw, err := base64.StdEncoding.DecodeString(blob); err != nil || len(raw) == 0 { + t.Fatal("missing remote configuration") + } + } + if _, found, err := pair.MachineBook().Machine("build-box"); err != nil || !found { + t.Fatal("R5.3 alias not pinned before join") + } + if mode == "cancelled" { + cancel() + } else { + name := "build-box" + if mode == "other-name" { + name = "other" + } + boxHome := t.TempDir() + _, err := pair.Join(ctx, route, pair.Joining{Home: boxHome, Label: name}, offer.Code, devboxJoinScreen{}) + if (err == nil) != (mode == "joined") { + t.Fatalf("join: %v", err) + } + if _, err := os.Stat(filepath.Join(boxHome, "v3", "remote", "device.key")); !os.IsNotExist(err) { + t.Fatal("identity join installed remote access") + } + } + select { + case err := <-done: + if (err == nil) != (mode == "joined") { + t.Fatalf("offer: %v", err) + } + case <-time.After(time.Second): + t.Fatal("offer did not finish") + } + known, found, err := pair.MachineBook().Machine("build-box") + if err != nil || !found { + t.Fatalf("alias after offer: %v %v", found, err) + } + if found && (known.Alias != "build-box" || known.Name == known.Alias || known.Service != route.URL) { + t.Fatal("alias does not pin the box") + } + }) + } +} + +type devboxJoinScreen struct{} + +func (devboxJoinScreen) Waiting(string) {} + +// R5.8: compact root usage and full command usage expose their applicable flags. +func TestDevboxR58UsageShowsYesAndDir(t *testing.T) { + for _, args := range [][]string{nil, {"--help"}} { + out, errs := captureUsage(t) + var err error + if args == nil { + err = usage(nil) + } else { + err = devboxCommand(context.Background(), args, devbox.Flow{}) + } + if err != nil && exitCodeOf(err) != 0 || errs.Len() != 0 { + t.Fatalf("help: %v %s", err, errs) + } + var rows []string + for _, row := range strings.Split(out.String(), "\n") { + if strings.Contains(row, "codeaf devbox") { + rows = append(rows, row) + } + } + if args == nil { + if len(rows) != 1 || !strings.Contains(rows[0], "up | down · status []") || !strings.Contains(rows[0], "--yes") || !strings.Contains(rows[0], "--dir") { + t.Fatalf("R5.8 root usage: %s", out) + } + continue + } + for _, flag := range []string{"--region", "--workspace", "--channel"} { + if !strings.Contains(out.String(), flag) { + t.Errorf("R5.8 full devbox help omits %s", flag) + } + } + for _, action := range []string{"up", "status", "down"} { + var shape string + for _, row := range rows { + if strings.Contains(row, "codeaf devbox "+action) { + shape = row + } + } + if !strings.Contains(shape, "--dir") || action != "status" && !strings.Contains(shape, "--yes") { + t.Errorf("devbox %s usage omits its flags: %s", action, out) + } + } + } +} + +// R3.3: expired or vanished identity joins map to the retry sentinel without saving an alias. +func TestDevboxR33OfferMapsInterruptedJoin(t *testing.T) { + for _, cause := range []error{pairbox.ErrGone, context.DeadlineExceeded, pairbox.ErrForbidden} { + t.Run(cause.Error(), func(t *testing.T) { + laptopHome := t.TempDir() + t.Setenv("CODEAF_HOME", laptopHome) + id, err := identity.Ensure(laptopHome) + if err != nil { + t.Fatal(err) + } + box := devboxInterruptedMailbox{Box: pairbox.NewMemory(pairbox.DefaultLimits, nil), err: cause} + route := pair.Mailbox{URL: "https://relay.example", Host: "relay.example", Box: box} + door := pairDoor{term: newTerminal(strings.NewReader(""), io.Discard), mailbox: func(string) (pair.Mailbox, error) { return route, nil }, grant: func(pair.Mailbox) (pair.Grant, error) { return pair.Grant{Identity: id, SyncURL: route.URL}, nil }} + err = offerDevbox(context.Background(), door, "build-box", func(devbox.Offer) {}) + if errors.Is(err, devbox.ErrJoinInterrupted) != (cause != pairbox.ErrForbidden) { + t.Fatalf("mapped result: %v", err) + } + if _, found, err := pair.MachineBook().Machine("build-box"); err != nil || found { + t.Fatal("failed offer remembered alias") + } + }) + } +} + +type devboxInterruptedMailbox struct { + pairbox.Box + err error +} + +func (b devboxInterruptedMailbox) Poll(context.Context, string, pairbox.Side, int, time.Duration) (pairbox.Batch, error) { + return pairbox.Batch{}, b.err +} + +// R5.8: root help preserves section separators and the two-line command shape within existing caps. +func TestDevboxR58RootHelpKeepsSeparatorsAndDescription(t *testing.T) { + out, _ := captureUsage(t) + if err := usage(nil); err != nil { + t.Fatal(err) + } + text := out.String() + for _, want := range []string{"\n\nLook at what happened", "\n\nExamples:", "\n codeaf devbox up | down · status [] [--yes] [--dir D]\n an always-on computer of yours on your own Railway account\n"} { + if !strings.Contains(text, want) { + t.Errorf("R5.8 help lacks %q", want) + } + } +} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 4ea0ece01f..e4ed320811 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1424,22 +1424,31 @@ func engineLaunchOptions(hello remote.Hello, workspace, sessionFlag string) v3Op // For the pipe engine this changes nothing at all: one conversation calls it // once, exactly as before. var engineProcess struct { - once sync.Once + mu sync.Mutex proc *v3Process - err error } func openEngineProcess() (*v3Process, error) { - engineProcess.once.Do(func() { - engineProcess.proc, engineProcess.err = openV3Process("engine") - }) - return engineProcess.proc, engineProcess.err + engineProcess.mu.Lock() + defer engineProcess.mu.Unlock() + if engineProcess.proc != nil { + return engineProcess.proc, nil + } + // A first connection can precede the serving computer's first vault pull. + // Cache only a successful profile load so the next connection can try again. + proc, err := openV3Process("engine") + if err == nil { + engineProcess.proc = proc + } + return proc, err } // closeEngineProcess closes the once-per-process resources after the serving // road has returned. In particular, closeAll cancels and joins model discovery // before the engine process can exit or its profile can be reused. func closeEngineProcess() { + engineProcess.mu.Lock() + defer engineProcess.mu.Unlock() if engineProcess.proc != nil { engineProcess.proc.closeAll() } diff --git a/cmd/codeaf/helpwidth_test.go b/cmd/codeaf/helpwidth_test.go index 1a9d52d967..67adddb186 100644 --- a/cmd/codeaf/helpwidth_test.go +++ b/cmd/codeaf/helpwidth_test.go @@ -126,6 +126,10 @@ func helpPages(t *testing.T) []helpPage { {"why", runWhy}, {"cache", runCache}, {"devices", runDevices}, + {"devbox", runDevbox}, + {"devbox up", func(args []string) error { return runDevbox(append([]string{"up"}, args...)) }}, + {"devbox status", func(args []string) error { return runDevbox(append([]string{"status"}, args...)) }}, + {"devbox down", func(args []string) error { return runDevbox(append([]string{"down"}, args...)) }}, {"pair", runPair}, {"plan new", func(args []string) error { return runPlanNew("plan new", args) }}, {"plan run", func(args []string) error { return runGraph("plan run", args) }}, diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 95d7fe80b1..8ed13bc9c6 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -334,6 +334,8 @@ func run() error { return relayserve.Main(os.Args[2:]) case "cluster": return runCluster(os.Args[2:]) + case "devbox": + return runDevbox(os.Args[2:]) case "devices": // Who is allowed to open a conversation here, and the door for taking // that back. REVOKING IS THIS MACHINE'S DECISION AND ONLY THIS @@ -608,6 +610,8 @@ Housekeeping — changes state on disk or on the network add this device to yours: shows a link to approve from one you use codeaf pair approve | [--replace] | --code approve a new device; or use or show a six-digit code for your chats + codeaf devbox up | down · status [] [--yes] [--dir D] + an always-on computer of yours on your own Railway account codeaf serve [--workspace path] [--via url] be reachable from your other devices without ssh, with a pairing code codeaf devices | codeaf cluster status | lead diff --git a/cmd/codeaf/serve_presence.go b/cmd/codeaf/serve_presence.go new file mode 100644 index 0000000000..0a913f1367 --- /dev/null +++ b/cmd/codeaf/serve_presence.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// A watch socket is the directory's online signal. Merely upserting a record +// does not advance last seen, because those timestamps belong to the relay. +func keepServePresence(ctx context.Context, _ string) { + ticks := time.NewTicker(syncsetup.DefaultInterval) + defer ticks.Stop() + keepServeHomePresence(ctx, home.Dir(), ticks.C) +} + +// Reopening uses the home-sync gates and releases the watch on a changed binding. +// Serving itself can continue while a newly paired identity starts its own lifetime. +func keepServeHomePresence(ctx context.Context, dir string, ticks <-chan time.Time) { + if ctx.Err() != nil || cell.UnavailableReason() != "" || syncsetup.Quiet(dir) { + return + } + current, ok, err := syncsetup.Open(dir) + if err != nil || !ok { + return + } + var refreshed time.Time + servePresence(ctx, current.Follow(), ticks, func(ctx context.Context) error { + if cell.UnavailableReason() != "" || syncsetup.Quiet(dir) { + return directory.ErrRevoked + } + next, ok, err := syncsetup.Open(dir) + if err != nil || !ok { + return directory.ErrRevoked + } + if next.Identity.ID() != current.Identity.ID() || next.Device.ID() != current.Device.ID() || next.Relay != current.Relay { + return directory.ErrRevoked + } + if !refreshed.IsZero() && time.Since(refreshed) < serveRecordEvery { + return nil + } + err = syncsetup.PutOwnDevice(ctx, next.Dir, next.Identity, next.Device, devname.Name(dir)) + if err == nil { + refreshed = time.Now() + } + return err + }) +} + +// Follow shares the one process-wide socket; ticks refresh records synchronously. +func servePresence(ctx context.Context, feed dirwatch.Follower, ticks <-chan time.Time, put func(context.Context) error) { + if feed != nil { + defer feed.Close() + } + if ctx.Err() != nil { + return + } + refreshServeRecord(ctx, ticks, put) +} + +const serveRecordEvery = 2 * time.Minute + +func refreshServeRecord(ctx context.Context, ticks <-chan time.Time, put func(context.Context) error) { + for ctx.Err() == nil { + request, cancel := context.WithTimeout(ctx, listRequestWithin) + err := put(request) + cancel() + if errors.Is(err, directory.ErrRevoked) { + return + } + select { + case <-ctx.Done(): + return + case _, ok := <-ticks: + if !ok { + return + } + } + } +} diff --git a/cmd/codeaf/serve_presence_test.go b/cmd/codeaf/serve_presence_test.go new file mode 100644 index 0000000000..2c836daf2f --- /dev/null +++ b/cmd/codeaf/serve_presence_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/rotate" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// R5.2: an explicit serving relay cannot bypass any home-sync gate. +func TestDevboxR52ServePresenceRespectsEligibility(t *testing.T) { + celltest.Available(t) + for _, mode := range []string{"quiet", "off", "unavailable", "rotating"} { + t.Run(mode, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "unexpected", 404) })) + defer server.Close() + dir := t.TempDir() + t.Setenv(home.EnvVar, dir) + t.Setenv(syncsetup.URLVar, server.URL) + if mode == "quiet" { + if _, err := identity.EnsureSolo(dir); err != nil { + t.Fatal(err) + } + } else { + if _, err := identity.Ensure(dir); err != nil { + t.Fatal(err) + } + } + switch mode { + case "off": + t.Setenv(syncsetup.URLVar, "off") + case "unavailable": + t.Cleanup(cell.UseAvailabilityInTests(false)) + case "rotating": + if err := os.WriteFile(filepath.Join(dir, rotate.File), []byte("pending"), 0o600); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + keepServePresence(ctx, server.URL) + if requests.Load() != 0 { + t.Fatalf("R5.2 %s published %d requests", mode, requests.Load()) + } + }) + } +} + +// R5.2: a serving worker releases its old signer after identity/device/relay rebinding. +func TestDevboxR52ServePresenceStopsOnRebinding(t *testing.T) { + celltest.Available(t) + for _, mode := range []string{"identity", "device", "relay"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + t.Setenv(home.EnvVar, dir) + started := make(chan struct{}, 8) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case started <- struct{}{}: + default: + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{}`)) + })) + defer server.Close() + t.Setenv(syncsetup.URLVar, server.URL) + if _, err := identity.Ensure(dir); err != nil { + t.Fatal(err) + } + ticks := make(chan time.Time) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + go func() { defer close(done); keepServeHomePresence(ctx, dir, ticks) }() + t.Cleanup(func() { cancel(); <-done }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("R5.2 no initial presence") + } + switch mode { + case "identity": + id, err := identity.Ensure(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := identity.Save(dir, id); err != nil { + t.Fatal(err) + } + case "device": + if err := os.Remove(filepath.Join(dir, "device.json")); err != nil { + t.Fatal(err) + } + if _, err := identity.Device(dir); err != nil { + t.Fatal(err) + } + case "relay": + t.Setenv(syncsetup.URLVar, "https://new-relay.example") + } + select { + case ticks <- time.Now(): + case <-done: + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("R5.2 retained old presence binding") + } + }) + } +} diff --git a/cmd/codeaf/serve_state.go b/cmd/codeaf/serve_state.go new file mode 100644 index 0000000000..6d1e9e93da --- /dev/null +++ b/cmd/codeaf/serve_state.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// startServeKeeping starts serve's home-state and presence workers only when +// syncsetup.ServeStateVar asks for them (a dev box sets it), and returns their +// join. +func startServeKeeping(ctx context.Context, service string) func() { + return serveKeeping(ctx, service, syncsetup.ServeKeepsState()) +} + +// 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. +func serveKeeping(ctx context.Context, service string, keep bool) func() { + if !keep { + return func() {} + } + joinState := startServeHomeState(ctx, home.Dir(), func(err error) { + fmt.Fprintln(os.Stderr, "could not sync this machine's saved keys and settings:", err) + }) + presenceCtx, stopPresence := context.WithCancel(ctx) + presenceDone := make(chan struct{}) + guard.Go("serve/presence", func() { defer close(presenceDone); keepServePresence(presenceCtx, service) }) + return func() { + stopPresence() + <-presenceDone + joinState() + } +} + +// startServeHomeState gives the serving process one home-state worker and a +// join. Its first round runs asynchronously so a slow relay cannot delay serving. +func startServeHomeState(ctx context.Context, home string, report func(error)) func() { + ctx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + guard.Go("serve/home-state", func() { + defer close(done) + syncsetup.RunHomeState(ctx, home, report) + }) + return func() { cancel(); <-done } +} diff --git a/cmd/codeaf/serve_state_test.go b/cmd/codeaf/serve_state_test.go new file mode 100644 index 0000000000..4c67c9f9cc --- /dev/null +++ b/cmd/codeaf/serve_state_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestR43ServeHomeStateCancelsAndJoinsInFlightRound(t *testing.T) { + celltest.Available(t) + t.Setenv(config.ProfileDirEnv, "") + started := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "cluster") { + t.Error("serve started a cluster member") + } + select { + case started <- struct{}{}: + default: + } + <-r.Context().Done() + })) + defer server.Close() + t.Setenv(syncsetup.URLVar, server.URL) + stateHome := t.TempDir() + if _, err := identity.Ensure(stateHome); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + join := startServeHomeState(ctx, stateHome, func(err error) { t.Errorf("cancelled round printed: %v", err) }) + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("serve did not start its home-state round") + } + cancel() + joined := make(chan struct{}) + go func() { join(); close(joined) }() + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("serve returned without joining its home-state goroutine") + } +} + +func TestR45DevicesPrintsMachineAliasAndFallsBackToRelayName(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + now := time.Now().Add(-time.Minute) + for _, known := range []pair.Known{ + {Name: "finch-magnolia-60", Alias: "build-box", Key: "aliased-test-key", Since: now}, + {Name: "otter-lamp-42", Key: "plain-test-key", Since: now}, + } { + if err := pair.MachineBook().Remember(known); err != nil { + t.Fatal(err) + } + } + printed := listed(t, bookKind{pair.DeviceBook()}) + for _, want := range []string{"machines this device can reach", "build-box", "otter-lamp-42", "open one with `codeaf chat --at `"} { + if !strings.Contains(printed, want) { + t.Errorf("devices lacks %q: %s", want, printed) + } + } + if strings.Contains(printed, "finch-magnolia-60") { + t.Errorf("alias row uses its relay name: %s", printed) + } +} + +// R4.4 goes through the real engine boot and an HTTP provider request. A daemon +// may already hold its process resources when a serving round writes the key. +func TestR44ServeEngineReadsProviderKeyThatArrivedAfterStartup(t *testing.T) { + server := newV3ClientDoorServer(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv(config.ProfileDirEnv, "") + t.Setenv(config.APIKeyEnv, "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("CODEAF_BASE_URL", server.URL) + workspace := t.TempDir() + t.Chdir(workspace) + freshEngineProcess(t) + if _, err := bootEngine(remote.Hello{Version: remote.Version, Workspace: workspace, Model: "test/model"}, "", ""); !errors.Is(err, config.ErrNoAPIKey) { + t.Fatalf("keyless engine boot = %v", err) + } + if _, err := os.Stat(config.BudgetConfigPath("")); !os.IsNotExist(err) { + t.Fatalf("expected a keyless profile at startup: %v", err) + } + const arrivedKey = "sk-or-v1-FAKE-arrived-from-vault" + if err := config.WriteAPIKey("", arrivedKey); err != nil { + t.Fatal(err) + } + engine, err := bootEngine(remote.Hello{Version: remote.Version, Workspace: workspace, Model: "test/model"}, "", "") + if err != nil { + t.Fatalf("engine ignored the arrived key: %v", err) + } + defer engine.Agent.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + events, err := engine.Agent.Submit(ctx, "say done") + if err != nil { + t.Fatal(err) + } + for range events { + } + if got := server.call(t, 0).authorization; got != "Bearer "+arrivedKey { + t.Fatal("engine did not use the newly saved provider key") + } +} diff --git a/cmd/codeaf/team_launch_test.go b/cmd/codeaf/team_launch_test.go index 7ebc180463..f52f30081c 100644 --- a/cmd/codeaf/team_launch_test.go +++ b/cmd/codeaf/team_launch_test.go @@ -134,9 +134,7 @@ func freshEngineProcess(t *testing.T) { t.Helper() reset := func() { closeEngineProcess() - engineProcess.once = sync.Once{} engineProcess.proc = nil - engineProcess.err = nil } reset() t.Cleanup(reset) diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go index c578952c85..d769e174bc 100644 --- a/cmd/codeaf/usage.go +++ b/cmd/codeaf/usage.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/devbox" ) // This file is the ONE SEAM every subcommand's flags are built and parsed at. @@ -350,12 +351,18 @@ var longerCommands = []string{"cache clean"} // flag anybody added, which is the same defect the environment table's // interpolated dollar figures were fixed for. func usageForCommand(name string) string { + if name == "devbox" { + return devboxUsage + } // Collection editing has its own short reference page so the front page // can introduce organization without listing every membership operation. if name == "collections" { return collectionsUsage } lines := strings.Split(usageText, "\n") + if strings.HasPrefix(name, "devbox ") { + lines = strings.Split(devboxUsage, "\n") + } var blocks []string for index := 0; index < len(lines); index++ { if !strings.HasPrefix(lines[index], " codeaf ") { @@ -450,6 +457,10 @@ func askedForHelp(args []string) bool { // commandHelp answers that gesture for a door with no flag set of its own. func commandHelp(name string) error { + if name == "devbox" { + writeCommandUsage(usageOut, devboxFlags("devbox", true, &devbox.Options{})) + return exitHelped + } if name == "cluster" { fmt.Fprint(usageOut, clusterHelp) return exitHelped @@ -523,7 +534,7 @@ func commandWords() []string { // programs this build carries are joined to it where it is read // ([commandWords]). var knownCommands = []string{ - "chat", "resume", "pair", "serve", "devices", "cluster", "do", "plan", "revise", "run", "exec", + "chat", "resume", "pair", "serve", "devbox", "devices", "cluster", "do", "plan", "revise", "run", "exec", "show", "models", "pool", "notebook", "collections", "competence", "services", "patch", "doc", "web", "image", "doctor", diff --git a/docs/changes/unreleased/2081-devbox.md b/docs/changes/unreleased/2081-devbox.md new file mode 100644 index 0000000000..6c25a2aa2d --- /dev/null +++ b/docs/changes/unreleased/2081-devbox.md @@ -0,0 +1,28 @@ +--- +kind: added +title: Create a paired always-on computer on your own Railway account with codeaf devbox +pr: 2081 +surface: [chat, remote, docs] +invalidates: + - "codeaf could reach a remote computer someone had already set up, but could not create one. `codeaf devbox up ` now writes a readable Terraform folder and creates a paired computer on the person's own Railway account; `status` and `down` inspect and remove it." + - "A computer that only ran codeaf serve never received the identity's saved provider keys, so a --at chat there stopped on a missing key. serve now keeps keys, settings, shared memories and presence in step when started with CODEAF_SERVE_SYNC=1, which a dev box sets; without that variable serve behaves exactly as before." + - "An engine whose first profile load failed kept returning that failure to every later connection. It now caches only a successful load, so a connection after the keys arrive works." +--- + +`codeaf devbox up`, `status` and `down` create, inspect and remove one private +project, one service and one persistent volume with the person's own +RAILWAY_TOKEN; the person pays Railway and owns the Terraform folder (default +`/devbox//`). Up applies in three guarded steps: the box +without secrets, then a one-time pairing code with the box's remote key and +admission book (only the variable collection may change), then, once the +pinned remote key answers, a clear that removes those values from Railway and +from the state and deletes the backup that held them. The identity code still +never gives remote access; the laptop that ran up is the one admitted, and +`codeaf devices` and `--at` use the chosen name. + +Up sends the creating computer's saved keys once the box answers, so the first +`--at` chat has them. The box pins its sync relay to the laptop's (each release +channel has its own relay), and its first install uses the laptop's own release +when they share a channel, so it does not depend on GitHub's API limit. Pairing +and serve behave as before for anyone who never runs devbox. An AWS module is in +the source but has never been applied, and the command does not offer it. diff --git a/internal/config/settings.go b/internal/config/settings.go index f0476c1abf..54cd194db2 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1126,6 +1126,19 @@ var OperatorEnvPins = []string{ // line. Plumbing rather than a row for the plainest reason there is: a row // would persist a preference this binary cannot act on. "CODEAF_INSTALL_NAME", + // The dev box's one-time setup values (internal/devbox). They are service + // variables Terraform hands the box's boot script for its first join, and + // codeaf never reads them from its own environment: Go spells them only to + // find them in the Terraform state and remove them once the box answers. A + // row would persist a pairing code and a private key, so plumbing. + "CODEAF_DEVBOX_CODE", + "CODEAF_DEVBOX_REMOTE_KEY", + "CODEAF_DEVBOX_REMOTE_DEVICES", + // CODEAF_SERVE_SYNC asks `codeaf serve` to keep the identity's saved keys, + // settings and presence in step (internal/syncsetup). A dev box's boot + // script sets it for the one process it starts; it is one launch's posture + // rather than a preference, so plumbing, like CODEAF_NO_AUTOMATIONS. + "CODEAF_SERVE_SYNC", // The two pins on the model-call log (internal/calllog). CODEAF_CALL_LOG // switches it off or moves the file; CODEAF_CALL_LOG_BODIES adds the whole // request and response to every line. Plumbing rather than settings rows, diff --git a/internal/devbox/applies_test.go b/internal/devbox/applies_test.go new file mode 100644 index 0000000000..a42d5c2ddd --- /dev/null +++ b/internal/devbox/applies_test.go @@ -0,0 +1,379 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" +) + +// R6.1: the non-secret install version is present and unchanged through all three phases. +// R5.4: the clear is a third guarded apply with no second confirmation. +// R3.1: no offer or secrets exist until the confirmed first apply has returned. +// R3.2: the second apply follows the settle wait and a variables-only guard. +func TestDevboxR31R32R54R61ThreeAppliesSeparateCreationPairingAndClear(t *testing.T) { + t.Setenv("TF_VAR_pair_code", "inherited-code") + t.Setenv("TF_VAR_remote_key", "inherited-key") + t.Setenv("TF_VAR_remote_devices", "inherited-devices") + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Interactive = true + f.In = strings.NewReader("y\n") + var offered atomic.Bool + firstApplied, settled := false, false + joined := make(chan struct{}) + var order []string + const version = "dev-20261030-aaaaaaaaaaaa" + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + order = append(order, args[0]) + raw, err := os.ReadFile(filepath.Join(dir, "codeaf.auto.tfvars.json")) + if err != nil { + t.Fatal(err) + } + var vars map[string]string + if err := json.Unmarshal(raw, &vars); err != nil { + t.Fatal(err) + } + if vars["install_version"] != version { + t.Fatalf("R6.1 version during %s = %q, want %q", args[0], vars["install_version"], version) + } + if !firstApplied { + if offered.Load() { + t.Error("offer started before first apply returned") + } + for _, entry := range env { + if strings.HasPrefix(entry, "TF_VAR_") { + t.Errorf("phase A secret environment: %s", strings.SplitN(entry, "=", 2)[0]) + } + } + if vars["via"] != "" { + t.Error("phase A has a relay") + } + for _, key := range []string{"pair_code", "remote_key", "remote_devices"} { + if _, found := vars[key]; found { + t.Errorf("phase A contains %s", key) + } + } + } else if args[0] == "plan" && !settled { + t.Error("second plan before settle") + } + err = RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && err == nil { + if !firstApplied { + firstApplied = true + } else if slices.Contains(env, "TF_VAR_pair_code=42-123-456") { + close(joined) + } + } + return err + } + f.Settle = func(_ context.Context, token, service, environment string) error { + if !firstApplied || offered.Load() || token != "fixture-token" || service != "service" || environment != "environment" { + t.Error("wrong settle order or inputs") + } + order = append(order, "settle") + settled = true + return nil + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + offered.Store(true) + if !settled { + t.Error("offer before settle") + } + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-joined: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Version: version}); err != nil { + t.Fatal(err) + } + if got := strings.Join(order, ","); got != "init,plan,apply,output,settle,plan,show,apply,plan,show,apply" { + t.Fatalf("order: %s", got) + } + if strings.Count(out.String(), "Create these on your Railway account? [y/N]") != 1 { + t.Fatalf("confirmation: %s", &out) + } + if strings.Contains(out.String(), "show-only-sensitive-value") { + t.Fatal("plan JSON streamed") + } + if _, err := os.Stat(filepath.Join(dir, "terraform.tfstate.backup")); !os.IsNotExist(err) { + t.Fatal("R5.4 backup remains") + } + for _, name := range []string{"terraform.tfstate"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("state is not private: %s %v", name, err) + } + } +} + +// R3.2: only the variable collection may change; unsafe or unreadable plans never apply or leak JSON. +func TestDevboxR32GuardRefusesOtherResourcesAndDiscardsSensitiveJSON(t *testing.T) { + for _, mode := range []string{"service", "project", "replacement", "read", "malformed", "show-failed"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + address, actions := "railway_service.box", `["update"]` + if mode == "project" { + address = "railway_project.box" + } + if mode == "replacement" { + actions = `["delete","create"]` + } + if mode == "read" { + actions = `["read"]` + } + raw := `{"resource_changes":[{"address":"railway_variable_collection.box","change":{"actions":["update"],"after":{"secret":"show-only-sensitive-value"}}},{"address":"` + address + `","change":{"actions":` + actions + `,"after":{"secret":"show-only-sensitive-value"}}}]}` + if mode == "malformed" { + raw = `{"resource_changes":["show-only-sensitive-value` + } + t.Setenv("GUARD_JSON", raw) + if mode == "show-failed" { + t.Setenv("FAIL_ACTION", "show") + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + <-ctx.Done() + return ctx.Err() + } + err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}) + if err == nil { + t.Fatal("guard accepted unsafe plan") + } + all := out.String() + err.Error() + for _, secret := range []string{"fixture-token", "42-123-456", "fixture-remote-key", "fixture-remote-devices", "show-only-sensitive-value"} { + if strings.Contains(all, secret) { + t.Errorf("leaked %s", secret) + } + } + if mode != "malformed" && mode != "show-failed" && !strings.Contains(err.Error(), address) { + t.Fatalf("missing refused address: %v", err) + } + calls, _ := os.ReadFile(filepath.Join(dir, "calls")) + if strings.Count(string(calls), "apply -input=false") != 1 { + t.Fatalf("applied guarded plan: %s", calls) + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) != 0 { + t.Fatal("sensitive plan retained") + } + }) + } +} + +// R5.3: remote credentials never change across retries; only the pairing code is fresh. +// R3.3: interrupted or expired joins get at most three fresh codes; other errors end immediately. +// R3.4: reaching the settle bound still proceeds with a single notice. +func TestDevboxR33R34R53FreshCodesReuseRemoteCredentials(t *testing.T) { + for _, mode := range []string{"joined-on-second", "three-interruptions", "expired", "refused", "cancelled"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Settle = func(context.Context, string, string, string) error { return errors.New("wait ended") } + var attempts atomic.Int32 + prepared := 0 + f.Prepare = func(context.Context, string) (Offer, error) { + prepared++ + if attempts.Load() != 0 { + t.Error("R5.3 prepared remote files after the first code") + } + return Offer{Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}, nil + } + var joined atomic.Bool + f.Probe = func(context.Context, string) bool { return joined.Load() } + applied := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if args[0] == "plan" && attempts.Load() > 0 && !joined.Load() { + for key, want := range map[string]string{"TF_VAR_pair_code": fmt.Sprintf("fixture-TF_VAR_pair_code-%d", attempts.Load()), "TF_VAR_remote_key": "fixture-remote-key", "TF_VAR_remote_devices": "fixture-remote-devices"} { + if !slices.Contains(env, key+"="+want) { + t.Errorf("R5.3 incorrect %s across retries", key) + } + } + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && attempts.Load() > 0 && !joined.Load() { + applied <- struct{}{} + } + return err + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + n := attempts.Add(1) + ready(Offer{Code: fmt.Sprintf("fixture-TF_VAR_pair_code-%d", n), Via: "https://relay.example", RemoteKey: "", RemoteDevices: ""}) + select { + case <-applied: + case <-ctx.Done(): + return ctx.Err() + } + if mode == "joined-on-second" && n == 2 { + joined.Store(true) + return nil + } + if mode == "refused" { + return errors.New("refused on the other device") + } + if mode == "cancelled" { + cancel() + return context.Canceled + } + if mode == "expired" { + return context.DeadlineExceeded + } + return ErrJoinInterrupted + } + err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: true}) + wantAttempts := int32(3) + if mode == "joined-on-second" { + wantAttempts = 2 + } else if mode == "refused" || mode == "cancelled" { + wantAttempts = 1 + } + if prepared != 1 { + t.Fatalf("R5.3 prepared remote files %d times", prepared) + } + if attempts.Load() != wantAttempts { + t.Fatalf("attempts %d, want %d (error %v)", attempts.Load(), wantAttempts, err) + } + if (err == nil) != (mode == "joined-on-second") { + t.Fatalf("result: %v", err) + } + if err != nil { + reason := "joining was interrupted or the pairing code expired" + if mode == "refused" { + reason = "refused on the other device" + } + if mode == "cancelled" { + reason = "context canceled" + } + want := "box did not join: " + reason + ". It is on your Railway account; remove it with `codeaf devbox down box --dir " + shellQuote(dir) + "`, then run up again." + if err.Error() != want { + t.Fatalf("failure: %v", err) + } + } + if strings.Count(out.String(), "Railway did not settle") != 1 { + t.Fatalf("settle notice: %s", &out) + } + for n := int32(2); n <= wantAttempts; n++ { + if !strings.Contains(out.String(), fmt.Sprintf("box did not finish joining; sending a new code (%d of 3).", n)) { + t.Fatalf("retry receipt: %s", &out) + } + } + if mode != "joined-on-second" && strings.Contains(out.String(), "box is up") { + t.Fatal("claimed join") + } + if strings.Contains(out.String(), "fixture-TF_VAR") { + t.Fatal("fresh secret leaked") + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) != 0 { + t.Fatal("sensitive plan retained") + } + }) + } +} + +// R5.4/R3.6: all three plans filter piped output and keep correct flags. +func TestDevboxR36R54TerraformOutputAndFlags(t *testing.T) { + for _, terminal := range []bool{false, true} { + t.Run(fmt.Sprint(terminal), func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.StdoutTerminal = terminal + applied := make(chan struct{}) + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-applied: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + var commands []string + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + commands = append(commands, args[0]) + if slices.Contains(args, "-no-color") == terminal || !terminal && (len(args) < 2 || args[1] != "-no-color") { + t.Errorf("terminal %v: %v", terminal, args) + } + if args[0] == "plan" { + for _, chunk := range []string{"\x1b[32mPlan: 3 to add.\x1b[0m\nTo perform exactly these actions, run the fol", "lowing command to apply:\n terraform apply \"", "private-plan-name\"\n", "Next real line\n"} { + _, _ = io.WriteString(stdout, chunk) + } + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && slices.Contains(env, "TF_VAR_pair_code=42-123-456") { + close(applied) + } + return err + } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"To perform exactly", "terraform apply", "private-plan-name"} { + if strings.Contains(out.String(), forbidden) { + t.Errorf("unfiltered %q: %s", forbidden, &out) + } + } + if !terminal && strings.Contains(out.String(), "\x1b") { + t.Fatal("piped ANSI output") + } + if strings.Count(out.String(), "Next real line") != 3 { + t.Fatal("filter lost real output") + } + f.Out = &out + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + if !slices.Contains(commands, "output") || !slices.Contains(commands, "destroy") { + t.Fatal("missed command flags") + } + }) + } +} + +// R3.2: a failed Terraform write still makes both state files owner-only. +func TestDevboxR32FailedTerraformWriteProtectsState(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + f.RunTerraform = func(_ context.Context, _, dir string, _, _ []string, _, _ io.Writer) error { + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("sensitive state"), 0o644); err != nil { + t.Fatal(err) + } + } + return errors.New("write failed") + } + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}); err == nil { + t.Fatal("failure hidden") + } + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("state permissions: %s %v", name, err) + } + } +} diff --git a/internal/devbox/boot_test.go b/internal/devbox/boot_test.go new file mode 100644 index 0000000000..9185a2a6e9 --- /dev/null +++ b/internal/devbox/boot_test.go @@ -0,0 +1,369 @@ +package devbox + +import ( + "context" + "encoding/base64" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// R6.2: the first install pins its release, retries unpinned, and later boots follow the channel. +func TestDevboxR62BootInstallVersion(t *testing.T) { + const version = "dev-20261030-aaaaaaaaaaaa" + for _, row := range []struct { + name, version, failure, want string + existing bool + backoff bool + }{ + {name: "first-install-pins", version: version, want: version + "\n"}, + {name: "failed-pin-retries-unpinned", version: version, failure: "pinned", want: version + "\n\n"}, + {name: "failed-both-back-off-once", version: version, failure: "all", want: version + "\n\n", backoff: true}, + {name: "existing-binary-refreshes-unpinned", version: version, existing: true, want: "\n"}, + {name: "failed-refresh-keeps-earlier-binary", version: version, existing: true, failure: "all", want: "\n"}, + {name: "no-version-installs-unpinned", want: "\n"}, + {name: "no-version-failure-backs-off", failure: "all", want: "\n", backoff: true}, + } { + t.Run(row.name, func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "fake-bin") + if err := os.Mkdir(bin, 0o700); err != nil { + t.Fatal(err) + } + for name, script := range map[string]string{ + "curl": `#!/bin/bash +cat <<'INSTALLER' +set -eu +printf '%s\n' "${VERSION-}" >> "$CODEAF_DEVBOX_DATA_ROOT/versions" +if [[ "$INSTALL_FAILURE" == all || ( "$INSTALL_FAILURE" == pinned && -n "${VERSION:-}" ) ]]; then exit 22; fi +mkdir -p "$CODEAF_INSTALL_DIR" +cp "$CODEAF_DEVBOX_DATA_ROOT/fake-bin/codeaf" "$CODEAF_INSTALL_DIR/$CODEAF_INSTALL_NAME" +INSTALLER +`, + "sleep": "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/sleeps\"\n", + "codeaf": "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/calls\"\n", + } { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + if row.existing { + installed := filepath.Join(data, "home", ".codeaf", "bin") + if err := os.MkdirAll(installed, 0o700); err != nil { + t.Fatal(err) + } + fake, err := os.ReadFile(filepath.Join(bin, "codeaf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "codeaf"), fake, 0o700); err != nil { + t.Fatal(err) + } + } + raw, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash") + cmd.Stdin = strings.NewReader(string(raw)) + cmd.Env = append(os.Environ(), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "PATH="+bin+":"+os.Getenv("PATH"), "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_CODE=42-123-456", "CODEAF_DEVBOX_NAME=box", "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_VERSION="+row.version, "VERSION=stray-environment-version", "INSTALL_FAILURE="+row.failure, "CODEAF_DEVBOX_REMOTE_KEY=", "CODEAF_DEVBOX_REMOTE_DEVICES=") + if row.version == "" { + cmd.Env = slices.DeleteFunc(cmd.Env, func(entry string) bool { return strings.HasPrefix(entry, "CODEAF_DEVBOX_VERSION=") }) + } + out, err := cmd.CombinedOutput() + versions, readErr := os.ReadFile(filepath.Join(data, "versions")) + if readErr != nil || string(versions) != row.want { + t.Fatalf("R6.2 installer versions = %q (%v), want %q; boot: %v %s", versions, readErr, row.want, err, out) + } + sleeps, _ := os.ReadFile(filepath.Join(data, "sleeps")) + calls, _ := os.ReadFile(filepath.Join(data, "calls")) + if row.backoff { + if err == nil || string(sleeps) != "60\n" || len(calls) != 0 || !strings.Contains(string(out), "installation failed and no earlier binary is available") { + t.Fatalf("R6.2 backoff: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + } else if err != nil || len(sleeps) != 0 || !strings.Contains(string(calls), "serve --workspace "+filepath.Join(data, "home", "work")) { + t.Fatalf("R6.2 boot: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + }) + } +} + +// Contracts 3 and 4: the real boot script pairs once, persists HOME and serves through the same relay. +func TestDevboxR35BootPairsOnceAcrossRestarts(t *testing.T) { + for _, firstFailure := range []bool{false, true} { + t.Run(map[bool]string{true: "retry", false: "restart"}[firstFailure], func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "home", ".codeaf", "bin") + if err := os.MkdirAll(bin, 0o700); err != nil { + t.Fatal(err) + } + fake := `#!/bin/bash +set -eu +printf '%s|%s\n' "$HOME" "$*" >> "$CODEAF_DEVBOX_DATA_ROOT/calls" +if [[ "$1" == pair ]]; then + [[ "$2" == 42-123-456 && "$3" == --name && "$4" == box && "$5" == --via && "$6" == https://relay.example ]] + [[ -z "${FAIL_PAIR:-}" ]] +else + [[ -z "${CODEAF_DEVBOX_CODE+x}" && -z "${CODEAF_DEVBOX_REMOTE_KEY+x}" && -z "${CODEAF_DEVBOX_REMOTE_DEVICES+x}" ]] + cmp "$CODEAF_HOME/v3/remote/device.key" "$CODEAF_DEVBOX_DATA_ROOT/expected-key" + cmp "$CODEAF_HOME/v3/remote/devices.json" "$CODEAF_DEVBOX_DATA_ROOT/expected-devices" + [[ "$1" == serve && "$2" == --workspace && "$3" == "$HOME/work" && "$4" == --via && "$5" == https://relay.example ]] +fi +` + if err := os.WriteFile(filepath.Join(bin, "codeaf"), []byte(fake), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "sleep"), []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/sleeps\"\n"), 0o700); err != nil { + t.Fatal(err) + } + // A failed refresh must fall back to the existing binary on the volume. + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte("#!/bin/sh\nexit 22\n"), 0o700); err != nil { + t.Fatal(err) + } + script, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + run := func(code string, fail bool) (string, error) { + cmd := exec.Command("bash") + cmd.Stdin = strings.NewReader(string(script)) + cmd.Env = append(os.Environ(), "HOME="+filepath.Join(data, "home"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_NAME=box", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_CODE="+code, "FAIL_PAIR="+map[bool]string{true: "1", false: ""}[fail]) + out, err := cmd.CombinedOutput() + return string(out), err + } + key := "key file bytes\n" + devices := "admission file bytes\n" + for name, raw := range map[string]string{"expected-key": key, "expected-devices": devices} { + if err := os.WriteFile(filepath.Join(data, name), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", base64.StdEncoding.EncodeToString([]byte(key))) + t.Setenv("CODEAF_DEVBOX_REMOTE_DEVICES", base64.StdEncoding.EncodeToString([]byte(devices))) + marker := filepath.Join(data, ".codeaf-devbox-joined") + if firstFailure { + if out, err := run("42-123-456", true); err != nil { + t.Fatalf("failed join did not wait: %v %s", err, out) + } + if sleeps, _ := os.ReadFile(filepath.Join(data, "sleeps")); string(sleeps) != "infinity\n" { + t.Fatalf("R3.5 failed pair did not sleep: %q", sleeps) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("failed join wrote marker") + } + } + if out, err := run("42-123-456", false); err != nil { + t.Fatalf("boot: %v %s", err, out) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal(err) + } + // Each release channel syncs through its own relay, so the box must + // sync through the one the laptop paired it through, whatever its + // installed build's own default is. + savedRelay := filepath.Join(data, "home", ".codeaf", syncsetup.SavedURLFile) + if raw, err := os.ReadFile(savedRelay); err != nil || string(raw) != "https://relay.example\n" { + t.Fatalf("the box's saved relay = %q (%v); want the laptop's relay", raw, err) + } + remote := filepath.Join(data, "home", ".codeaf", "v3", "remote") + for path, mode := range map[string]os.FileMode{remote: 0o700, filepath.Join(remote, "device.key"): 0o600, filepath.Join(remote, "devices.json"): 0o600} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != mode { + t.Fatalf("%s mode = %o, want %o", path, info.Mode().Perm(), mode) + } + } + // Later admissions and revocations must survive restarts, even with stale service variables. + devices = "changed admissions\n" + if err := os.WriteFile(filepath.Join(data, "home", ".codeaf", "v3", "remote", "devices.json"), []byte(devices), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, "expected-devices"), []byte(devices), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", base64.StdEncoding.EncodeToString([]byte("different key"))) + // A relay a later pairing saved belongs to the volume, like its admissions. + if err := os.WriteFile(savedRelay, []byte("https://later.example\n"), 0o600); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(data, "calls")) + if out, err := run("", false); err != nil { + t.Fatalf("restart: %v %s", err, out) + } + after, _ := os.ReadFile(filepath.Join(data, "calls")) + added := strings.TrimPrefix(string(after), string(before)) + if strings.Contains(added, "pair") || !strings.Contains(added, "serve --workspace "+filepath.Join(data, "home", "work")) { + t.Fatalf("restart: %s", added) + } + if raw, _ := os.ReadFile(savedRelay); string(raw) != "https://later.example\n" { + t.Fatalf("a restart replaced the saved relay with %q", raw) + } + // An existing admission book is retained even when the key alone needs seeding. + if err := os.Remove(filepath.Join(data, "home", ".codeaf", "v3", "remote", "device.key")); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", base64.StdEncoding.EncodeToString([]byte(key))) + if out, err := run("", false); err != nil { + t.Fatalf("key-only seed: %v %s", err, out) + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", "") + t.Setenv("CODEAF_DEVBOX_REMOTE_DEVICES", "") + if out, err := run("", false); err != nil { + t.Fatalf("unseeded restart: %v %s", err, out) + } + }) + } +} + +// R3.5: a missing code waits before downloading, a failed pair waits, and an install failure backs off. +func TestDevboxR35BootWaitsWithoutCodeAndBacksOffFailedInstall(t *testing.T) { + for _, mode := range []string{"no-code", "install-failure"} { + t.Run(mode, func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "fake-bin") + if err := os.Mkdir(bin, 0o700); err != nil { + t.Fatal(err) + } + for name, script := range map[string]string{ + "curl": "#!/bin/sh\necho download >> \"$CODEAF_DEVBOX_DATA_ROOT/calls\"\nexit 22\n", + "sleep": "#!/bin/sh\necho \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/sleeps\"\n", + } { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + raw, _ := module.ReadFile("terraform/boot.sh") + cmd := exec.Command("bash") + cmd.Stdin = strings.NewReader(string(raw)) + cmd.Env = append(os.Environ(), "HOME="+filepath.Join(data, "home"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "PATH="+bin+":"+os.Getenv("PATH"), "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_CODE=", "CODEAF_DEVBOX_BINARY=", "CODEAF_DEVBOX_INSTALL_URL=") + if mode == "install-failure" { + cmd.Env = append(cmd.Env, "CODEAF_DEVBOX_CODE=42-123-456", "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf") + } + out, err := cmd.CombinedOutput() + sleeps, _ := os.ReadFile(filepath.Join(data, "sleeps")) + calls, _ := os.ReadFile(filepath.Join(data, "calls")) + if mode == "no-code" { + if err != nil || string(sleeps) != "infinity\n" || len(calls) != 0 || string(out) != "codeaf devbox: waiting for a pairing code from codeaf devbox up\n" { + t.Fatalf("wait: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + } else if err == nil || string(sleeps) != "60\n" || string(calls) != "download\n" { + t.Fatalf("backoff: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + for _, path := range []string{".codeaf-devbox-joined", "home/.codeaf/v3/remote/device.key"} { + if _, err := os.Stat(filepath.Join(data, path)); !os.IsNotExist(err) { + t.Fatalf("created %s", path) + } + } + }) + } +} + +// R5.5: each absent seed is atomic and independent; existing admissions belong to the volume. +func TestDevboxR55BootSeedsIndependentlyAndAtomically(t *testing.T) { + for _, mode := range []string{"key-present", "book-present", "bad-key", "bad-book"} { + t.Run(mode, func(t *testing.T) { + data := t.TempDir() + remote := filepath.Join(data, "home", ".codeaf", "v3", "remote") + bin := filepath.Join(data, "home", ".codeaf", "bin") + for _, dir := range []string{remote, bin} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + for name, body := range map[string]string{"curl": "exit 22", "codeaf": "exit 0"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(data, ".codeaf-devbox-joined"), nil, 0o600); err != nil { + t.Fatal(err) + } + key, book := base64.StdEncoding.EncodeToString([]byte("new-key")), base64.StdEncoding.EncodeToString([]byte("new-book")) + if mode == "key-present" { + if err := os.WriteFile(filepath.Join(remote, "device.key"), []byte("existing-key"), 0o600); err != nil { + t.Fatal(err) + } + } + if mode == "book-present" { + if err := os.WriteFile(filepath.Join(remote, "devices.json"), []byte("existing-book"), 0o600); err != nil { + t.Fatal(err) + } + } + if mode == "bad-key" { + key = "cGFydGlhbA==!" + } + if mode == "bad-book" { + book = "cGFydGlhbA==!" + } + raw, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash") + cmd.Stdin = strings.NewReader(string(raw)) + cmd.Env = append(os.Environ(), "HOME="+filepath.Join(data, "home"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_REMOTE_KEY="+key, "CODEAF_DEVBOX_REMOTE_DEVICES="+book) + out, err := cmd.CombinedOutput() + if strings.HasPrefix(mode, "bad-") { + if err == nil { + t.Fatalf("R5.5 accepted failed decode: %s", out) + } + name := "device.key" + if mode == "bad-book" { + name = "devices.json" + } + if _, err := os.Stat(filepath.Join(remote, name)); !os.IsNotExist(err) { + t.Fatalf("R5.5 partial final %s remains", name) + } + files, err := os.ReadDir(remote) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if strings.HasPrefix(file.Name(), ".seed-") { + t.Fatal("R5.5 temporary seed remains") + } + } + return + } + if err != nil { + t.Fatalf("R5.5 boot: %v %s", err, out) + } + for name, want := range map[string]string{"device.key": "new-key", "devices.json": "new-book"} { + if mode == "key-present" && name == "device.key" { + want = "existing-key" + } + if mode == "book-present" && name == "devices.json" { + want = "existing-book" + } + got, err := os.ReadFile(filepath.Join(remote, name)) + if err != nil || string(got) != want { + t.Fatalf("R5.5 %s = %q, %v", name, got, err) + } + } + }) + } +} + +// Contract 3 and stability: the box's serve, and only the box's, asks to keep +// the identity's keys and presence in step, spelled with the one constant. +func TestDevboxBootAsksServeToKeepStateInStep(t *testing.T) { + script, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + export := "export " + syncsetup.ServeStateVar + "=1\n" + at := strings.Index(string(script), export) + if at < 0 || at > strings.Index(string(script), "serve --workspace") { + t.Fatalf("boot.sh must export %q before it starts serve", strings.TrimSpace(export)) + } +} diff --git a/internal/devbox/bootstrap.go b/internal/devbox/bootstrap.go new file mode 100644 index 0000000000..fb95f235d9 --- /dev/null +++ b/internal/devbox/bootstrap.go @@ -0,0 +1,123 @@ +package devbox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const deviceReceipt = "codeaf.devbox.json" + +func (f Flow) recordDevice(ctx context.Context, o Options) error { + if f.Devices == nil { + return nil + } + devices, err := f.devices(ctx) + if err != nil { + return err + } + var id string + for _, d := range devices { + if !d.Removed && d.ID != "" && strings.EqualFold(d.Name, o.Name) { + if id != "" && id != d.ID { + return errors.New("more than one active device has the box's name") + } + id = d.ID + } + } + if id == "" { + return errors.New("the joined device is not in this identity's list yet") + } + raw, err := json.Marshal(struct { + ID string `json:"device_id"` + }{id}) + if err != nil { + return err + } + file, err := os.CreateTemp(o.Dir, ".codeaf-device-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, writeErr := file.Write(append(raw, '\n')) + if err := errors.Join(writeErr, file.Close()); err != nil { + return err + } + return os.Rename(file.Name(), filepath.Join(o.Dir, deviceReceipt)) +} + +func readDeviceID(dir string) (string, error) { + raw, err := os.ReadFile(filepath.Join(dir, deviceReceipt)) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + var receipt struct { + ID string `json:"device_id"` + } + if err := json.Unmarshal(raw, &receipt); err != nil { + return "", err + } + return receipt.ID, nil +} + +// State, including its previous copy after a failed clear, is the cleanup receipt. +// Read only the three bootstrap values, retaining them solely for redaction. +func bootstrapSecrets(dir string) ([]string, error) { + var secrets []string + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, errors.New("could not read the Terraform state for setup cleanup") + } + var state any + if json.Unmarshal(raw, &state) != nil { + return nil, errors.New("could not read the Terraform state for setup cleanup") + } + var visit func(any) + visit = func(value any) { + switch v := value.(type) { + case map[string]any: + if name, ok := v["name"].(string); ok && (name == "CODEAF_DEVBOX_CODE" || name == "CODEAF_DEVBOX_REMOTE_KEY" || name == "CODEAF_DEVBOX_REMOTE_DEVICES") { + if secret, ok := v["value"].(string); ok && secret != "" { + secrets = append(secrets, secret) + } + } + for _, child := range v { + visit(child) + } + case []any: + for _, child := range v { + visit(child) + } + } + } + visit(state) + } + return secrets, nil +} + +func (f Flow) clearBootstrap(ctx context.Context, terraform, token string, o Options, secrets []string) error { + secrets = append(secrets, token) + fmt.Fprintln(f.Out, "Removing the one-time setup values from Railway.") + if err := f.applyPlan(ctx, terraform, o.Dir, []string{"RAILWAY_TOKEN=" + token}, secrets, true, func() error { return ctx.Err() }); err != nil { + return f.clearFailed(o, err, secrets...) + } + if err := os.Remove(filepath.Join(o.Dir, "terraform.tfstate.backup")); err != nil && !errors.Is(err, os.ErrNotExist) { + return f.clearFailed(o, err, secrets...) + } + return nil +} + +func (f Flow) clearFailed(o Options, err error, secrets ...string) error { + return fmt.Errorf("%s is up, but its one-time setup values are still on Railway and in %s: %s. Run %s again to finish, or %s to remove it.", o.Name, o.Dir, reason(errors.New(redact(err.Error(), secrets...))), f.command("up", o), f.command("down", o)) +} diff --git a/internal/devbox/deployments.go b/internal/devbox/deployments.go new file mode 100644 index 0000000000..72b79550c9 --- /dev/null +++ b/internal/devbox/deployments.go @@ -0,0 +1,88 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "time" +) + +const deploymentPollEvery = 3 * time.Second +const deploymentSettleWithin = 3 * time.Minute + +func settleDeployments(ctx context.Context, endpoint, token, serviceID, environmentID string) error { + if endpoint == "" { + endpoint = railwayGraphQLURL + } + return pollDeployments(ctx, &http.Client{Timeout: 20 * time.Second}, endpoint, token, serviceID, environmentID, deploymentPollEvery, deploymentSettleWithin) +} + +func pollDeployments(ctx context.Context, client *http.Client, endpoint, token, serviceID, environmentID string, every, within time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, within) + defer cancel() + body, _ := json.Marshal(struct { + Query string `json:"query"` + Variables map[string]string `json:"variables"` + }{ + Query: `query($s: String!, $e: String!) { deployments(first: 10, input: {serviceId: $s, environmentId: $e}) { edges { node { id status createdAt } } } }`, + Variables: map[string]string{"s": serviceID, "e": environmentID}, + }) + ticker := time.NewTicker(every) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return errors.New("Railway deployments did not settle within the wait") + } + settled, err := deploymentsSettled(ctx, client, endpoint, token, body) + if err == nil && settled { + return nil + } + select { + case <-ctx.Done(): + return errors.New("Railway deployments did not settle within the wait") + case <-ticker.C: + } + } +} + +func deploymentsSettled(ctx context.Context, client *http.Client, endpoint, token string, body []byte) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return false, errors.New("could not check Railway deployments") + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return false, errors.New("could not check Railway deployments") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, errors.New("could not check Railway deployments") + } + var response struct { + Data struct { + Deployments *struct { + Edges []struct { + Node struct { + Status string `json:"status"` + } `json:"node"` + } `json:"edges"` + } `json:"deployments"` + } `json:"data"` + Errors []json.RawMessage `json:"errors"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&response); err != nil || len(response.Errors) != 0 || response.Data.Deployments == nil { + return false, errors.New("could not read Railway deployments") + } + for _, edge := range response.Data.Deployments.Edges { + switch edge.Node.Status { + case "WAITING", "QUEUED", "INITIALIZING", "BUILDING", "DEPLOYING", "REMOVING": + return false, nil + } + } + return true, nil +} diff --git a/internal/devbox/deployments_test.go b/internal/devbox/deployments_test.go new file mode 100644 index 0000000000..479ce7933a --- /dev/null +++ b/internal/devbox/deployments_test.go @@ -0,0 +1,131 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync/atomic" + "testing" + "time" +) + +// R3.4: polling checks every in-progress status and sends credentials only in the header. +func TestDevboxR34DeploymentsSettleAndKeepTokenInHeader(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fixture-token" || r.URL.RawQuery != "" || r.Method != http.MethodPost { + t.Error("wrong authorization or method") + } + var body struct { + Query string `json:"query"` + Variables map[string]string `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if strings.Contains(body.Query, "fixture-token") || strings.Contains(fmt.Sprint(body.Variables), "fixture-token") { + t.Error("token in body") + } + if body.Variables["s"] != "service" || body.Variables["e"] != "environment" || !strings.Contains(body.Query, "deployments(first: 10") || !strings.Contains(body.Query, "id status createdAt") { + t.Errorf("query: %+v", body) + } + statuses := []string{"WAITING", "QUEUED", "INITIALIZING", "BUILDING", "DEPLOYING", "REMOVING"} + n := int(calls.Add(1)) + status := "SUCCESS" + if n <= len(statuses) { + status = statuses[n-1] + } + fmt.Fprintf(w, `{"data":{"deployments":{"edges":[{"node":{"id":"deployment","status":%q,"createdAt":"2026-10-30T12:00:00Z"}},{"node":{"id":"old","status":"REMOVED"}}]}}}`, status) + })) + defer server.Close() + if err := pollDeployments(context.Background(), server.Client(), server.URL, "fixture-token", "service", "environment", time.Millisecond, time.Second); err != nil { + t.Fatal(err) + } + if calls.Load() != 7 { + t.Fatalf("polls = %d, want 7", calls.Load()) + } +} + +// R3.4: an unsettled service, bad response, or hanging request cannot outlive the wait bound. +func TestDevboxR34DeploymentsWaitIsBoundedAndErrorsHideResponse(t *testing.T) { + for _, mode := range []string{"busy", "bad-json", "graphql-error", "http-error", "hanging"} { + t.Run(mode, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + // Consume the request so net/http can observe disconnects while the response hangs. + _, _ = io.Copy(io.Discard, r.Body) + switch mode { + case "hanging": + <-r.Context().Done() + case "busy": + fmt.Fprint(w, `{"data":{"deployments":{"edges":[{"node":{"status":"DEPLOYING"}}]}}}`) + case "bad-json": + fmt.Fprint(w, `fixture-token`) + case "graphql-error": + fmt.Fprint(w, `{"errors":[{"message":"fixture-token"}]}`) + case "http-error": + w.WriteHeader(503) + fmt.Fprint(w, `fixture-token`) + } + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + err := pollDeployments(ctx, server.Client(), server.URL, "fixture-token", "service", "environment", time.Millisecond, 20*time.Millisecond) + if err == nil || strings.Contains(err.Error(), "fixture-token") || calls.Load() == 0 || ctx.Err() != nil { + t.Fatalf("bounded wait: %v (%d requests, parent %v)", err, calls.Load(), ctx.Err()) + } + }) + } +} + +// R3.4: the real up flow polls the overridable GraphQL endpoint before sending a code. +func TestDevboxR34UpUsesDeploymentEndpoint(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var polled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fixture-token" { + t.Error("missing token header") + } + polled.Store(true) + fmt.Fprint(w, `{"data":{"deployments":{"edges":[]}}}`) + })) + defer server.Close() + f.GraphQLEndpoint = server.URL + f.Settle = nil + joined := make(chan struct{}) + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + if !polled.Load() { + t.Error("pairing before polling") + } + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-joined: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && slices.Contains(env, "TF_VAR_pair_code=42-123-456") { + close(joined) + } + return err + } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "fixture-token") { + t.Fatal("token in polling output") + } +} diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go new file mode 100644 index 0000000000..799f33f5cd --- /dev/null +++ b/internal/devbox/devbox.go @@ -0,0 +1,665 @@ +// Package devbox owns the readable Terraform folders for a person's computers. +package devbox + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/guard" +) + +const TokenNeeded = "codeaf devbox needs a Railway token: create one at https://railway.com/account/tokens and set RAILWAY_TOKEN." +const TerraformNeeded = "codeaf devbox needs Terraform: install it from https://developer.hashicorp.com/terraform/install." +const FreshFor = 5 * time.Minute +const probeWithin = 3 * time.Second +const maxPairingCodes = 3 +const reachableEvery = 5 * time.Second +const reachableWithin = 3 * time.Minute + +var errNotReachable = errors.New("the pinned remote key did not answer within three minutes") + +// An acknowledged offer needs a distinct recovery receipt when reachability fails. +type reachabilityError struct{ error } + +// ErrJoinInterrupted allows a fresh offer after a deployment swap or an expired code. +var ErrJoinInterrupted = errors.New("joining was interrupted or the pairing code expired") + +// Options describes one command, including the folder the person owns. +type Options struct { + Name, Dir, Region, Workspace, Channel, Version string + Yes bool +} + +type Device struct { + ID string + Name string + LastSeen time.Time + Removed bool + Online *bool +} + +// Offer carries configuration secrets only in memory until Terraform consumes them. +type Offer struct { + Code, Via, RemoteKey, RemoteDevices string +} + +// Runner executes Terraform without putting credentials in its arguments. +type Runner func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error + +// Flow connects provisioning to the existing identity and pairing doors. +type Flow struct { + Home string + In io.Reader + Out, Err io.Writer + Interactive bool + StdoutTerminal bool + GraphQLEndpoint string + Settle func(ctx context.Context, token, serviceID, environmentID string) error + Getenv func(string) string + LookPath func(string) (string, error) + RunTerraform Runner + Workspaces func(context.Context, string) ([]Workspace, error) + // Prepare creates and pins the remote configuration once, before any identity code. + Prepare func(context.Context, string) (Offer, error) + // Pair blocks until the identity offer ends. Ready supplies only a fresh code and relay. + Pair func(ctx context.Context, name string, ready func(Offer)) error + // Probe verifies a remembered remote key; failure falls back to directory presence. + Probe func(context.Context, string) bool + Devices func(context.Context) ([]Device, error) + Remove func(context.Context, string, string) (bool, error) + // ShareState sends this computer's saved provider keys and settings to the + // identity once. Nil sends nothing. + ShareState func(context.Context) error + Unavailable func() string + probeEvery, readyWithin time.Duration +} + +func (f Flow) Run(ctx context.Context, action string, o Options) error { + if f.Out == nil { + f.Out = io.Discard + } + if f.Err == nil { + f.Err = f.Out + } + if f.In == nil { + f.In = strings.NewReader("") + } + if f.Getenv == nil { + f.Getenv = os.Getenv + } + if f.LookPath == nil { + f.LookPath = exec.LookPath + } + if f.RunTerraform == nil { + f.RunTerraform = RunTerraform + } + if f.Workspaces == nil { + f.Workspaces = LookupWorkspaces + } + if f.Settle == nil { + f.Settle = func(ctx context.Context, token, serviceID, environmentID string) error { + return settleDeployments(ctx, f.GraphQLEndpoint, token, serviceID, environmentID) + } + } + var token string + if action == "up" || action == "down" { + token = strings.TrimSpace(f.Getenv("RAILWAY_TOKEN")) + if token == "" { + token = strings.TrimSpace(f.Getenv("RAILWAY_API_TOKEN")) + } + if token == "" { + return errors.New(TokenNeeded) + } + } + terraform, err := f.LookPath("terraform") + if err != nil { + return errors.New(TerraformNeeded) + } + if o.Name != "" { + if err := ValidateName(o.Name); err != nil { + return err + } + } + if o.Dir == "" { + o.Dir = filepath.Join(f.Home, "devbox", o.Name) + } + switch action { + case "up": + return f.up(ctx, terraform, token, o) + case "down": + return f.down(ctx, terraform, token, o) + case "status": + return f.status(ctx, terraform, o) + default: + return errors.New("usage: codeaf devbox up | status [] | down ") + } +} + +var projectName = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +func ValidateName(name string) error { + clean, err := devname.Clean(name) + if err != nil { + return err + } + if clean != name || !projectName.MatchString(name) { + return errors.New("a dev box name uses lowercase letters, digits and hyphens, starting and ending with a letter or digit") + } + return nil +} + +func (f Flow) devices(ctx context.Context) ([]Device, error) { + if f.Devices == nil { + return nil, nil + } + return f.Devices(ctx) +} + +func (f Flow) up(ctx context.Context, terraform, token string, o Options) error { + if o.Name == "" { + return errors.New("usage: codeaf devbox up ") + } + if o.Channel == "" { + o.Channel = "dev" + } + installURL, binary, err := Channel(o.Channel) + if err != nil { + return err + } + exists, err := hasProject(o.Dir) + if err != nil { + return err + } + if exists { + if f.probe(ctx, o.Name) { + secrets, err := bootstrapSecrets(o.Dir) + if err != nil { + return f.clearFailed(o, err, token) + } + if len(secrets) > 0 { + id, err := readDeviceID(o.Dir) + if err != nil { + return f.clearFailed(o, err, append(secrets, token)...) + } + if id == "" { + if err := f.recordDevice(ctx, o); err != nil { + return f.clearFailed(o, err, append(secrets, token)...) + } + } + if err := f.clearBootstrap(ctx, terraform, token, o, secrets); err != nil { + return err + } + f.sayUp(o) + return nil + } + } + return fmt.Errorf("%s is already up — `%s`", o.Name, f.command("status", o)) + } + if f.Unavailable != nil { + if reason := f.Unavailable(); reason != "" { + return errors.New(reason) + } + } + devices, err := f.devices(ctx) + if err != nil { + return err + } + for _, d := range devices { + if !d.Removed && strings.EqualFold(d.Name, o.Name) { + return fmt.Errorf("a device is already called %s — choose another dev box name", o.Name) + } + } + workspace, err := resolveWorkspace(ctx, token, o.Workspace, f.Workspaces) + if err != nil { + return err + } + if f.Pair == nil || f.Prepare == nil { + return errors.New("pairing is unavailable here") + } + // Create the infrastructure before offering a code, so deployment swaps cannot spend it. + vars := map[string]string{"name": o.Name, "workspace_id": workspace, "region": o.Region, "image": DefaultImage, "install_url": installURL, "binary": binary, "install_version": o.Version, "via": ""} + if err := WriteFolder(o.Dir, vars); err != nil { + return err + } + env := []string{"RAILWAY_TOKEN=" + token} + if err := f.terraform(ctx, terraform, o.Dir, []string{"init", "-input=false"}, env, token, ""); err != nil { + return err + } + if err := f.applyPlan(ctx, terraform, o.Dir, env, []string{token}, false, func() error { + return f.confirm(ctx, o.Yes, "Create these on your Railway account? [y/N]") + }); err != nil { + return err + } + outputs, err := f.outputs(ctx, terraform, o.Dir, env, token) + if err != nil { + return f.pairingFailed(o, err) + } + if outputs["service_id"] == "" || outputs["environment_id"] == "" { + return f.pairingFailed(o, errors.New("Terraform did not return the service and environment ids")) + } + fmt.Fprintln(f.Out, "Waiting for Railway to finish starting the box.") + if err := f.Settle(ctx, token, outputs["service_id"], outputs["environment_id"]); err != nil { + if ctx.Err() != nil { + return f.pairingFailed(o, ctx.Err()) + } + fmt.Fprintln(f.Out, "Railway did not settle before the wait ended; sending the pairing code anyway.") + } + remote, err := f.Prepare(ctx, o.Name) + if err != nil { + return f.pairingFailed(o, err) + } + secrets := []string{token, remote.RemoteKey, remote.RemoteDevices} + for attempt := 1; attempt <= maxPairingCodes; attempt++ { + if err := ctx.Err(); err != nil { + return f.pairingFailed(o, err) + } + err := f.sendPairingCode(ctx, terraform, token, o, vars, remote, &secrets) + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + err = ErrJoinInterrupted + } + if err == nil || errors.Is(err, ErrJoinInterrupted) && f.probe(ctx, o.Name) { + f.shareState(ctx, o.Name) + if err := f.recordDevice(ctx, o); err != nil { + return f.clearFailed(o, err, secrets...) + } + if err := f.clearBootstrap(ctx, terraform, token, o, secrets); err != nil { + return err + } + f.sayUp(o) + return nil + } + var unreachable reachabilityError + if errors.As(err, &unreachable) { + return fmt.Errorf("%s took its pairing code but is not reachable yet: %s. It is on your Railway account; check it with %s, or remove it with %s.", o.Name, reason(err), f.command("status", o), f.command("down", o)) + } + if ctx.Err() != nil { + return f.pairingFailed(o, ctx.Err()) + } + if errors.Is(err, context.DeadlineExceeded) { + err = ErrJoinInterrupted + } + if !errors.Is(err, ErrJoinInterrupted) || attempt == maxPairingCodes { + return f.pairingFailed(o, errors.New(redact(err.Error(), secrets...))) + } + fmt.Fprintf(f.Out, "%s did not finish joining; sending a new code (%d of %d).\n", o.Name, attempt+1, maxPairingCodes) + } + return f.pairingFailed(o, ErrJoinInterrupted) +} + +// shareState runs once the box answers. A laptop that was a fleet of one until +// this up offered its identity has never sent its saved keys anywhere, so the +// box would find none and the first `--at` chat would stop on a missing key. A +// failure is said once and is not fatal: codeaf sends them the next time it +// opens here. +func (f Flow) shareState(ctx context.Context, name string) { + if f.ShareState == nil { + return + } + if err := f.ShareState(ctx); err != nil && ctx.Err() == nil { + fmt.Fprintf(f.Out, "Could not send this computer's saved keys yet: %s. Open codeaf here once, or a chat on %s can stop on a missing key.\n", reason(err), name) + } +} + +func (f Flow) sayUp(o Options) { + fmt.Fprintf(f.Out, "%s is up. Open a chat on it: codeaf chat --at %s\nThe Terraform is in %s.\n", o.Name, o.Name, o.Dir) +} + +func (f Flow) probe(ctx context.Context, name string) bool { + if f.Probe == nil || ctx.Err() != nil { + return false + } + ctx, cancel := context.WithTimeout(ctx, probeWithin) + defer cancel() + return f.Probe(ctx, name) +} + +func (f Flow) sendPairingCode(ctx context.Context, terraform, token string, o Options, vars map[string]string, remote Offer, secrets *[]string) error { + offerCtx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + var pairErr error + ready := make(chan Offer, 1) + guard.Go("devbox pairing", func() { + defer close(finished) + pairErr = f.Pair(offerCtx, o.Name, func(offer Offer) { + select { + case ready <- offer: + case <-offerCtx.Done(): + } + }) + }) + defer func() { cancel(); <-finished }() + var offer Offer + select { + case offer = <-ready: + case <-finished: + select { + case offer = <-ready: + default: + if pairErr != nil { + return pairErr + } + return errors.New("no device joined") + } + case <-ctx.Done(): + return ctx.Err() + } + if offer.Via != remote.Via { + return errors.New("the pairing relay changed while setting up the box") + } + vars["via"] = remote.Via + if err := WriteFolder(o.Dir, vars); err != nil { + return err + } + *secrets = append(*secrets, offer.Code) + env := []string{"RAILWAY_TOKEN=" + token, "TF_VAR_pair_code=" + offer.Code, "TF_VAR_remote_key=" + remote.RemoteKey, "TF_VAR_remote_devices=" + remote.RemoteDevices} + if err := f.applyPlan(ctx, terraform, o.Dir, env, *secrets, true, func() error { + select { + case <-finished: + if pairErr != nil { + return pairErr + } + default: + } + if err := ctx.Err(); err != nil { + return err + } + fmt.Fprintf(f.Out, "Sending %s its pairing code.\n", o.Name) + return nil + }); err != nil { + return err + } + fmt.Fprintln(f.Out, "Waiting for the box to install codeaf, join and become reachable; this can take a few minutes.") + return f.awaitReachable(ctx, o.Name, finished, func() error { return pairErr }) +} + +// Probe independently of the acknowledgement so a lost reply cannot strand a joined box. +func (f Flow) awaitReachable(ctx context.Context, name string, finished <-chan struct{}, result func() error) error { + every, within := f.probeEvery, f.readyWithin + if every <= 0 { + every = reachableEvery + } + if within <= 0 { + within = reachableWithin + } + probeCtx, cancel := context.WithCancel(ctx) + done, reachable := make(chan struct{}), make(chan struct{}) + guard.Go("devbox reachability", func() { + defer close(done) + ticks := time.NewTicker(every) + defer ticks.Stop() + for probeCtx.Err() == nil { + if f.probe(probeCtx, name) { + close(reachable) + return + } + select { + case <-probeCtx.Done(): + return + case <-ticks.C: + } + } + }) + defer func() { cancel(); <-done }() + var timeout <-chan time.Time + var timer *time.Timer + defer func() { + if timer != nil { + timer.Stop() + } + }() + for { + select { + case <-reachable: + return nil + case <-finished: + err := result() + if err != nil { + if errors.Is(err, ErrJoinInterrupted) || errors.Is(err, context.DeadlineExceeded) { + return ErrJoinInterrupted + } + return err + } + finished = nil + timer = time.NewTimer(within) + timeout = timer.C + case <-timeout: + return reachabilityError{errNotReachable} + case <-ctx.Done(): + if finished == nil { + return reachabilityError{ctx.Err()} + } + return ctx.Err() + } + } +} + +func reason(err error) string { + if err == nil { + return "no device joined" + } + return strings.TrimRight(strings.Join(strings.Fields(err.Error()), " "), ".") +} + +func (f Flow) pairingFailed(o Options, err error) error { + return fmt.Errorf("%s did not join: %s. It is on your Railway account; remove it with `%s`, then run up again.", o.Name, reason(err), f.command("down", o)) +} + +// A recovery command must keep pointing to the folder that owns the paid resources. +func (f Flow) command(action string, o Options) string { + command := "codeaf devbox " + action + " " + o.Name + actual, _ := filepath.Abs(o.Dir) + defaultDir, _ := filepath.Abs(filepath.Join(f.Home, "devbox", o.Name)) + if actual != defaultDir { + command += " --dir " + shellQuote(o.Dir) + } + return command +} + +var shellBare = regexp.MustCompile(`^[a-zA-Z0-9_./:@%+=,-]+$`) + +func shellQuote(s string) string { + if shellBare.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +func (f Flow) down(ctx context.Context, terraform, token string, o Options) error { + if o.Name == "" { + return errors.New("usage: codeaf devbox down ") + } + if _, err := os.Stat(filepath.Join(o.Dir, "main.tf")); err != nil { + return fmt.Errorf("read the Terraform folder: %w", err) + } + if raw, err := os.ReadFile(filepath.Join(o.Dir, "codeaf.auto.tfvars.json")); err == nil { + var vars struct { + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &vars); err != nil { + return fmt.Errorf("read the Terraform variables: %w", err) + } + if vars.Name != "" && vars.Name != o.Name { + return fmt.Errorf("this Terraform folder belongs to %s; use `codeaf devbox down %s --dir %s`", vars.Name, vars.Name, shellQuote(o.Dir)) + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if err := f.confirm(ctx, o.Yes, fmt.Sprintf("Destroy %s and everything in project codeaf-%s? [y/N]", o.Name, o.Name)); err != nil { + return err + } + if err := f.terraform(ctx, terraform, o.Dir, []string{"destroy", "-input=false", "-auto-approve"}, []string{"RAILWAY_TOKEN=" + token}, token, ""); err != nil { + return err + } + fmt.Fprintf(f.Out, "The Terraform is still in %s.\n", o.Dir) + if f.Remove != nil { + id, err := readDeviceID(o.Dir) + if err != nil { + return fmt.Errorf("the box was destroyed, but its saved device id could not be read: %w", err) + } + removed, err := f.Remove(ctx, o.Name, id) + if err != nil { + return fmt.Errorf("the box was destroyed, but its device could not be removed: %w", err) + } + if !removed { + fmt.Fprintf(f.Out, "No active device matches %s; no device was removed.\n", o.Name) + } + } + fmt.Fprintf(f.Out, "%s is down.\n", o.Name) + return nil +} + +func (f Flow) confirm(ctx context.Context, yes bool, prompt string) error { + if yes { + return nil + } + if !f.Interactive { + return errors.New("codeaf devbox needs --yes when stdin is not a terminal") + } + fmt.Fprint(f.Out, prompt+" ") + answer := make(chan string, 1) + go func() { line, _ := bufio.NewReader(f.In).ReadString('\n'); answer <- strings.TrimSpace(line) }() + select { + case line := <-answer: + if strings.EqualFold(line, "y") || strings.EqualFold(line, "yes") { + return nil + } + return errors.New("cancelled") + case <-ctx.Done(): + return ctx.Err() + } +} + +func hasProject(dir string) (bool, error) { + raw, err := os.ReadFile(filepath.Join(dir, "terraform.tfstate")) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + var state struct { + Resources []struct { + Type string + Instances []json.RawMessage + } + } + if err := json.Unmarshal(raw, &state); err != nil { + return false, fmt.Errorf("read Terraform state: %w", err) + } + for _, r := range state.Resources { + if r.Type == "railway_project" && len(r.Instances) > 0 { + return true, nil + } + } + return false, nil +} + +func (f Flow) status(ctx context.Context, terraform string, o Options) error { + names := []string{o.Name} + if o.Name == "" { + entries, err := os.ReadDir(o.Dir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + names = nil + for _, entry := range entries { + if entry.IsDir() { + names = append(names, entry.Name()) + } + } + } + var devices []Device + devicesLoaded := false + for _, name := range names { + dir := o.Dir + if o.Name == "" { + dir = filepath.Join(dir, name) + } + up, err := hasProject(dir) + if err != nil { + return err + } + if !up { + fmt.Fprintf(f.Out, "%s: not up\n", name) + continue + } + values, err := f.outputs(ctx, terraform, dir, nil) + if err != nil { + return err + } + get := func(key string) string { return values[key] } + state := "not up" + var last time.Time + reached := false + reached = f.probe(ctx, name) + if reached { + state = "up" + last = time.Now() + } else { + if !devicesLoaded { + var err error + devices, err = f.devices(ctx) + if err != nil { + return err + } + devicesLoaded = true + } + for _, d := range devices { + if !d.Removed && strings.EqualFold(d.Name, name) { + last = d.LastSeen + if d.Online != nil { + if *d.Online { + state = "up" + } + } else if !last.IsZero() && time.Since(last) <= FreshFor { + state = "up (seen recently)" + } + break + } + } + } + fields := []string{name + ": " + state} + if region := get("region"); region != "" { + fields = append(fields, "region "+region) + } + if project := get("project_name"); project != "" { + fields = append(fields, "project "+project) + } + if !last.IsZero() { + fields = append(fields, "last seen "+last.UTC().Format(time.RFC3339)) + } + fmt.Fprintln(f.Out, strings.Join(fields, "; ")) + } + return nil +} + +func (f Flow) terraform(ctx context.Context, path, dir string, args, env []string, secrets ...string) error { + filteredOut := &terraformOutput{out: f.Out, stripColor: !f.StdoutTerminal} + filteredErr := &terraformOutput{out: f.Err, stripColor: !f.StdoutTerminal} + out := newRedactor(filteredOut, secrets...) + errs := newRedactor(filteredErr, secrets...) + err := f.runTerraform(ctx, path, dir, args, env, out, errs) + out.Flush() + errs.Flush() + filteredOut.Flush() + filteredErr.Flush() + if err != nil { + return fmt.Errorf("terraform %s failed: %s", args[0], redact(err.Error(), secrets...)) + } + return nil +} diff --git a/internal/devbox/devbox_test.go b/internal/devbox/devbox_test.go new file mode 100644 index 0000000000..dddbaf392d --- /dev/null +++ b/internal/devbox/devbox_test.go @@ -0,0 +1,593 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Contract 1: prerequisites refuse before any local or external side effect. +func TestDevboxMissingPrerequisitesCreateNothing(t *testing.T) { + for _, action := range []string{"up", "down"} { + t.Run(action, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := Flow{Out: &out, Getenv: func(string) string { return "" }, LookPath: func(string) (string, error) { t.Fatal("terraform looked up before token"); return "", nil }} + err := f.Run(context.Background(), action, Options{Name: "box", Dir: dir}) + if err == nil || err.Error() != TokenNeeded { + t.Fatalf("refusal = %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("folder created: %v", err) + } + if out.Len() != 0 || strings.Count(TokenNeeded, "\n") != 0 { + t.Fatal("extra output") + } + }) + } +} + +// Contract 1: a missing executable has the same absence of side effects. +func TestDevboxMissingTerraformCreatesNothing(t *testing.T) { + for _, action := range []string{"up", "down"} { + dir := filepath.Join(t.TempDir(), "box") + f := Flow{Getenv: func(string) string { return "token" }, LookPath: func(string) (string, error) { return "", os.ErrNotExist }} + if err := f.Run(context.Background(), action, Options{Name: "box", Dir: dir}); err == nil || err.Error() != TerraformNeeded { + t.Fatalf("refusal %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("folder created") + } + } +} + +func fakeTerraform(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "terraform") + script := `#!/bin/sh +set -eu +action="$1" +printf '%s\n' "$*" >> calls +shift +positional= +for arg in "$@"; do + case "$arg" in + -*) [ -z "$positional" ] || exit 54 ;; + *) positional=1 ;; + esac +done +if [ "${1:-}" = -no-color ]; then shift; fi +set -- "$action" "$@" +if [ ! -f terraform.tfstate ]; then + [ -z "${TF_VAR_pair_code:-}" ] || exit 51 + [ -z "${TF_VAR_remote_key:-}" ] || exit 52 + [ -z "${TF_VAR_remote_devices:-}" ] || exit 53 +fi +if [ "$action" != output ]; then + [ "${RAILWAY_TOKEN:-}" = fixture-token ] || exit 41 + [ -z "${RAILWAY_API_TOKEN:-}" ] || exit 42 + [ -z "${TF_LOG:-}" ] || exit 43 +fi +if [ -n "${TF_VAR_pair_code:-}" ]; then + [ -n "${TF_VAR_remote_key:-}" ] || exit 47 + [ -n "${TF_VAR_remote_devices:-}" ] || exit 48 +else + [ -z "${TF_VAR_remote_key:-}" ] || exit 49 + [ -z "${TF_VAR_remote_devices:-}" ] || exit 50 +fi +if [ "${FAIL_ACTION:-}" = "$action" ]; then echo 'fixture failure' >&2; exit 44; fi +case "$action" in + init) ;; + plan) + for arg in "$@"; do case "$arg" in -out=*) plan="${arg#-out=}";; esac; done + printf 'saved plan' > "$plan" + printf 'Create project codeaf-box\nSensitive: fixture-token %s %s %s\n' "${TF_VAR_pair_code:-}" "${TF_VAR_remote_key:-}" "${TF_VAR_remote_devices:-}" + ;; + show) + printf '%s' "${GUARD_JSON:-}" + if [ -z "${GUARD_JSON:-}" ]; then + printf '%s' '{"format_version":"1.2","resource_changes":[{"address":"railway_project.box","change":{"actions":["no-op"]}},{"address":"railway_service.box","change":{"actions":["no-op"]}},{"address":"railway_variable_collection.box","change":{"actions":["update"],"after":{"variables":[{"name":"secret","value":"show-only-sensitive-value"}]},"after_sensitive":{"variables":true}}}]}' + fi + ;; + apply) + test -f "$3" + if [ -f terraform.tfstate ]; then cp terraform.tfstate terraform.tfstate.backup; fi + printf '{"resources":[{"type":"railway_project","instances":[{"attributes":{"id":"project"}}]},{"type":"railway_variable_collection","instances":[{"attributes":{"variables":[{"name":"CODEAF_DEVBOX_CODE","value":"%s"},{"name":"CODEAF_DEVBOX_REMOTE_KEY","value":"%s"},{"name":"CODEAF_DEVBOX_REMOTE_DEVICES","value":"%s"}]}}]}]}' "${TF_VAR_pair_code:-}" "${TF_VAR_remote_key:-}" "${TF_VAR_remote_devices:-}" > terraform.tfstate + if [ ! -f terraform.tfstate.backup ]; then cp terraform.tfstate terraform.tfstate.backup; fi + chmod 644 terraform.tfstate terraform.tfstate.backup + ;; + destroy) printf '{"resources":[]}' > terraform.tfstate; chmod 644 terraform.tfstate ;; + output) printf '{"project_name":{"value":"codeaf-box"},"region":{"value":"europe-west4"},"service_id":{"value":"service"},"environment_id":{"value":"environment"}}' ;; +esac +` + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return path +} + +func fixtureFlow(t *testing.T, dir string, out *bytes.Buffer) Flow { + t.Helper() + executable := fakeTerraform(t) + return Flow{Home: t.TempDir(), Out: out, Prepare: func(context.Context, string) (Offer, error) { + return Offer{Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}, nil + }, Probe: func(context.Context, string) bool { return true }, StdoutTerminal: true, Settle: func(context.Context, string, string, string) error { return nil }, Getenv: func(key string) string { + if key == "RAILWAY_TOKEN" { + return "fixture-token" + } + return "" + }, LookPath: func(string) (string, error) { return executable, nil }, Workspaces: func(context.Context, string) ([]Workspace, error) { + return []Workspace{{ID: "workspace", Name: "Personal"}}, nil + }} +} + +// R5.3/R5.4: standalone files, secret isolation, guarded clear and a reachable receipt. +// R6.1: an up without a matching release keeps its install version empty. +func TestDevboxR53R54UpWaitsForJoinAndKeepsOnlyNonsecretInputs(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + applied := make(chan struct{}) + join := make(chan struct{}) + f.Probe = func(context.Context, string) bool { + select { + case <-join: + return true + default: + return false + } + } + done := make(chan error, 1) + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if strings.Contains(strings.Join(args, " "), "fixture-") || strings.Contains(strings.Join(args, " "), "42-123-456") { + t.Error("secret argument") + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + close(applied) + } + return err + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-join: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + go func() { + done <- f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true, Channel: "staging"}) + }() + select { + case <-applied: + case <-time.After(5 * time.Second): + t.Fatal("apply never ran") + } + select { + case err := <-done: + t.Fatalf("reported before join: %v", err) + default: + } + close(join) + if err := <-done; err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "box is up") || !strings.Contains(out.String(), "codeaf chat --at box") { + t.Fatal(out.String()) + } + calls, err := os.ReadFile(filepath.Join(dir, "calls")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 10 || lines[0] != "init -input=false" || !strings.HasPrefix(lines[1], "plan -input=false -out=") || !strings.HasPrefix(lines[2], "apply -input=false ") || lines[3] != "output -json" || !strings.HasPrefix(lines[4], "plan -input=false -out=") || !strings.HasPrefix(lines[5], "show -json ") || !strings.HasPrefix(lines[6], "apply -input=false ") { + t.Fatalf("order: %s", calls) + } + for _, name := range []string{"main.tf", "variables.tf", "outputs.tf", "boot.sh", "codeaf.auto.tfvars.json"} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "fixture-") || strings.Contains(string(raw), "42-123-456") { + t.Fatalf("secret in %s", name) + } + } + vars, _ := os.ReadFile(filepath.Join(dir, "codeaf.auto.tfvars.json")) + var choices map[string]string + if err := json.Unmarshal(vars, &choices); err != nil { + t.Fatal(err) + } + if version, present := choices["install_version"]; !present || version != "" { + t.Fatalf("R6.1 an unpinned up must save an empty version, got %q (present %v)", version, present) + } + for _, want := range []string{"https://relay.example", "https://agentfield.ai/get/stageaf", `"binary": "stageaf"`, `"name": "box"`} { + if !strings.Contains(string(vars), want) { + t.Errorf("missing %s: %s", want, vars) + } + } + if strings.Contains(out.String(), "fixture-") || strings.Contains(out.String(), "42-123-456") { + t.Fatal("secret in output") + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) != 0 { + t.Fatal("plan retained") + } + f.Pair = func(context.Context, string, func(Offer)) error { t.Fatal("second offer"); return nil } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}); err == nil || !strings.Contains(err.Error(), "already up") { + t.Fatalf("second up: %v", err) + } +} + +// R6.1: the service gets the optional first-install version through the empty-value filter. +func TestDevboxR61VariableCollectionFiltersEmptyVersion(t *testing.T) { + dir := t.TempDir() + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + main, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`{ name = "CODEAF_DEVBOX_VERSION", value = var.install_version }`, `] : variable if variable.value != ""]`} { + if !strings.Contains(string(main), want) { + t.Fatalf("R6.1 module is missing %s", want) + } + } +} + +// Contract 2: existing device names and unsupported names refuse before folder writes. +func TestDevboxUpRefusesDuplicateAndInvalidNames(t *testing.T) { + for _, name := range []string{"box", "../box", "UPPER", "bad name", "-box", "box-", strings.Repeat("a", 33)} { + t.Run(name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "new") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Devices = func(context.Context) ([]Device, error) { return []Device{{Name: "BOX"}}, nil } + f.Pair = func(context.Context, string, func(Offer)) error { t.Fatal("offer started"); return nil } + if err := f.Run(context.Background(), "up", Options{Name: name, Dir: dir, Yes: true}); err == nil { + t.Fatal("accepted") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("created folder") + } + }) + } +} + +// R5.6: destruction precedes removal, and a failed destroy retains access. +func TestDevboxR56DownRemovesDeviceOnlyAfterSuccessfulDestroy(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(fmt.Sprint(fail), func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + t.Setenv("FAIL_ACTION", map[bool]string{true: "destroy", false: ""}[fail]) + removed := false + f.Remove = func(context.Context, string, string) (bool, error) { + calls, _ := os.ReadFile(filepath.Join(dir, "calls")) + if !strings.Contains(string(calls), "destroy") { + t.Fatal("removed before destroy") + } + removed = true + return true, nil + } + err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}) + if (err != nil) != fail || removed == fail { + t.Fatalf("err=%v removed=%v", err, removed) + } + if _, err := os.Stat(filepath.Join(dir, "main.tf")); err != nil { + t.Fatal("folder removed") + } + }) + } +} + +// R5.3: a failed probe retains observed presence and unknown fields stay absent. +func TestDevboxR53StatusUsesDevicePresenceAndOmitsUnknownFields(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Probe = nil + if err := os.WriteFile(filepath.Join(dir, "terraform.tfstate"), []byte(`{"resources":[{"type":"railway_project","instances":[{}]}]}`), 0o600); err != nil { + t.Fatal(err) + } + online := true + f.Devices = func(context.Context) ([]Device, error) { + return []Device{{Name: "box", Online: &online, LastSeen: time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)}}, nil + } + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + for _, want := range []string{"box: up", "region europe-west4", "last seen 2026-10-30T12:00:00Z"} { + if !strings.Contains(out.String(), want) { + t.Fatal(out.String()) + } + } + out.Reset() + f.Devices = nil + f.RunTerraform = func(context.Context, string, string, []string, []string, io.Writer, io.Writer) error { return nil } + // Empty output is an error, not a fabricated region or status. + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err == nil { + t.Fatal("invalid outputs accepted") + } + f.RunTerraform = func(_ context.Context, _, _ string, _, _ []string, out, _ io.Writer) error { + _, err := io.WriteString(out, `{}`) + return err + } + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + if out.String() != "box: not up\n" { + t.Fatalf("unknown fields: %q", out.String()) + } +} + +// Contract 2: rewrites preserve state and initialized providers. +func TestDevboxFolderRewriteKeepsStateAndProviderFiles(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".terraform"), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup", ".terraform/keep"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil || info.Mode().Perm() != 0o700 { + t.Fatalf("folder is not private: %v %v", info, err) + } + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup", ".terraform/keep"} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || string(raw) != "keep" { + t.Fatalf("lost %s", name) + } + } +} + +// Contract 2: declining a plan or losing the offer never applies or leaves its code on disk. +func TestDevboxUpDeclineAndPairingFailureCleanPlans(t *testing.T) { + for _, mode := range []string{"decline", "nonterminal", "plan-failure", "apply-failure", "expired", "cancelled"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Interactive = mode != "nonterminal" + f.Probe = nil + f.In = strings.NewReader("n\n") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + applied := make(chan struct{}) + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, out, errs io.Writer) error { + err := RunTerraform(ctx, path, dir, args, env, out, errs) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + close(applied) + } + return err + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + if mode == "expired" || mode == "cancelled" { + select { + case <-applied: + if mode == "cancelled" { + cancel() + <-ctx.Done() + return ctx.Err() + } + return errors.New("offer expired") + case <-ctx.Done(): + return ctx.Err() + } + } + <-ctx.Done() + return ctx.Err() + } + if mode == "plan-failure" { + t.Setenv("FAIL_ACTION", "plan") + } + if mode == "apply-failure" { + t.Setenv("FAIL_ACTION", "apply") + } + yes := mode != "decline" && mode != "nonterminal" + err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: yes}) + if err == nil { + t.Fatal("reported up") + } + if (mode == "expired" || mode == "cancelled") && !strings.Contains(err.Error(), "codeaf devbox down box") { + t.Fatalf("missing cleanup: %v", err) + } + calls, _ := os.ReadFile(filepath.Join(dir, "calls")) + if (mode == "decline" || mode == "nonterminal" || mode == "plan-failure") && strings.Contains(string(calls), "apply") { + t.Fatal("applied without consent or plan") + } + if strings.Contains(string(calls), "destroy") || strings.Contains(out.String(), "box is up") { + t.Fatal("destroyed automatically or reported success") + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) > 0 { + t.Fatal("plan retained") + } + }) + } +} + +// Contract 1: token preference and fallback are passed only as the canonical child environment. +func TestDevboxTokenPreferenceAndTerraformEnvironment(t *testing.T) { + for _, primary := range []bool{true, false} { + t.Run(fmt.Sprint(primary), func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + WriteFolder(dir, map[string]string{"name": "box"}) + f.Getenv = func(key string) string { + if key == "RAILWAY_API_TOKEN" || primary && key == "RAILWAY_TOKEN" { + return "fixture-token" + } + return "" + } + t.Setenv("RAILWAY_API_TOKEN", "inherited-token") + t.Setenv("RAILWAY_TOKEN", "inherited-token") + t.Setenv("TF_VAR_pair_code", "inherited-code") + t.Setenv("TF_VAR_remote_key", "inherited-key") + t.Setenv("TF_VAR_remote_devices", "inherited-devices") + t.Setenv("TF_LOG", "TRACE") + t.Setenv("TF_CLI_ARGS", "bad-injected-argument") + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + }) + } +} + +// Contract 2: the embedded module contains one project, one service, and one persistent volume. +func TestDevboxStandaloneModuleHasOneComputerAndOptionalRegion(t *testing.T) { + for _, region := range []string{"", "europe-west4"} { + dir := t.TempDir() + if err := WriteFolder(dir, map[string]string{"name": "box", "region": region}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(dir, "main.tf")) + hcl := string(raw) + for _, resource := range []string{`resource "railway_project"`, `resource "railway_service"`, `volume = {`} { + if strings.Count(hcl, resource) != 1 { + t.Fatalf("resource %s: %s", resource, hcl) + } + } + if strings.Contains(hcl, "railway_service_domain") || strings.Contains(hcl, "railway_tcp_proxy") { + t.Fatal("inbound endpoint") + } + if (region != "") != strings.Contains(hcl, "region = var.region") { + t.Fatalf("region %q: %s", region, hcl) + } + if _, err := os.Stat(filepath.Join(dir, ".terraform.lock.hcl")); !os.IsNotExist(err) { + t.Fatal("lockfile shipped") + } + } +} + +// Contract 6: all default folders are listed, with empty state rendered as not up. +func TestDevboxStatusListsEveryDefaultFolderWithoutToken(t *testing.T) { + home := t.TempDir() + for _, name := range []string{"one", "two"} { + os.MkdirAll(filepath.Join(home, "devbox", name), 0o700) + } + var out bytes.Buffer + f := fixtureFlow(t, "", &out) + f.Home = home + f.Getenv = func(string) string { t.Fatal("status read token"); return "" } + if err := f.Run(context.Background(), "status", Options{}); err != nil { + t.Fatal(err) + } + if out.String() != "one: not up\ntwo: not up\n" { + t.Fatal(out.String()) + } +} + +// Contract 2: secrets remain hidden even when process writes split through their middle. +func TestDevboxTerraformOutputRedactsSplitSecrets(t *testing.T) { + var out bytes.Buffer + r := newRedactor(&out, "fixture-token", "42-123-456", "fixture-remote-key", "fixture-remote-devices") + for _, part := range []string{"before fi", "xture-t", "oken and 42-", "123-456 and fixture-remo", "te-key and fixture-remote-", "devices after"} { + if _, err := io.WriteString(r, part); err != nil { + t.Fatal(err) + } + } + r.Flush() + if out.String() != "before [redacted] and [redacted] and [redacted] and [redacted] after" { + t.Fatal(out.String()) + } +} + +// Contract 5: a destructive confirmation always names the folder's actual box. +func TestDevboxDownConfirmsAndRejectsADifferentFoldersName(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + if err := f.Run(context.Background(), "down", Options{Name: "different", Dir: dir, Yes: true}); err == nil || !strings.Contains(err.Error(), "belongs to box") { + t.Fatalf("wrong folder: %v", err) + } + f.Interactive = true + f.In = strings.NewReader("n\n") + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir}); err == nil { + t.Fatal("decline accepted") + } + if out.String() != "Destroy box and everything in project codeaf-box? [y/N] " { + t.Fatalf("prompt: %q", out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "calls")); !os.IsNotExist(err) { + t.Fatal("Terraform ran before confirmation") + } +} + +// Round 2: released serve is reachable even without a directory watch; failures retain the existing fallback. +func TestDevboxStatusProbesBeforeFallingBackToPresence(t *testing.T) { + for _, reachable := range []bool{true, false} { + t.Run(fmt.Sprint(reachable), func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "terraform.tfstate"), []byte(`{"resources":[{"type":"railway_project","instances":[{}]}]}`), 0o600); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + offline := false + seen := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + f.Devices = func(context.Context) ([]Device, error) { + if reachable { + t.Fatal("reachable box depended on directory presence") + } + return []Device{{Name: "box", Online: &offline, LastSeen: seen}}, nil + } + probed := 0 + f.Probe = func(ctx context.Context, name string) bool { + probed++ + deadline, ok := ctx.Deadline() + if name != "box" || !ok || time.Until(deadline) > 5*time.Second { + t.Error("probe has no short bound") + } + return reachable + } + before := time.Now().UTC().Truncate(time.Second) + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + if probed != 1 { + t.Fatalf("probes: %d", probed) + } + if reachable { + if !strings.HasPrefix(out.String(), "box: up;") || strings.Contains(out.String(), "2020-") { + t.Fatal(out.String()) + } + stamp := strings.TrimSpace(strings.Split(out.String(), "last seen ")[1]) + now, err := time.Parse(time.RFC3339, stamp) + if err != nil || now.Before(before) || now.After(time.Now()) { + t.Fatalf("last seen %s: %v", stamp, err) + } + } else if !strings.Contains(out.String(), "box: not up;") || !strings.Contains(out.String(), seen.Format(time.RFC3339)) { + t.Fatal(out.String()) + } + }) + } +} diff --git a/internal/devbox/output.go b/internal/devbox/output.go new file mode 100644 index 0000000000..f7886ed124 --- /dev/null +++ b/internal/devbox/output.go @@ -0,0 +1,62 @@ +package devbox + +import ( + "io" + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Terraform's apply hint names a temporary file that codeaf applies and then deletes. +// Buffer lines so split writes and color sequences cannot leave half of that hint behind. +type terraformOutput struct { + out io.Writer + stripColor bool + pending string + applyHint bool +} + +func (w *terraformOutput) Write(p []byte) (int, error) { + w.pending += string(p) + for { + line, rest, found := strings.Cut(w.pending, "\n") + if !found { + break + } + w.pending = rest + if err := w.line(line, "\n"); err != nil { + return 0, err + } + } + return len(p), nil +} + +func (w *terraformOutput) line(line, ending string) error { + plain := ansi.Strip(line) + clean := strings.TrimSpace(plain) + if strings.HasPrefix(clean, "To perform exactly these actions, run the following command to apply:") { + w.applyHint = true + return nil + } + if w.applyHint { + if clean == "" { + return nil + } + w.applyHint = false + if strings.HasPrefix(clean, "terraform apply ") { + return nil + } + } + if w.stripColor { + line = plain + } + _, err := io.WriteString(w.out, line+ending) + return err +} + +func (w *terraformOutput) Flush() { + if w.pending != "" { + _ = w.line(w.pending, "") + w.pending = "" + } +} diff --git a/internal/devbox/reachable_test.go b/internal/devbox/reachable_test.go new file mode 100644 index 0000000000..0d8eb13284 --- /dev/null +++ b/internal/devbox/reachable_test.go @@ -0,0 +1,240 @@ +package devbox + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +// R5.3: an acknowledgement alone is insufficient; only the pinned probe can announce up. +func TestDevboxR53UpRequiresReachabilityAndFindsLostAcknowledgement(t *testing.T) { + for _, mode := range []string{"not-reachable", "lost-ack", "interrupted-ack", "delayed-probe"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "custom folder") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var applied, cancelled atomic.Bool + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + applied.Store(true) + } + return err + } + var probes atomic.Int32 + f.Probe = func(ctx context.Context, name string) bool { + if name != "box" { + t.Error("R5.3 wrong pinned alias") + } + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > probeWithin { + t.Error("R5.3 unbounded probe") + } + n := probes.Add(1) + return applied.Load() && mode != "not-reachable" && (mode != "delayed-probe" || n >= 2) + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + defer func() { + if mode == "lost-ack" && ctx.Err() != nil { + cancelled.Store(true) + } + }() + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + for !applied.Load() { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Millisecond): + } + } + if mode == "lost-ack" { + <-ctx.Done() + cancelled.Store(true) + return ctx.Err() + } + if mode == "interrupted-ack" { + return ErrJoinInterrupted + } + return nil + } + f.probeEvery = time.Millisecond + f.readyWithin = 25 * time.Millisecond + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: true}) + if mode == "not-reachable" { + want := "box took its pairing code but is not reachable yet: the pinned remote key did not answer within three minutes. It is on your Railway account; check it with codeaf devbox status box --dir '" + dir + "', or remove it with codeaf devbox down box --dir '" + dir + "'." + if err == nil || err.Error() != want || strings.Contains(out.String(), "box is up") { + t.Fatalf("R5.3/R5.7 premature up or receipt: %v %s", err, &out) + } + } else if err != nil || !strings.Contains(out.String(), "box is up") || mode == "lost-ack" && !cancelled.Load() { + t.Fatalf("R5.3 readiness: %v, cancelled %v, %s", err, cancelled.Load(), &out) + } + if probes.Load() == 0 { + t.Fatal("R5.3 never probed") + } + }) + } +} + +// R5.4: clear uses a guarded third apply without any bootstrap inputs, erases the backup and can resume. +func TestDevboxR54ClearSecretsAndResumeWithoutPairing(t *testing.T) { + for _, mode := range []string{"success", "apply-fails", "apply-wrote-then-failed", "guard-refuses"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "custom folder") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var joined atomic.Bool + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + for !joined.Load() { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Millisecond): + } + } + return nil + } + f.Probe = func(context.Context, string) bool { return joined.Load() } + var clears, guards int + failed := false + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + clear := joined.Load() + if clear { + for _, entry := range env { + if strings.HasPrefix(entry, "TF_VAR_pair_code=") || strings.HasPrefix(entry, "TF_VAR_remote_key=") || strings.HasPrefix(entry, "TF_VAR_remote_devices=") { + t.Error("R5.4 clear contains bootstrap input") + } + } + } + if clear && args[0] == "show" { + guards++ + if mode == "guard-refuses" && !failed { + failed = true + io.WriteString(stdout, `{"resource_changes":[{"address":"railway_service.box","change":{"actions":["delete","create"]}}]}`) + return nil + } + } + if clear && args[0] == "apply" { + clears++ + if mode == "apply-fails" && !failed { + failed = true + io.WriteString(stderr, "fixture-remote-key 42-123-456 fixture-remote-devices") + return errors.New("fixture-remote-key rejected") + } + if mode == "apply-wrote-then-failed" && !failed { + failed = true + if err := RunTerraform(ctx, path, dir, args, env, stdout, stderr); err != nil { + return err + } + return errors.New("provider lost the clear reply") + } + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + joined.Store(true) + } + return err + } + opts := Options{Name: "box", Dir: dir, Yes: true} + err := f.Run(t.Context(), "up", opts) + if mode != "success" { + if err == nil || !strings.HasPrefix(err.Error(), "box is up, but its one-time setup values are still on Railway and in "+dir+": ") || !strings.Contains(err.Error(), "codeaf devbox up box --dir '"+dir+"' again to finish, or codeaf devbox down box --dir '"+dir+"' to remove it.") { + t.Fatalf("R5.4/R5.7 clear failure: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "terraform.tfstate")); err != nil { + t.Fatal("R5.4 lost state") + } + f.Pair = func(context.Context, string, func(Offer)) error { t.Fatal("R5.4 rerun re-paired"); return nil } + f.Prepare = func(context.Context, string) (Offer, error) { + t.Fatal("R5.4 rerun regenerated key") + return Offer{}, nil + } + if err := f.Run(t.Context(), "up", opts); err != nil { + t.Fatalf("R5.4 rerun: %v", err) + } + } else if err != nil { + t.Fatal(err) + } + wantGuards, wantClears := 1, 1 + if mode != "success" { + wantGuards = 2 + } + if mode == "apply-fails" || mode == "apply-wrote-then-failed" { + wantClears = 2 + } + if guards != wantGuards || clears != wantClears { + t.Fatalf("R5.4 clear guard/apply count: %d/%d, want %d/%d", guards, clears, wantGuards, wantClears) + } + secrets, err := bootstrapSecrets(dir) + if err != nil || len(secrets) != 0 { + t.Fatalf("R5.4 state still carries setup values: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "terraform.tfstate.backup")); !os.IsNotExist(err) { + t.Fatalf("R5.4 secret backup remains: %v", err) + } + for _, secret := range []string{"fixture-token", "fixture-remote-key", "fixture-remote-devices", "42-123-456"} { + if strings.Contains(out.String(), secret) { + t.Fatalf("R5.4 leaked %s", secret) + } + } + before := clears + if err := f.Run(t.Context(), "up", opts); err == nil || !strings.Contains(err.Error(), "already up") { + t.Fatalf("R5.4 clean rerun: %v", err) + } + if clears != before { + t.Fatal("R5.4 clean rerun applied again") + } + }) + } +} + +// Contract 3: a box created from a laptop that was a fleet of one receives that +// laptop's saved keys, because up sends them once the box answers and before it +// says the box is up. A failed send is said once and does not undo a working box. +func TestDevboxUpSendsThisComputersSavedKeysOnceTheBoxAnswers(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "sent", true: "failed"}[fail], func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var answered atomic.Bool + f.Probe = func(context.Context, string) bool { answered.Store(true); return true } + f.Pair = func(_ context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example"}) + return nil + } + var shares atomic.Int32 + f.ShareState = func(context.Context) error { + shares.Add(1) + if !answered.Load() || strings.Contains(out.String(), "box is up") { + t.Error("the keys were sent before the box answered, or after up said it was up") + } + if fail { + return errors.New("relay unreachable.") + } + return nil + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + if shares.Load() != 1 || !strings.Contains(out.String(), "box is up") { + t.Fatalf("keys sent %d times; %s", shares.Load(), &out) + } + warning := "Could not send this computer's saved keys yet: relay unreachable. Open codeaf here once, or a chat on box can stop on a missing key." + if strings.Contains(out.String(), warning) != fail { + t.Fatalf("the one-line warning appeared = %v, want %v: %s", !fail, fail, &out) + } + }) + } +} diff --git a/internal/devbox/receipts_test.go b/internal/devbox/receipts_test.go new file mode 100644 index 0000000000..8807528b22 --- /dev/null +++ b/internal/devbox/receipts_test.go @@ -0,0 +1,61 @@ +package devbox + +import ( + "bytes" + "context" + "io" + "path/filepath" + "strings" + "testing" +) + +// R5.7: every recovery receipt preserves the actual folder, including shell-significant names. +func TestDevboxR57RecoveryReceiptsKeepTheFolder(t *testing.T) { + for _, mode := range []string{"default", "plain", "spaces", "apostrophe", "tilde"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + var out bytes.Buffer + f := fixtureFlow(t, "", &out) + dir := filepath.Join(f.Home, "devbox", "box") + suffix := "" + switch mode { + case "plain": + dir = filepath.Join(root, "custom") + suffix = " --dir " + dir + case "spaces": + dir = filepath.Join(root, "custom folder") + suffix = " --dir '" + dir + "'" + case "apostrophe": + dir = filepath.Join(root, "owner's $box") + suffix = " --dir '" + root + "/owner'\"'\"'s $box'" + case "tilde": + dir = "~/box" + suffix = " --dir '~/box'" + } + f.Pair = func(context.Context, string, func(Offer)) error { + t.Fatal("R5.7 missing outputs started pairing") + return nil + } + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if args[0] == "output" { + _, err := io.WriteString(stdout, `{}`) + return err + } + return RunTerraform(ctx, path, dir, args, env, stdout, stderr) + } + opts := Options{Name: "box", Dir: dir, Yes: true} + err := f.Run(t.Context(), "up", opts) + if err == nil || !strings.Contains(err.Error(), "`codeaf devbox down box"+suffix+"`") { + t.Fatalf("R5.7 pairing receipt: %v", err) + } + err = f.Run(t.Context(), "up", opts) + if err == nil || !strings.Contains(err.Error(), "`codeaf devbox status box"+suffix+"`") { + t.Fatalf("R5.7 existing receipt: %v", err) + } + if mode == "default" && strings.Contains(err.Error(), "--dir") { + t.Fatal("R5.7 default unnecessarily changes command") + } + }) + } +} diff --git a/internal/devbox/terraform.go b/internal/devbox/terraform.go new file mode 100644 index 0000000000..e083bc2089 --- /dev/null +++ b/internal/devbox/terraform.go @@ -0,0 +1,231 @@ +package devbox + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +const DefaultImage = "buildpack-deps:bookworm-scm" + +//go:embed terraform/railway/*.tf terraform/boot.sh +var module embed.FS + +func Channel(channel string) (installURL, binary string, err error) { + switch channel { + case "stable": + binary = "codeaf" + case "staging": + binary = "stageaf" + case "dev": + binary = "devaf" + default: + return "", "", fmt.Errorf("channel must be stable, staging or dev") + } + return "https://agentfield.ai/get/" + binary, binary, nil +} + +// WriteFolder replaces only the files shipped by codeaf, preserving local state and provider downloads. +func WriteFolder(dir string, variables map[string]string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + for _, name := range []string{"main.tf", "variables.tf", "outputs.tf", "boot.sh"} { + path := "terraform/railway/" + name + if name == "boot.sh" { + path = "terraform/boot.sh" + } + raw, err := module.ReadFile(path) + if err != nil { + return err + } + if name == "main.tf" && variables["region"] != "" { + raw = []byte(strings.Replace(string(raw), "regions = null", "regions = [{ region = var.region, num_replicas = 1 }]", 1)) + } + if err := os.WriteFile(filepath.Join(dir, name), raw, 0o600); err != nil { + return err + } + } + raw, err := json.MarshalIndent(variables, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "codeaf.auto.tfvars.json"), append(raw, '\n'), 0o600) +} + +func RunTerraform(ctx context.Context, path, dir string, args, extraEnv []string, stdout, stderr io.Writer) error { + cmd := exec.CommandContext(ctx, path, args...) + cmd.Dir = dir + cmd.Stdout = stdout + cmd.Stderr = stderr + // Disable Terraform debug logs and inherited argument injection: plans carry the remote key and single-use code. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if key == "RAILWAY_TOKEN" || key == "RAILWAY_API_TOKEN" || key == "TF_VAR_pair_code" || key == "TF_VAR_remote_key" || key == "TF_VAR_remote_devices" || strings.HasPrefix(key, "TF_LOG") || strings.HasPrefix(key, "TF_CLI_ARGS") { + continue + } + cmd.Env = append(cmd.Env, entry) + } + cmd.Env = append(cmd.Env, extraEnv...) + return cmd.Run() +} + +// redactor withholds enough trailing bytes to cover secrets split across writes. +type redactor struct { + out io.Writer + secrets []string + pending string + keep int +} + +func newRedactor(out io.Writer, secrets ...string) *redactor { + r := &redactor{out: out} + for _, secret := range secrets { + if secret != "" { + r.secrets = append(r.secrets, secret) + r.keep = max(r.keep, len(secret)-1) + } + } + return r +} +func redact(s string, secrets ...string) string { + for _, secret := range secrets { + if secret != "" { + s = strings.ReplaceAll(s, secret, "[redacted]") + } + } + return s +} +func (r *redactor) Write(p []byte) (int, error) { + r.pending += string(p) + n := len(r.pending) - r.keep + if n > 0 { + // Move the cut before any secret spanning it. + for _, s := range r.secrets { + start := strings.LastIndex(r.pending[:min(len(r.pending), n+len(s)-1)], s) + if start >= 0 && start < n && start+len(s) > n { + n = start + } + } + if n > 0 { + if _, err := io.WriteString(r.out, redact(r.pending[:n], r.secrets...)); err != nil { + return 0, err + } + r.pending = r.pending[n:] + } + } + return len(p), nil +} +func (r *redactor) Flush() { + _, _ = io.WriteString(r.out, redact(r.pending, r.secrets...)) + r.pending = "" +} + +// Every command goes through the same output flags and state permissions, including failed writes. +func (f Flow) runTerraform(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if !f.StdoutTerminal { + args = append([]string{args[0], "-no-color"}, args[1:]...) + } + runErr := f.RunTerraform(ctx, path, dir, args, env, stdout, stderr) + var stateErr error + switch args[0] { + case "init", "plan", "apply", "destroy": + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + if err := os.Chmod(filepath.Join(dir, name), 0o600); err != nil && !os.IsNotExist(err) { + stateErr = errors.Join(stateErr, fmt.Errorf("keep Terraform state private: %w", err)) + } + } + } + return errors.Join(runErr, stateErr) +} + +func (f Flow) applyPlan(ctx context.Context, terraform, dir string, env, secrets []string, guarded bool, beforeApply func() error) error { + plan, err := os.CreateTemp(dir, ".codeaf-plan-*") + if err != nil { + return err + } + defer os.Remove(plan.Name()) + if err := plan.Close(); err != nil { + return err + } + name := filepath.Base(plan.Name()) + if err := f.terraform(ctx, terraform, dir, []string{"plan", "-input=false", "-out=" + name}, env, secrets...); err != nil { + return err + } + if guarded { + if err := f.guardPlan(ctx, terraform, dir, name, env); err != nil { + return err + } + } + if err := beforeApply(); err != nil { + return err + } + return f.terraform(ctx, terraform, dir, []string{"apply", "-input=false", name}, env, secrets...) +} + +func (f Flow) guardPlan(ctx context.Context, terraform, dir, plan string, env []string) error { + // Show exposes sensitive values in plain text. Neither stream nor its errors may leave memory. + var raw bytes.Buffer + if err := f.runTerraform(ctx, terraform, dir, []string{"show", "-json", plan}, env, &raw, io.Discard); err != nil { + return errors.New("could not inspect the pairing plan") + } + var planJSON struct { + Resources []struct { + Address string `json:"address"` + Change struct { + Actions []string `json:"actions"` + } `json:"change"` + } `json:"resource_changes"` + } + if err := json.Unmarshal(raw.Bytes(), &planJSON); err != nil { + return errors.New("could not read the pairing plan") + } + var refused []string + for _, resource := range planJSON.Resources { + if len(resource.Change.Actions) == 1 && resource.Change.Actions[0] == "no-op" { + continue + } + if resource.Address != "railway_variable_collection.box" { + refused = append(refused, strconv.Quote(resource.Address)) + } + } + if len(refused) != 0 { + return fmt.Errorf("refusing a pairing plan that changes %s", strings.Join(refused, ", ")) + } + return nil +} + +func (f Flow) outputs(ctx context.Context, terraform, dir string, env []string, secrets ...string) (map[string]string, error) { + var raw bytes.Buffer + errs := newRedactor(f.Err, secrets...) + err := f.runTerraform(ctx, terraform, dir, []string{"output", "-json"}, env, &raw, errs) + errs.Flush() + if err != nil { + return nil, fmt.Errorf("terraform output failed: %s", redact(err.Error(), secrets...)) + } + var values map[string]struct { + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(raw.Bytes(), &values); err != nil { + return nil, errors.New("could not read Terraform outputs") + } + result := make(map[string]string, len(values)) + for key, output := range values { + var value string + _ = json.Unmarshal(output.Value, &value) + result[key] = value + } + return result, nil +} diff --git a/internal/devbox/terraform/aws/main.tf b/internal/devbox/terraform/aws/main.tf new file mode 100644 index 0000000000..3c73997443 --- /dev/null +++ b/internal/devbox/terraform/aws/main.tf @@ -0,0 +1,120 @@ +# This module has never been applied. The devbox command does not offer AWS. +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.region +} + +data "aws_ami" "debian" { + most_recent = true + owners = ["136693071363"] + filter { + name = "name" + values = ["debian-12-amd64-*"] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } + filter { + name = "architecture" + values = ["x86_64"] + } +} + +data "aws_subnet" "box" { + id = var.subnet_id +} + +resource "aws_security_group" "box" { + name_prefix = "codeaf-${var.name}-" + vpc_id = data.aws_subnet.box.vpc_id + ingress = [] + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } +} + +resource "aws_ebs_volume" "home" { + availability_zone = data.aws_subnet.box.availability_zone + size = var.volume_size + type = "gp3" + encrypted = true + tags = { Name = "codeaf-${var.name}-home" } +} + +resource "aws_volume_attachment" "home" { + device_name = "/dev/sdf" + volume_id = aws_ebs_volume.home.id + instance_id = aws_instance.box.id +} + +resource "aws_instance" "box" { + ami = data.aws_ami.debian.id + instance_type = var.instance_type + subnet_id = var.subnet_id + vpc_security_group_ids = [aws_security_group.box.id] + tags = { Name = "codeaf-${var.name}" } + user_data = <<-USERDATA + #!/bin/bash + set -euo pipefail + apt-get update + apt-get install -y bash curl ca-certificates tar git + # Nitro exposes EBS disks by volume id; attachment can finish after boot. + volume_id='${replace(aws_ebs_volume.home.id, "-", "")}' + device="" + for attempt in $(seq 1 180); do + for candidate in /dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_$volume_id /dev/sdf /dev/xvdf; do + if [ -b "$candidate" ]; then device="$candidate"; break; fi + done + [ -n "$device" ] && break + sleep 2 + done + [ -n "$device" ] + if ! blkid "$device"; then mkfs.ext4 "$device"; fi + mkdir -p /data + uuid=$(blkid -s UUID -o value "$device") + printf 'UUID=%s /data ext4 defaults,nofail 0 2\n' "$uuid" >> /etc/fstab + mount /data + printf %s '${base64encode(file("${path.module}/../boot.sh"))}' | base64 -d > /usr/local/lib/codeaf-boot.sh + printf %s '${base64encode(join("\n", [ + "CODEAF_DEVBOX_NAME=${var.name}", + "CODEAF_DEVBOX_INSTALL_URL=${var.install_url}", + "CODEAF_DEVBOX_BINARY=${var.binary}", + "CODEAF_DEVBOX_VIA=${var.via}", + "CODEAF_DEVBOX_CODE=${var.pair_code}", + "CODEAF_DEVBOX_REMOTE_KEY=${var.remote_key}", + "CODEAF_DEVBOX_REMOTE_DEVICES=${var.remote_devices}", +]))}' | base64 -d > /etc/codeaf-devbox.env + chmod 600 /etc/codeaf-devbox.env + cat > /etc/systemd/system/codeaf-devbox.service <<'UNIT' + [Unit] + Description=codeaf devbox + Wants=network-online.target + After=network-online.target + RequiresMountsFor=/data + [Service] + Type=simple + EnvironmentFile=/etc/codeaf-devbox.env + ExecStart=/bin/bash /usr/local/lib/codeaf-boot.sh + Restart=always + RestartSec=10 + [Install] + WantedBy=multi-user.target + UNIT + systemctl daemon-reload + systemctl enable --now codeaf-devbox.service + USERDATA +} diff --git a/internal/devbox/terraform/aws/outputs.tf b/internal/devbox/terraform/aws/outputs.tf new file mode 100644 index 0000000000..ed6cbf92b4 --- /dev/null +++ b/internal/devbox/terraform/aws/outputs.tf @@ -0,0 +1,12 @@ +output "instance_id" { + value = aws_instance.box.id +} +output "name" { + value = var.name +} +output "region" { + value = var.region +} +output "volume_id" { + value = aws_ebs_volume.home.id +} diff --git a/internal/devbox/terraform/aws/variables.tf b/internal/devbox/terraform/aws/variables.tf new file mode 100644 index 0000000000..5088aecc64 --- /dev/null +++ b/internal/devbox/terraform/aws/variables.tf @@ -0,0 +1,45 @@ +variable "name" { + type = string +} +variable "region" { + type = string + default = "us-east-1" +} +variable "subnet_id" { + type = string + description = "An existing subnet with outbound internet access." +} +variable "instance_type" { + type = string + default = "t3.small" +} +variable "volume_size" { + type = number + default = 20 +} +variable "install_url" { + type = string + default = "https://agentfield.ai/get/codeaf" +} +variable "binary" { + type = string + default = "codeaf" +} +variable "via" { + type = string +} +variable "pair_code" { + type = string + sensitive = true + default = "" +} +variable "remote_key" { + type = string + sensitive = true + default = "" +} +variable "remote_devices" { + type = string + sensitive = true + default = "" +} diff --git a/internal/devbox/terraform/boot.sh b/internal/devbox/terraform/boot.sh new file mode 100644 index 0000000000..841e5e7a9d --- /dev/null +++ b/internal/devbox/terraform/boot.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The override lets the boot contract run against an isolated disposable volume. +data_root="${CODEAF_DEVBOX_DATA_ROOT:-/data}" +export HOME="$data_root/home" +export CODEAF_HOME="$HOME/.codeaf" +export CODEAF_INSTALL_DIR="$CODEAF_HOME/bin" +export CODEAF_NO_MODIFY_PATH=1 CODEAF_NO_START=1 +export PATH="$CODEAF_INSTALL_DIR:$PATH" +mkdir -p "$HOME" +umask 077 + +say() { printf 'codeaf devbox: %s\n' "$*"; } +marker="$data_root/.codeaf-devbox-joined" +if [[ ! -f "$marker" && -z "${CODEAF_DEVBOX_CODE:-}" ]]; then + say 'waiting for a pairing code from codeaf devbox up' + exec sleep infinity +fi +export CODEAF_INSTALL_NAME="$CODEAF_DEVBOX_BINARY" +install_codeaf() ( + # The release pin is only for the first install; refreshes follow the channel. + unset VERSION + if [[ ! -x "$CODEAF_INSTALL_DIR/$CODEAF_DEVBOX_BINARY" && -n "${CODEAF_DEVBOX_VERSION:-}" ]]; then + if curl -fsSL "$CODEAF_DEVBOX_INSTALL_URL" | VERSION="$CODEAF_DEVBOX_VERSION" bash; then + return 0 + fi + say 'pinned installation failed; trying the newest channel build' + fi + curl -fsSL "$CODEAF_DEVBOX_INSTALL_URL" | bash +) +say 'installing codeaf' +if ! install_codeaf; then + if [[ ! -x "$CODEAF_INSTALL_DIR/$CODEAF_DEVBOX_BINARY" ]]; then + say 'installation failed and no earlier binary is available' + sleep 60 + exit 1 + fi + say 'installation failed; using the earlier binary' +fi + +# Each release channel syncs through its own relay, and a pairing saves none when +# the creating laptop uses its own build's default. Pin the box to the laptop's +# relay the way a pairing that named one would, so a box whose installed build +# defaults elsewhere still shares the laptop's keys and devices. A relay saved +# later by another pairing is left alone. +if [[ -n "${CODEAF_DEVBOX_VIA:-}" && ! -e "$CODEAF_HOME/sync-url" ]]; then + mkdir -p "$CODEAF_HOME" + printf '%s\n' "$CODEAF_DEVBOX_VIA" > "$CODEAF_HOME/sync-url" +fi +if [[ ! -f "$marker" ]]; then + if ! "$CODEAF_DEVBOX_BINARY" pair "$CODEAF_DEVBOX_CODE" --name "$CODEAF_DEVBOX_NAME" --via "$CODEAF_DEVBOX_VIA" "$temporary"; then + rm -f "$temporary" + return 1 + fi + chmod 600 "$temporary" + mv "$temporary" "$remote_dir/$name" +} +seed_remote device.key "${CODEAF_DEVBOX_REMOTE_KEY:-}" +seed_remote devices.json "${CODEAF_DEVBOX_REMOTE_DEVICES:-}" +unset CODEAF_DEVBOX_CODE CODEAF_DEVBOX_REMOTE_KEY CODEAF_DEVBOX_REMOTE_DEVICES +mkdir -p "$HOME/work" +say 'serving the work folder' +# A box only serves, so it asks serve to keep the identity's saved keys, +# settings and presence in step; builds that predate the variable ignore it. +export CODEAF_SERVE_SYNC=1 +# `--take-tasks` joins this line once serve ships it; a build without it would refuse the flag. +exec "$CODEAF_DEVBOX_BINARY" serve --workspace "$HOME/work" --via "$CODEAF_DEVBOX_VIA" diff --git a/internal/devbox/terraform/railway/main.tf b/internal/devbox/terraform/railway/main.tf new file mode 100644 index 0000000000..81f68aa2bf --- /dev/null +++ b/internal/devbox/terraform/railway/main.tf @@ -0,0 +1,50 @@ +terraform { + required_version = ">= 1.5" + required_providers { + railway = { + source = "terraform-community-providers/railway" + version = "~> 0.6" + } + } + backend "local" {} +} + +provider "railway" {} + +resource "railway_project" "box" { + name = "codeaf-${var.name}" + private = true + workspace_id = var.workspace_id == "" ? null : var.workspace_id +} + +resource "railway_service" "box" { + name = "box" + project_id = railway_project.box.id + source_image = var.image + restart_policy_type = "ALWAYS" + serverless = false + # The folder writer adds an explicit region only when requested. + # Provider 0.6.3 cannot validate an unknown conditional regions list. + regions = null + volume = { + name = "home" + mount_path = "/data" + } + start_command = "bash -c 'printf %s \"$CODEAF_DEVBOX_BOOT\" | base64 -d > /tmp/codeaf-boot.sh && exec bash /tmp/codeaf-boot.sh'" +} + +resource "railway_variable_collection" "box" { + environment_id = railway_project.box.default_environment.id + service_id = railway_service.box.id + variables = [for variable in [ + { name = "CODEAF_DEVBOX_BOOT", value = filebase64("${path.module}/boot.sh") }, + { name = "CODEAF_DEVBOX_INSTALL_URL", value = var.install_url }, + { name = "CODEAF_DEVBOX_BINARY", value = var.binary }, + { name = "CODEAF_DEVBOX_VERSION", value = var.install_version }, + { name = "CODEAF_DEVBOX_NAME", value = var.name }, + { name = "CODEAF_DEVBOX_VIA", value = var.via }, + { name = "CODEAF_DEVBOX_CODE", value = var.pair_code }, + { name = "CODEAF_DEVBOX_REMOTE_KEY", value = var.remote_key }, + { name = "CODEAF_DEVBOX_REMOTE_DEVICES", value = var.remote_devices }, + ] : variable if variable.value != ""] +} diff --git a/internal/devbox/terraform/railway/outputs.tf b/internal/devbox/terraform/railway/outputs.tf new file mode 100644 index 0000000000..377b05c4b1 --- /dev/null +++ b/internal/devbox/terraform/railway/outputs.tf @@ -0,0 +1,18 @@ +output "project_id" { + value = railway_project.box.id +} +output "project_name" { + value = railway_project.box.name +} +output "service_id" { + value = railway_service.box.id +} +output "environment_id" { + value = railway_project.box.default_environment.id +} +output "region" { + value = try(railway_service.box.regions[0].region, "") +} +output "name" { + value = var.name +} diff --git a/internal/devbox/terraform/railway/variables.tf b/internal/devbox/terraform/railway/variables.tf new file mode 100644 index 0000000000..1df2ba7679 --- /dev/null +++ b/internal/devbox/terraform/railway/variables.tf @@ -0,0 +1,49 @@ +variable "name" { + type = string + description = "The name your devices use for this computer." +} +variable "workspace_id" { + type = string + default = "" +} +variable "region" { + type = string + default = "" +} +variable "image" { + type = string + default = "buildpack-deps:bookworm-scm" +} +variable "install_url" { + type = string + default = "https://agentfield.ai/get/codeaf" +} +variable "binary" { + type = string + default = "codeaf" +} +variable "install_version" { + type = string + default = "" + description = "The creating computer's release tag, used only for the first install." +} +variable "via" { + default = "" + type = string + description = "The relay used for both identity pairing and serving." +} +variable "pair_code" { + type = string + sensitive = true + default = "" +} +variable "remote_key" { + type = string + sensitive = true + default = "" +} +variable "remote_devices" { + type = string + sensitive = true + default = "" +} diff --git a/internal/devbox/workspaces.go b/internal/devbox/workspaces.go new file mode 100644 index 0000000000..3be0f07c26 --- /dev/null +++ b/internal/devbox/workspaces.go @@ -0,0 +1,107 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +const railwayGraphQLURL = "https://backboard.railway.com/graphql/v2" + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` +} + +var workspaceID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +func resolveWorkspace(ctx context.Context, token, chosen string, lookup func(context.Context, string) ([]Workspace, error)) (string, error) { + if workspaceID.MatchString(chosen) { + return chosen, nil + } + workspaces, err := lookup(ctx, token) + if err != nil { + return "", err + } + if len(workspaces) == 0 { + if chosen != "" { + return "", errors.New("this token cannot look up workspace names; use --workspace with its id") + } + return "", nil + } + if chosen != "" { + for _, w := range workspaces { + if strings.EqualFold(w.Name, chosen) { + return w.ID, nil + } + } + return "", fmt.Errorf("no Railway workspace is called %q; use --workspace with a listed name or id", chosen) + } + if len(workspaces) == 1 { + return workspaces[0].ID, nil + } + names := make([]string, len(workspaces)) + for i, w := range workspaces { + names[i] = w.Name + } + return "", fmt.Errorf("choose a Railway workspace with --workspace : %s", strings.Join(names, ", ")) +} + +func LookupWorkspaces(ctx context.Context, token string) ([]Workspace, error) { + return lookupWorkspaces(ctx, &http.Client{Timeout: 20 * time.Second}, token) +} + +// The client seam lets tests answer the public GraphQL request without a network. +func lookupWorkspaces(ctx context.Context, client *http.Client, token string) ([]Workspace, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, railwayGraphQLURL, bytes.NewBufferString(`{"query":"{ me { workspaces { id name } } }"}`)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, errors.New("could not look up Railway workspaces; check the connection and token") + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Railway workspace lookup failed (%d)", resp.StatusCode) + } + var body struct { + Data struct { + Me *struct { + Workspaces []Workspace `json:"workspaces"` + } `json:"me"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + Path []string `json:"path"` + } `json:"errors"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return nil, errors.New("could not read Railway workspaces") + } + if len(body.Errors) > 0 { + for _, e := range body.Errors { + text := strings.ToLower(e.Message) + if strings.Contains(text, "unauthor") || strings.Contains(text, "forbidden") || strings.Contains(text, "not authorized") || strings.Contains(text, "not authenticated") { + return nil, nil + } + } + return nil, errors.New("Railway could not list workspaces; use --workspace with its id") + } + if body.Data.Me == nil { + return nil, nil + } + return body.Data.Me.Workspaces, nil +} diff --git a/internal/devbox/workspaces_test.go b/internal/devbox/workspaces_test.go new file mode 100644 index 0000000000..fd02c9241e --- /dev/null +++ b/internal/devbox/workspaces_test.go @@ -0,0 +1,50 @@ +package devbox + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// Contract 2: workspace resolution uses only the public query, with no live account. +func TestDevboxWorkspaceLookupAndSelection(t *testing.T) { + client := &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(r.Body) + if r.Header.Get("Authorization") != "Bearer fixture-token" || string(body) != `{"query":"{ me { workspaces { id name } } }"}` { + t.Fatalf("request: %s %s", r.Header, body) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"data":{"me":{"workspaces":[{"id":"one","name":"Personal"},{"id":"two","name":"Team"}]}}}`))}, nil + })} + lookup := func(ctx context.Context, token string) ([]Workspace, error) { + return lookupWorkspaces(ctx, client, token) + } + if _, err := resolveWorkspace(context.Background(), "fixture-token", "", lookup); err == nil || !strings.Contains(err.Error(), "Personal, Team") || !strings.Contains(err.Error(), "--workspace") { + t.Fatalf("ambiguity: %v", err) + } + if id, err := resolveWorkspace(context.Background(), "fixture-token", "Team", lookup); err != nil || id != "two" { + t.Fatalf("named: %s %v", id, err) + } + id := "11111111-2222-3333-4444-555555555555" + if got, err := resolveWorkspace(context.Background(), "fixture-token", id, func(context.Context, string) ([]Workspace, error) { t.Fatal("id looked up"); return nil, nil }); err != nil || got != id { + t.Fatalf("id: %s %v", got, err) + } +} + +// Contract 2: a workspace-scoped token may create without looking up me. +func TestDevboxWorkspaceTokenCanOmitWorkspace(t *testing.T) { + for _, response := range []string{`{"errors":[{"message":"Not Authorized","path":["me"]}]}`, `{"data":{"me":null}}`} { + client := &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response))}, nil + })} + got, err := lookupWorkspaces(context.Background(), client, "fixture-token") + if err != nil || len(got) != 0 { + t.Fatalf("workspace token: %v %v", got, err) + } + } +} 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 new file mode 100644 index 0000000000..93d715430b --- /dev/null +++ b/internal/manual/chat/a-dev-box-on-your-own-railway.md @@ -0,0 +1,198 @@ +# A dev box on your own Railway account + +## Can I run codeaf on my own server or an always-on computer in the cloud? + +Yes. `codeaf devbox up ` creates an always-on computer on **your own Railway +account** and pairs it into your identity. You pay Railway for its compute and +persistent volume. Your laptop can sleep while the box keeps running. + +The box holds your whole identity, including access to chats, memory and saved provider +keys, just like any paired computer. The pairing code joins only that identity: +**one code never gives both** chats and remote access. Terraform separately writes +the box's remote key and admits the laptop that creates it in the box's +configuration. `codeaf chat --at ` on that laptop opens a chat there without +another prompt. `/devices` and Settings → Devices list the chosen name. The box +dials out through the same relay as the laptop; it opens no public port or domain. +Restarts preserve its remote key and any later admissions or revocations. + +For a server you already own, install codeaf there, pair it with `codeaf pair`, +and run `CODEAF_SERVE_SYNC=1 codeaf serve` so it receives your saved keys, or use +`codeaf chat --host ` when ssh works. + +## Set up a dev box with codeaf devbox up + +Install Terraform from https://developer.hashicorp.com/terraform/install. Create +a Railway token at https://railway.com/account/tokens and set `RAILWAY_TOKEN`. +`RAILWAY_API_TOKEN` is also accepted when `RAILWAY_TOKEN` is empty. No token or no +Terraform means nothing is created. + +Run `codeaf devbox up build-box`. Names use lowercase letters, digits and hyphens, +start and end with a letter or digit, and are at most 32 characters. An existing +device name is refused. An existing project is left alone; if its box is reachable +and setup values remain, up finishes removing those values without pairing again. + +The command shows a Terraform plan and asks once: +`Create these on your Railway account? [y/N]`. After approval, codeaf creates the +box without a pairing code, waits up to three minutes for Railway to finish +starting it, then sends a one-time pairing code. It checks deployments every +three seconds; if they have not settled by the limit, it says so and proceeds. +Only the service variables may change when sending the code; a plan changing +other resources is refused without applying it or asking you again. + +Allow a few minutes to install and join. If Railway swaps the deployment while it joins, or +the code expires, codeaf sends a fresh code, up to three codes in total, keeping the same remote key and admission list. Up +probes the pinned key every five seconds while waiting, including when a join +acknowledgement was lost. After acknowledgement it waits at most three minutes +for reachability. It says ` is up` only after a successful connection and +removal of the one-time setup values. Then open `codeaf chat --at build-box`. + +## Dev box up flags for folder, region, workspace and channel + +- `--dir `: keep the Terraform in this folder. +- `--yes`: skip confirmation; required when stdin is not a terminal. +- `--region `: choose a region; empty lets Railway choose. +- `--workspace `: choose a Railway workspace. With several workspaces, + name one; a workspace token that cannot list names can use an id or omit it. +- `--channel stable|staging|dev`: follow that channel. The default follows the + running binary; a source build uses `dev`, and a release candidate uses stable. + Each channel syncs through its own relay by default, so the box is pinned to + the relay of the laptop that created it, whichever channel it installs. + +## How saved provider keys, settings and shared memories reach the box + +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` +syncs nothing, as before. `CODEAF_SYNC_INTERVAL_MS` changes that +interval in milliseconds. `codeaf devbox up` sends the +creating computer's saved keys and settings once the box answers, so the first +chat there has them; if that fails it says `Could not send this computer's saved +keys yet` and opening codeaf on that computer sends them. Keys saved on another +computer reach the identity once that computer has opened codeaf. +Keys held only in environment variables are not saved or shared. + +Serving begins while the first round is still running. A `codeaf chat --at ` +started before that round finishes can report a missing provider key. Wait a +moment and open the chat again. + +## Where the Terraform and the always-on computer's identity live + +By default the Terraform is in `/devbox//`. `CODEAF_HOME` moves +that home. `--dir ` selects another folder for up, status or down. You own +the folder: read or edit the module, or run Terraform there yourself with +`RAILWAY_TOKEN` set. Keep using that same folder for later status and down. + +There is one private project `codeaf-`, one service `box`, and one volume +`home` mounted at `/data`. The service runs continuously with one replica. The +volume keeps the identity, installed binary and work folder across restarts and +redeploys. A successful first join writes a marker, so it never reuses that code. +Before its first join, a boot without a code waits without downloading. A failed +join waits for a new code from up. The first install uses the same release as the +computer that ran up when both use the same stable, staging or dev channel, so +it does not depend on GitHub's API limit. If that install fails, it tries the +channel's newest build once in the same boot. Source builds, release candidates +and a different `--channel` install the channel's newest build directly. Later +boots refresh to the channel's newest build and keep the earlier volume copy if +that fails. With no earlier binary, a failed install waits a minute before +exiting so Railway can retry. + +## One-time setup values, Terraform state and private files + +Non-secret choices are in `codeaf.auto.tfvars.json`. The Railway token is passed +only through the Terraform process environment. The pairing code and base64 +remote files are passed only as `TF_VAR_pair_code`, `TF_VAR_remote_key` and `TF_VAR_remote_devices`, never +written to that variables file. The saved plan is deleted on completion or +failure. Once the pinned remote key answers, codeaf says +`Removing the one-time setup values from Railway.` and applies a plan that can +change only the service variables. It asks no second confirmation. This removes +the spent pairing code, the box's remote key and its admission list from Railway +and the folder's current state, then deletes `terraform.tfstate.backup` containing +the previous state. The box restarts and keeps using its seeded volume files. + +Until cleanup succeeds, **state and backups hold those secrets**. Keep the folder +private (0700); state and backups are tightened to 0600 after Terraform writes. +A hand-run apply after joining needs no setup values. +When created with `--region`, change `region` in the variables file to move it. +Without `--region`, the module leaves placement to Railway; to pick one later, +edit the service's `regions` setting in `main.tf`. + +## Check whether my dev box is up and when it was last seen + +`codeaf devbox status ` reads the project's Terraform outputs and your device +list; it needs Terraform but no Railway token. `codeaf devbox status` checks every +folder under the default devbox directory. Both accept `--dir ` and +`--yes` (status has no confirmation). + +The status shows the name, region, project and last seen when available. On the +creating laptop it first opens and immediately closes a remote connection with +the box's pinned key, with a three-second timeout. A successful probe means `up` +and last seen now, including with released boxes that hold no directory watch. +It logs a short connection on the box and may start its engine process, without +opening a chat or making a provider call. + +If that connection fails or this laptop has no alias, status falls back to +directory online presence, then `up (seen recently)` for a record seen within +five minutes when presence is unavailable. Missing readings are omitted. Empty +state says `: not up`. A `codeaf serve` started with `CODEAF_SERVE_SYNC=1`, +as the box's is, holds one shared directory watch and refreshes its name and +capabilities every two minutes when home sync is eligible. Quiet homes, sync off, unavailable cells and pending rotation +publish nothing. A changed identity, device or relay ends that presence worker. + +## Opening the dev box from another computer + +Only the laptop that ran `codeaf devbox up` is admitted for remote access. Pairing +another computer into your chats does not admit it or copy the creating laptop's +local name alias. With the box's key-derived name, a new computer is asked for a +remote pairing code; even a correct code is refused by headless `serve` because +there is no terminal to approve it. It does not wait for a yes/no answer there. +An unadmitted key with a saved pin is also refused. There is no unattended command +to admit another computer yet; use the creating laptop. + +## How do I remove my dev box or stop paying for it? + +Run `codeaf devbox down ` with the Railway token set. It asks +`Destroy and everything in project codeaf-? [y/N]`. `--yes` skips the +question; `--dir ` selects the Terraform folder used to create it. + +Down destroys the project, service and volume, including the box's work and +identity, then removes its device from your identity. If destruction fails, it +keeps the device. The Terraform folder stays on your laptop; a later up can use +it again. Removing a device in Settings alone does not destroy the paid cloud +resources. + +Down selects the saved joined device id from `codeaf.devbox.json`, so renaming +it does not prevent removal. Without that id it selects the active device by +name, ignoring removed records. If none matches, the receipt says no device was +removed. Reusing the name after down works. + +## Dev box pairing, reachability and setup cleanup failures + +` did not join: ...` means the offer ended without a successful connection. +The box remains on your Railway account. Run the printed `codeaf devbox down +` command, then up again. A lost acknowledgement can leave it running; +this receipt does not claim it is idle. Nothing is destroyed automatically. + +` took its pairing code but is not reachable yet: ...` means the offer +succeeded but its pinned key did not answer within the wait. It remains on your +Railway account. Run the printed `codeaf devbox status ` command to check, +or `codeaf devbox down ` to remove it. + +` is up, but its one-time setup values are still on Railway and in +: ...` means reachability was proved but cleanup did not finish. Secrets +may remain in Railway, state or its backup. Run the printed `codeaf devbox up +` command again to clear them without pairing, or down to remove the box. +When cleanup is already complete, up keeps saying it is already up. + +Recovery commands include `--dir ` for a non-default folder, quoted when +needed. Keep that flag when copying a command. + +## How much does a dev box cost? Does codeaf offer AWS? + +You pay Railway directly for the computer's compute and persistent storage, +according to your Railway plan and usage. codeaf does not set a fixed price or +include that computer in a subscription. Provider calls also spend your own +provider keys as usual. `codeaf devbox down ` removes the paid resources; +closing your laptop or ending a remote chat keeps the computer running. + +AWS: a Terraform module exists in codeaf's source but has never been applied, and +`codeaf devbox` does not offer it yet. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index 423adf9173..780853ae64 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -1,5 +1,7 @@ # Reaching a machine with a pairing code +For an always-on computer on your own Railway account, see [A dev box](a-dev-box-on-your-own-railway.md) and `codeaf devbox up `. + ## Two codes, two grants — remote access and your chats You learn one thing: **a code shown on a screen you own, typed on the other computer.** A code can grant two different things. They stay two things, because they are two different powers. @@ -44,6 +46,8 @@ way, use that; the two doors are compared at the bottom of this page. ## codeaf serve, and the name a machine gets +`codeaf serve` keeps the identity's saved keys and settings in step the way an open terminal does only when it is started with `CODEAF_SERVE_SYNC=1`, which a dev box sets. Without it, serve syncs nothing while it serves, as it always has. + Run it on the machine that owns the work — the one with the files, the API key and the things you have set up: @@ -58,9 +62,10 @@ It prints, exactly: let a device use this machine with code 715 302 (valid 10 minutes) ``` -**The name is not chosen, it is derived from that machine's own key.** Two words and two -digits, the same every time, on any relay. You cannot pick a nicer one, and nobody else -can register under yours while your machine is connected. +**The relay name is derived from that machine's own key.** Two words and two +digits, the same every time, on any relay. Nobody else can register under yours +while your machine is connected. A dev box also has the chosen alias saved on +the laptop that created it; `codeaf devices` shows that alias for `--at`. `--workspace` is the directory a connection works in when it does not name one; empty means the directory you ran the command in. For `codeaf serve`, the relay is, in order: diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index 969c73b1fd..a2ab016a89 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -645,7 +645,7 @@ the same error is counted against itself. There is no command or panel for it, it is not on the Memories tab, and the noticing setting does not turn it off: the one line on a failed row is the whole of what it says. -## Does /remember work on my other computer — hand edited memories follow my paired computers, memory missing on the new laptop +## 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 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. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 8789e7587a..27dd24a62b 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -34,6 +34,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { page string }{ {"why didn't my task's changes show up in my folder", "how-tasks-run"}, + {"can I run codeaf on my own server", "a-dev-box-on-your-own-railway"}, + {"set up a dev box", "a-dev-box-on-your-own-railway"}, + {"always-on computer in the cloud", "a-dev-box-on-your-own-railway"}, + {"how do I remove my dev box", "a-dev-box-on-your-own-railway"}, + {"how much does a dev box cost", "a-dev-box-on-your-own-railway"}, // R6/R7/R8/R15/R30 keep leadership, fallback and worker limits reachable. {"can my phone lead", "coordination-and-workers"}, {"can my phone be the leader", "coordination-and-workers"}, diff --git a/internal/manual/devbox_test.go b/internal/manual/devbox_test.go new file mode 100644 index 0000000000..b00903237c --- /dev/null +++ b/internal/manual/devbox_test.go @@ -0,0 +1,31 @@ +package manual + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/packed" +) + +// Contract 7: the shipped compression path preserves answers to dev box questions. +func TestDevboxPackedManualAnswersTheSameQuestions(t *testing.T) { + archive, err := packed.Pack("chat") + if err != nil { + t.Fatal(err) + } + if err := packed.Verify(archive, "chat"); err != nil { + t.Fatal(err) + } + corpus := newCorpus(packed.New(archive), "chat/*.md") + const page = "a-dev-box-on-your-own-railway" + for _, question := range []string{"can I run codeaf on my own server", "set up a dev box", "always-on computer in the cloud", "how do I remove my dev box", "how much does a dev box cost"} { + found := false + for _, section := range corpus.Search(question, DefaultResults) { + if section.Page == page { + found = true + } + } + if !found { + t.Errorf("packed lookup for %q misses %s", question, page) + } + } +} diff --git a/internal/pair/book.go b/internal/pair/book.go index 01f90198bb..84724ea821 100644 --- a/internal/pair/book.go +++ b/internal/pair/book.go @@ -31,8 +31,10 @@ import ( // Known is one machine this device has paired with, as the surface remembers it. type Known struct { - // Name is what a person types after --at. + // Name is the key-derived name registered with the relay. Name string `json:"name"` + // Alias is a chosen dev box name pinned by its Terraform configuration. + Alias string `json:"alias,omitempty"` // Key is the machine's long-term public key, base64 raw-url. THIS IS THE // PINNED THING: a connection is to this key, and the name is only how the // relay finds it. @@ -92,7 +94,7 @@ func (b *Book) Machine(name string) (Known, bool, error) { return Known{}, false, err } for _, one := range known { - if one.Name == name { + if one.Name == name || (one.Alias != "" && one.Alias == name) { return one, true, nil } } @@ -110,7 +112,7 @@ func (b *Book) Remember(one Known) error { return b.update(func(known []Known) []Known { kept := known[:0] for _, existing := range known { - if existing.Name != one.Name { + if existing.Name != one.Name && (one.Alias == "" || existing.Alias != one.Alias) { kept = append(kept, existing) } } diff --git a/internal/pair/devbox.go b/internal/pair/devbox.go new file mode 100644 index 0000000000..4f49ab545d --- /dev/null +++ b/internal/pair/devbox.go @@ -0,0 +1,64 @@ +package pair + +import ( + "encoding/base64" + "os" + "path/filepath" + "time" +) + +// DevboxRemote prepares released serve's key and admissions as Terraform configuration. +// Running the existing writers in a private temporary folder keeps their formats identical. +func DevboxRemote(name, service string, client Device, label string) (key, devices []byte, known Known, err error) { + dir, err := os.MkdirTemp("", "codeaf-devbox-remote-*") + if err != nil { + return nil, nil, Known{}, err + } + defer os.RemoveAll(dir) + keeper := FileKeeper{Path: filepath.Join(dir, "device.key")} + machine, err := ThisDevice(keeper) + if err != nil { + return nil, nil, Known{}, err + } + now := time.Now() + bookPath := filepath.Join(dir, "devices.json") + if err := BookAt(bookPath).Admit(Paired{Label: label, Key: base64.RawURLEncoding.EncodeToString(client.Public()), Since: now}); err != nil { + return nil, nil, Known{}, err + } + key, err = os.ReadFile(keeper.Path) + if err != nil { + return nil, nil, Known{}, err + } + devices, err = os.ReadFile(bookPath) + if err != nil { + return nil, nil, Known{}, err + } + return key, devices, Known{Name: machine.Name(), Alias: name, Key: base64.RawURLEncoding.EncodeToString(machine.Public()), Service: service, Since: now}, nil +} + +// ForgetAlias drops the creating laptop's pin after its dev box is destroyed. +func (b *Book) ForgetAlias(name string) error { + known, err := b.Machines() + if err != nil { + return err + } + found := false + for _, one := range known { + if one.Alias == name { + found = true + break + } + } + if !found { + return nil + } + return b.update(func(known []Known) []Known { + kept := known[:0] + for _, one := range known { + if one.Alias != name { + kept = append(kept, one) + } + } + return kept + }) +} diff --git a/internal/pair/devbox_alias_test.go b/internal/pair/devbox_alias_test.go new file mode 100644 index 0000000000..d5340f4240 --- /dev/null +++ b/internal/pair/devbox_alias_test.go @@ -0,0 +1,216 @@ +package pair + +import ( + "context" + "encoding/base64" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/relay" +) + +// Contract 3: --at resolves the chosen alias to a pinned key through a real relay. +func TestDevboxAliasOpensItsPinnedRemoteComputer(t *testing.T) { + service := devboxTestRelay(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := ThisDevice(FileKeeper{Path: filepath.Join(t.TempDir(), "client.key")}) + if err != nil { + t.Fatal(err) + } + key, devices, known, err := DevboxRemote("build-box", service, client, "laptop") + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + dir := filepath.Join(home, "v3", "remote") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + for name, raw := range map[string][]byte{"device.key": key, "devices.json": devices} { + if err := os.WriteFile(filepath.Join(dir, name), raw, 0o600); err != nil { + t.Fatal(err) + } + } + machine, err := ThisDevice(FileKeeper{Path: filepath.Join(home, "v3", "remote", "device.key")}) + if err != nil { + t.Fatal(err) + } + known.Service = service + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(known); err != nil { + t.Fatal(err) + } + reachable := make(chan struct{}, 1) + desk := &Desk{} + host := &Host{Service: service, Device: machine, Devices: BookAt(filepath.Join(home, "v3", "remote", "devices.json")), Desk: desk, Say: func(string) { + select { + case reachable <- struct{}{}: + default: + } + }, Open: func(conn io.ReadWriteCloser) { defer conn.Close(); _, _ = io.WriteString(conn, "hello from the box") }} + ended := make(chan error, 1) + go func() { ended <- host.Run(ctx) }() + defer func() { + cancel() + select { + case err := <-ended: + if err != nil { + t.Error(err) + } + case <-time.After(time.Second): + t.Error("host did not stop") + } + }() + select { + case <-reachable: + case <-ctx.Done(): + t.Fatal("host never registered") + } + tunnel, err := (Reach{Name: "build-box", Device: client, Machines: book, Label: "laptop"}).Open(ctx) + if err != nil { + t.Fatal(err) + } + defer tunnel.Close() + message, err := io.ReadAll(tunnel) + if err != nil || string(message) != "hello from the box" { + t.Fatalf("remote message %q %v", message, err) + } + stranger := aDevice(t) + // Knowing the host's key does not admit a different client key. + if conn, err := (Reach{Name: "build-box", Device: stranger, Machines: book, Label: "other"}).Open(ctx); err == nil { + conn.Close() + t.Fatal("unknown client was admitted") + } + code, err := desk.Offer() + if err != nil { + t.Fatal(err) + } + otherBook := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if conn, err := (Reach{Name: machine.Name(), Sync: service, Device: stranger, Machines: otherBook, Label: "other", AskCode: func(string) (string, error) { return code.Shown(), nil }}).Open(ctx); err == nil { + conn.Close() + t.Fatal("headless host admitted a new client") + } else if !strings.Contains(err.Error(), "was refused there") { + t.Fatalf("headless admission: %v", err) + } +} + +func devboxTestRelay(t *testing.T, handlers ...http.Handler) string { + t.Helper() + var listener net.Listener + for port := 8900; port <= 8999; port++ { + opened, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err == nil { + listener = opened + break + } + } + if listener == nil { + t.Fatal("no allowed test relay port is free") + } + var handler http.Handler = &relay.Server{} + if len(handlers) > 0 { + handler = handlers[0] + } + server := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second} + t.Cleanup(func() { _ = server.Close() }) + go func() { _ = server.Serve(listener) }() + return "http://" + listener.Addr().String() + +} + +// Round 2: the status probe's short context also bounds an established but silent handshake. +func TestDevboxAliasProbeHonorsCancellationDuringHandshake(t *testing.T) { + service := devboxTestRelay(t) + machine, client := aDevice(t), aDevice(t) + registration, err := relay.Register(context.Background(), service, machine.Private()) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(Known{Name: machine.Name(), Alias: "box", Service: service, Key: base64.RawURLEncoding.EncodeToString(machine.Public())}); err != nil { + t.Fatal(err) + } + accepted := make(chan struct{}) + go func() { + conn, err := registration.Accept() + if err == nil { + defer conn.Close() + var intent [1]byte + if _, err := io.ReadFull(conn, intent[:]); err != nil { + return + } + if _, err := readRecord(conn); err != nil { + return + } + close(accepted) + io.Copy(io.Discard, conn) + } + }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + tunnel, err := (Reach{Name: "box", Device: client, Machines: book}).Open(ctx) + if tunnel != nil { + tunnel.Close() + } + done <- err + }() + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("probe never dialed") + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("silent machine answered") + } + case <-time.After(time.Second): + t.Fatal("probe ignored cancellation") + } +} + +// Round 2: a legacy relay's raw HTTP upgrade must obey the same short probe deadline. +func TestDevboxAliasProbeBoundsLegacyRelayUpgrade(t *testing.T) { + service := devboxTestRelay(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + w.WriteHeader(http.StatusUpgradeRequired) + return + } + <-r.Context().Done() + })) + machine, client := aDevice(t), aDevice(t) + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(Known{Name: machine.Name(), Alias: "box", Service: service, Key: base64.RawURLEncoding.EncodeToString(machine.Public())}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { + tunnel, err := (Reach{Name: "box", Device: client, Machines: book}).Open(ctx) + if tunnel != nil { + tunnel.Close() + } + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("silent relay answered") + } + case <-time.After(time.Second): + t.Fatal("legacy relay ignored probe deadline") + } +} diff --git a/internal/pair/devbox_test.go b/internal/pair/devbox_test.go new file mode 100644 index 0000000000..690122da7f --- /dev/null +++ b/internal/pair/devbox_test.go @@ -0,0 +1,54 @@ +package pair + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +// Round 2: Terraform receives the exact files released serve loads, independently of identity pairing. +func TestDevboxRemoteFilesLoadWithServeReaders(t *testing.T) { + client, err := ThisDevice(FileKeeper{Path: filepath.Join(t.TempDir(), "client.key")}) + if err != nil { + t.Fatal(err) + } + key, devices, known, err := DevboxRemote("build-box", "https://relay.example", client, "laptop") + if err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + if err := os.MkdirAll(Dir(), 0o700); err != nil { + t.Fatal(err) + } + for path, raw := range map[string][]byte{DeviceKeyPath(): key, filepath.Join(Dir(), "devices.json"): devices} { + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + } + machine, err := ThisDevice(OpenKeeper()) + if err != nil { + t.Fatal(err) + } + if machine.Name() != known.Name || base64.RawURLEncoding.EncodeToString(machine.Public()) != known.Key || known.Alias != "build-box" || known.Service != "https://relay.example" { + t.Fatalf("wrong pin: %+v", known) + } + allowed, ok, err := DeviceBook().Allows(client.Public()) + if err != nil || !ok || allowed.Label != "laptop" { + t.Fatalf("admission: %+v %v %v", allowed, ok, err) + } + if raw, err := os.ReadFile(DeviceKeyPath()); err != nil || !bytes.Equal(raw, key) { + t.Fatal("serve regenerated its key", err) + } + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(known); err != nil { + t.Fatal(err) + } + if err := book.ForgetAlias("build-box"); err != nil { + t.Fatal(err) + } + if _, found, err := book.Machine("build-box"); err != nil || found { + t.Fatalf("destroyed alias retained: %v %v", found, err) + } +} diff --git a/internal/pair/dial.go b/internal/pair/dial.go index 304b897dd8..72970af632 100644 --- a/internal/pair/dial.go +++ b/internal/pair/dial.go @@ -98,13 +98,16 @@ func (r Reach) say(line string) { // ways this fails that a person cannot tell apart by looking, and errors.go // keeps one sentence for each. func (r Reach) Open(ctx context.Context) (*Tunnel, error) { - if !relay.ValidName(r.Name) { - return nil, fmt.Errorf("%q is not the shape of a machine name — they look like otter-lamp-42, and `codeaf serve` prints the one for a machine", r.Name) - } known, found, err := r.Machines.Machine(r.Name) if err != nil { return nil, err } + if found && known.Alias == r.Name { + r.Name = known.Name + } + if !relay.ValidName(r.Name) { + return nil, fmt.Errorf("%q is not the shape of a machine name — they look like otter-lamp-42, and `codeaf serve` prints the one for a machine", r.Name) + } service := RelayFor(known, found, r.Sync) if strings.TrimSpace(service) == "" { return nil, NoRelay(r.Name) @@ -130,10 +133,20 @@ func (r Reach) Open(ctx context.Context) (*Tunnel, error) { if err != nil { return nil, err } - withDeadline(stream, r.now().Add(HandshakeWithin)) + // A status probe may cancel after dialing; the stream otherwise outlives its dial context. + stopClosing := context.AfterFunc(ctx, func() { _ = stream.Close() }) + defer stopClosing() + deadline := r.now().Add(HandshakeWithin) + if until, ok := ctx.Deadline(); ok && until.Before(deadline) { + deadline = until + } + withDeadline(stream, deadline) tunnel, err := connectAsSurface(stream, machineKey, r.Device, r.Label) if err != nil { _ = stream.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } if errors.Is(err, ErrNotThatMachine) { return nil, NotThatMachine(r.Name) } diff --git a/internal/pair/list.go b/internal/pair/list.go index bea93ea09b..d6e7821f73 100644 --- a/internal/pair/list.go +++ b/internal/pair/list.go @@ -14,6 +14,7 @@ package pair // only ever draw a table that has rows. import ( + "cmp" "encoding/base64" "errors" "fmt" @@ -76,10 +77,10 @@ func MachinesList(known []Known, now time.Time) string { out.WriteString("\nmachines this device can reach\n\n") widest := 0 for _, one := range known { - widest = max(widest, width(one.Name)) + widest = max(widest, width(cmp.Or(one.Alias, one.Name))) } for _, one := range known { - fmt.Fprintf(&out, " %s paired %s\n", padded(one.Name, widest), since(one.Since, now)) + fmt.Fprintf(&out, " %s paired %s\n", padded(cmp.Or(one.Alias, one.Name), widest), since(one.Since, now)) } out.WriteString("\nopen one with `codeaf chat --at `.\n") return out.String() diff --git a/internal/relay/client.go b/internal/relay/client.go index e82eb701b4..b13be9c8dd 100644 --- a/internal/relay/client.go +++ b/internal/relay/client.go @@ -186,6 +186,10 @@ func upgradeRaw(ctx context.Context, address *url.URL, base, path string, header if err != nil { return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) } + // A caller can stop a probe while an older relay is silent during HTTP upgrade. + raw := conn + stopClosing := context.AfterFunc(ctx, func() { _ = raw.Close() }) + defer stopClosing() if address.Scheme == "https" { secure := tls.Client(conn, &tls.Config{ServerName: address.Hostname()}) if err := secure.HandshakeContext(ctx); err != nil { @@ -205,7 +209,11 @@ func upgradeRaw(ctx context.Context, address *url.URL, base, path string, header request.Header.Set("Upgrade", Protocol) request.Header.Set("Connection", "Upgrade") - _ = conn.SetDeadline(time.Now().Add(HandshakeWithin)) + deadline := time.Now().Add(HandshakeWithin) + if until, ok := ctx.Deadline(); ok && until.Before(deadline) { + deadline = until + } + _ = conn.SetDeadline(deadline) if err := request.Write(conn); err != nil { _ = conn.Close() return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) diff --git a/internal/syncsetup/relayurl.go b/internal/syncsetup/relayurl.go index e7a39eb8b6..5424aa83ee 100644 --- a/internal/syncsetup/relayurl.go +++ b/internal/syncsetup/relayurl.go @@ -86,12 +86,15 @@ func init() { pair.LinkOrigin = LinkOriginOf(HostedRelayURL) } -// savedURLFile is where a computer keeps the relay another device told it to +// SavedURLFile is where a computer keeps the relay another device told it to // sync through when they were paired. It is a file beside the identity and not a // settings row, because CODEAF_SYNC_URL is deliberately never a row: a persisted // value would point a machine at a relay it was never told about, and this one -// was told, once, by the device the person had just approved. -const savedURLFile = "sync-url" +// was told, once, by the device the person had just approved. It is exported +// because a dev box's boot script writes it before its first join (the laptop +// that created the box told it, through Terraform), and the devbox package +// holds that script to this one spelling. +const SavedURLFile = "sync-url" // offWord is the value of CODEAF_SYNC_URL that turns sync off. const offWord = "off" @@ -160,7 +163,7 @@ func fromVariable(string) (Relay, bool) { } func fromSavedFile(home string) (Relay, bool) { - raw, err := os.ReadFile(filepath.Join(home, savedURLFile)) + raw, err := os.ReadFile(filepath.Join(home, SavedURLFile)) saved := strings.TrimSpace(string(raw)) return Relay{URL: saved}, err == nil && saved != "" } @@ -175,5 +178,5 @@ func SaveRelayURL(home, url string) error { if err := os.MkdirAll(home, 0o700); err != nil { return err } - return os.WriteFile(filepath.Join(home, savedURLFile), []byte(url+"\n"), 0o600) + return os.WriteFile(filepath.Join(home, SavedURLFile), []byte(url+"\n"), 0o600) } diff --git a/internal/syncsetup/serve_state_test.go b/internal/syncsetup/serve_state_test.go new file mode 100644 index 0000000000..34917c78b3 --- /dev/null +++ b/internal/syncsetup/serve_state_test.go @@ -0,0 +1,289 @@ +package syncsetup + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// R4.1 exercises the serving lifetime over the existing authenticated fake +// relay, including a key saved by another computer after serving began. +func TestR41ServeHomeStateReceivesProviderKeysAtStartAndOnInterval(t *testing.T) { + for _, atStart := range []bool{true, false} { + t.Run(map[bool]string{true: "startup", false: "interval"}[atStart], func(t *testing.T) { + server := relay(t) + var cluster atomic.Int64 + var directoryRequests atomic.Int64 + next := server.Config.Handler + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/cluster/") { + cluster.Add(1) + } + if strings.HasPrefix(r.URL.Path, "/v1/dir/") { + directoryRequests.Add(1) + } + next.ServeHTTP(w, r) + }) + aHome := machine(t, server.URL) + t.Setenv(config.ProfileDirEnv, "") + a, ok, err := Open(aHome) + if err != nil || !ok { + t.Fatalf("open source: %v", err) + } + bHome := t.TempDir() + mustNil(t, identity.Save(bHome, a.Identity)) + if atStart { + t.Setenv(IntervalVar, "3600000") + mustNil(t, config.WriteAPIKey(aHome, fakeKeyOne)) + mustNil(t, a.SyncState(t.Context())) + } else { + t.Setenv(IntervalVar, "25") + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + errors := make(chan error, 16) + go func() { + defer close(done) + RunHomeState(ctx, bHome, func(err error) { errors <- err }) + }() + t.Cleanup(func() { cancel(); <-done }) + if !atStart { + waitServeState(t, func() bool { return directoryRequests.Load() > 0 }) + mustNil(t, config.WriteAPIKey(aHome, fakeKeyTwo)) + // B owns stateMu during that first request. A's SyncState cannot + // publish this key until B's startup round releases it, so only + // a later interval round can receive the key on B. + mustNil(t, a.SyncState(t.Context())) + } + want := fakeKeyTwo + if atStart { + want = fakeKeyOne + } + waitServeState(t, func() bool { return config.PersistedAPIKey(bHome) == want }) + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("R4.3: serving home-state goroutine did not join") + } + select { + case err := <-errors: + t.Fatalf("home-state round: %v", err) + default: + } + if cluster.Load() != 0 { + t.Fatal("R4.3: serve started a cluster member") + } + }) + } +} + +func TestR42ServeHomeStateIneligibleHomesStaySilent(t *testing.T) { + t.Setenv(config.ProfileDirEnv, "") + for _, reason := range []string{"no identity", "cells unavailable", "quiet"} { + t.Run(reason, func(t *testing.T) { + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer server.Close() + home := machine(t, server.URL) + switch reason { + case "no identity": + mustNil(t, os.RemoveAll(home)) + mustNil(t, os.MkdirAll(home, 0o700)) + case "quiet": + mustNil(t, os.RemoveAll(home)) + _, err := identity.EnsureSolo(home) + mustNil(t, err) + case "cells unavailable": + t.Cleanup(cell.UseAvailabilityInTests(false)) + } + before, err := os.ReadDir(home) + mustNil(t, err) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + RunHomeState(ctx, home, func(err error) { t.Errorf("ineligible home reported: %v", err) }) + if ctx.Err() != nil { + t.Fatal("ineligible home kept its loop alive") + } + if requests.Load() != 0 { + t.Fatal("ineligible home contacted the relay") + } + entries, err := os.ReadDir(home) + mustNil(t, err) + if len(entries) != len(before) { + t.Fatal("ineligible home created state") + } + for i, entry := range entries { + if entry.Name() != before[i].Name() { + t.Errorf("ineligible home created %s", entry.Name()) + } + } + }) + } +} + +func TestR42ServeHomeStateRevokedDeviceStopsQuietly(t *testing.T) { + t.Setenv(config.ProfileDirEnv, "") + for _, known := range []bool{false, true} { + t.Run(map[bool]string{false: "relay refusal", true: "saved removal"}[known], func(t *testing.T) { + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"err":"revoked"}`)) + })) + defer server.Close() + home := machine(t, server.URL) + if known { + s, _, err := Open(home) + mustNil(t, err) + s.rememberRemoved() + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + RunHomeState(ctx, home, func(err error) { t.Errorf("revoked home reported: %v", err) }) + if ctx.Err() != nil { + t.Fatal("revoked device kept its loop alive") + } + if got := requests.Load(); (known && got != 0) || (!known && got != 1) { + t.Fatalf("removal requests = %d", got) + } + }) + } +} + +func TestR42ServeHomeStateBecomesQuietDuringRound(t *testing.T) { + t.Setenv(config.ProfileDirEnv, "") + started, release := make(chan struct{}, 1), make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case started <- struct{}{}: + default: + } + select { + case <-release: + http.Error(w, "relay retry fixture", http.StatusServiceUnavailable) + case <-r.Context().Done(): + } + })) + defer server.Close() + home := machine(t, server.URL) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + var reports []error + go func() { + defer close(done) + RunHomeState(ctx, home, func(err error) { reports = append(reports, err) }) + }() + defer func() { cancel(); <-done }() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("home-state round did not start") + } + // Leave the request alive while removing its identity. The response must + // end the loop silently even when the relay also reports a failure. + mustNil(t, os.Remove(filepath.Join(home, identity.File))) + close(release) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("quiet home did not stop its in-flight round") + } + if len(reports) != 0 { + t.Fatalf("quiet home reported %v", reports) + } +} + +func TestR41R42ServeHomeStateRetriesDistinctFailuresAndReopensIdentity(t *testing.T) { + server := relay(t) + t.Setenv(config.ProfileDirEnv, "") + other := machine(t, server.URL) + source, _, err := Open(other) + mustNil(t, err) + mustNil(t, config.WriteAPIKey(other, fakeKeyTwo)) + mustNil(t, source.SyncState(t.Context())) + home := machine(t, server.URL) + t.Setenv(IntervalVar, "10") + var status atomic.Int64 + status.Store(http.StatusServiceUnavailable) + var requests atomic.Int64 + next := server.Config.Handler + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if code := int(status.Load()); code != 0 { + http.Error(w, "relay retry fixture", code) + return + } + next.ServeHTTP(w, r) + }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + var reports []string + go func() { + defer close(done) + RunHomeState(ctx, home, func(err error) { reports = append(reports, err.Error()) }) + }() + t.Cleanup(func() { cancel(); <-done }) + for _, code := range []int{http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusServiceUnavailable} { + before := requests.Load() + status.Store(int64(code)) + waitServeState(t, func() bool { return requests.Load() >= before+3 }) + } + _, err = identity.Adopt(home, source.Identity, true) + mustNil(t, err) + status.Store(0) + waitServeState(t, func() bool { return config.PersistedAPIKey(home) == fakeKeyTwo }) + // Removing the joined identity while serve remains open ends its worker. + mustNil(t, os.Remove(filepath.Join(home, identity.File))) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("R4.2: removing the identity did not stop the loop") + } + if len(reports) != 2 || reports[0] == reports[1] { + t.Fatalf("expected each of two distinct failures once, got %v", reports) + } +} + +func waitServeState(t *testing.T, ready func() bool) { + t.Helper() + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + for !ready() { + select { + case <-deadline.C: + t.Fatal("serving state did not arrive") + case <-tick.C: + } + } +} + +// Stability: a serve nobody asked to keep state in step behaves exactly as it +// always has. Only the value 1 opts in; unset, empty and anything else do not. +func TestServeKeepsStateOnlyWhenADevBoxAsks(t *testing.T) { + t.Setenv(env.Legacy(ServeStateVar), "") + for value, want := range map[string]bool{"": false, "0": false, "true": false, "on": false, " 1 ": true, "1": true} { + t.Setenv(ServeStateVar, value) + if got := ServeKeepsState(); got != want { + t.Errorf("%s=%q keeps state = %v, want %v", ServeStateVar, value, got, want) + } + } +} diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index dd6eb0043f..4b8947c9b0 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -2,9 +2,11 @@ package syncsetup import ( "context" + "errors" "sync" "time" + "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/vaultsync" @@ -12,6 +14,58 @@ import ( var stateMu sync.Mutex +// RunHomeState keeps a serving computer's saved keys, preferences and shared +// memories current without starting a cluster member. The caller owns and joins +// 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)) { + interval, err := flushInterval() + if err != nil { + interval = DefaultInterval + } + ticks := time.NewTicker(interval) + defer ticks.Stop() + var current *Sync + reported := make(map[string]bool) + for ctx.Err() == nil { + if cell.UnavailableReason() != "" || Quiet(home) { + return + } + next, ok, err := Open(home) + if errors.Is(err, ErrNoIdentity) || (err == nil && !ok) { + return + } + if err == nil { + if current == nil || next.Identity.ID() != current.Identity.ID() || next.Device.ID() != current.Device.ID() || next.Relay != current.Relay { + current = next + } + if current.isRemoved() { + return + } + err = current.SyncState(ctx) + } + // A pairing can be removed while a request is in flight. Its quiet + // refusal ends this lifetime without turning it into a sync warning. + if Quiet(home) || errors.Is(err, ErrQuiet) { + return + } + if errors.Is(err, directory.ErrRevoked) || (current != nil && current.isRemoved()) { + return + } + if err != nil && ctx.Err() == nil && !reported[err.Error()] { + reported[err.Error()] = true + if report != nil { + report(err) + } + } + select { + case <-ctx.Done(): + return + case <-ticks.C: + } + } +} + // SyncState completes one encrypted home-state round even when no chat is // open. The quiet gate is checked before any capture or request (C30.4). func (s *Sync) SyncState(ctx context.Context) error { diff --git a/internal/syncsetup/syncsetup.go b/internal/syncsetup/syncsetup.go index 7e3931fbe7..258240b0f7 100644 --- a/internal/syncsetup/syncsetup.go +++ b/internal/syncsetup/syncsetup.go @@ -40,6 +40,12 @@ const ( URLVar = "CODEAF_SYNC_URL" // IntervalVar is how often unsaved turns are flushed, in milliseconds. IntervalVar = "CODEAF_SYNC_INTERVAL_MS" + // ServeStateVar opts `codeaf serve` into keeping this computer's saved keys, + // settings, shared memories and directory presence in step while it serves, + // the way an open terminal does. A dev box's boot script sets it to 1. + // UNSET, SERVE BEHAVES EXACTLY AS IT ALWAYS HAS: it syncs nothing and + // publishes no presence, so nobody who never asked sees a change. + ServeStateVar = "CODEAF_SERVE_SYNC" // DefaultInterval is the flush interval when IntervalVar is unset. DefaultInterval = 5 * time.Second // requestTimeout bounds one directory request so a silent relay cannot hang @@ -47,6 +53,10 @@ const ( requestTimeout = 30 * time.Second ) +// ServeKeepsState reports whether ServeStateVar asks `codeaf serve` to keep +// home state and presence in step; only the value 1 does. +func ServeKeepsState() bool { return strings.TrimSpace(env.Get(ServeStateVar)) == "1" } + // ErrNoIdentity is what a machine says when a relay is set but it has no // identity to sign with. Its words are the list's own, so every surface agrees. var ErrNoIdentity = errors.New(chatlist.NoIdentity)