From 586178e0244336bc2775bc40644fe734f1860371 Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Fri, 9 Oct 2026 19:21:34 +0200 Subject: [PATCH 1/5] GO-7383 Pin anytype-heart to go-7383-full-wrapper (92ed139) --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index b08dbc6..6eadd9f 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/anyproto/anytype-cli go 1.26.5 require ( - github.com/anyproto/any-sync v0.13.5 - github.com/anyproto/anytype-heart v0.51.4 + github.com/anyproto/any-sync v0.13.7 + github.com/anyproto/anytype-heart v0.51.6-nightly.20261008.1.0.20261009162250-92ed13917ebb github.com/cheggaaa/mb/v3 v3.0.3 github.com/chzyer/readline v1.5.1 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 @@ -152,7 +152,7 @@ require ( github.com/kovidgoyal/imaging v1.6.4 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect - github.com/libp2p/go-libp2p v0.49.0 // indirect + github.com/libp2p/go-libp2p v0.50.0 // indirect github.com/libp2p/zeroconf/v2 v2.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6 // indirect @@ -192,7 +192,7 @@ require ( github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.62.0 // indirect + github.com/quic-go/quic-go v0.63.0 // indirect github.com/quic-go/webtransport-go v0.13.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/cors v1.11.1 // indirect diff --git a/go.sum b/go.sum index 4b803f9..4b5c4da 100644 --- a/go.sum +++ b/go.sum @@ -31,10 +31,10 @@ github.com/anyproto/any-block v0.0.0-20260919082013-9906a7c36b68 h1:7EAL+fDiqZs/ github.com/anyproto/any-block v0.0.0-20260919082013-9906a7c36b68/go.mod h1:ctWUBba2Nm+mHwkh4bBej57avQHaW2HDzDZDSlbVchs= github.com/anyproto/any-store v1.0.2 h1:vQvgKKDDmM0j9EheBWGq8bMVU7Oi3061DKJFG8D3sTQ= github.com/anyproto/any-store v1.0.2/go.mod h1:8cqb52gjZSaYnlybugqpSqSG1RQygY96D2vWMbSJsLo= -github.com/anyproto/any-sync v0.13.5 h1:wWDEcdJReNdZh1yuVcUgWA/VsPom7uP59KM25SdqSCU= -github.com/anyproto/any-sync v0.13.5/go.mod h1:c6f4LXwoji9JkJYt14dXuVbPzYBRa3Z85+sBQ4HX5+4= -github.com/anyproto/anytype-heart v0.51.4 h1:pd+oIWZaRKU9e6+NUR3B+td/tSrRKJVXwSMgJlLWY9o= -github.com/anyproto/anytype-heart v0.51.4/go.mod h1:L/IXpU2eOy79+b4rrrVvvvrqJVNQCCOaNLnyb/Sljt4= +github.com/anyproto/any-sync v0.13.7 h1:J5cEGYDSM1bmKPWjqmyPks1GqGz3Tceg9TERUt6twgk= +github.com/anyproto/any-sync v0.13.7/go.mod h1:d56Pxkt2zaK8K9SAEf8KkhBwJx/a6mEGeRY6h717x0w= +github.com/anyproto/anytype-heart v0.51.6-nightly.20261008.1.0.20261009162250-92ed13917ebb h1:bVmRsTMDuagLl7eW/pHJKFkrjKTg2ouS6We2sxGbXnU= +github.com/anyproto/anytype-heart v0.51.6-nightly.20261008.1.0.20261009162250-92ed13917ebb/go.mod h1:jDeXMTEpBDMOwQYhFlMra1VReUZAKS1aXFFcbwf/2lk= github.com/anyproto/anytype-publish-server/publishclient v0.0.0-20260925080338-0fb960f00458 h1:kg/BHaimoLI2suFe4Kq1MfNxFvWYlFfPPkgVehU9+FE= github.com/anyproto/anytype-publish-server/publishclient v0.0.0-20260925080338-0fb960f00458/go.mod h1:lRppnNvn5vAw/ASBOYV6LTYPqEg+XC34AzloxK1E/Ac= github.com/anyproto/anytype-push-server/pushclient v0.0.0-20260407093150-0b14187dfd27 h1:O/n87PsZozTtfIbKOad8lS2safL/A1CuzFNrLbin18M= @@ -542,8 +542,8 @@ github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6 github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-flow-metrics v0.3.0 h1:q31zcHUvHnwDO0SHaukewPYgwOBSxtt830uJtUx6784= github.com/libp2p/go-flow-metrics v0.3.0/go.mod h1:nuhlreIwEguM1IvHAew3ij7A8BMlyHQJ279ao24eZZo= -github.com/libp2p/go-libp2p v0.49.0 h1:ibXuYPIHmMIPShob1BktQvSuFQkq/MemhQOLKfGujjw= -github.com/libp2p/go-libp2p v0.49.0/go.mod h1:lzjVcOBk5fCn1QD2XbSOKLZesB6gEsry8SLjCsAAGT4= +github.com/libp2p/go-libp2p v0.50.0 h1:A0tBP6mr6GV7l5ip2Q04a/IxPqVYYaCiif5s8JuXkN4= +github.com/libp2p/go-libp2p v0.50.0/go.mod h1:RjqB+dxCWNZ33yw0yeK5Pu157oNxKuaTZY6vLed4t8w= github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= github.com/libp2p/go-libp2p-kad-dht v0.42.2 h1:5RtESYeBbjaF+KIPmU47xz2DOb2iLqvoRCZcsjxCS48= @@ -730,8 +730,8 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= -github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A= +github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/quic-go/webtransport-go v0.13.0 h1:RJLrTUHlTj8jJaQlQJUy0z0Mf7u1fVM0I6L1b9pe2M0= github.com/quic-go/webtransport-go v0.13.0/go.mod h1:K83X9YHbAqgSLO6ikS6BXCMdWOvqh9JTHALulvb2JVk= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= From a9c656787c173f652d6c6adea06603b6ece1d1ae Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Fri, 9 Oct 2026 23:45:43 +0200 Subject: [PATCH 2/5] GO-7383 Add resumable JSON automation for native onboarding Add schema-versioned account, service, membership and scoped credential commands for automation. Keep private inputs off argv, persist operation receipts, and verify observed runtime identity and listener state. Document the contract and cover recovery, lifecycle uncertainty and credential handling. --- cmd/machine/machine.go | 189 ++++++ cmd/machine/machine_test.go | 100 +++ cmd/root.go | 33 + cmd/shell/shell.go | 13 + core/auth.go | 6 +- core/automation/account.go | 396 +++++++++++ core/automation/account_test.go | 203 ++++++ core/automation/automation_test.go | 637 ++++++++++++++++++ core/automation/engine.go | 323 +++++++++ core/automation/keys.go | 270 ++++++++ core/automation/runtime_test.go | 100 +++ core/automation/service.go | 374 ++++++++++ core/automation/service_test.go | 196 ++++++ core/automation/spaces.go | 222 ++++++ core/automation/storage.go | 68 ++ core/automation/types.go | 203 ++++++ core/credentials/store.go | 183 +++++ core/credentials/store_test.go | 193 ++++++ core/grpcserver/observation.go | 140 ++++ .../grpcserver/observation_continuity_test.go | 98 +++ core/grpcserver/observation_order.go | 41 ++ core/grpcserver/observation_order_test.go | 436 ++++++++++++ core/grpcserver/observation_test.go | 141 ++++ core/grpcserver/server.go | 37 +- core/keyring.go | 120 +--- core/keyring_test.go | 222 ++---- core/localstate/files_unix.go | 34 + core/localstate/files_windows.go | 109 +++ core/localstate/files_windows_test.go | 34 + core/localstate/state.go | 134 ++++ core/localstate/state_test.go | 37 + core/runtimeinfo/runtime.go | 325 +++++++++ core/runtimeinfo/runtime_test.go | 167 +++++ core/serviceprogram/serviceprogram.go | 45 +- core/serviceprogram/serviceprogram_test.go | 24 +- docs/AUTOMATION.md | 321 +++++++++ 36 files changed, 5888 insertions(+), 286 deletions(-) create mode 100644 cmd/machine/machine.go create mode 100644 cmd/machine/machine_test.go create mode 100644 core/automation/account.go create mode 100644 core/automation/account_test.go create mode 100644 core/automation/automation_test.go create mode 100644 core/automation/engine.go create mode 100644 core/automation/keys.go create mode 100644 core/automation/runtime_test.go create mode 100644 core/automation/service.go create mode 100644 core/automation/service_test.go create mode 100644 core/automation/spaces.go create mode 100644 core/automation/storage.go create mode 100644 core/automation/types.go create mode 100644 core/credentials/store.go create mode 100644 core/credentials/store_test.go create mode 100644 core/grpcserver/observation.go create mode 100644 core/grpcserver/observation_continuity_test.go create mode 100644 core/grpcserver/observation_order.go create mode 100644 core/grpcserver/observation_order_test.go create mode 100644 core/grpcserver/observation_test.go create mode 100644 core/localstate/files_unix.go create mode 100644 core/localstate/files_windows.go create mode 100644 core/localstate/files_windows_test.go create mode 100644 core/localstate/state.go create mode 100644 core/localstate/state_test.go create mode 100644 core/runtimeinfo/runtime.go create mode 100644 core/runtimeinfo/runtime_test.go create mode 100644 docs/AUTOMATION.md diff --git a/cmd/machine/machine.go b/cmd/machine/machine.go new file mode 100644 index 0000000..7a8b38a --- /dev/null +++ b/cmd/machine/machine.go @@ -0,0 +1,189 @@ +// Package machine is an additive parser for the existing command paths. The +// human command implementations are never invoked to perform RPC mutations. +package machine + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "strings" + "time" + + "github.com/anyproto/anytype-cli/core/automation" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/spf13/cobra" +) + +func Requested(args []string) bool { + for _, arg := range args { + if arg == "--" { + break + } + if arg == "--json" || strings.HasPrefix(arg, "--json=") { + return true + } + } + return false +} + +type Runner interface { + Run(context.Context, automation.Request) automation.Envelope +} + +func Execute(ctx context.Context, args []string, in io.Reader, out io.Writer, runner Runner) int { + var request automation.Request + var machineMode, noninteractive, noUpdate bool + var timeout time.Duration + var envelope automation.Envelope + written := false + root := &cobra.Command{Use: "anytype", Short: "Anytype automation (schema version 1)", SilenceErrors: true, SilenceUsage: true} + root.SetArgs(args) + root.SetIn(in) + var discard bytes.Buffer + root.SetOut(&discard) + root.SetErr(&discard) + root.PersistentFlags().BoolVar(&machineMode, "json", false, "Return a versioned JSON envelope; never prompt") + root.PersistentFlags().BoolVar(&noninteractive, "non-interactive", false, "Never prompt (implied by --json)") + root.PersistentFlags().BoolVar(&noUpdate, "no-update-check", true, "No update checks in machine mode") + root.PersistentFlags().StringVar(&request.Instance, "instance", "default", "Native instance (only default is supported)") + root.PersistentFlags().StringVar(&request.ExpectedAccount, "expect-account", "", "Full expected account ID; none guards an empty instance") + root.PersistentFlags().DurationVar(&timeout, "timeout", 45*time.Second, "Whole-command deadline, 1s to 5m") + root.SetHelpFunc(func(cmd *cobra.Command, _ []string) { + envelope = automation.Success(map[string]any{"help": cmd.UsageString()}, false) + written = true + }) + add := func(parent *cobra.Command, use, path string, mutating bool) *cobra.Command { + cmd := &cobra.Command{Use: use, Short: path, Args: cobra.NoArgs} + if mutating { + cmd.Flags().StringVar(&request.OperationId, "operation-id", "", "Durable operation ID; reuse it on retry") + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + request.Command = path + if len(args) == 1 { + switch path { + case "auth create", "auth apikey create": + request.Name = args[0] + case "auth apikey revoke": + request.CredentialId = args[0] + case "space status": + request.SpaceId = args[0] + } + } + if timeout < time.Second || timeout > 5*time.Minute { + envelope = automation.Failure(&automation.Error{Code: "invalid_timeout", Message: "The timeout must be between 1s and 5m.", Exit: 2}) + written = true + return nil + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if request.IncludeSecret && !privateChannel(out) { + envelope = automation.Failure(&automation.Error{Code: "private_output_required", Message: "Capture --include-secret output through a pipe or an owner-only regular file.", Exit: 2}) + written = true + return nil + } + keyStdin, _ := cmd.Flags().GetBool("key-stdin") + inviteStdin, _ := cmd.Flags().GetBool("invite-stdin") + if keyStdin || inviteStdin { + if !privateChannel(in) { + envelope = automation.Failure(&automation.Error{Code: "private_input_required", Message: "Supply stdin through a pipe or an owner-only regular file; machine mode never prompts.", Exit: 2}) + written = true + return nil + } + type inputResult struct { + data []byte + err error + } + ch := make(chan inputResult, 1) + go func() { data, err := io.ReadAll(io.LimitReader(in, 65537)); ch <- inputResult{data, err} }() + select { + case result := <-ch: + if result.err != nil || len(result.data) > 65536 { + envelope = automation.Failure(&automation.Error{Code: "invalid_private_input", Message: "Private input could not be read or exceeded 65536 bytes.", Exit: 2}) + written = true + return nil + } + request.Secret = strings.TrimSpace(string(result.data)) + case <-ctx.Done(): + envelope = automation.Failure(&automation.Error{Code: "private_input_timeout", Message: "Private input timed out.", Exit: 4, Retryable: true}) + written = true + return nil + } + } + envelope = runner.Run(ctx, request) + written = true + return nil + } + parent.AddCommand(cmd) + return cmd + } + add(root, "inspect", "inspect", false) + add(root, "capabilities", "capabilities", false) + service := &cobra.Command{Use: "service", Short: "Inspect and provision the default user service"} + root.AddCommand(service) + add(service, "status", "service status", false) + for _, name := range []string{"install", "start"} { + cmd := add(service, name, "service "+name, true) + cmd.Flags().StringVar(&request.ListenAddress, "listen-address", "", "Explicit API listen address; existing binding is preserved") + } + auth := &cobra.Command{Use: "auth", Short: "Inspect or provision an account"} + root.AddCommand(auth) + add(auth, "status", "auth status", false) + create := add(auth, "create ", "auth create", true) + create.Args = cobra.ExactArgs(1) + login := add(auth, "login", "auth login", true) + login.Flags().Bool("key-stdin", false, "Read the private account key from stdin") + for _, cmd := range []*cobra.Command{create, login} { + cmd.Flags().StringVar(&request.ListenAddress, "listen-address", "", "Explicit API listen address") + cmd.Flags().StringVar(&request.NetworkConfig, "network-config", "", "Network configuration file (preserved for existing accounts)") + cmd.Flags().BoolVar(&request.IncludeSecret, "include-secret", false, "Include recovery credential in private output") + } + rename := add(auth, "rename ", "auth rename", false) + rename.Args = cobra.ExactArgs(1) + keys := &cobra.Command{Use: "apikey", Short: "Manage integration credentials"} + auth.AddCommand(keys) + add(keys, "list", "auth apikey list", false) + keyCreate := add(keys, "create ", "auth apikey create", true) + keyCreate.Args = cobra.ExactArgs(1) + keyCreate.Flags().StringArrayVar(&request.Grant.Spaces, "space", nil, "Full space ID or exact unique name (repeatable)") + keyCreate.Flags().BoolVar(&request.Grant.AllSpaces, "all-spaces", false, "Grant access to all current and future spaces") + keyCreate.Flags().BoolVar(&request.Grant.ReadOnly, "read-only", false, "Read-only permission") + keyCreate.Flags().BoolVar(&request.Grant.ReadWrite, "read-write", false, "Read and write permission") + keyCreate.Flags().BoolVar(&request.IncludeSecret, "include-secret", false, "Return the verified credential secret to the private caller") + revoke := add(keys, "revoke ", "auth apikey revoke", true) + revoke.Args = cobra.ExactArgs(1) + spaces := &cobra.Command{Use: "space", Short: "Native membership; HTTP usability is probed separately"} + root.AddCommand(spaces) + add(spaces, "list", "space list", false) + spaceStatus := add(spaces, "status ", "space status", false) + spaceStatus.Args = cobra.ExactArgs(1) + join := add(spaces, "join", "space join", true) + join.Flags().Bool("invite-stdin", false, "Read a private invitation URL from stdin") + join.Flags().StringVar(&request.SpaceId, "space-id", "", "Require this invitation target (optional)") + if err := root.ExecuteContext(ctx); err != nil { + envelope = automation.Failure(&automation.Error{Code: "invalid_arguments", Message: "Invalid machine command or arguments. Use --json --help for the supported interface; secrets must use stdin.", Exit: 2}) + written = true + } + if !written { + envelope = automation.Success(map[string]any{"help": root.UsageString()}, false) + } + if err := json.NewEncoder(out).Encode(envelope); err != nil { + return 1 + } + return envelope.ExitCode() +} +func privateChannel(v any) bool { + f, ok := v.(*os.File) + if !ok { + return true + } // injected streams are owned by the caller + info, err := f.Stat() + if err != nil { + return false + } + if info.Mode().IsRegular() { + return localstate.CheckPrivateFile(f) == nil + } + return info.Mode()&os.ModeNamedPipe != 0 || info.Mode()&os.ModeSocket != 0 +} diff --git a/cmd/machine/machine_test.go b/cmd/machine/machine_test.go new file mode 100644 index 0000000..853def7 --- /dev/null +++ b/cmd/machine/machine_test.go @@ -0,0 +1,100 @@ +package machine + +import ( + "bytes" + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/anyproto/anytype-cli/core/automation" +) + +type recorder struct { + calls int + request automation.Request +} + +func (r *recorder) Run(_ context.Context, request automation.Request) automation.Envelope { + r.calls++ + r.request = request + return automation.Success(map[string]string{"ok": "yes"}, false) +} +func TestMachineParserPrivateChannels(t *testing.T) { + r := new(recorder) + var out bytes.Buffer + code := Execute(context.Background(), []string{"auth", "login", "--json", "--key-stdin", "--expect-account", "none", "--operation-id", "login-0001"}, strings.NewReader("private-key\n"), &out, r) + if code != 0 || r.calls != 1 || r.request.Secret != "private-key" { + t.Fatal(code, r) + } + if strings.Contains(out.String(), "private-key") { + t.Fatal("private stdin leaked") + } + var envelope map[string]any + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatal("non-JSON stdout", err) + } +} +func TestParseErrorsNeverEchoArguments(t *testing.T) { + for _, args := range [][]string{{"auth", "login", "private-key", "--json"}, {"space", "join", "https://invite#private-key", "--json"}, {"--json", "--private-key=secret"}, {"unknown-private-key", "--json"}, {"auth", "create", "--json"}} { + r := new(recorder) + var out bytes.Buffer + code := Execute(context.Background(), args, strings.NewReader(""), &out, r) + if code != 2 || r.calls != 0 { + t.Fatalf("parse %v: %d", args, code) + } + if strings.Contains(out.String(), "private-key") { + t.Fatal("argument echoed") + } + var envelope automation.Envelope + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + } +} +func TestMachineHelpIsSingleEnvelope(t *testing.T) { + r := new(recorder) + var out bytes.Buffer + if code := Execute(context.Background(), []string{"auth", "apikey", "create", "--json", "--help"}, strings.NewReader(""), &out, r); code != 0 { + t.Fatal(code) + } + var envelope automation.Envelope + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatal(err, out.String()) + } + if r.calls != 0 || envelope.State != "ready" { + t.Fatal(envelope) + } +} +func TestSecretInputLimit(t *testing.T) { + r := new(recorder) + var out bytes.Buffer + code := Execute(context.Background(), []string{"auth", "login", "--json", "--key-stdin"}, strings.NewReader(strings.Repeat("x", 65537)), &out, r) + if code != 2 || r.calls != 0 { + t.Fatal(code) + } +} +func TestRequestedStopsAtDelimiter(t *testing.T) { + if Requested([]string{"auth", "create", "--", "--json"}) { + t.Fatal("name selected machine parser") + } + if !Requested([]string{"--json=true", "inspect"}) { + t.Fatal("json not recognized") + } +} +func TestSecretOutputRejectsPublicFile(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err = os.Chmod(file.Name(), 0644); err != nil { + t.Fatal(err) + } + r := new(recorder) + code := Execute(context.Background(), []string{"auth", "create", "Agent", "--json", "--include-secret"}, strings.NewReader(""), file, r) + if code != 2 || r.calls != 0 { + t.Fatal("public secret output accepted") + } +} diff --git a/cmd/root.go b/cmd/root.go index 260c247..889b56a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,14 +2,19 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "strings" + "github.com/anyproto/anytype-cli/cmd/machine" "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/automation" + "github.com/anyproto/anytype-cli/core/localstate" "github.com/anyproto/anytype-cli/core/output" "github.com/anyproto/anytype-cli/core/updatecheck" "github.com/spf13/cobra" + "strconv" "github.com/anyproto/anytype-cli/cmd/auth" "github.com/anyproto/anytype-cli/cmd/config" @@ -51,10 +56,37 @@ var ( ) func Execute() { + if machine.Requested(os.Args[1:]) { + engine, closeEngine, err := automation.New() + if err != nil { + _ = json.NewEncoder(os.Stdout).Encode(automation.Failure(err)) + os.Exit(1) + } + code := machine.Execute(context.Background(), os.Args[1:], os.Stdin, os.Stdout, engine) + closeEngine() + if code != 0 { + os.Exit(code) + } + return + } + var unlock func() + if localstate.HumanMutation(os.Args[1:]) && os.Getenv("ANYTYPE_CLI_LOCK_PARENT_PID") != strconv.Itoa(os.Getppid()) { + var err error + unlock, err = localstate.Lock(localstate.Dir()) + if err != nil { + fmt.Fprintln(os.Stderr, "Another CLI operation is in progress or instance coordination is unavailable.") + os.Exit(1) + } + defer unlock() + } + rootCmd.SilenceErrors = true rootCmd.SilenceUsage = true if err := rootCmd.Execute(); err != nil { + if unlock != nil { + unlock() + } fmt.Fprintf(os.Stderr, "✗ %v\n", err) os.Exit(1) } @@ -63,6 +95,7 @@ func Execute() { func init() { rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "Show version information") rootCmd.Flags().BoolP("help", "h", false, "Show help for command") + rootCmd.PersistentFlags().Bool("json", false, "Use the versioned noninteractive JSON interface (see anytype --json --help)") rootCmd.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false, "Disable the background check for new CLI releases") rootCmd.AddCommand( diff --git a/cmd/shell/shell.go b/cmd/shell/shell.go index 4f7035a..3fb1522 100644 --- a/cmd/shell/shell.go +++ b/cmd/shell/shell.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/anyproto/anytype-cli/core/localstate" "github.com/anyproto/anytype-cli/core/output" "github.com/anyproto/anytype-cli/core/updatecheck" ) @@ -89,6 +90,18 @@ func executeLine(rootCmd *cobra.Command, line string) error { return errAlreadyInShell } + for _, arg := range args { + if arg == "--json" || strings.HasPrefix(arg, "--json=") { + return errors.New("run machine commands as a separate anytype process, outside the interactive shell") + } + } + if localstate.HumanMutation(args) { + unlock, err := localstate.Lock(localstate.Dir()) + if err != nil { + return err + } + defer unlock() + } resetFlags(rootCmd) rootCmd.SetArgs(args) return rootCmd.Execute() diff --git a/core/auth.go b/core/auth.go index 9e50a39..38af0c1 100644 --- a/core/auth.go +++ b/core/auth.go @@ -86,7 +86,7 @@ func Authenticate(accountKey, rootPath, apiAddr, networkConfigPath string) error } if !savedToKeyring { output.Warning("System keyring unavailable (requires D-Bus on Linux, Keychain on macOS, Credential Manager on Windows)") - output.Warning("Storing credentials in config file: %s (insecure)", config.GetConfigManager().GetFilePath()) + output.Warning("Storing credentials in a private local file under the CLI configuration directory") } er, err := ListenForEvents(sessionToken) @@ -197,7 +197,7 @@ func Login(accountKey, rootPath, apiAddr, networkConfigPath string) error { } else if savedToKeyring { output.Success("Account key saved to keychain.") } else { - output.Success("Account key saved to config file.") + output.Success("Account key saved to private local storage.") } return nil @@ -317,7 +317,7 @@ func CreateWallet(name, rootPath, apiAddr, networkConfigPath string) (string, st } if !savedToKeyring { output.Warning("System keyring unavailable (requires D-Bus on Linux, Keychain on macOS, Credential Manager on Windows)") - output.Warning("Storing credentials in config file: %s (insecure)", config.GetConfigManager().GetFilePath()) + output.Warning("Storing credentials in a private local file under the CLI configuration directory") } _, err = ListenForEvents(sessionToken) diff --git a/core/automation/account.go b/core/automation/account.go new file mode 100644 index 0000000..1833c90 --- /dev/null +++ b/core/automation/account.go @@ -0,0 +1,396 @@ +package automation + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + heartconfig "github.com/anyproto/anytype-heart/core/anytype/config" + "github.com/anyproto/anytype-heart/pb" + heartcore "github.com/anyproto/anytype-heart/pkg/lib/core" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +func identityFromKey(key string) (string, error) { + derived, err := heartcore.WalletDeriveFromAccountMasterNode(key) + if err != nil { + return "", problem("invalid_account_key", "The private input is not a valid bot account key.", 2) + } + return derived.Identity.GetPublic().Account(), nil +} + +func validateAddress(address string) error { + host, port, err := net.SplitHostPort(address) + p, pErr := strconv.Atoi(port) + if err != nil || pErr != nil || p < 1 || p > 65535 || net.ParseIP(host) == nil { + return problem("invalid_address", "Use a numeric IP address and a fixed port from 1 to 65535.", 2) + } + return nil +} + +func (e *Engine) authMutation(ctx context.Context, r Request, op *operation, retry bool) Envelope { + fail := func(err error) Envelope { + out := Failure(err) + out.Result = map[string]any{"operation_id": op.Id, "account_id": op.AccountId, "stage": op.Stage} + return out + } + if r.Command == "auth create" && r.ExpectedAccount != "none" { + return fail(problem("expected_account_required", "Creation requires --expect-account none.", 2)) + } + c, err := e.config() + if err != nil { + return fail(err) + } + s, err := e.live(ctx) + if err != nil { + return fail(err) + } + addr := config.ResolveAPIListenAddr("", false, c.ApiListenAddr) + if c.ApiListenAddr == "" && s.AccountId != "" && s.APIBound && s.APIAddress != "" { + addr = s.APIAddress + } + if r.ListenAddress != "" { + if c.AccountId != "" && r.ListenAddress != addr { + return fail(problem("settings_conflict", "Existing account API settings must be preserved.", 2)) + } + addr = r.ListenAddress + } + if s.AccountId != "" && s.APIBound && !runtimeinfo.SameAPIAddress(s.APIAddress, addr) { + return fail(problem("settings_conflict", "The observed live API listener must be preserved.", 2)) + } + if err = validateAddress(addr); err != nil { + return fail(err) + } + network := c.NetworkConfigPath + networkDigest := "" + selectedNetworkId := "" + if network != "" { + network, err = filepath.Abs(network) + if err != nil { + return fail(problem("invalid_network_config", "The saved network configuration path is invalid.", 2)) + } + } + if r.NetworkConfig != "" { + requestedNetwork, pathErr := filepath.Abs(r.NetworkConfig) + if pathErr != nil { + return fail(problem("invalid_network_config", "The network configuration path is invalid.", 2)) + } + if c.AccountId != "" && network != requestedNetwork { + return fail(problem("settings_conflict", "Existing account network settings must be preserved.", 2)) + } + network = requestedNetwork + } + if network != "" { + networkId, readErr := config.ReadNetworkIdFromYAML(network) + if readErr != nil || networkId == "" { + return fail(problem("invalid_network_config", "The selected network configuration must be readable and contain a networkId.", 2)) + } + selectedNetworkId = networkId + data, readErr := os.ReadFile(network) + if readErr != nil { + return fail(problem("invalid_network_config", "The selected network configuration cannot be read.", 2)) + } + digest := sha256.Sum256(data) + networkDigest = hex.EncodeToString(digest[:]) + } else { + // Read the embedded default (or Heart's explicit environment override) + // into a fresh in-memory config; this does not initialize account storage. + cfg := heartconfig.New() + nodes, readErr := cfg.GetNodeConfWithError() + if readErr != nil || nodes.NetworkId == "" { + return fail(problem("invalid_network_config", "The default network configuration cannot be resolved.", 2)) + } + selectedNetworkId = nodes.NetworkId + } + if c.NetworkId != "" && c.NetworkId != selectedNetworkId || s.AccountId != "" && s.NetworkId != selectedNetworkId { + return fail(problem("settings_conflict", "The selected network must match the configured and observed live account network.", 2)) + } + settings := addr + "|" + network + "|" + networkDigest + if op.Settings != "" && op.Settings != settings { + return fail(problem("settings_conflict", "Instance listener/network settings changed since this operation began.", 2)) + } + op.Settings = settings + // Installation arguments and account selection must agree about the listener. + svc, err := e.Service.Status(ctx) + if err != nil { + return fail(err) + } + if svc.Installed && (!svc.BindingVerified || svc.ListenAddress != addr) { + return fail(problem("service_binding_conflict", "The installed service has a different or unverified binding. Preserve its settings or repair it explicitly.", 2)) + } + if svc.Running && svc.PID > 0 && svc.PID != s.PID { + return fail(problem("service_instance_mismatch", "The service manager and native RPC endpoint identify different processes.", 2)) + } + savedKey, _, keyErr := e.Secrets.Get("account-key") + if keyErr != nil && !errors.Is(keyErr, os.ErrNotExist) { + return fail(problem("credential_storage_failed", "Cannot read existing account credentials; restore access before provisioning.", 2)) + } + if !retry { + if err = guard(s, r.ExpectedAccount, r.Command == "auth login"); err != nil { + return fail(err) + } + if r.ExpectedAccount == "none" { + entries, dirErr := os.ReadDir(e.DataPath) + if c.AccountId != "" || c.AccountKey != "" || savedKey != "" || dirErr != nil && !errors.Is(dirErr, os.ErrNotExist) || len(entries) > 0 { + return fail(problem("existing_account_data", "This instance already has credentials or account data. Reuse or explicitly recover it; creation cannot replace it.", 2)) + } + } + } + if c.AccountId != "" && c.AccountId != op.AccountId && c.AccountId != r.ExpectedAccount { + return fail(problem("account_mismatch", "Configured account does not match this operation.", 2)) + } + if retry && s.AccountId != "" && s.AccountId != op.AccountId { + return fail(problem("account_mismatch", "The live account no longer belongs to this operation.", 2)) + } + expected := r.ExpectedAccount + if expected == "none" { + expected = op.AccountId + } + for _, durableKey := range []string{savedKey, c.AccountKey} { + if durableKey == "" { + continue + } + id, deriveErr := e.deriveIdentity(durableKey) + if deriveErr != nil || expected == "" || id != expected { + return fail(problem("account_mismatch", "Saved account credentials do not match this operation. Recover the existing account explicitly.", 2)) + } + } + key, _, secretErr := e.Secrets.Get("operation-account-" + op.Id) + if secretErr != nil && !errors.Is(secretErr, os.ErrNotExist) { + return fail(problem("credential_storage_failed", "Cannot read the operation credential.", 2)) + } + if r.Command == "auth login" { + if r.ExpectedAccount == "" { + return fail(problem("expected_account_required", "Login requires --expect-account with the full account ID.", 2)) + } + if r.Secret != "" { + id, err := e.deriveIdentity(r.Secret) + if err != nil { + return fail(err) + } + if r.ExpectedAccount != "none" && id != r.ExpectedAccount { + return fail(problem("account_mismatch", "The supplied key belongs to a different account.", 2)) + } + if key != "" && key != r.Secret { + return fail(problem("operation_conflict", "The private input differs from the credential saved for this operation.", 2)) + } + key = r.Secret + } + if key == "" { + return fail(problem("private_input_required", "Provide the account key through --key-stdin on the first login attempt.", 2)) + } + id, err := e.deriveIdentity(key) + if err != nil { + return fail(err) + } + if r.ExpectedAccount != "none" && id != r.ExpectedAccount { + return fail(problem("account_mismatch", "Saved operation credential does not match the expected account.", 2)) + } + if op.AccountId != "" && op.AccountId != id { + return fail(problem("operation_conflict", "The supplied account differs from this operation’s durable identity.", 2)) + } + op.AccountId = id + if !retry { + if err = e.saveOp(op, "prepared"); err != nil { + return fail(err) + } + } + if _, err = e.Secrets.Set("operation-account-"+op.Id, key); err != nil { + return fail(problem("credential_storage_failed", "Cannot durably store the account credential.", 2)) + } + } else if key == "" { + if retry && op.Stage != "prepared" { + return fail(uncertain()) + } + if secretErr != nil && !errors.Is(secretErr, os.ErrNotExist) { + return fail(problem("credential_storage_failed", "Cannot read the operation credential.", 2)) + } + if err = e.initial(ctx); err != nil { + return fail(err) + } + if err = e.saveOp(op, "wallet_sent"); err != nil { + return fail(err) + } + response, rpcErr := e.Client.WalletCreate(ctx, &pb.RpcWalletCreateRequest{RootPath: e.DataPath}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + // Save recovery material immediately, before session/account creation. + if response.GetAccountKey() != "" { + key = response.AccountKey + if _, err = e.Secrets.Set("operation-account-"+op.Id, key); err != nil { + return fail(problem("credential_storage_failed", "Wallet outcome is uncertain because its recovery credential could not be saved. Do not create another wallet.", 2)) + } + } + if err = protoError("Create wallet", response.GetError().GetCode().String()); err != nil { + return fail(err) + } + if key == "" { + return fail(uncertain()) + } + id, err := e.deriveIdentity(key) + if err != nil { + return fail(err) + } + op.AccountId = id + if err = e.saveOp(op, "wallet_saved"); err != nil { + return fail(err) + } + } + id, err := e.deriveIdentity(key) + if err != nil { + return fail(err) + } + if op.AccountId != "" && op.AccountId != id { + return fail(problem("account_mismatch", "The saved recovery credential does not match the operation identity.", 2)) + } + if op.AccountId == "" { + op.AccountId = id + if err = e.saveOp(op, "wallet_saved"); err != nil { + return fail(err) + } + } + // A live account with the exact identity proves AccountCreate completed only + // after profile readback confirms the requested name. Never repeat that RPC. + if s.AccountId == op.AccountId && !s.AccountTransition { + a, aErr := e.account(ctx, s) + if aErr == nil { + if r.Command == "auth create" && a.DisplayName != r.Name { + return fail(problem("account_name_mismatch", "The created account exists but its observed name differs. Inspect it; automatic rename/replacement is disabled.", 2)) + } + return e.finishAccount(r, op, key, a, s.TechSpaceId, s.NetworkId, addr, network, false) + } + if op.Stage == "complete" { + return fail(aErr) + } + } + if r.Command == "auth create" && op.Stage == "account_sent" { + return fail(uncertain()) + } + if err = e.initial(ctx); err != nil { + return fail(err) + } + // Recover only this operation's durably stored identity. No new wallet is + // minted on retry; WalletRecover is deterministic for the saved key. + response, rpcErr := e.Client.WalletRecover(ctx, &pb.RpcWalletRecoverRequest{RootPath: e.DataPath, AccountKey: key}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if err = protoError("Recover wallet", response.GetError().GetCode().String()); err != nil { + return fail(err) + } + session, rpcErr := e.Client.WalletCreateSession(ctx, &pb.RpcWalletCreateSessionRequest{Auth: &pb.RpcWalletCreateSessionRequestAuthOfAccountKey{AccountKey: key}}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if err = protoError("Create session", session.GetError().GetCode().String()); err != nil { + return fail(err) + } + if session.AccountId != op.AccountId || session.Token == "" { + return fail(problem("account_mismatch", "The server authenticated an unexpected identity.", 2)) + } + if _, err = e.Secrets.Set("session-token", session.Token); err != nil { + return fail(problem("credential_storage_failed", "The session could not be saved; resume this operation.", 2)) + } + authCtx, _, err := e.authContext(ctx) + if err != nil { + return fail(err) + } + mode := pb.RpcAccount_DefaultConfig + if network != "" { + mode = pb.RpcAccount_CustomConfig + } + var account *model.Account + if r.Command == "auth create" && op.Stage != "account_observed" && op.Stage != "select_sent" && op.Stage != "complete" { + if err = e.saveOp(op, "account_sent"); err != nil { + return fail(err) + } + result, rpcErr := e.Client.AccountCreate(authCtx, &pb.RpcAccountCreateRequest{Name: r.Name, StorePath: e.DataPath, JsonApiListenAddr: addr, NetworkMode: mode, NetworkCustomConfigFilePath: network}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + // AccountCreate can report an error with a partially created account. Keep + // the recovery key and sent marker, and never interpret that as success. + if err = protoError("Create account", result.GetError().GetCode().String()); err != nil { + return fail(err) + } + account = result.Account + } else { + if err = e.saveOp(op, "select_sent"); err != nil { + return fail(err) + } + result, rpcErr := e.Client.AccountSelect(authCtx, &pb.RpcAccountSelectRequest{Id: op.AccountId, RootPath: e.DataPath, JsonApiListenAddr: addr, NetworkMode: mode, NetworkCustomConfigFilePath: network}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if err = protoError("Select account", result.GetError().GetCode().String()); err != nil { + return fail(err) + } + account = result.Account + } + if account == nil || account.Id != op.AccountId || account.Info == nil { + return fail(uncertain()) + } + if err = e.saveOp(op, "account_observed"); err != nil { + return fail(err) + } + fresh, err := e.live(ctx) + if err != nil { + return fail(err) + } + if fresh.AccountId != op.AccountId { + return fail(problem("account_mismatch", "Account changed during provisioning.", 2)) + } + if fresh.NetworkId != selectedNetworkId || fresh.APIBound && !runtimeinfo.SameAPIAddress(fresh.APIAddress, addr) { + return fail(problem("settings_conflict", "The observed account network or API listener differs from the operation settings.", 2)) + } + a, err := e.account(ctx, fresh) + if err != nil { + return fail(err) + } + if r.Command == "auth create" && a.DisplayName != r.Name { + return fail(problem("account_name_mismatch", "The account exists, but its observed name does not match the requested name.", 2)) + } + return e.finishAccount(r, op, key, a, account.Info.TechSpaceId, account.Info.NetworkId, addr, network, true) +} + +func (e *Engine) deriveIdentity(key string) (string, error) { + if e.IdentityFromKey != nil { + return e.IdentityFromKey(key) + } + return identityFromKey(key) +} +func (e *Engine) initial(ctx context.Context) error { + r, err := e.Client.InitialSetParameters(ctx, &pb.RpcInitialSetParametersRequest{Platform: runtime.GOOS, Version: core.GetVersion(), Workdir: e.WorkPath}) + if err != nil { + return rpcError(err) + } + return protoError("Initialize client", r.GetError().GetCode().String()) +} +func (e *Engine) finishAccount(r Request, op *operation, key string, a Account, tech, networkId, addr, network string, changed bool) Envelope { + storage, err := e.Secrets.Set("account-key", key) + if err != nil { + return Failure(problem("credential_storage_failed", "Account exists; resume to save its credential.", 2)) + } + if err = e.saveConfig(map[string]any{"accountId": a.Id, "techSpaceId": tech, "networkId": networkId, "networkConfigPath": network, "apiListenAddr": addr}); err != nil { + return Failure(err) + } + if err = e.saveOp(op, "complete"); err != nil { + return Failure(err) + } + a.CredentialStorage = storage + result := map[string]any{"operation_id": op.Id, "account": a} + if r.IncludeSecret { + result["account_key"] = key + } + return Success(result, changed) +} diff --git a/core/automation/account_test.go b/core/automation/account_test.go new file mode 100644 index 0000000..6798544 --- /dev/null +++ b/core/automation/account_test.go @@ -0,0 +1,203 @@ +package automation + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/anyproto/anytype-cli/core/credentials" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-cli/core/runtimeinfo" +) + +func TestUnreadableSavedKeyBlocksAccountProvisioning(t *testing.T) { + for _, command := range []string{"auth create", "auth login"} { + e, rpc, _ := fixture(t, false) + store := NewSecrets(e.StateDir, e.ConfigPath) + store.Backend = "" + store.GetKeyring = func(string, string) (string, error) { return "", os.ErrPermission } + writes := 0 + store.SetKeyring = func(string, string, string) error { writes++; return nil } + e.Secrets = store + r := createRequest() + if command == "auth login" { + r = Request{Command: command, OperationId: "login-denied", ExpectedAccount: "account-a", Secret: "account-private-fixture"} + } + assertCode(t, run(e, r), "credential_storage_failed") + if rpc.initialCalls+rpc.walletCalls+rpc.recoverCalls+rpc.sessionCalls+rpc.createCalls+rpc.selectCalls+writes != 0 { + t.Fatal("unreadable credential treated as absence") + } + } +} + +func TestPrivateBackendCreatePreservesLegacyKeyringAndReplays(t *testing.T) { + e, rpc, _ := fixture(t, false) + t.Setenv(credentials.BackendEnv, "private_file") + store := NewSecrets(e.StateDir, e.ConfigPath) + legacyKey := "wrong" + keyringCalls := 0 + store.GetKeyring = func(string, string) (string, error) { keyringCalls++; return "", os.ErrPermission } + store.SetKeyring = func(_, name, value string) error { + keyringCalls++ + if name == "account-key" { + legacyKey = value + } + return nil + } + e.Secrets = store + // The fake server's precondition uses this mirror; verify the real store at + // the account RPC boundary rather than weakening the existing assertion. + rpc.secrets["operation-account-create-0001"] = "account-private-fixture" + rpc.afterCreate = func() { + if key, _, err := store.Get("operation-account-create-0001"); err != nil || key != "account-private-fixture" { + t.Fatal("recovery credential not durable", err) + } + } + r := createRequest() + out := run(e, r) + if out.ExitCode() != 0 || out.Result.(map[string]any)["account"].(Account).CredentialStorage != "private_file" { + t.Fatal(out) + } + t.Setenv(credentials.BackendEnv, "") + restarted := NewSecrets(e.StateDir, e.ConfigPath) + restarted.GetKeyring, restarted.SetKeyring = store.GetKeyring, store.SetKeyring + e.Secrets = restarted + if out = run(e, r); out.ExitCode() != 0 || rpc.createCalls != 1 || keyringCalls != 0 || legacyKey != "wrong" { + t.Fatal(out, rpc.createCalls, keyringCalls, "legacy key changed", legacyKey != "wrong") + } +} + +func TestFullExpectedIdentityGuardsAllSavedKeysBeforeMutation(t *testing.T) { + for _, source := range []string{"credential_store", "legacy_config", "retry"} { + t.Run(source, func(t *testing.T) { + e, rpc, _ := fixture(t, false) + r := Request{Command: "auth login", OperationId: "login-saved-identity", ExpectedAccount: "account-a", Secret: "account-private-fixture"} + if source == "legacy_config" { + if err := localstate.WriteJSON(e.ConfigPath, map[string]string{"accountKey": "wrong"}); err != nil { + t.Fatal(err) + } + } else { + rpc.secrets["account-key"] = "wrong" + } + if source == "retry" { + op, _, err := e.operation(r) + if err != nil { + t.Fatal(err) + } + op.AccountId = "account-a" + if err = e.saveOp(op, "prepared"); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(e.DataPath, "other-account-data"), 0700); err != nil { + t.Fatal(err) + } + assertCode(t, run(e, r), "account_mismatch") + if rpc.initialCalls+rpc.walletCalls+rpc.recoverCalls+rpc.sessionCalls+rpc.selectCalls != 0 || rpc.secrets["operation-account-"+r.OperationId] != "" { + t.Fatal("different durable identity caused side effects") + } + if source != "legacy_config" && rpc.secrets["account-key"] != "wrong" { + t.Fatal("saved key replaced") + } + }) + } +} + +func TestSameAccountLoginGuardsLiveNetworkBeforeSaving(t *testing.T) { + for _, savedConfig := range []bool{false, true} { + e, rpc, _ := fixture(t, true) + path := filepath.Join(t.TempDir(), "network.yml") + if err := os.WriteFile(path, []byte("networkId: other-network\n"), 0600); err != nil { + t.Fatal(err) + } + if savedConfig { + if err := localstate.WriteJSON(e.ConfigPath, map[string]string{"accountId": "account-a", "networkId": rpc.snapshot.NetworkId, "networkConfigPath": path}); err != nil { + t.Fatal(err) + } + } + r := Request{Command: "auth login", OperationId: "login-network", ExpectedAccount: "account-a", Secret: "account-private-fixture", NetworkConfig: path} + assertCode(t, run(e, r), "settings_conflict") + if !savedConfig { + if _, err := os.Stat(e.ConfigPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("config written", err) + } + } + if rpc.initialCalls+rpc.recoverCalls+rpc.sessionCalls+rpc.selectCalls != 0 || rpc.secrets["account-key"] != "" { + t.Fatal("settings conflict mutated credentials/account") + } + } +} + +func TestSameAccountLoginPreservesLiveListener(t *testing.T) { + for _, requested := range []string{"", "127.0.0.1:4700"} { + e, rpc, _ := fixture(t, true) + rpc.snapshot.APIAddress, rpc.snapshot.APIBound = "127.0.0.1:4600", true + r := Request{Command: "auth login", OperationId: "login-listener", ExpectedAccount: "account-a", Secret: "account-private-fixture", ListenAddress: requested} + out := run(e, r) + if requested != "" { + assertCode(t, out, "settings_conflict") + continue + } + cfg, err := e.config() + if out.ExitCode() != 0 || err != nil || cfg.ApiListenAddr != "127.0.0.1:4600" || rpc.selectCalls != 0 { + t.Fatal(out, cfg, err) + } + if out = run(e, r); out.ExitCode() != 0 { + t.Fatal(out) + } + } +} + +func TestCompleteCreateReceiptRecoversAfterServiceRestart(t *testing.T) { + e, rpc, _ := fixture(t, false) + r := createRequest() + if out := run(e, r); out.ExitCode() != 0 { + t.Fatal(out) + } + *rpc.snapshot = runtimeinfo.Snapshot{SchemaVersion: 1} + r.IncludeSecret = true + out := run(e, r) + if out.ExitCode() != 0 || rpc.createCalls != 1 || rpc.walletCalls != 1 || rpc.selectCalls != 1 || out.Result.(map[string]any)["account_key"] != "account-private-fixture" { + t.Fatal("completed creation did not deterministically select", out, rpc.createCalls, rpc.selectCalls) + } + var op operation + if err := localstate.ReadJSON(e.opPath(r.OperationId), &op); err != nil || op.Stage != "complete" { + t.Fatal(op, err) + } + if out = run(e, r); out.ExitCode() != 0 || rpc.selectCalls != 1 || rpc.createCalls != 1 { + t.Fatal(out) + } +} + +func TestWildcardListenerAliasPreservesAccountSettingsOnReplay(t *testing.T) { + for _, login := range []bool{false, true} { + e, rpc, _ := fixture(t, login) + r := createRequest() + if login { + r = Request{Command: "auth login", OperationId: "login-wildcard", ExpectedAccount: "account-a", Secret: "account-private-fixture"} + rpc.snapshot.APIAddress, rpc.snapshot.APIBound = "[::]:4500", true + } else { + rpc.afterCreate = func() { rpc.snapshot.APIAddress = "[::]:4500" } + } + r.ListenAddress = "0.0.0.0:4500" + if out := run(e, r); out.ExitCode() != 0 { + t.Fatal(out) + } + if out := run(e, r); out.ExitCode() != 0 { + t.Fatal(out) + } + cfg, err := e.config() + if err != nil || cfg.ApiListenAddr != "0.0.0.0:4500" { + t.Fatal("listener spelling changed", cfg, err) + } + out := run(e, Request{Command: "inspect"}) + if out.ExitCode() != 0 { + t.Fatal(out) + } + result := out.Result.(map[string]any) + if result["api"].(map[string]any)["origin"] != "http://127.0.0.1:4500" || result["mcp"].(map[string]any)["url"] != "http://127.0.0.1:4500/mcp/full" { + t.Fatal("wildcard exposed unusable client origin", out) + } + } +} diff --git a/core/automation/automation_test.go b/core/automation/automation_test.go new file mode 100644 index 0000000..8255743 --- /dev/null +++ b/core/automation/automation_test.go @@ -0,0 +1,637 @@ +package automation + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + heartconfig "github.com/anyproto/anytype-heart/core/anytype/config" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pb/service" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" + "github.com/gogo/protobuf/types" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type memorySecrets map[string]string + +func (s memorySecrets) Get(k string) (string, string, error) { + v, ok := s[k] + if !ok { + return "", "", os.ErrNotExist + } + return v, "mock", nil +} +func (s memorySecrets) Set(k, v string) (string, error) { s[k] = v; return "mock", nil } + +type fakeService struct { + value ServiceStatus + installs, starts int +} + +func (s *fakeService) Status(context.Context) (ServiceStatus, error) { return s.value, nil } +func (s *fakeService) Install(_ context.Context, addr string) error { + s.installs++ + s.value = ServiceStatus{Installed: true, BindingVerified: true, DefinitionHash: "service-hash", ListenAddress: addr} + return nil +} +func (s *fakeService) Start(context.Context) error { s.starts++; s.value.Running = true; return nil } + +type fakeRPC struct { + service.ClientCommandsClient + snapshot *runtimeinfo.Snapshot + secrets memorySecrets + names string + apps []*model.AccountAuthAppInfo + records []*types.Struct + walletCalls, createCalls, selectCalls, keyCalls, joinCalls, revokeCalls int + initialCalls, recoverCalls, sessionCalls int + walletErr, createErr, keyErr, joinErr error + createCode pb.RpcAccountCreateResponseErrorCode + badGrant bool + afterKey func() + afterCreate func() +} + +func (f *fakeRPC) InitialSetParameters(context.Context, *pb.RpcInitialSetParametersRequest, ...grpc.CallOption) (*pb.RpcInitialSetParametersResponse, error) { + f.initialCalls++ + return &pb.RpcInitialSetParametersResponse{}, nil +} +func (f *fakeRPC) WalletCreate(context.Context, *pb.RpcWalletCreateRequest, ...grpc.CallOption) (*pb.RpcWalletCreateResponse, error) { + f.walletCalls++ + return &pb.RpcWalletCreateResponse{AccountKey: "account-private-fixture"}, f.walletErr +} +func (f *fakeRPC) WalletRecover(context.Context, *pb.RpcWalletRecoverRequest, ...grpc.CallOption) (*pb.RpcWalletRecoverResponse, error) { + f.recoverCalls++ + return &pb.RpcWalletRecoverResponse{}, nil +} +func (f *fakeRPC) WalletCreateSession(context.Context, *pb.RpcWalletCreateSessionRequest, ...grpc.CallOption) (*pb.RpcWalletCreateSessionResponse, error) { + f.sessionCalls++ + return &pb.RpcWalletCreateSessionResponse{AccountId: "account-a", Token: "session-private-fixture"}, nil +} +func (f *fakeRPC) account() *model.Account { + f.snapshot.AccountId = "account-a" + f.snapshot.ProfileId = "profile-a" + f.snapshot.TechSpaceId = "tech-a" + if f.snapshot.NetworkId == "" { + cfg := heartconfig.New() + nodes, err := cfg.GetNodeConfWithError() + if err != nil { + panic(err) + } + f.snapshot.NetworkId = nodes.NetworkId + } + f.snapshot.AccountTransition = false + return &model.Account{Id: "account-a", Info: &model.AccountInfo{ProfileObjectId: "profile-a", TechSpaceId: "tech-a", NetworkId: f.snapshot.NetworkId}} +} + +func (f *fakeRPC) settings(network, addr string) { + if network != "" { + f.snapshot.NetworkId, _ = config.ReadNetworkIdFromYAML(network) + } + f.snapshot.APIAddress, f.snapshot.APIBound = addr, addr != "" +} +func (f *fakeRPC) AccountCreate(_ context.Context, r *pb.RpcAccountCreateRequest, _ ...grpc.CallOption) (*pb.RpcAccountCreateResponse, error) { + f.createCalls++ + if f.secrets["operation-account-create-0001"] == "" { + panic("AccountCreate before recovery key persisted") + } + f.names = r.Name + f.settings(r.NetworkCustomConfigFilePath, r.JsonApiListenAddr) + a := f.account() + if f.afterCreate != nil { + f.afterCreate() + } + return &pb.RpcAccountCreateResponse{Account: a, Error: &pb.RpcAccountCreateResponseError{Code: f.createCode}}, f.createErr +} +func (f *fakeRPC) AccountSelect(_ context.Context, r *pb.RpcAccountSelectRequest, _ ...grpc.CallOption) (*pb.RpcAccountSelectResponse, error) { + f.selectCalls++ + f.settings(r.NetworkCustomConfigFilePath, r.JsonApiListenAddr) + return &pb.RpcAccountSelectResponse{Account: f.account()}, nil +} +func (f *fakeRPC) ObjectSearch(_ context.Context, r *pb.RpcObjectSearchRequest, _ ...grpc.CallOption) (*pb.RpcObjectSearchResponse, error) { + if len(r.Filters) > 0 && r.Filters[0].RelationKey == "id" { + return &pb.RpcObjectSearchResponse{Records: []*types.Struct{{Fields: map[string]*types.Value{"id": pbtypes.String("profile-a"), "name": pbtypes.String(f.names)}}}}, nil + } + return &pb.RpcObjectSearchResponse{Records: f.records}, nil +} +func (f *fakeRPC) AccountLocalLinkListApps(context.Context, *pb.RpcAccountLocalLinkListAppsRequest, ...grpc.CallOption) (*pb.RpcAccountLocalLinkListAppsResponse, error) { + return &pb.RpcAccountLocalLinkListAppsResponse{App: f.apps}, nil +} +func (f *fakeRPC) AccountLocalLinkUpdateApp(context.Context, *pb.RpcAccountLocalLinkUpdateAppRequest, ...grpc.CallOption) (*pb.RpcAccountLocalLinkUpdateAppResponse, error) { + return &pb.RpcAccountLocalLinkUpdateAppResponse{Error: &pb.RpcAccountLocalLinkUpdateAppResponseError{Code: pb.RpcAccountLocalLinkUpdateAppResponseError_BAD_INPUT}}, nil +} +func (f *fakeRPC) AccountLocalLinkCreateApp(_ context.Context, r *pb.RpcAccountLocalLinkCreateAppRequest, _ ...grpc.CallOption) (*pb.RpcAccountLocalLinkCreateAppResponse, error) { + f.keyCalls++ + a := *r.App + a.AppHash = "credential-new" + a.AppKey = "api-private-fixture" + if f.badGrant { + a.Grant = nil + } + f.apps = append(f.apps, &a) + if f.afterKey != nil { + f.afterKey() + } + return &pb.RpcAccountLocalLinkCreateAppResponse{AppKey: a.AppKey}, f.keyErr +} +func (f *fakeRPC) AccountLocalLinkRevokeApp(_ context.Context, r *pb.RpcAccountLocalLinkRevokeAppRequest, _ ...grpc.CallOption) (*pb.RpcAccountLocalLinkRevokeAppResponse, error) { + f.revokeCalls++ + var kept []*model.AccountAuthAppInfo + for _, a := range f.apps { + if a.AppHash != r.AppHash { + kept = append(kept, a) + } + } + f.apps = kept + return &pb.RpcAccountLocalLinkRevokeAppResponse{}, nil +} +func (f *fakeRPC) SpaceInviteView(context.Context, *pb.RpcSpaceInviteViewRequest, ...grpc.CallOption) (*pb.RpcSpaceInviteViewResponse, error) { + return &pb.RpcSpaceInviteViewResponse{SpaceId: "space-full-id", SpaceName: "shared"}, nil +} +func (f *fakeRPC) SpaceJoin(context.Context, *pb.RpcSpaceJoinRequest, ...grpc.CallOption) (*pb.RpcSpaceJoinResponse, error) { + f.joinCalls++ + return &pb.RpcSpaceJoinResponse{}, f.joinErr +} + +func fixture(t *testing.T, authenticated bool) (*Engine, *fakeRPC, *fakeService) { + t.Helper() + dir := t.TempDir() + secrets := memorySecrets{} + snap := &runtimeinfo.Snapshot{SchemaVersion: 1} + rpc := &fakeRPC{snapshot: snap, secrets: secrets, names: "Existing Name"} + svc := &fakeService{} + e := &Engine{Client: rpc, ConfigPath: filepath.Join(dir, "config.json"), DataPath: filepath.Join(dir, "data"), WorkPath: filepath.Join(dir, "work"), StateDir: filepath.Join(dir, "automation"), RuntimePath: filepath.Join(dir, "runtime.json"), Secrets: secrets, Service: svc, Live: func(context.Context) (*runtimeinfo.Snapshot, error) { return snap, nil }, IdentityFromKey: func(key string) (string, error) { + if key == "wrong" { + return "other", nil + } + return "account-a", nil + }} + if authenticated { + rpc.account() + secrets["session-token"] = "session-private-fixture" + } + return e, rpc, svc +} +func run(e *Engine, r Request) Envelope { return e.Run(context.Background(), r) } +func assertCode(t *testing.T, out Envelope, code string) { + t.Helper() + if out.Error == nil || out.Error.Code != code { + t.Fatalf("want %s, got %+v", code, out) + } +} +func createRequest() Request { + return Request{Command: "auth create", OperationId: "create-0001", ExpectedAccount: "none", Name: " Агент 🌿 "} +} +func keyRequest() Request { + return Request{Command: "auth apikey create", OperationId: "apikey-0001", ExpectedAccount: "account-a", Name: "stable app", Grant: core.GrantFlags{AllSpaces: true, ReadWrite: true}} +} +func TestNameValidation(t *testing.T) { + for _, name := range []string{"", " ", "a\x00b", "a\nb", strings.Repeat("x", 4097), string([]byte{255})} { + if _, err := ValidateName(name); err == nil { + t.Fatalf("accepted invalid name") + } + } + if name, err := ValidateName(" Агент 🌿 "); err != nil || name != "Агент 🌿" { + t.Fatal(name, err) + } +} +func TestCreateResumeAndPrivateOutput(t *testing.T) { + e, rpc, _ := fixture(t, false) + r := createRequest() + out := run(e, r) + if out.ExitCode() != 0 || rpc.createCalls != 1 { + t.Fatalf("create: %+v", out) + } + b, _ := json.Marshal(out) + if strings.Contains(string(b), "private-fixture") { + t.Fatal("secret leaked") + } + r.IncludeSecret = true + out = run(e, r) + if out.ExitCode() != 0 || out.Changed || rpc.createCalls != 1 || rpc.walletCalls != 1 { + t.Fatalf("resume: %+v", out) + } + b, _ = json.Marshal(out) + if !strings.Contains(string(b), "account-private-fixture") { + t.Fatal("explicit recovery secret missing") + } + journal, _ := os.ReadFile(e.opPath(r.OperationId)) + if strings.Contains(string(journal), "private-fixture") { + t.Fatal("journal contains secret") + } +} +func TestCreateLostResponseReconcilesLiveIdentity(t *testing.T) { + e, rpc, _ := fixture(t, false) + rpc.createErr = status.Error(codes.Unavailable, "secret from upstream") + r := createRequest() + out := run(e, r) + assertCode(t, out, "rpc_unavailable") + rpc.createErr = nil + out = run(e, r) + if out.ExitCode() != 0 || rpc.createCalls != 1 { + t.Fatalf("did not reconcile: %+v", out) + } +} +func TestCreateUncertainNeverDuplicates(t *testing.T) { + e, rpc, _ := fixture(t, false) + rpc.createErr = status.Error(codes.DeadlineExceeded, "private-fixture") + rpc.afterCreate = func() { rpc.snapshot.AccountId = "" } + r := createRequest() + run(e, r) + rpc.createErr = nil + out := run(e, r) + assertCode(t, out, "operation_uncertain") + if rpc.createCalls != 1 || rpc.walletCalls != 1 { + t.Fatal("duplicate account mutation") + } +} +func TestAccountObservedRestartSelectsOnly(t *testing.T) { + e, rpc, _ := fixture(t, false) + r := createRequest() + out := run(e, r) + if out.ExitCode() != 0 { + t.Fatal(out) + } + op, _, err := e.operation(Request{Command: r.Command, OperationId: r.OperationId, ExpectedAccount: r.ExpectedAccount, Name: "Агент 🌿"}) + if err != nil { + t.Fatal(err) + } + if err = e.saveOp(op, "account_observed"); err != nil { + t.Fatal(err) + } + rpc.snapshot.AccountId = "" + out = run(e, r) + if out.ExitCode() != 0 || rpc.createCalls != 1 || rpc.selectCalls != 1 { + t.Fatalf("duplicate or no recovery: %+v", out) + } +} +func TestCreationPartialErrorIsNotSuccess(t *testing.T) { + e, rpc, _ := fixture(t, false) + rpc.createCode = pb.RpcAccountCreateResponseErrorCode(101) + out := run(e, createRequest()) + if out.ExitCode() == 0 { + t.Fatal("partial RPC success claimed") + } + if rpc.secrets["operation-account-create-0001"] == "" { + t.Fatal("lost recovery credential") + } +} +func TestAccountMismatchAndOperationConflict(t *testing.T) { + e, rpc, _ := fixture(t, true) + out := run(e, createRequest()) + assertCode(t, out, "account_mismatch") + if rpc.createCalls != 0 || rpc.walletCalls != 0 { + t.Fatal("mutated existing account") + } + r := keyRequest() + if out = run(e, r); out.ExitCode() != 0 { + t.Fatal(out) + } + r.Name = "different" + assertCode(t, run(e, r), "operation_conflict") +} +func TestLoginEmptyDerivesIdentityPreservesName(t *testing.T) { + e, rpc, _ := fixture(t, false) + r := Request{Command: "auth login", OperationId: "login-0001", ExpectedAccount: "none", Secret: "account-private-fixture"} + out := run(e, r) + if out.ExitCode() != 0 || rpc.names != "Existing Name" || rpc.createCalls != 0 { + t.Fatalf("login: %+v", out) + } + r.Secret = "" + out = run(e, r) + if out.ExitCode() != 0 || rpc.selectCalls != 1 { + t.Fatalf("login retry: %+v", out) + } +} +func TestLoginWrongExpectedKeyStopsBeforeRPC(t *testing.T) { + e, rpc, _ := fixture(t, false) + r := Request{Command: "auth login", OperationId: "login-0001", ExpectedAccount: "account-a", Secret: "wrong"} + assertCode(t, run(e, r), "account_mismatch") + if rpc.selectCalls != 0 { + t.Fatal("selected wrong account") + } +} +func TestKeyRotationIdentityRecoveryAndSecrecy(t *testing.T) { + e, rpc, _ := fixture(t, true) + rpc.apps = []*model.AccountAuthAppInfo{{AppHash: "old-key", AppName: "stable app", AppKey: "old-private-fixture"}} + r := keyRequest() + out := run(e, r) + if out.ExitCode() != 0 { + t.Fatal(out) + } + b, _ := json.Marshal(out) + if strings.Contains(string(b), "private-fixture") { + t.Fatal("secret leaked") + } + r.IncludeSecret = true + out = run(e, r) + if out.ExitCode() != 0 || rpc.keyCalls != 1 || rpc.revokeCalls != 0 || len(rpc.apps) != 2 { + t.Fatalf("rotation: %+v", out) + } + out = run(e, Request{Command: "auth apikey list"}) + b, _ = json.Marshal(out) + if strings.Contains(string(b), "private-fixture") { + t.Fatal("list leaked keys") + } +} +func TestUnreceiptedKeyNotMatchedByApplicationName(t *testing.T) { + e, rpc, _ := fixture(t, true) + rpc.keyErr = status.Error(codes.Unavailable, "private-fixture") + r := keyRequest() + run(e, r) + rpc.keyErr = nil + assertCode(t, run(e, r), "operation_uncertain") + if rpc.keyCalls != 1 { + t.Fatal("duplicate key") + } +} +func TestKeyGrantMismatchNeverReturnsSecret(t *testing.T) { + e, rpc, _ := fixture(t, true) + rpc.badGrant = true + r := keyRequest() + r.IncludeSecret = true + out := run(e, r) + assertCode(t, out, "grant_mismatch") + b, _ := json.Marshal(out) + if strings.Contains(string(b), "api-private-fixture") { + t.Fatal("unverified key returned") + } +} +func TestMembershipStatesAndJoinRetry(t *testing.T) { + e, rpc, _ := fixture(t, true) + r := Request{Command: "space join", OperationId: "join-00001", ExpectedAccount: "account-a", Secret: "https://example.test/cid#invite-secret"} + out := run(e, r) + if out.State != "request_sent" || out.ExitCode() != 3 { + t.Fatal(out) + } + rpc.records = []*types.Struct{{Fields: map[string]*types.Value{"targetSpaceId": pbtypes.String("space-full-id"), "myParticipantStatus": pbtypes.Int64(0), "spaceAccountStatus": pbtypes.Int64(9)}}} + r.Secret = "" + out = run(e, r) + if out.State != "pending_approval" || rpc.joinCalls != 1 { + t.Fatal(out) + } + rpc.records[0].Fields["myParticipantStatus"] = pbtypes.Int64(3) + out = run(e, Request{Command: "space status", SpaceId: "space-full-id"}) + assertCode(t, out, "membership_rejected") + journal, _ := os.ReadFile(e.opPath(r.OperationId)) + if strings.Contains(string(journal), "invite-secret") || strings.Contains(string(journal), "example.test") { + t.Fatal("invitation journal leak") + } +} +func TestUnknownJoinOutcomeNoResubmit(t *testing.T) { + e, rpc, _ := fixture(t, true) + rpc.joinErr = status.Error(codes.DeadlineExceeded, "invite-secret") + r := Request{Command: "space join", OperationId: "join-00001", ExpectedAccount: "account-a", Secret: "https://example.test/cid#invite-secret"} + run(e, r) + r.Secret = "" + assertCode(t, run(e, r), "operation_uncertain") + if rpc.joinCalls != 1 { + t.Fatal("resubmitted join") + } +} +func TestMutationLockAndUnsupportedInstance(t *testing.T) { + e, rpc, _ := fixture(t, true) + unlock, err := localstate.Lock(e.StateDir) + if err != nil { + t.Fatal(err) + } + defer unlock() + assertCode(t, run(e, keyRequest()), "instance_busy") + r := keyRequest() + r.Instance = "hermes-profile" + assertCode(t, run(e, r), "unsupported_instance") + if rpc.keyCalls != 0 { + t.Fatal("mutation bypassed guard") + } +} +func TestServiceReuseAndBindingConflict(t *testing.T) { + e, _, svc := fixture(t, false) + svc.value = ServiceStatus{Installed: true, BindingVerified: true, ListenAddress: config.DefaultAPIAddress, DefinitionHash: "same"} + r := Request{Command: "service install", OperationId: "service-001"} + out := run(e, r) + if out.ExitCode() != 0 || out.Changed || svc.installs != 0 { + t.Fatal(out) + } + r.OperationId = "service-002" + r.ListenAddress = "127.0.0.1:5555" + assertCode(t, run(e, r), "service_binding_conflict") +} +func TestInspectionReadOnlyAndNoReadyClaims(t *testing.T) { + e, _, _ := fixture(t, false) + out := run(e, Request{Command: "inspect"}) + if out.ExitCode() != 0 { + t.Fatal(out) + } + if _, err := os.Stat(e.StateDir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("inspection wrote state") + } + b, _ := json.Marshal(out) + if strings.Contains(string(b), `"authenticated":true`) || strings.Contains(string(b), `"ready":true`) { + t.Fatal("false readiness") + } +} + +func TestPreviouslyCompletedKeyChangedExternallyNotRevoked(t *testing.T) { + e, rpc, _ := fixture(t, true) + r := keyRequest() + if out := run(e, r); out.ExitCode() != 0 { + t.Fatal(out) + } + rpc.apps[0].Grant = nil + assertCode(t, run(e, r), "grant_mismatch") + if rpc.revokeCalls != 0 { + t.Fatal("externally changed completed key revoked") + } +} +func TestInvalidGrantCleanupOnlyNewCredential(t *testing.T) { + e, rpc, _ := fixture(t, true) + rpc.apps = []*model.AccountAuthAppInfo{{AppHash: "old", AppName: "stable app"}} + rpc.badGrant = true + out := run(e, keyRequest()) + assertCode(t, out, "grant_mismatch") + if rpc.revokeCalls != 1 || len(rpc.apps) != 1 || rpc.apps[0].AppHash != "old" { + t.Fatal("wrong cleanup identity") + } + out = run(e, keyRequest()) + assertCode(t, out, "grant_mismatch") + if rpc.keyCalls != 1 { + t.Fatal("new create on cleanup retry") + } +} +func TestInstallBlocksReachableWrongInstance(t *testing.T) { + e, _, svc := fixture(t, false) + for _, code := range []string{"service_upgrade_required", "instance_mismatch", "rpc_timeout"} { + e.Live = func(context.Context) (*runtimeinfo.Snapshot, error) { return nil, problem(code, "fixture", 2) } + out := run(e, Request{Command: "service install", OperationId: "service-001"}) + assertCode(t, out, code) + } + if svc.installs != 0 { + t.Fatal("installed over incompatible listener") + } +} +func TestCallerDeadlineIsPreserved(t *testing.T) { + e, _, _ := fixture(t, false) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + want, _ := ctx.Deadline() + e.Live = func(ctx context.Context) (*runtimeinfo.Snapshot, error) { + got, _ := ctx.Deadline() + if !got.Equal(want) { + t.Fatal("caller deadline shortened") + } + return &runtimeinfo.Snapshot{}, nil + } + if out := e.Run(ctx, Request{Command: "inspect"}); out.ExitCode() != 0 { + t.Fatal(out) + } +} + +func membershipRecord(id, name string, local, account model.SpaceStatus, participant *model.ParticipantStatus) *types.Struct { + record := &types.Struct{Fields: map[string]*types.Value{ + "targetSpaceId": pbtypes.String(id), "name": pbtypes.String(name), + "spaceLocalStatus": pbtypes.Int64(int64(local)), "spaceAccountStatus": pbtypes.Int64(int64(account)), + }} + if participant != nil { + record.Fields["myParticipantStatus"] = pbtypes.Int64(int64(*participant)) + } + return record +} + +func TestRejectedJoinOverridesStaleJoiningWithoutResubmission(t *testing.T) { + e, rpc, _ := fixture(t, true) + request := Request{Command: "space join", OperationId: "join-rejected", ExpectedAccount: "account-a", Secret: "https://example.test/cid#secret"} + if out := run(e, request); out.ExitCode() != 3 { + t.Fatal(out) + } + joining := model.ParticipantStatus_Joining + rpc.records = []*types.Struct{membershipRecord("space-full-id", "Shared", model.SpaceStatus_Unknown, model.SpaceStatus_SpaceDeleted, &joining)} + request.Secret = "" + assertCode(t, run(e, request), "membership_rejected") + assertCode(t, run(e, Request{Command: "space status", SpaceId: "space-full-id"}), "membership_rejected") + if rpc.joinCalls != 1 { + t.Fatal("rejected join resubmitted") + } +} + +func TestPersonalSpaceMembershipAndFixedGrant(t *testing.T) { + for _, participant := range []*model.ParticipantStatus{nil, new(model.ParticipantStatus_Active)} { + e, rpc, _ := fixture(t, true) + rpc.records = []*types.Struct{membershipRecord("personal-space", "Personal", model.SpaceStatus_Ok, model.SpaceStatus_Unknown, participant)} + out := run(e, Request{Command: "space status", SpaceId: "personal-space"}) + if out.ExitCode() != 0 || out.Result.(Space).State != "joined" { + t.Fatal(out) + } + request := keyRequest() + request.Grant = core.GrantFlags{Spaces: []string{"Personal"}, ReadOnly: true} + out = run(e, request) + if out.ExitCode() != 0 || len(rpc.apps) != 1 || !grantsEqual(rpc.apps[0].Grant, &model.AccountAuthAppGrant{SpaceIds: []string{"personal-space"}, Perm: model.AccountAuthAppGrant_Read}) { + t.Fatal(out) + } + } +} + +type lostReadbackRPC struct { + *fakeRPC + lists int +} + +func (f *lostReadbackRPC) AccountLocalLinkListApps(ctx context.Context, request *pb.RpcAccountLocalLinkListAppsRequest, opts ...grpc.CallOption) (*pb.RpcAccountLocalLinkListAppsResponse, error) { + f.lists++ + if f.lists == 2 { + return nil, status.Error(codes.Unavailable, "lost readback") + } + return f.fakeRPC.AccountLocalLinkListApps(ctx, request, opts...) +} +func TestKeyRecoveryUsesDurableGrantAfterSpaceRename(t *testing.T) { + for _, reuseName := range []bool{false, true} { + e, rpc, _ := fixture(t, true) + e.Client = &lostReadbackRPC{fakeRPC: rpc} + rpc.records = []*types.Struct{membershipRecord("space-original", "Team", model.SpaceStatus_Ok, model.SpaceStatus_SpaceActive, nil)} + request := keyRequest() + request.Grant = core.GrantFlags{Spaces: []string{"Team"}, ReadWrite: true} + rpc.afterKey = func() { + var op operation + if err := localstate.ReadJSON(e.opPath(request.OperationId), &op); err != nil || op.ResolvedGrant == nil || op.ResolvedGrant.SpaceIds[0] != "space-original" { + t.Fatal("grant not durable before creation", op, err) + } + } + assertCode(t, run(e, request), "rpc_unavailable") + rpc.records[0].Fields["name"] = pbtypes.String("Archived Team") + if reuseName { + rpc.records = append(rpc.records, membershipRecord("space-new", "Team", model.SpaceStatus_Ok, model.SpaceStatus_SpaceActive, nil)) + } + request.IncludeSecret = true + out := run(e, request) + if out.ExitCode() != 0 || out.Changed || rpc.keyCalls != 1 || rpc.revokeCalls != 0 || rpc.apps[0].Grant.SpaceIds[0] != "space-original" { + t.Fatal(out, rpc.keyCalls, rpc.revokeCalls) + } + // Completed replay also uses the immutable grant. + if out = run(e, request); out.ExitCode() != 0 || rpc.revokeCalls != 0 { + t.Fatal(out) + } + } +} +func TestLegacyKeyReceiptWithoutGrantDoesNotMutate(t *testing.T) { + e, rpc, _ := fixture(t, true) + request := keyRequest() + if out := run(e, request); out.ExitCode() != 0 { + t.Fatal(out) + } + var op operation + if err := localstate.ReadJSON(e.opPath(request.OperationId), &op); err != nil { + t.Fatal(err) + } + op.ResolvedGrant = nil + if err := e.saveOp(&op, "verified_identity"); err != nil { + t.Fatal(err) + } + assertCode(t, run(e, request), "operation_uncertain") + if rpc.keyCalls != 1 || rpc.revokeCalls != 0 { + t.Fatal("legacy receipt mutated credential") + } +} +func TestRelativeNetworkPathReplaysAfterPersistingAbsolutePath(t *testing.T) { + e, rpc, _ := fixture(t, false) + network := filepath.Join(t.TempDir(), "network.yml") + if err := os.WriteFile(network, []byte("networkId: network-a\n"), 0600); err != nil { + t.Fatal(err) + } + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(cwd, network) + if err != nil { + t.Fatal(err) + } + request := createRequest() + request.NetworkConfig = relative + if out := run(e, request); out.ExitCode() != 0 { + t.Fatal(out) + } + request.IncludeSecret = true + if out := run(e, request); out.ExitCode() != 0 || rpc.createCalls != 1 { + t.Fatal(out) + } + saved, err := e.config() + if err != nil || saved.NetworkConfigPath != network { + t.Fatal(saved, err) + } + if err := os.WriteFile(network, []byte("networkId: changed\n"), 0600); err != nil { + t.Fatal(err) + } + assertCode(t, run(e, request), "settings_conflict") +} diff --git a/core/automation/engine.go b/core/automation/engine.go new file mode 100644 index 0000000..ec178d2 --- /dev/null +++ b/core/automation/engine.go @@ -0,0 +1,323 @@ +package automation + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pb/service" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func New() (*Engine, func(), error) { + conn, err := grpc.NewClient(config.GRPCDNSAddress, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, problem("rpc_unavailable", "Cannot connect to the Anytype service.", 4) + } + e := &Engine{Client: service.NewClientCommandsClient(conn), ConfigPath: config.GetConfigFilePath(), DataPath: config.GetDataDir(), WorkPath: config.GetWorkDir(), StateDir: localstate.Dir(), RuntimePath: runtimeinfo.Path(), Timeout: 45 * time.Second} + if absolute, err := filepath.Abs(e.DataPath); err == nil { + e.DataPath = absolute + } + e.Secrets = NewSecrets(e.StateDir, e.ConfigPath) + e.Service = NewNativeService() + return e, func() { _ = conn.Close() }, nil +} + +func (e *Engine) live(ctx context.Context) (*runtimeinfo.Snapshot, error) { + if e.Live != nil { + return e.Live(ctx) + } + rpcCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + var header metadata.MD + response, err := e.Client.AppGetVersion(rpcCtx, &pb.RpcAppGetVersionRequest{}, grpc.Header(&header)) + if err != nil { + return nil, rpcError(err) + } + if response == nil || response.GetError().GetCode() != pb.RpcAppGetVersionResponseError_NULL { + return nil, problem("rpc_unavailable", "The service did not answer its health probe.", 4) + } + nonce := header.Get(runtimeinfo.Header) + if len(nonce) != 1 || nonce[0] == "" { + return nil, problem("service_upgrade_required", "The running service does not provide CLI runtime observations. Explicitly upgrade/restart that service before native automation.", 1) + } + observation := new(runtimeinfo.Snapshot) + if localstate.ReadJSON(e.RuntimePath, observation) != nil || observation.SchemaVersion != 1 || observation.Nonce != nonce[0] || observation.PID <= 0 || filepath.Clean(observation.ConfigPath) != filepath.Clean(e.ConfigPath) || filepath.Clean(observation.DataPath) != filepath.Clean(e.DataPath) { + return nil, problem("instance_mismatch", "The live service does not match this CLI configuration and data binding.", 2) + } + return observation, nil +} + +func rpcError(err error) error { + switch status.Code(err) { + case codes.Unauthenticated, codes.PermissionDenied: + return problem("authentication_required", "The native session is unavailable or expired. Log in explicitly with the expected account.", 2) + case codes.DeadlineExceeded, codes.Canceled: + return problem("rpc_timeout", "The Anytype RPC timed out or was canceled. Resume the same operation ID for a mutation.", 4) + case codes.Unimplemented: + return problem("unsupported_rpc", "The running service does not implement the required RPC.", 1) + default: + return problem("rpc_unavailable", "The Anytype RPC did not complete. Resume the same operation ID for a mutation.", 4) + } +} +func protoError(method, code string) error { + if code == "NULL" { + return nil + } + exit := 2 + if code == "UNKNOWN_ERROR" || code == "REQUEST_FAILED" { + exit = 4 + } + return problem("rpc_"+strings.ToLower(code), method+" did not complete ("+code+"). Resume the same operation ID after inspection.", exit) +} +func (e *Engine) authContext(ctx context.Context) (context.Context, string, error) { + token, storage, err := e.Secrets.Get("session-token") + if err != nil || token == "" { + return nil, "", problem("authentication_required", "A native session is required. Log in with the expected account.", 2) + } + return metadata.AppendToOutgoingContext(ctx, "token", token), storage, nil +} + +func (e *Engine) account(ctx context.Context, s *runtimeinfo.Snapshot) (Account, error) { + a := Account{Id: s.AccountId, Observation: "live"} + if s.AccountTransition { + return a, problem("account_transition", "An account transition is incomplete. Resume its operation before continuing.", 2) + } + if s.AccountId == "" { + return a, nil + } + ctx, storage, err := e.authContext(ctx) + if err != nil { + return a, err + } + a.CredentialStorage = storage + if s.ProfileId == "" || s.TechSpaceId == "" { + return a, problem("account_info_unavailable", "The service has not observed complete account information.", 4) + } + response, err := e.Client.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{SpaceId: s.TechSpaceId, + Filters: []*model.BlockContentDataviewFilter{{RelationKey: bundle.RelationKeyId.String(), Condition: model.BlockContentDataviewFilter_Equal, Value: pbtypes.String(s.ProfileId)}}, + Keys: []string{bundle.RelationKeyId.String(), bundle.RelationKeyName.String()}, Limit: 2}) + if err != nil { + return a, rpcError(err) + } + if err = protoError("Read account profile", response.GetError().GetCode().String()); err != nil { + return a, err + } + if len(response.Records) != 1 || pbtypes.GetString(response.Records[0], "id") != s.ProfileId { + return a, problem("profile_unavailable", "The account profile is not yet readable.", 4) + } + a.DisplayName = pbtypes.GetString(response.Records[0], "name") + a.Authenticated = true + return a, nil +} +func guard(s *runtimeinfo.Snapshot, expected string, allowEmpty bool) error { + if expected == "" { + return problem("expected_account_required", "Provide --expect-account with the full account ID (or none for creation).", 2) + } + if s.AccountTransition { + return problem("account_transition", "An account transition is incomplete; resume its operation.", 2) + } + if expected == "none" { + if s.AccountId != "" { + return problem("account_mismatch", "An account already exists; creation cannot replace it.", 2) + } + return nil + } + if s.AccountId == expected || allowEmpty && s.AccountId == "" { + return nil + } + return problem("account_mismatch", "The live account differs from the expected account; migration requires a separate explicit workflow.", 2) +} + +func (e *Engine) Run(ctx context.Context, r Request) Envelope { + if r.Instance != "" && r.Instance != "default" { + return Failure(problem("unsupported_instance", "Only --instance default is supported; Hermes profiles do not create isolated native instances.", 1)) + } + if e.Timeout <= 0 { + e.Timeout = 45 * time.Second + } + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, e.Timeout) + defer cancel() + } + if r.Command == "capabilities" { + return Success(capabilities(), false) + } + switch r.Command { + case "inspect", "auth status", "service status": + return e.inspect(ctx, r) + case "space list", "space status", "auth apikey list": + return e.read(ctx, r) + case "auth rename": + return Failure(problem("unsupported_operation", "Account rename is not supported by this automation contract. Reuse preserves the existing name.", 1)) + } + unlock, err := localstate.Lock(e.StateDir) + if err != nil { + if errors.Is(err, localstate.ErrBusy) { + return Failure(problem("instance_busy", "Another CLI operation owns this instance. Retry with the same operation ID.", 4)) + } + return Failure(problem("coordination_failed", "Cannot acquire instance coordination.", 2)) + } + defer unlock() + if r.Command == "auth create" { + r.Name, err = ValidateName(r.Name) + if err != nil { + return Failure(err) + } + } + op, retry, err := e.operation(r) + if err != nil { + return Failure(err) + } + switch r.Command { + case "service install", "service start": + return e.serviceMutation(ctx, r, op, retry) + case "auth create", "auth login": + return e.authMutation(ctx, r, op, retry) + case "space join": + return e.join(ctx, r, op, retry) + case "auth apikey create", "auth apikey revoke": + return e.keyMutation(ctx, r, op, retry) + default: + return Failure(problem("unsupported_operation", "This command has no machine interface.", 1)) + } +} + +func (e *Engine) inspect(ctx context.Context, r Request) Envelope { + c, err := e.config() + if err != nil { + return Failure(err) + } + svc, svcErr := e.Service.Status(ctx) + result := map[string]any{"cli_version": core.GetVersion(), "instance": map[string]any{"id": "default", "config_path": e.ConfigPath, "data_path": e.DataPath, "work_path": e.WorkPath, "grpc_address": config.DefaultGRPCAddress, "grpc_web_address": config.DefaultGRPCWebAddress, "isolated": false}, "service": svc, "capabilities": capabilities(), "account": Account{Id: c.AccountId, Observation: "configured"}, "network": map[string]any{"id": c.NetworkId, "config_path": c.NetworkConfigPath, "observation": "configured"}, "api": map[string]any{"configured_origin": apiOrigin(config.ResolveAPIListenAddr("", false, c.ApiListenAddr)), "ready": false, "observation": "configured"}} + s, err := e.live(ctx) + if err != nil { + out := Failure(err) + out.Result = result + return out + } + svc.Health = "grpc_ready" + svc.RuntimePID = s.PID + if svc.Running && svc.PID > 0 && svc.PID != s.PID { + out := Failure(problem("service_instance_mismatch", "The supervisor and native RPC endpoint belong to different processes.", 2)) + out.Result = result + return out + } + result["service"] = svc + a, aErr := e.account(ctx, s) + result["account"] = a + result["network"] = map[string]any{"id": s.NetworkId, "config_path": c.NetworkConfigPath, "observation": "live"} + origin := "" + if s.APIBound && !s.AccountTransition && s.AccountId != "" { + origin = apiOrigin(s.APIAddress) + } + apiNextAction := "" + if s.APIObservation == "unverified_event_order" || s.APIObservation == "rpc_outcome_unknown" || s.APIObservation == "stop_outcome_unknown" || s.APIObservation == "concurrent_lifecycle" || s.APIObservation == "account_start_outcome_unknown" { + apiNextAction = "API binding is unverified. Explicitly restart the native service or rebind with AccountChangeJsonApiAddr, then inspect and probe with the integration credential." + if s.AccountTransition { + apiNextAction = "Account lifecycle outcome is unverified. Resume the interrupted account operation or explicitly restart the native service, then inspect before probing with the integration credential." + } + } + result["api"] = map[string]any{"origin": origin, "listen_address": s.APIAddress, "bound": origin != "", "ready": false, "readiness": "requires_integration_key_probe", "observation": "live", "binding_observation": s.APIObservation, "next_action": apiNextAction} + mcpURL := "" + if origin != "" { + mcpURL = origin + "/mcp/full" + } + result["mcp"] = map[string]any{"url": mcpURL, "authentication": "bearer", "token_source": "same_api_key", "same_grant": true, "ready": false, "readiness": "not_probed"} + if r.ExpectedAccount != "" { + if err = guard(s, r.ExpectedAccount, false); err != nil { + out := Failure(err) + out.Result = result + return out + } + } + if aErr != nil { + out := Failure(aErr) + out.Result = result + return out + } + if svcErr != nil { + out := Failure(svcErr) + out.Result = result + return out + } + return Success(result, false) +} + +func (e *Engine) read(ctx context.Context, r Request) Envelope { + s, err := e.live(ctx) + if err != nil { + return Failure(err) + } + if r.ExpectedAccount != "" { + if err = guard(s, r.ExpectedAccount, false); err != nil { + return Failure(err) + } + } + if s.AccountTransition { + return Failure(problem("account_transition", "Wait for the account transition to finish.", 4)) + } + if s.AccountId == "" { + return Failure(problem("authentication_required", "Log in before reading native membership or credentials.", 2)) + } + ctx, _, err = e.authContext(ctx) + if err != nil { + return Failure(err) + } + if r.Command == "auth apikey list" { + apps, err := e.listApps(ctx) + if err != nil { + return Failure(err) + } + result := []any{} + for _, app := range apps { + result = append(result, keyResult(app, "")) + } + return Success(map[string]any{"account_id": s.AccountId, "credentials": result}, false) + } + spaces, err := e.spaces(ctx, s.TechSpaceId) + if err != nil { + return Failure(err) + } + if r.Command == "space list" { + return Success(map[string]any{"account_id": s.AccountId, "spaces": spaces}, false) + } + for _, space := range spaces { + if space.Id == r.SpaceId { + return spaceEnvelope(space, false) + } + } + return Failure(problem("space_not_found", "No native membership record exists for this full space ID.", 2)) +} + +// saveConfig patches only fields owned by account provisioning and preserves +// unknown future settings, all existing network choices, and legacy secrets. +func (e *Engine) saveConfig(fields map[string]any) error { + data := map[string]any{} + if err := localstate.ReadJSON(e.ConfigPath, &data); err != nil && !errors.Is(err, os.ErrNotExist) { + return problem("config_unreadable", "Cannot preserve the existing CLI configuration.", 2) + } + for k, v := range fields { + data[k] = v + } + if localstate.WriteJSON(e.ConfigPath, data) != nil { + return problem("config_write_failed", "Account state exists but configuration was not saved; resume the same operation ID.", 2) + } + return nil +} diff --git a/core/automation/keys.go b/core/automation/keys.go new file mode 100644 index 0000000..634c975 --- /dev/null +++ b/core/automation/keys.go @@ -0,0 +1,270 @@ +package automation + +import ( + "context" + "slices" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +func (e *Engine) listApps(ctx context.Context) ([]*model.AccountAuthAppInfo, error) { + r, err := e.Client.AccountLocalLinkListApps(ctx, &pb.RpcAccountLocalLinkListAppsRequest{}) + if err != nil { + return nil, rpcError(err) + } + if err = protoError("List credentials", r.GetError().GetCode().String()); err != nil { + return nil, err + } + return r.App, nil +} +func keyResult(app *model.AccountAuthAppInfo, key string) map[string]any { + result := map[string]any{"credential_id": app.AppHash, "application_name": app.AppName, "scope": app.Scope.String(), "grant": grantJSON(app.Grant), "created_at": app.CreatedAt, "expires_at": app.ExpireAt} + if key != "" { + result["api_key"] = key + } + return result +} +func grantsEqual(a, b *model.AccountAuthAppGrant) bool { + if a == nil || b == nil { + return a == b + } + if a.AllSpaces != b.AllSpaces || a.Perm != b.Perm { + return false + } + as, bs := slices.Clone(a.SpaceIds), slices.Clone(b.SpaceIds) + slices.Sort(as) + slices.Sort(bs) + return slices.Equal(as, bs) +} +func (e *Engine) requestedGrant(ctx context.Context, r Request, tech string) (*model.AccountAuthAppGrant, error) { + if err := core.ValidateGrantFlags(r.Grant); err != nil { + return nil, problem("invalid_grant", err.Error(), 2) + } + var resolved []core.ResolvedSpace + if !r.Grant.AllSpaces { + native, err := e.spaces(ctx, tech) + if err != nil { + return nil, err + } + spaces := []core.SpaceListItem{} + for _, s := range native { + if s.State == "joined" || s.State == "syncing" { + spaces = append(spaces, core.SpaceListItem{SpaceId: s.Id, Name: s.Name}) + } + } + resolved, err = core.ResolveSpaces(r.Grant.Spaces, spaces, tech) + if err != nil { + return nil, problem("invalid_space_grant", "A selected space is missing or its name is ambiguous. Use full IDs from space list.", 2) + } + } + grant, err := core.BuildGrant(r.Grant, resolved) + if err != nil { + return nil, problem("invalid_grant", "An explicit space scope and permission are required.", 2) + } + slices.Sort(grant.SpaceIds) + return grant, nil +} +func (e *Engine) keyMutation(ctx context.Context, r Request, op *operation, retry bool) Envelope { + fail := func(err error) Envelope { + out := Failure(err) + out.Result = map[string]any{"operation_id": op.Id, "credential_id": op.ResourceId, "stage": op.Stage} + return out + } + s, err := e.live(ctx) + if err != nil { + return fail(err) + } + if err = guard(s, r.ExpectedAccount, false); err != nil { + return fail(err) + } + if s.AccountId == "" { + return fail(problem("authentication_required", "Log in before managing credentials.", 2)) + } + if op.AccountId != "" && op.AccountId != s.AccountId { + return fail(problem("account_mismatch", "The operation belongs to another account.", 2)) + } + op.AccountId = s.AccountId + ctx, _, err = e.authContext(ctx) + if err != nil { + return fail(err) + } + apps, err := e.listApps(ctx) + if err != nil { + return fail(err) + } + if r.Command == "auth apikey revoke" { + if r.CredentialId == "" { + return fail(problem("credential_id_required", "Pass the full credential ID to revoke.", 2)) + } + var app *model.AccountAuthAppInfo + for _, a := range apps { + if a.AppHash == r.CredentialId { + app = a + break + } + } + if app == nil { + if !retry { + if err = e.saveOp(op, "complete"); err != nil { + return fail(err) + } + } + return Success(map[string]any{"operation_id": op.Id, "credential_id": r.CredentialId, "revoked": true}, false) + } + op.ResourceId = app.AppHash + if err = e.saveOp(op, "revoke_sent"); err != nil { + return fail(err) + } + response, rpcErr := e.Client.AccountLocalLinkRevokeApp(ctx, &pb.RpcAccountLocalLinkRevokeAppRequest{AppHash: app.AppHash}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if err = protoError("Revoke credential", response.GetError().GetCode().String()); err != nil { + return fail(err) + } + verified, err := e.listApps(ctx) + if err != nil { + return fail(err) + } + for _, a := range verified { + if a.AppHash == app.AppHash { + return fail(uncertain()) + } + } + if err = e.saveOp(op, "complete"); err != nil { + return fail(err) + } + return Success(map[string]any{"operation_id": op.Id, "credential_id": app.AppHash, "revoked": true}, true) + } + if err = core.ValidateAPIKeyName(r.Name); err != nil { + return fail(problem("invalid_application_name", "An explicit application name of 1–128 UTF-8 bytes is required.", 2)) + } + grant := op.ResolvedGrant + if grant == nil { + if retry && op.Stage != "prepared" { + // Old receipts cannot prove what a mutable space name resolved to. + // Never recreate or revoke a key based on its current resolution. + return fail(uncertain()) + } + grant, err = e.requestedGrant(ctx, r, s.TechSpaceId) + if err != nil { + return fail(err) + } + op.ResolvedGrant = grant + if err = e.saveOp(op, "prepared"); err != nil { + return fail(err) + } + } + key, _, _ := e.Secrets.Get("operation-apikey-" + op.Id) + var app *model.AccountAuthAppInfo + changed := false + if retry && op.Stage != "prepared" { + for _, a := range apps { + if op.ResourceId != "" && a.AppHash == op.ResourceId || key != "" && a.AppKey == key { + app = a + break + } + } + if app == nil { + if op.Stage == "cleanup_sent" || op.Stage == "unverified_revoked" { + if err = e.saveOp(op, "unverified_revoked"); err != nil { + return fail(err) + } + return fail(problem("grant_mismatch", "The unverified newly created credential was revoked. Older credentials remain valid.", 1)) + } + return fail(uncertain()) + } + } else { + // Empty AppHash is a no-op BAD_INPUT probe; old services return Unimplemented. + probe, rpcErr := e.Client.AccountLocalLinkUpdateApp(ctx, &pb.RpcAccountLocalLinkUpdateAppRequest{}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if probe.GetError().GetCode() != pb.RpcAccountLocalLinkUpdateAppResponseError_BAD_INPUT { + return fail(problem("grant_support_unverified", "The running service did not prove scoped credential support.", 1)) + } + if err = e.saveOp(op, "create_sent"); err != nil { + return fail(err) + } + response, rpcErr := e.Client.AccountLocalLinkCreateApp(ctx, &pb.RpcAccountLocalLinkCreateAppRequest{App: &model.AccountAuthAppInfo{AppName: r.Name, Scope: model.AccountAuth_JsonAPI, Grant: grant}}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if err = protoError("Create credential", response.GetError().GetCode().String()); err != nil { + return fail(err) + } + key = response.AppKey + if key == "" { + return fail(uncertain()) + } + if _, err = e.Secrets.Set("operation-apikey-"+op.Id, key); err != nil { + return fail(problem("credential_storage_failed", "Credential may exist but its recovery receipt could not be saved. Do not create a duplicate.", 2)) + } + apps, err = e.listApps(ctx) + if err != nil { + return fail(err) + } + for _, a := range apps { + if a.AppKey == key { + app = a + break + } + } + if app == nil { + return fail(uncertain()) + } + changed = true + } + if app.AppHash == "" { + return fail(uncertain()) + } + completedBefore := op.Stage == "complete" + op.ResourceId = app.AppHash + if !completedBefore { + if err = e.saveOp(op, "verified_identity"); err != nil { + return fail(err) + } + } + if app.AppName != r.Name || app.Scope != model.AccountAuth_JsonAPI || !grantsEqual(app.Grant, grant) { + if completedBefore { + return fail(problem("grant_mismatch", "This completed operation's credential was changed externally. No token was returned and it was not revoked.", 1)) + } + if err = e.saveOp(op, "cleanup_sent"); err != nil { + return fail(err) + } + response, rpcErr := e.Client.AccountLocalLinkRevokeApp(ctx, &pb.RpcAccountLocalLinkRevokeAppRequest{AppHash: app.AppHash}) + if rpcErr != nil || response.GetError().GetCode() != pb.RpcAccountLocalLinkRevokeAppResponseError_NULL { + return fail(problem("unverified_key_cleanup_uncertain", "The newly created credential did not match its requested grant and cleanup could not be confirmed. Resume this operation; do not distribute its secret.", 2)) + } + remaining, listErr := e.listApps(ctx) + if listErr != nil { + return fail(problem("unverified_key_cleanup_uncertain", "Unverified credential revocation was sent but could not be verified. Resume this operation.", 2)) + } + for _, a := range remaining { + if a.AppHash == app.AppHash { + return fail(problem("unverified_key_cleanup_uncertain", "The unverified credential remains present; resume cleanup.", 2)) + } + } + if err = e.saveOp(op, "unverified_revoked"); err != nil { + return fail(err) + } + return fail(problem("grant_mismatch", "The newly created credential did not match its requested grant and was revoked. Older credentials remain valid.", 1)) + } + if err = e.saveOp(op, "complete"); err != nil { + return fail(err) + } + token := "" + if r.IncludeSecret { + token = app.AppKey + if token == "" { + return fail(problem("secret_unavailable", "The credential exists but its secret is unavailable for recovery.", 2)) + } + } + result := keyResult(app, token) + result["operation_id"] = op.Id + result["account_id"] = s.AccountId + result["grant_verified"] = true + return Success(result, changed) +} diff --git a/core/automation/runtime_test.go b/core/automation/runtime_test.go new file mode 100644 index 0000000..827930e --- /dev/null +++ b/core/automation/runtime_test.go @@ -0,0 +1,100 @@ +package automation + +import ( + "context" + "os" + "testing" + + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pb/service" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +type versionRPC struct { + service.ClientCommandsClient + nonce string +} + +func (v *versionRPC) AppGetVersion(_ context.Context, _ *pb.RpcAppGetVersionRequest, opts ...grpc.CallOption) (*pb.RpcAppGetVersionResponse, error) { + for _, opt := range opts { + if h, ok := opt.(grpc.HeaderCallOption); ok { + *h.HeaderAddr = metadata.Pairs(runtimeinfo.Header, v.nonce) + } + } + return &pb.RpcAppGetVersionResponse{Version: "test"}, nil +} +func TestRuntimeObservationMustMatchLiveNonceAndBinding(t *testing.T) { + for _, kind := range []string{"valid", "missing", "stale", "wrong_config", "wrong_data", "wrong_pid", "old_service"} { + t.Run(kind, func(t *testing.T) { + e, _, _ := fixture(t, false) + e.Live = nil + v := &versionRPC{nonce: "nonce-current"} + e.Client = v + s := runtimeinfo.Snapshot{SchemaVersion: 1, Nonce: v.nonce, PID: 123, ConfigPath: e.ConfigPath, DataPath: e.DataPath} + switch kind { + case "stale": + s.Nonce = "prior-process" + case "wrong_config": + s.ConfigPath += "wrong" + case "wrong_data": + s.DataPath += "wrong" + case "wrong_pid": + s.PID = 0 + case "old_service": + v.nonce = "" + } + if kind != "missing" { + if err := localstate.WriteJSON(e.RuntimePath, s); err != nil { + t.Fatal(err) + } + } + _, err := e.live(context.Background()) + if kind == "valid" && err != nil { + t.Fatal(err) + } + if kind != "valid" && err == nil { + t.Fatal("invalid runtime accepted") + } + if _, statErr := os.Stat(e.StateDir); !os.IsNotExist(statErr) { + t.Fatal("inspection created state") + } + }) + } +} + +func TestInspectionMakesUnverifiedAPIBindingActionable(t *testing.T) { + for _, observation := range []string{"unverified_event_order", "rpc_outcome_unknown", "stop_outcome_unknown", "concurrent_lifecycle"} { + e, rpc, _ := fixture(t, true) + rpc.snapshot.APIObservation = observation + rpc.snapshot.APIAddress, rpc.snapshot.APIBound = "", false + out := run(e, Request{Command: "inspect"}) + if out.ExitCode() != 0 { + t.Fatal(out) + } + result := out.Result.(map[string]any) + api := result["api"].(map[string]any) + mcp := result["mcp"].(map[string]any) + if api["origin"] != "" || api["bound"] != false || api["ready"] != false || api["binding_observation"] != observation || api["next_action"] == "" || mcp["url"] != "" || mcp["ready"] != false { + t.Fatal("unverified binding exposed endpoints or lacked next action", result) + } + } +} + +func TestInspectionExplainsUncertainAccountLifecycle(t *testing.T) { + for _, observation := range []string{"stop_outcome_unknown", "concurrent_lifecycle", "account_start_outcome_unknown"} { + e, rpc, _ := fixture(t, true) + rpc.snapshot.AccountTransition = true + rpc.snapshot.APIObservation = observation + rpc.snapshot.APIAddress, rpc.snapshot.APIBound = "", false + out := run(e, Request{Command: "inspect"}) + assertCode(t, out, "account_transition") + result := out.Result.(map[string]any) + api := result["api"].(map[string]any) + if api["origin"] != "" || api["bound"] != false || api["binding_observation"] != observation || api["next_action"] == "" || result["account"].(Account).Authenticated { + t.Fatal(out) + } + } +} diff --git a/core/automation/service.go b/core/automation/service.go new file mode 100644 index 0000000..ba313f1 --- /dev/null +++ b/core/automation/service.go @@ -0,0 +1,374 @@ +package automation + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "time" + + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/localstate" +) + +type NativeService struct { + Platform, Home, Executable string + Run func(context.Context, string, ...string) ([]byte, error) +} + +func NewNativeService() *NativeService { + home, _ := os.UserHomeDir() + exe, _ := os.Executable() + return &NativeService{runtime.GOOS, home, exe, runProcess} +} +func runProcess(ctx context.Context, name string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return exec.CommandContext(ctx, name, args...).Output() +} +func (n *NativeService) Status(ctx context.Context) (ServiceStatus, error) { + s := ServiceStatus{Name: "anytype", Health: "not_running"} + switch n.Platform { + case "darwin": + s.Manager = "launchd-user" + s.DefinitionPath = filepath.Join(n.Home, "Library/LaunchAgents/anytype.plist") + case "linux": + s.Manager = "systemd-user" + s.DefinitionPath = filepath.Join(n.Home, ".config/systemd/user/anytype.service") + default: + s.Manager = "unsupported" + return s, problem("unsupported_service_platform", "Native automation supports launchd user agents and systemd user services on this release.", 1) + } + data, err := os.ReadFile(s.DefinitionPath) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return s, problem("service_inspection_failed", "Cannot read the existing service definition.", 2) + } + s.Installed = true + sum := sha256.Sum256(data) + s.DefinitionHash = hex.EncodeToString(sum[:]) + var args []string + effectiveOverrides := false + if n.Platform == "darwin" { + args, effectiveOverrides = plistArguments(data) + out, err := n.Run(ctx, "launchctl", "list", "anytype") + if err == nil { + s.PID = launchdPID(string(out)) + s.Running = s.PID > 0 + } + } else { + // kardianos/service quotes each ExecStart token. Accept its generated format + // only; arbitrary externally managed unit syntax is reported unverified. + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "ExecStart=") { + args = parseUnitArgs(strings.TrimPrefix(line, "ExecStart=")) + } + } + out, err := n.Run(ctx, "systemctl", "--user", "show", "anytype.service", "--property=ActiveState,MainPID,FragmentPath,DropInPaths,Environment,LoadState,NeedDaemonReload,ExecStart") + if err != nil { + return s, problem("service_manager_unavailable", "The user service manager is unavailable.", 4) + } + properties := map[string]string{} + for _, line := range strings.Split(string(out), "\n") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + properties[parts[0]] = parts[1] + } + } + s.Running = properties["ActiveState"] == "active" + s.PID, _ = strconv.Atoi(properties["MainPID"]) + effectiveOverrides = properties["FragmentPath"] != s.DefinitionPath || properties["DropInPaths"] != "" || properties["Environment"] != "" + // The file can differ from the manager's cached definition. Never start + // that cached command or silently reload an externally modified service. + loadedPath, loadedArgs := loadedUnitCommand(properties["ExecStart"]) + if properties["LoadState"] != "loaded" || properties["NeedDaemonReload"] != "no" || len(args) == 0 || loadedPath != args[0] || !slices.Equal(loadedArgs, args) { + effectiveOverrides = true + } + + } + if s.Running { + s.Health = "running" + } + if len(args) > 0 { + s.Executable = args[0] + } + s.ListenAddress = config.DefaultAPIAddress + if len(args) >= 2 && args[1] == "serve" { + recognized := true + explicitAddress := false + for i := 2; i < len(args); i++ { + if args[i] == "--listen-address" && i+1 < len(args) { + i++ + s.ListenAddress = args[i] + explicitAddress = true + } else { + recognized = false + } + } + if !explicitAddress { + // The foreground serve command inherits the persisted address when + // its service definition does not set --listen-address. + var saved config.Config + err := localstate.ReadJSON(filepath.Join(n.Home, config.AnytypeDirName, config.ConfigFileName), &saved) + if err != nil && !errors.Is(err, os.ErrNotExist) { + recognized = false + } else { + s.ListenAddress = config.ResolveAPIListenAddr("", false, saved.ApiListenAddr) + } + } + // Data-path/environment overrides are not currently modeled per instance. + if n.Platform == "darwin" && (bytes.Contains(data, []byte("EnvironmentVariables")) || bytes.Contains(data, []byte("RootDirectory")) || bytes.Contains(data, []byte("UserName"))) { + recognized = false + } + if n.Platform == "linux" { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "EnvironmentFile=") { + if line != "EnvironmentFile=-/etc/sysconfig/anytype" { + recognized = false + } else if _, err := os.Stat("/etc/sysconfig/anytype"); !errors.Is(err, os.ErrNotExist) { + recognized = false + } + } else if strings.HasPrefix(line, "Environment=") || strings.HasPrefix(line, "RootDirectory=") || strings.HasPrefix(line, "User=") { + recognized = false + } + } + } + if effectiveOverrides { + recognized = false + } + executableMatches := sameExecutable(s.Executable, n.Executable) + s.BindingVerified = recognized && executableMatches && validateAddress(s.ListenAddress) == nil + } + return s, nil +} + +// loadedUnitCommand accepts the single ExecStart structure printed by +// systemctl show. Unrecognized/ambiguous escaping or multiple commands remain +// unverified; there is no shell evaluation or supervisor mutation here. +func loadedUnitCommand(value string) (string, []string) { + if !strings.HasPrefix(value, "{ ") || !strings.HasSuffix(value, " }") { + return "", nil + } + inner := value[2 : len(value)-2] + if strings.ContainsAny(inner, "{}\n") { + return "", nil + } + fields := map[string]string{} + for _, field := range strings.Split(inner, " ; ") { + parts := strings.SplitN(field, "=", 2) + if len(parts) != 2 { + return "", nil + } + if _, duplicate := fields[parts[0]]; duplicate { + return "", nil + } + fields[parts[0]] = parts[1] + } + if fields["ignore_errors"] != "no" { + return "", nil + } + return fields["path"], parseUnitArgs(fields["argv[]"]) +} +func sameExecutable(a, b string) bool { + aa, ea := filepath.EvalSymlinks(a) + bb, eb := filepath.EvalSymlinks(b) + return ea == nil && eb == nil && aa == bb +} +func plistArguments(data []byte) (args []string, unsupported bool) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + found := false + for { + token, err := decoder.Token() + if err == io.EOF { + return args, unsupported + } + if err != nil { + return nil, true + } + if start, ok := token.(xml.StartElement); ok && start.Name.Local == "key" { + var key string + if decoder.DecodeElement(&key, &start) != nil { + return nil, true + } + // launchd Program overrides the executable in ProgramArguments. + // This release only verifies the service library's no-override form. + if key == "Program" || key == "ProgramArguments" && args != nil { + unsupported = true + } + found = key == "ProgramArguments" + continue + } + if start, ok := token.(xml.StartElement); ok && start.Name.Local == "array" && found { + var array struct { + Values []string `xml:"string"` + } + if decoder.DecodeElement(&array, &start) != nil { + return nil, true + } + args = array.Values + found = false + } + } +} +func parseUnitArgs(value string) []string { + // The service library uses Go-style double quoted arguments. Do not evaluate + // shell syntax, specifiers, environment expansion, or systemd command prefixes. + var result []string + for len(strings.TrimSpace(value)) > 0 { + value = strings.TrimSpace(value) + if value[0] != '"' { + i := strings.IndexAny(value, " \t") + if i < 0 { + i = len(value) + } + token := value[:i] + if strings.ContainsAny(token, "%$\\'`") { + return nil + } + result = append(result, token) + value = value[i:] + continue + } + end := 1 + for end < len(value) { + if value[end] == '\\' { + end += 2 + continue + } + if value[end] == '"' { + break + } + end++ + } + if end >= len(value) { + return nil + } + token, err := strconv.Unquote(value[:end+1]) + if err != nil || strings.ContainsAny(token, "%$") { + return nil + } + result = append(result, token) + value = value[end+1:] + } + return result +} +func launchdPID(output string) int { + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "\"PID\"") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + pid, _ := strconv.Atoi(strings.Trim(strings.TrimSpace(parts[1]), ";")) + return pid + } + } + } + return 0 +} +func (n *NativeService) child(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, n.Executable, args...) + cmd.Env = append(os.Environ(), "ANYTYPE_CLI_LOCK_PARENT_PID="+strconv.Itoa(os.Getpid())) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if cmd.Run() != nil { + return problem("service_action_unavailable", "The service action did not complete. Inspect status and resume the same operation ID.", 4) + } + return nil +} +func (n *NativeService) Install(ctx context.Context, addr string) error { + return n.child(ctx, "--no-update-check", "service", "install", "--listen-address", addr) +} +func (n *NativeService) Start(ctx context.Context) error { + return n.child(ctx, "--no-update-check", "service", "start") +} +func (e *Engine) serviceMutation(ctx context.Context, r Request, op *operation, retry bool) Envelope { + c, err := e.config() + if err != nil { + return Failure(err) + } + svc, err := e.Service.Status(ctx) + if err != nil { + return Failure(err) + } + addr := config.ResolveAPIListenAddr("", false, c.ApiListenAddr) + if r.ListenAddress != "" { + addr = r.ListenAddress + } + if err = validateAddress(addr); err != nil { + return Failure(err) + } + if c.AccountId != "" && addr != config.ResolveAPIListenAddr("", false, c.ApiListenAddr) { + return Failure(problem("settings_conflict", "Service provisioning cannot change an existing account's listener.", 2)) + } + if os.Getenv("DATA_PATH") != "" { + return Failure(problem("unsupported_service_binding", "Automatic service management does not support DATA_PATH overrides. Use an externally managed instance.", 1)) + } + if svc.Installed { + if !svc.BindingVerified || svc.ListenAddress != addr { + return Failure(problem("service_binding_conflict", "An existing service has a different or unverified binding; it will not be replaced or reinstalled.", 2)) + } + if op.ResourceId != "" && op.ResourceId != svc.DefinitionHash { + return Failure(problem("service_binding_conflict", "The service definition changed during this operation.", 2)) + } + } else if r.Command == "service start" { + return Failure(problem("service_not_installed", "Install the service explicitly before starting it.", 2)) + } + changed := false + if r.Command == "service install" && !svc.Installed { + // Do not install over an independently running native server. + if _, liveErr := e.live(ctx); liveErr == nil { + return Failure(problem("external_service_running", "A native server is already running without this service definition. Reuse it or manage it externally.", 2)) + } else { + var detail *Error + if !errors.As(liveErr, &detail) || detail.Code != "rpc_unavailable" { + return Failure(liveErr) + } + } + if retry && op.Stage != "prepared" { + return Failure(uncertain()) + } + if err = e.saveOp(op, "install_sent"); err != nil { + return Failure(err) + } + if err = e.Service.Install(ctx, addr); err != nil { + return Failure(err) + } + changed = true + } + if r.Command == "service start" && !svc.Running { + op.ResourceId = svc.DefinitionHash + if err = e.saveOp(op, "start_sent"); err != nil { + return Failure(err) + } + if err = e.Service.Start(ctx); err != nil { + return Failure(err) + } + changed = true + } + observed, err := e.Service.Status(ctx) + if err != nil { + return Failure(err) + } + if !observed.Installed || !observed.BindingVerified || observed.ListenAddress != addr { + return Failure(problem("service_binding_conflict", "Service installation could not be verified; inspect it before resuming.", 2)) + } + if r.Command == "service start" && !observed.Running { + return Failure(problem("service_start_pending", "The supervisor has not yet reported the service running. Retry status.", 4)) + } + op.ResourceId = observed.DefinitionHash + if err = e.saveOp(op, "complete"); err != nil { + return Failure(err) + } + return Success(map[string]any{"operation_id": op.Id, "service": observed, "api_ready": false, "next_probe": "inspect; API availability is independent of installation/start"}, changed) +} diff --git a/core/automation/service_test.go b/core/automation/service_test.go new file mode 100644 index 0000000..8a9d540 --- /dev/null +++ b/core/automation/service_test.go @@ -0,0 +1,196 @@ +package automation + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/localstate" +) + +func TestNativeServiceInspectionDoesNotCreateDirectories(t *testing.T) { + dir := t.TempDir() + called := false + n := &NativeService{Platform: "darwin", Home: dir, Run: func(context.Context, string, ...string) ([]byte, error) { called = true; return nil, nil }} + s, err := n.Status(context.Background()) + if err != nil || s.Installed || called { + t.Fatal(s, err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Fatal("read created directories") + } +} +func TestLaunchdBindingAndSupervisorPID(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "anytype") + if err := os.WriteFile(exe, []byte("fixture"), 0700); err != nil { + t.Fatal(err) + } + definition := filepath.Join(dir, "Library/LaunchAgents/anytype.plist") + if err := os.MkdirAll(filepath.Dir(definition), 0700); err != nil { + t.Fatal(err) + } + xml := `ProgramArguments` + exe + `serve--listen-address127.0.0.1:4000` + if err := os.WriteFile(definition, []byte(xml), 0600); err != nil { + t.Fatal(err) + } + n := &NativeService{Platform: "darwin", Home: dir, Executable: exe, Run: func(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "launchctl" || strings.Join(args, " ") != "list anytype" { + t.Fatal(name, args) + } + return []byte(`{ "PID" = 123;`), nil + }} + s, err := n.Status(context.Background()) + if err != nil || !s.Installed || !s.BindingVerified || s.ListenAddress != "127.0.0.1:4000" || s.PID != 123 || s.DefinitionHash == "" { + t.Fatal(s, err) + } +} +func TestUnitParserDoesNotEvaluateShellOrEnvironment(t *testing.T) { + if got := parseUnitArgs(`"/opt/Anytype CLI/anytype" "serve" "--listen-address" "127.0.0.1:4000"`); len(got) != 4 || got[0] != "/opt/Anytype CLI/anytype" { + t.Fatal(got) + } + for _, value := range []string{`/bin/anytype $ARGS`, `/bin/anytype %h/serve`, `"unterminated`} { + if got := parseUnitArgs(value); got != nil { + t.Fatal(got) + } + } +} + +func TestServiceDefinitionInheritsSavedListenAddress(t *testing.T) { + for _, platform := range []string{"darwin", "linux"} { + for _, explicit := range []bool{false, true} { + dir := t.TempDir() + exe := filepath.Join(dir, "anytype") + if err := os.WriteFile(exe, []byte("fixture"), 0700); err != nil { + t.Fatal(err) + } + if err := localstate.WriteJSON(filepath.Join(dir, config.AnytypeDirName, config.ConfigFileName), config.Config{ApiListenAddr: "127.0.0.1:4500"}); err != nil { + t.Fatal(err) + } + definition := filepath.Join(dir, "Library/LaunchAgents/anytype.plist") + content := `ProgramArguments` + exe + `serve` + if explicit { + content += `--listen-address127.0.0.1:4600` + } + content += `` + if platform == "linux" { + definition = filepath.Join(dir, ".config/systemd/user/anytype.service") + content = "[Service]\nExecStart=" + exe + " serve" + if explicit { + content += " --listen-address 127.0.0.1:4600" + } + } + if err := os.MkdirAll(filepath.Dir(definition), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(definition, []byte(content), 0600); err != nil { + t.Fatal(err) + } + n := &NativeService{Platform: platform, Home: dir, Executable: exe, Run: func(context.Context, string, ...string) ([]byte, error) { + args := exe + " serve" + if explicit { + args += " --listen-address 127.0.0.1:4600" + } + return []byte("ActiveState=inactive\nFragmentPath=" + definition + "\nLoadState=loaded\nNeedDaemonReload=no\nExecStart={ path=" + exe + " ; argv[]=" + args + " ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0 }"), nil + }} + s, err := n.Status(context.Background()) + want := "127.0.0.1:4500" + if explicit { + want = "127.0.0.1:4600" + } + if err != nil || !s.BindingVerified || s.ListenAddress != want { + t.Fatal(platform, explicit, s, err) + } + } + } +} + +func TestSystemdLoadedBindingMustMatchCurrentDefinition(t *testing.T) { + for _, scenario := range []string{"matching", "stale", "wrong_executable", "wrong_listener", "missing_reload_state", "unloaded", "multiple_commands", "unparseable"} { + t.Run(scenario, func(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "anytype") + if err := os.WriteFile(exe, []byte("fixture"), 0700); err != nil { + t.Fatal(err) + } + definition := filepath.Join(dir, ".config/systemd/user/anytype.service") + if err := os.MkdirAll(filepath.Dir(definition), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(definition, []byte("[Service]\nExecStart="+exe+" serve --listen-address 127.0.0.1:4500\n"), 0600); err != nil { + t.Fatal(err) + } + loadedExe, listener, reload, loadState := exe, "127.0.0.1:4500", "no", "loaded" + switch scenario { + case "stale": + reload = "yes" + case "wrong_executable": + loadedExe = "/old/anytype" + case "wrong_listener": + listener = "127.0.0.1:4600" + case "missing_reload_state": + reload = "" + case "unloaded": + loadState = "not-found" + } + command := "{ path=" + loadedExe + " ; argv[]=" + loadedExe + " serve --listen-address " + listener + " ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0 }" + if scenario == "multiple_commands" { + command += " " + command + } + if scenario == "unparseable" { + command = "unknown" + } + calls := 0 + n := &NativeService{Platform: "linux", Home: dir, Executable: exe, Run: func(_ context.Context, name string, args ...string) ([]byte, error) { + calls++ + if name != "systemctl" || len(args) != 4 || args[1] != "show" || !strings.Contains(args[3], "NeedDaemonReload,ExecStart") { + t.Fatal("unexpected supervisor operation", name, args) + } + return []byte("ActiveState=inactive\nMainPID=0\nFragmentPath=" + definition + "\nLoadState=" + loadState + "\nNeedDaemonReload=" + reload + "\nExecStart=" + command), nil + }} + s, err := n.Status(context.Background()) + if err != nil || s.BindingVerified != (scenario == "matching") || calls != 1 { + t.Fatal(s, err, calls) + } + if scenario != "matching" { + e, _, _ := fixture(t, false) + e.Service = n + for _, command := range []string{"service install", "service start"} { + assertCode(t, run(e, Request{Command: command, OperationId: "service-loaded", ListenAddress: "127.0.0.1:4500"}), "service_binding_conflict") + } + } + }) + } +} + +func TestLaunchdProgramOverrideIsNeverVerified(t *testing.T) { + for _, before := range []bool{true, false} { + dir := t.TempDir() + exe := filepath.Join(dir, "anytype") + if err := os.WriteFile(exe, []byte("fixture"), 0700); err != nil { + t.Fatal(err) + } + arguments := `ProgramArguments` + exe + `serve` + override := `Program/some/other/executable` + content := arguments + override + if before { + content = override + arguments + } + definition := filepath.Join(dir, "Library/LaunchAgents/anytype.plist") + if err := os.MkdirAll(filepath.Dir(definition), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(definition, []byte(``+content+``), 0600); err != nil { + t.Fatal(err) + } + n := &NativeService{Platform: "darwin", Home: dir, Executable: exe, Run: func(context.Context, string, ...string) ([]byte, error) { return nil, nil }} + s, err := n.Status(context.Background()) + if err != nil || !s.Installed || s.BindingVerified { + t.Fatal(s, err) + } + } +} diff --git a/core/automation/spaces.go b/core/automation/spaces.go new file mode 100644 index 0000000..b94ee84 --- /dev/null +++ b/core/automation/spaces.go @@ -0,0 +1,222 @@ +package automation + +import ( + "context" + "net/url" + "strings" + + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +type Space struct { + Id string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + LocalStatus string `json:"local_status"` + AccountStatus string `json:"account_status"` + ParticipantStatus string `json:"participant_status,omitempty"` + HTTPReady bool `json:"http_ready"` +} + +func (e *Engine) spaces(ctx context.Context, tech string) ([]Space, error) { + if tech == "" { + return nil, problem("account_info_unavailable", "The account's native membership index is unavailable.", 4) + } + // Do not filter out joining/rejected spaces as the human list command does. + r, err := e.Client.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{SpaceId: tech, Filters: []*model.BlockContentDataviewFilter{{RelationKey: bundle.RelationKeyResolvedLayout.String(), Condition: model.BlockContentDataviewFilter_Equal, Value: pbtypes.Int64(int64(model.ObjectType_spaceView))}}, Keys: []string{"targetSpaceId", "name", "spaceLocalStatus", "spaceAccountStatus", "myParticipantStatus"}}) + if err != nil { + return nil, rpcError(err) + } + if err = protoError("List spaces", r.GetError().GetCode().String()); err != nil { + return nil, err + } + spaces := []Space{} + for _, v := range r.Records { + id := pbtypes.GetString(v, "targetSpaceId") + if id == "" { + continue + } + local := model.SpaceStatus(pbtypes.GetInt64(v, "spaceLocalStatus")) + account := model.SpaceStatus(pbtypes.GetInt64(v, "spaceAccountStatus")) + s := Space{Id: id, Name: pbtypes.GetString(v, "name"), State: "unknown", LocalStatus: local.String(), AccountStatus: account.String()} + participant, hasParticipant := v.Fields["myParticipantStatus"] + if hasParticipant { + p := model.ParticipantStatus(int32(participant.GetNumberValue())) + s.ParticipantStatus = p.String() + switch p { + case model.ParticipantStatus_Declined: + s.State = "rejected" + case model.ParticipantStatus_Joining: + s.State = "pending_approval" + case model.ParticipantStatus_Removed, model.ParticipantStatus_Removing, model.ParticipantStatus_Canceled: + s.State = "removed" + } + } + // Heart's rejected join retains participant Joining while its account + // status becomes SpaceDeleted. Terminal account state wins over it. + if account == model.SpaceStatus_SpaceDeleted { + if s.ParticipantStatus == model.ParticipantStatus_Joining.String() || s.State == "rejected" { + s.State = "rejected" + } else { + s.State = "removed" + } + } else if account == model.SpaceStatus_SpaceRemoving { + s.State = "removed" + } + if s.State == "unknown" { + switch account { + case model.SpaceStatus_SpaceJoining: + s.State = "pending_approval" + case model.SpaceStatus_SpaceActive: + if local == model.SpaceStatus_Ok { + s.State = "joined" + } else { + s.State = "syncing" + } + case model.SpaceStatus_Unknown: + // Owned/personal spaces need not have an account-tree status. + // A loaded local space still proves native membership. + if local == model.SpaceStatus_Ok { + s.State = "joined" + } + } + } + spaces = append(spaces, s) + } + return spaces, nil +} +func spaceEnvelope(s Space, changed bool) Envelope { + out := Success(s, changed) + switch s.State { + case "pending_approval", "request_sent", "syncing": + out.State = s.State + out.NextAction = "Poll space status with the full space ID; do not submit the invitation again." + case "rejected", "removed", "unknown": + out = Failure(problem("membership_"+s.State, "Native membership is "+s.State+". Inspect the membership with its operator.", 2)) + out.Result = s + out.Changed = changed + } + return out +} +func invitation(value string) (string, string, error) { + u, err := url.Parse(strings.TrimSpace(value)) + if err != nil { + return "", "", problem("invalid_invitation", "Private input is not a supported invitation URL.", 2) + } + cid, key := "", "" + switch u.Scheme { + case "http", "https": + if u.Host != "" { + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + cid = parts[len(parts)-1] + key = u.Fragment + } + case "anytype": + if u.Host == "invite" || strings.HasPrefix(u.Path, "/invite") { + cid = u.Query().Get("cid") + key = u.Query().Get("key") + } + } + if cid == "" || key == "" { + return "", "", problem("invalid_invitation", "Private input must contain an invitation CID and key.", 2) + } + return cid, key, nil +} +func (e *Engine) join(ctx context.Context, r Request, op *operation, retry bool) Envelope { + fail := func(err error) Envelope { + out := Failure(err) + out.Result = map[string]any{"operation_id": op.Id, "space_id": op.ResourceId, "stage": op.Stage} + return out + } + s, err := e.live(ctx) + if err != nil { + return fail(err) + } + if err = guard(s, r.ExpectedAccount, false); err != nil { + return fail(err) + } + if op.AccountId != "" && op.AccountId != s.AccountId { + return fail(problem("account_mismatch", "This join operation belongs to another account.", 2)) + } + op.AccountId = s.AccountId + ctx, _, err = e.authContext(ctx) + if err != nil { + return fail(err) + } + cid, key := "", "" + if r.Secret != "" { + cid, key, err = invitation(r.Secret) + if err != nil { + return fail(err) + } + invite, rpcErr := e.Client.SpaceInviteView(ctx, &pb.RpcSpaceInviteViewRequest{InviteCid: cid, InviteFileKey: key}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if err = protoError("View invitation", invite.GetError().GetCode().String()); err != nil { + return fail(err) + } + if invite.SpaceId == "" { + return fail(problem("invalid_invitation", "The invitation does not identify a space.", 2)) + } + if op.ResourceId != "" && op.ResourceId != invite.SpaceId || r.SpaceId != "" && r.SpaceId != invite.SpaceId { + return fail(problem("operation_conflict", "The invitation targets a different space from this operation.", 2)) + } + op.ResourceId = invite.SpaceId + } + if op.ResourceId == "" { + return fail(problem("private_input_required", "Provide the invitation through --invite-stdin on the first join attempt.", 2)) + } + spaces, err := e.spaces(ctx, s.TechSpaceId) + if err != nil { + return fail(err) + } + for _, space := range spaces { + if space.Id == op.ResourceId && space.State != "unknown" { + if err = e.saveOp(op, "observed"); err != nil { + return fail(err) + } + out := spaceEnvelope(space, false) + out.Result = map[string]any{"operation_id": op.Id, "space": space} + return out + } + } + if retry && op.Stage != "prepared" { + return fail(uncertain()) + } + if key == "" { + return fail(problem("private_input_required", "Re-enter the invitation privately; no join request was recorded.", 2)) + } + if s.NetworkId == "" { + return fail(problem("network_unavailable", "The live account network is unknown; refusing to guess the invitation network.", 2)) + } + if err = e.saveOp(op, "join_sent"); err != nil { + return fail(err) + } + response, rpcErr := e.Client.SpaceJoin(ctx, &pb.RpcSpaceJoinRequest{NetworkId: s.NetworkId, SpaceId: op.ResourceId, InviteCid: cid, InviteFileKey: key}) + if rpcErr != nil { + return fail(rpcError(rpcErr)) + } + if err = protoError("Join space", response.GetError().GetCode().String()); err != nil { + return fail(err) + } + if err = e.saveOp(op, "request_sent"); err != nil { + return fail(err) + } + spaces, err = e.spaces(ctx, s.TechSpaceId) + if err == nil { + for _, space := range spaces { + if space.Id == op.ResourceId { + out := spaceEnvelope(space, true) + out.Result = map[string]any{"operation_id": op.Id, "space": space} + return out + } + } + } + out := spaceEnvelope(Space{Id: op.ResourceId, State: "request_sent"}, true) + out.Result = map[string]any{"operation_id": op.Id, "space": out.Result} + return out +} diff --git a/core/automation/storage.go b/core/automation/storage.go new file mode 100644 index 0000000..09da42b --- /dev/null +++ b/core/automation/storage.go @@ -0,0 +1,68 @@ +package automation + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + + "github.com/anyproto/anytype-cli/core/credentials" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +var errMissing = os.ErrNotExist +var operationPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$`) + +type operation struct { + SchemaVersion int `json:"schema_version"` + Id string `json:"id"` + Binding string `json:"binding"` + Intent string `json:"intent"` + Command string `json:"command"` + AccountId string `json:"account_id,omitempty"` + Settings string `json:"settings,omitempty"` + Stage string `json:"stage"` + ResourceId string `json:"resource_id,omitempty"` + ResolvedGrant *model.AccountAuthAppGrant `json:"resolved_grant,omitempty"` +} + +func (e *Engine) opPath(id string) string { return filepath.Join(e.StateDir, "operations", id+".json") } +func (e *Engine) saveOp(op *operation, stage string) error { + op.Stage = stage + if localstate.WriteJSON(e.opPath(op.Id), op) != nil { + return problem("journal_write_failed", "Cannot persist operation progress; resume the same operation ID.", 2) + } + return nil +} +func (e *Engine) operation(r Request) (*operation, bool, error) { + if !operationPattern.MatchString(r.OperationId) { + return nil, false, problem("operation_id_required", "Provide an operation ID of 8–128 letters, digits, hyphens or underscores.", 2) + } + // Secret values and output preferences deliberately do not enter the journal. + intent, _ := json.Marshal(struct { + Command, Expected, Name, Network, Address, Space, Credential string + Grant any + }{r.Command, r.ExpectedAccount, r.Name, r.NetworkConfig, r.ListenAddress, r.SpaceId, r.CredentialId, r.Grant}) + digest := sha256.Sum256(intent) + op := &operation{SchemaVersion: 1, Id: r.OperationId, Binding: e.binding(), Intent: hex.EncodeToString(digest[:]), Command: r.Command, Stage: "prepared"} + previous := new(operation) + err := localstate.ReadJSON(e.opPath(op.Id), previous) + if err == nil { + if previous.SchemaVersion != 1 || previous.Binding != op.Binding || previous.Intent != op.Intent { + return nil, false, problem("operation_conflict", "This operation ID belongs to a different instance or request. Restore the original request.", 2) + } + return previous, true, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, false, problem("journal_unreadable", "Cannot safely read the operation record.", 2) + } + return op, false, nil +} + +type PrivateSecrets = credentials.Store + +var NewSecrets = credentials.New diff --git a/core/automation/types.go b/core/automation/types.go new file mode 100644 index 0000000..d8bdd69 --- /dev/null +++ b/core/automation/types.go @@ -0,0 +1,203 @@ +// Package automation implements the versioned, noninteractive CLI contract. +package automation + +import ( + "context" + "errors" + "net" + "path/filepath" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + "github.com/anyproto/anytype-heart/core/domain" + "github.com/anyproto/anytype-heart/pb/service" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +const SchemaVersion = 1 + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + Exit int `json:"-"` +} + +func (e *Error) Error() string { return e.Message } +func problem(code, message string, exit int) *Error { return &Error{code, message, exit == 4, exit} } +func uncertain() *Error { + return problem("operation_uncertain", "The operation may have completed. Inspect its recorded result; do not repeat it with a new operation ID.", 2) +} + +type Envelope struct { + SchemaVersion int `json:"schema_version"` + State string `json:"state"` + Changed bool `json:"changed"` + Result any `json:"result"` + NextAction string `json:"next_action"` + Error *Error `json:"error,omitempty"` +} + +func Success(result any, changed bool) Envelope { + return Envelope{SchemaVersion: SchemaVersion, State: "ready", Changed: changed, Result: result} +} +func Failure(err error) Envelope { + var e *Error + if !errors.As(err, &e) { + e = problem("internal_error", "The CLI could not complete the operation; no credentials are included in this diagnostic.", 1) + } + state := "needs_input" + if e.Exit == 4 { + state = "unavailable" + } + if e.Exit == 1 { + state = "incompatible" + } + return Envelope{SchemaVersion: SchemaVersion, State: state, NextAction: e.Message, Error: e} +} +func (e Envelope) ExitCode() int { + if e.Error != nil { + return e.Error.Exit + } + if e.State == "pending_approval" || e.State == "syncing" || e.State == "request_sent" { + return 3 + } + return 0 +} + +type Request struct { + Command string + Instance string + OperationId string + ExpectedAccount string + Name string + Secret string + IncludeSecret bool + NetworkConfig string + ListenAddress string + SpaceId string + CredentialId string + Grant core.GrantFlags +} + +type Account struct { + Id string `json:"id,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Authenticated bool `json:"authenticated"` + Observation string `json:"observation"` + CredentialStorage string `json:"credential_storage,omitempty"` +} + +type ServiceStatus struct { + Name string `json:"name"` + Manager string `json:"manager"` + DefinitionPath string `json:"definition_path,omitempty"` + DefinitionHash string `json:"definition_hash,omitempty"` + Executable string `json:"executable,omitempty"` + Installed bool `json:"installed"` + Running bool `json:"running"` + BindingVerified bool `json:"binding_verified"` + ListenAddress string `json:"listen_address,omitempty"` + RuntimePID int `json:"runtime_pid,omitempty"` + PID int `json:"pid,omitempty"` + Health string `json:"health"` +} + +type ServiceManager interface { + Status(context.Context) (ServiceStatus, error) + Install(context.Context, string) error + Start(context.Context) error +} + +type Secrets interface { + Get(string) (string, string, error) + Set(string, string) (string, error) +} + +type Engine struct { + Client service.ClientCommandsClient + ConfigPath string + DataPath string + WorkPath string + StateDir string + RuntimePath string + Secrets Secrets + Service ServiceManager + Live func(context.Context) (*runtimeinfo.Snapshot, error) + IdentityFromKey func(string) (string, error) + Timeout time.Duration +} + +func (e *Engine) config() (*config.Config, error) { + c := new(config.Config) + err := localstate.ReadJSON(e.ConfigPath, c) + if errors.Is(err, errMissing) { + return c, nil + } + if err != nil { + return nil, problem("config_unreadable", "Cannot read the selected instance configuration.", 2) + } + return c, nil +} + +func ValidateName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" || !utf8.ValidString(name) { + return "", problem("invalid_name", "An explicit nonempty UTF-8 display name is required.", 2) + } + for _, r := range name { + if unicode.IsControl(r) { + return "", problem("invalid_name", "Display names cannot contain control characters.", 2) + } + } + // Heart's AccountCreate/profile SetDetails path has no name-specific maximum. + // This is an explicit CLI input bound, not an invented Heart limit. + if len(name) > 4096 { + return "", problem("invalid_name", "The CLI accepts display names up to 4096 UTF-8 bytes.", 2) + } + return name, nil +} + +func capabilities() map[string]any { + return map[string]any{ + "instance_selection": []string{"default"}, "isolated_instances": false, "account_rename": false, + "account_create": true, "account_login": true, "space_join": true, "scoped_api_keys": true, + "operation_recovery": "durable_checkpoints_with_explicit_uncertainty", "coordination": "same-version CLI commands and service auto-login", + "credential_storage": []string{"system_keyring", "private_file"}, + "credential_backend_selection": map[string]any{"environment": "ANYTYPE_CLI_CREDENTIAL_BACKEND", "values": []string{"private_file"}, "default": "system_keyring_with_private_file_fallback", "existing_markers_preserved": true}, + "limits": map[string]any{"display_name_max_bytes": 4096, "display_name_limit_source": "CLI input bound; Heart has no name-specific bound", "application_name_max_bytes": domain.MaxIntegrationNameLen}, + "embedded_http_api": map[string]any{"versions": []string{"v1", "v2"}, "whoami": "/v2/auth/whoami", "readiness": "must_probe_with_integration_key"}, + "embedded_http_mcp": map[string]any{"path": "/mcp/full", "other_paths": []string{"/mcp/small", "/mcp/large"}, "transport": "streamable_http", "stateless": true, "authentication": "bearer", "token_source": "same_api_key", "same_grant": true, "protocol_versions": []string{"2025-06-18", "2025-11-25"}, "auth_whoami_tool": false, "readiness": "not_probed"}, + "invitation_expiry_distinguishable": false, + } +} + +func apiOrigin(addr string) string { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "" + } + if host == "0.0.0.0" || host == "::" || host == "" { + host = "127.0.0.1" + } + return "http://" + net.JoinHostPort(host, port) +} +func (e *Engine) binding() string { + return filepath.Clean(e.ConfigPath) + "|" + filepath.Clean(e.DataPath) +} +func grantJSON(g *model.AccountAuthAppGrant) any { + if g == nil { + return nil + } + perm := "read" + if g.Perm == model.AccountAuthAppGrant_ReadWrite { + perm = "read_write" + } + return map[string]any{"all_spaces": g.AllSpaces, "space_ids": g.SpaceIds, "permission": perm} +} diff --git a/core/credentials/store.go b/core/credentials/store.go new file mode 100644 index 0000000..dd1efae --- /dev/null +++ b/core/credentials/store.go @@ -0,0 +1,183 @@ +package credentials + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/zalando/go-keyring" +) + +const BackendEnv = "ANYTYPE_CLI_CREDENTIAL_BACKEND" + +type storedValue struct { + Secret string `json:"secret,omitempty"` + Backend string `json:"backend"` +} + +// Store reads the existing CLI keyring without test writes. New secrets +// use the keyring when possible and a separate private, atomic file otherwise. +// The file is authoritative when a fallback was needed (a stale keyring entry +// must never shadow a newer credential). +type Store struct { + Dir string + ConfigPath string + GetKeyring func(string, string) (string, error) + SetKeyring func(string, string, string) error + DeleteKeyring func(string, string) error + // Backend is empty (automatic) or private_file. The explicit file selector + // leaves unmarked legacy keyring entries alone; existing markers still win. + Backend string +} + +func New(dir, configPath string) *Store { + return &Store{Dir: dir, ConfigPath: configPath, GetKeyring: keyring.Get, SetKeyring: keyring.Set, DeleteKeyring: keyring.Delete, Backend: os.Getenv(BackendEnv)} +} +func (s *Store) file(name string) string { + sum := sha256.Sum256([]byte(name)) + return filepath.Join(s.Dir, "secrets", hex.EncodeToString(sum[:])+".json") +} +func (s *Store) Get(name string) (string, string, error) { + selected, err := s.selectedBackend() + if err != nil { + return "", "", err + } + var value storedValue + if err := privateRead(s.file(name), &value); err == nil { + if value.Backend == "system_keyring" { + v, err := s.GetKeyring("anytype-cli", name) + return v, "system_keyring", err + } + if value.Backend == "private_file" { + return value.Secret, "private_file", nil + } + return "", "", errors.New("unknown credential marker backend") + } else if !errors.Is(err, os.ErrNotExist) { + return "", "", err + } + var v string + if selected != "private_file" { + var err error + v, err = s.GetKeyring("anytype-cli", name) + if err != nil && !errors.Is(err, keyring.ErrNotFound) && !errors.Is(err, os.ErrNotExist) { + return "", "system_keyring", err + } + if err == nil && v != "" { + return v, "system_keyring", nil + } + } + if name == "account-key" || name == "session-token" { + c := new(config.Config) + if err := localstate.ReadJSON(s.ConfigPath, c); err == nil { + if name == "account-key" { + v = c.AccountKey + } else { + v = c.SessionToken + } + if v != "" { + return v, "legacy_config_file", nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", "legacy_config_file", err + } + } + return "", "", os.ErrNotExist +} +func (s *Store) Set(name, value string) (string, error) { + selected, err := s.selectedBackend() + if err != nil { + return "", err + } + var previous storedValue + err = privateRead(s.file(name), &previous) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err == nil && previous.Backend != "private_file" && previous.Backend != "system_keyring" { + return "", errors.New("unknown credential marker backend") + } + if s.Backend == "private_file" { + // Persist the explicit instance choice on the first write. Service + // auto-login and future operations then need no environment override. + if err = localstate.WriteJSON(filepath.Join(s.Dir, "credential-backend.json"), storedValue{Backend: "private_file"}); err != nil { + return "", err + } + } + backend := "system_keyring" + stored := "" + if previous.Backend == "private_file" || selected == "private_file" && previous.Backend == "" { + backend = "private_file" + stored = value + } else if err = s.SetKeyring("anytype-cli", name, value); err != nil { + if previous.Backend == "system_keyring" { + return "", err // Never silently migrate an existing keyring marker. + } + backend, stored = "private_file", value + } + err = localstate.WriteJSON(s.file(name), storedValue{stored, backend}) + return backend, err +} + +func (s *Store) selectedBackend() (string, error) { + if s.Backend != "" && s.Backend != "private_file" { + return "", errors.New("unsupported credential backend selector") + } + var saved storedValue + err := privateRead(filepath.Join(s.Dir, "credential-backend.json"), &saved) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err == nil { + if saved.Backend != "private_file" { + return "", errors.New("unsupported saved credential backend") + } + return saved.Backend, nil + } + return s.Backend, nil +} +func privateRead(path string, v any) error { + return localstate.ReadPrivateJSON(path, v) +} + +func (s *Store) Delete(name string) error { + selected, err := s.selectedBackend() + if err != nil { + return err + } + var previous storedValue + if err = privateRead(s.file(name), &previous); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + // Commit the tombstone first, so temporary keyring failures cannot resurrect + // the credential. Physical deletion and legacy config cleanup follow. + if err := localstate.WriteJSON(s.file(name), struct { + Secret string `json:"secret"` + Backend string `json:"backend"` + }{"", "private_file"}); err != nil { + return err + } + if s.DeleteKeyring != nil && (previous.Backend == "system_keyring" || previous.Backend == "" && selected != "private_file") { + _ = s.DeleteKeyring("anytype-cli", name) + } + if name == "account-key" || name == "session-token" { + data := map[string]any{} + err := localstate.ReadJSON(s.ConfigPath, &data) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if name == "account-key" { + delete(data, "accountKey") + } else { + delete(data, "sessionToken") + } + return localstate.WriteJSON(s.ConfigPath, data) + } + return nil +} diff --git a/core/credentials/store_test.go b/core/credentials/store_test.go new file mode 100644 index 0000000..2c87d4f --- /dev/null +++ b/core/credentials/store_test.go @@ -0,0 +1,193 @@ +package credentials + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/zalando/go-keyring" +) + +func storeFixture(t *testing.T) *Store { + t.Helper() + t.Setenv(BackendEnv, "") + dir := t.TempDir() + s := New(filepath.Join(dir, "automation"), filepath.Join(dir, "config.json")) + s.DeleteKeyring = func(string, string) error { return os.ErrPermission } + s.GetKeyring = func(string, string) (string, error) { return "", os.ErrNotExist } + s.SetKeyring = func(string, string, string) error { return os.ErrPermission } + return s +} + +func TestKeyringAbsenceIsDistinctFromUnreadableEvidence(t *testing.T) { + for _, readErr := range []error{keyring.ErrNotFound, os.ErrNotExist, os.ErrPermission, errors.New("credential service unavailable")} { + s := storeFixture(t) + s.GetKeyring = func(string, string) (string, error) { return "", readErr } + _, _, err := s.Get("account-key") + missing := errors.Is(readErr, keyring.ErrNotFound) || errors.Is(readErr, os.ErrNotExist) + if missing != errors.Is(err, os.ErrNotExist) || !missing && !errors.Is(err, readErr) { + t.Fatal("credential evidence misclassified", err) + } + } +} + +func TestExplicitPrivateBackendPreservesKeyringAndSurvivesRestart(t *testing.T) { + s := storeFixture(t) + t.Setenv(BackendEnv, "private_file") + s.Backend = os.Getenv(BackendEnv) + keyringCalls := 0 + s.GetKeyring = func(string, string) (string, error) { keyringCalls++; return "legacy-secret", nil } + s.SetKeyring = func(string, string, string) error { keyringCalls++; return nil } + s.DeleteKeyring = func(string, string) error { keyringCalls++; return nil } + if _, _, err := s.Get("account-key"); !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if _, err := os.Stat(s.Dir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("selector read wrote state") + } + if backend, err := s.Set("account-key", "new-secret"); err != nil || backend != "private_file" { + t.Fatal(backend, err) + } + t.Setenv(BackendEnv, "") + restarted := New(s.Dir, s.ConfigPath) + restarted.GetKeyring, restarted.SetKeyring, restarted.DeleteKeyring = s.GetKeyring, s.SetKeyring, s.DeleteKeyring + if value, backend, err := restarted.Get("account-key"); err != nil || value != "new-secret" || backend != "private_file" { + t.Fatal(value, backend, err) + } + if backend, err := restarted.Set("session-token", "new-session"); err != nil || backend != "private_file" { + t.Fatal(backend, err) + } + if err := restarted.Delete("account-key"); err != nil || keyringCalls != 0 { + t.Fatal("private backend touched legacy keyring", keyringCalls, err) + } +} + +func TestPrivateSelectorDoesNotMigrateExistingKeyringMarker(t *testing.T) { + s := storeFixture(t) + s.SetKeyring = func(string, string, string) error { return nil } + if _, err := s.Set("account-key", "keyring-secret"); err != nil { + t.Fatal(err) + } + s.Backend = "private_file" + s.GetKeyring = func(string, string) (string, error) { return "", os.ErrPermission } + s.SetKeyring = func(string, string, string) error { return os.ErrPermission } + if _, backend, err := s.Get("account-key"); !errors.Is(err, os.ErrPermission) || backend != "system_keyring" { + t.Fatal(backend, err) + } + if _, err := s.Set("account-key", "replacement"); !errors.Is(err, os.ErrPermission) { + t.Fatal("keyring marker migrated", err) + } + var marker storedValue + if err := privateRead(s.file("account-key"), &marker); err != nil || marker.Backend != "system_keyring" || marker.Secret != "" { + t.Fatal(marker, err) + } +} +func TestReadDoesNotWriteOrProbeKeyring(t *testing.T) { + s := storeFixture(t) + calls := 0 + s.SetKeyring = func(string, string, string) error { calls++; return nil } + if _, _, err := s.Get("account-key"); !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if calls != 0 { + t.Fatal("read mutated keyring") + } + if _, err := os.Stat(s.Dir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("read created directory") + } +} +func TestFallbackOverridesStaleKeyring(t *testing.T) { + s := storeFixture(t) + s.GetKeyring = func(string, string) (string, error) { return "stale", nil } + backend, err := s.Set("account-key", "new-fixture") + if err != nil || backend != "private_file" { + t.Fatal(backend, err) + } + value, backend, err := s.Get("account-key") + if err != nil || value != "new-fixture" || backend != "private_file" { + t.Fatal(backend, err) + } + if err = s.Delete("account-key"); err != nil { + t.Fatal(err) + } + value, _, err = s.Get("account-key") + if err != nil || value != "" { + t.Fatal("stale key reappeared") + } +} +func TestPrivateFileRejectsPublicAndSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX chmod/symlink semantics; Windows ACL rejection is covered in localstate") + } + for _, kind := range []string{"public", "symlink"} { + t.Run(kind, func(t *testing.T) { + s := storeFixture(t) + if _, err := s.Set("account-key", "fixture"); err != nil { + t.Fatal(err) + } + file := s.file("account-key") + if kind == "public" { + if err := os.Chmod(file, 0644); err != nil { + t.Fatal(err) + } + } else { + target := file + ".target" + if err := os.Rename(file, target); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, file); err != nil { + t.Fatal(err) + } + } + if _, _, err := s.Get("account-key"); err == nil { + t.Fatal("insecure file accepted") + } + }) + } +} + +func TestKeyringMarkerRoundTrip(t *testing.T) { + s := storeFixture(t) + s.SetKeyring = func(string, string, string) error { return nil } + s.GetKeyring = func(string, string) (string, error) { return "keyring-fixture", nil } + if backend, err := s.Set("account-key", "keyring-fixture"); err != nil || backend != "system_keyring" { + t.Fatal(backend, err) + } + if value, backend, err := s.Get("account-key"); err != nil || value != "keyring-fixture" || backend != "system_keyring" { + t.Fatal(value, backend, err) + } +} +func TestReadsLegacyConfigWithoutMutation(t *testing.T) { + s := storeFixture(t) + if err := localstate.WriteJSON(s.ConfigPath, map[string]string{"accountKey": "legacy-fixture"}); err != nil { + t.Fatal(err) + } + value, backend, err := s.Get("account-key") + if err != nil || value != "legacy-fixture" || backend != "legacy_config_file" { + t.Fatal(backend, err) + } + if _, err := os.Stat(s.Dir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("inspection migrated storage") + } +} +func TestDeleteRemovesReachableKeyringAndLegacySecret(t *testing.T) { + s := storeFixture(t) + deleted := "" + s.DeleteKeyring = func(_, name string) error { deleted = name; return nil } + if err := localstate.WriteJSON(s.ConfigPath, map[string]string{"accountKey": "old", "apiListenAddr": "127.0.0.1:4000"}); err != nil { + t.Fatal(err) + } + if err := s.Delete("account-key"); err != nil { + t.Fatal(err) + } + var values map[string]string + if err := localstate.ReadJSON(s.ConfigPath, &values); err != nil { + t.Fatal(err) + } + if deleted != "account-key" || values["accountKey"] != "" || values["apiListenAddr"] == "" { + t.Fatal("deletion lost compatibility", values) + } +} diff --git a/core/grpcserver/observation.go b/core/grpcserver/observation.go new file mode 100644 index 0000000..8339356 --- /dev/null +++ b/core/grpcserver/observation.go @@ -0,0 +1,140 @@ +package grpcserver + +import ( + "context" + "path" + + "github.com/anyproto/anytype-cli/core/runtimeinfo" + "github.com/anyproto/anytype-heart/pb" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// observationStream is an internal event sink registered with the existing +// GrpcSender. It never retains messages, credentials, or account metadata keys. +type observationStream struct { + grpc.ServerStream + recorder *runtimeinfo.Recorder +} + +func (s *observationStream) Context() context.Context { return context.Background() } +func (s *observationStream) Send(e *pb.Event) error { + for _, m := range e.Messages { + if v := m.GetAccountJsonApiStatus(); v != nil { + s.recorder.APIEvent(v.ListenAddr, v.Success) + } + } + return nil +} + +func observationInterceptor(r *runtimeinfo.Recorder) grpc.UnaryServerInterceptor { + order := &observationOrder{} + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + method := path.Base(info.FullMethod) + switch method { + case "WalletCreate", "WalletRecover", "AccountCreate", "AccountSelect", "AccountStop", "AppShutdown", "AccountChangeJsonApiAddr": + default: + resp, err := handler(ctx, req) + if method == "AppGetVersion" { + _ = grpc.SetHeader(ctx, metadata.Pairs(runtimeinfo.Header, r.Nonce())) + } + return resp, err + } + beginErr := order.begin(ctx, func(overlap bool) { + switch method { + case "WalletCreate", "WalletRecover": + r.WalletTransition() + case "AccountSelect": + r.Transition() + case "AccountCreate": + r.AccountSelection("") + case "AccountStop", "AppShutdown": + r.Stopping() + } + switch v := req.(type) { + case *pb.RpcWalletCreateRequest: + r.DataPath(v.RootPath) + case *pb.RpcWalletRecoverRequest: + r.DataPath(v.RootPath) + case *pb.RpcAccountSelectRequest: + r.DataPath(v.RootPath) + r.AccountSelection(v.Id) + r.APIStarting(v.JsonApiListenAddr) + case *pb.RpcAccountCreateRequest: + r.APIStarting(v.JsonApiListenAddr) + case *pb.RpcAccountChangeJsonApiAddrRequest: + r.APIChanging(v.ListenAddr) + } + if overlap { + r.LifecycleUncertain() + } + }) + if beginErr != nil { + return nil, beginErr + } + var resp any + var err error + // Always release the coordinator, including if Heart panics. Only a + // normally returned response can contribute an observation below. + returned := false + defer func() { + order.finish(func() { + if returned { + observeResponse(r, method, req, resp, err) + } + }) + }() + resp, err = handler(ctx, req) + returned = true + return resp, err + } +} + +func observeResponse(r *runtimeinfo.Recorder, method string, req, resp any, err error) { + apiOutcomeObserved := false + stopObserved := false + accountObserved := false + if err == nil { + switch v := resp.(type) { + case *pb.RpcAccountCreateResponse: + if v != nil && v.GetError().GetCode() == pb.RpcAccountCreateResponseError_NULL && v.Account != nil && v.Account.Id != "" { + r.Account(v.Account) + accountObserved = true + } + case *pb.RpcAccountSelectResponse: + if v != nil && v.GetError().GetCode() == pb.RpcAccountSelectResponseError_NULL && v.Account != nil && v.Account.Id != "" { + r.Account(v.Account) + accountObserved = true + } + case *pb.RpcAccountStopResponse: + if v != nil && v.GetError().GetCode() == pb.RpcAccountStopResponseError_NULL { + r.Stopped() + stopObserved = true + } + case *pb.RpcAppShutdownResponse: + if v != nil && v.GetError().GetCode() == pb.RpcAppShutdownResponseError_NULL { + r.Stopped() + stopObserved = true + } + case *pb.RpcAccountChangeJsonApiAddrResponse: + if v != nil && v.GetError().GetCode() == pb.RpcAccountChangeJsonApiAddrResponseError_NULL && v.Status != nil { + r.API(v.Status.ListenAddr, v.Status.Success) + apiOutcomeObserved = true + } else if request, ok := req.(*pb.RpcAccountChangeJsonApiAddrRequest); ok && request.ListenAddr == "" && v != nil && v.GetError().GetCode() == pb.RpcAccountChangeJsonApiAddrResponseError_NULL { + // Heart returns no status/event when a successful empty-address + // reassignment disables the API listener. + r.API("", false) + apiOutcomeObserved = true + } + } + } + if method == "AccountChangeJsonApiAddr" && !apiOutcomeObserved { + r.APIChangeUncertain() + } + if (method == "AccountStop" || method == "AppShutdown") && !stopObserved { + r.StopUncertain() + } + if (method == "AccountCreate" || method == "AccountSelect") && !accountObserved { + r.AccountStartUncertain() + } +} diff --git a/core/grpcserver/observation_continuity_test.go b/core/grpcserver/observation_continuity_test.go new file mode 100644 index 0000000..5a93634 --- /dev/null +++ b/core/grpcserver/observation_continuity_test.go @@ -0,0 +1,98 @@ +package grpcserver + +import ( + "context" + "errors" + "testing" + + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "google.golang.org/grpc" +) + +func recoverFixtureWallet(t *testing.T, f *observationFixture) { + t.Helper() + _, err := f.interceptor(context.Background(), &pb.RpcWalletRecoverRequest{AccountKey: "fixture"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/WalletRecover"}, func(context.Context, any) (any, error) { + return &pb.RpcWalletRecoverResponse{}, nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestFailedAccountSwitchCannotRestorePreSwitchBinding(t *testing.T) { + for _, requestedId := range []string{"account-b", "account-a"} { + for _, outcome := range []string{"protobuf_failure", "transport_failure", "missing_account"} { + t.Run(requestedId+"/"+outcome, func(t *testing.T) { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + recoverFixtureWallet(t, f) + _, _ = f.interceptor(context.Background(), &pb.RpcAccountSelectRequest{Id: requestedId, JsonApiListenAddr: "127.0.0.1:4500"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountSelect"}, func(context.Context, any) (any, error) { + // A different-account switch may already have closed A. Even a + // same-account error does not prove which Heart path executed. + if outcome == "transport_failure" { + return nil, errors.New("selection outcome lost") + } + if outcome == "missing_account" { + return &pb.RpcAccountSelectResponse{}, nil + } + return &pb.RpcAccountSelectResponse{Error: &pb.RpcAccountSelectResponseError{Code: pb.RpcAccountSelectResponseError_FAILED_TO_RUN_NODE}}, nil + }) + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound || !s.AccountTransition || s.APIObservation != "account_start_outcome_unknown" { + t.Fatal("failed selection retained usable evidence", s) + } + recoverFixtureWallet(t, f) + f.selectAccount("127.0.0.1:4500", func() { f.event("127.0.0.1:4500", false) }) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" || s.AccountTransition || s.AccountId != "account-a" || s.APIObservation != "unverified_event_order" { + t.Fatal("return to A revived pre-switch listener", s) + } + }) + } + } +} + +func TestFailedAccountStartClosesFreshEventWindow(t *testing.T) { + for _, create := range []bool{false, true} { + f := newObservationFixture(t) + method := "AccountSelect" + var req any = &pb.RpcAccountSelectRequest{Id: "account-a", JsonApiListenAddr: "127.0.0.1:4500"} + if create { + method = "AccountCreate" + req = &pb.RpcAccountCreateRequest{Name: "Fixture", JsonApiListenAddr: "127.0.0.1:4500"} + } + _, _ = f.interceptor(context.Background(), req, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/" + method}, func(context.Context, any) (any, error) { + return nil, errors.New("startup outcome unknown") + }) + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" || !s.AccountTransition || s.APIObservation != "account_start_outcome_unknown" { + t.Fatal("late startup event survived failed response", s) + } + } +} + +func TestAccountCreateNeverRetainsPriorAccountBinding(t *testing.T) { + for _, successfulCreate := range []bool{false, true} { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + recoverFixtureWallet(t, f) + _, _ = f.interceptor(context.Background(), &pb.RpcAccountCreateRequest{Name: "Fixture", JsonApiListenAddr: "127.0.0.1:4500"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountCreate"}, func(context.Context, any) (any, error) { + f.event("127.0.0.1:4500", false) + if !successfulCreate { + return nil, errors.New("create failed after closing previous app") + } + // Reusing the wallet may return the same ID, but creation never has + // AccountSelect's already-running-account fast path. + return &pb.RpcAccountCreateResponse{Account: &model.Account{Id: "account-a"}}, nil + }) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" { + t.Fatal("creation inherited prior binding", s) + } + f.selectAccount("127.0.0.1:4500", nil) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" { + t.Fatal("later selection inherited pre-create binding", s) + } + } +} diff --git a/core/grpcserver/observation_order.go b/core/grpcserver/observation_order.go new file mode 100644 index 0000000..92275d8 --- /dev/null +++ b/core/grpcserver/observation_order.go @@ -0,0 +1,41 @@ +package grpcserver + +import ( + "context" + "sync" +) + +// observationOrder protects observation bookkeeping only, never Heart handler +// execution. Overlapping lifecycle calls must reach Heart to supersede/cancel +// one another. Their native effect order is not known to this observer, so the +// whole overlapping batch remains uncertain until a later independent call. +type observationOrder struct { + mu sync.Mutex + active int + overlap bool +} + +func (o *observationOrder) begin(ctx context.Context, observe func(bool)) error { + o.mu.Lock() + defer o.mu.Unlock() + if err := ctx.Err(); err != nil { + return err + } + if o.active == 0 { + o.overlap = false + } else { + o.overlap = true + } + o.active++ + observe(o.overlap) + return nil +} + +func (o *observationOrder) finish(observe func()) { + o.mu.Lock() + defer o.mu.Unlock() + if !o.overlap { + observe() + } + o.active-- +} diff --git a/core/grpcserver/observation_order_test.go b/core/grpcserver/observation_order_test.go new file mode 100644 index 0000000..11182d5 --- /dev/null +++ b/core/grpcserver/observation_order_test.go @@ -0,0 +1,436 @@ +package grpcserver + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "google.golang.org/grpc" +) + +type observationFixture struct { + file string + r *runtimeinfo.Recorder + interceptor grpc.UnaryServerInterceptor + t *testing.T +} + +func newObservationFixture(t *testing.T) *observationFixture { + t.Helper() + file := filepath.Join(t.TempDir(), "runtime.json") + r, err := runtimeinfo.New(file, "config", "data") + if err != nil { + t.Fatal(err) + } + return &observationFixture{file: file, r: r, interceptor: observationInterceptor(r), t: t} +} + +func (f *observationFixture) snapshot() runtimeinfo.Snapshot { + f.t.Helper() + var snapshot runtimeinfo.Snapshot + if err := localstate.ReadJSON(f.file, &snapshot); err != nil { + f.t.Fatal(err) + } + return snapshot +} + +func (f *observationFixture) event(address string, success bool) { + f.t.Helper() + stream := &observationStream{recorder: f.r} + if err := stream.Send(&pb.Event{Messages: []*pb.EventMessage{{Value: &pb.EventMessageValueOfAccountJsonApiStatus{AccountJsonApiStatus: &pb.EventAccountJsonApiStatus{ListenAddr: address, Success: success}}}}}); err != nil { + f.t.Fatal(err) + } +} + +func (f *observationFixture) rebind(address string, response *pb.RpcAccountChangeJsonApiAddrResponse, rpcErr error) { + f.t.Helper() + _, _ = f.interceptor(context.Background(), &pb.RpcAccountChangeJsonApiAddrRequest{ListenAddr: address}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountChangeJsonApiAddr"}, func(context.Context, any) (any, error) { return response, rpcErr }) +} + +func (f *observationFixture) selectAccount(address string, during func()) { + f.t.Helper() + _, err := f.interceptor(context.Background(), &pb.RpcAccountSelectRequest{Id: "account-a", JsonApiListenAddr: address}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountSelect"}, func(context.Context, any) (any, error) { + if during != nil { + during() + } + return &pb.RpcAccountSelectResponse{Account: &model.Account{Id: "account-a"}}, nil + }) + if err != nil { + f.t.Fatal(err) + } +} + +func bindResponse(address string, success bool) *pb.RpcAccountChangeJsonApiAddrResponse { + return &pb.RpcAccountChangeJsonApiAddrResponse{Status: &pb.EventAccountJsonApiStatus{ListenAddr: address, Success: success}} +} + +func TestNewerAPIOutcomesRejectAllDelayedStatusEvents(t *testing.T) { + for _, outcome := range []string{"disable", "new_address_success", "same_address_success", "new_address_failure", "same_address_failure", "protobuf_error", "transport_error"} { + t.Run(outcome, func(t *testing.T) { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + address := "127.0.0.1:4600" + response := bindResponse(address, true) + var rpcErr error + switch outcome { + case "disable": + address, response = "", &pb.RpcAccountChangeJsonApiAddrResponse{} + case "same_address_success": + address = "127.0.0.1:4500" + response = bindResponse(address, true) + case "new_address_failure": + response = bindResponse(address, false) + case "same_address_failure": + address = "127.0.0.1:4500" + response = bindResponse(address, false) + case "protobuf_error": + response = &pb.RpcAccountChangeJsonApiAddrResponse{Error: &pb.RpcAccountChangeJsonApiAddrResponseError{Code: pb.RpcAccountChangeJsonApiAddrResponseErrorCode(101)}} + case "transport_error": + response = nil + rpcErr = errors.New("unknown outcome") + } + f.rebind(address, response, rpcErr) + before := f.snapshot() + if outcome == "disable" && (before.APIBound || before.APIAddress != "") { + t.Fatal(before) + } + if outcome == "protobuf_error" || outcome == "transport_error" { + if before.APIBound || before.APIAddress != "" || before.APIObservation != "rpc_outcome_unknown" { + t.Fatal(before) + } + } + // Both a stale success and a stale failure must lose to the newer + // RPC result, including when their address equals the newer result. + for _, oldAddress := range []string{"127.0.0.1:4500", "127.0.0.1:4600"} { + for _, success := range []bool{true, false} { + f.event(oldAddress, success) + if after := f.snapshot(); after != before { + t.Fatal("delayed event replaced newer RPC evidence", before, after) + } + } + } + f.rebind("127.0.0.1:4700", bindResponse("127.0.0.1:4700", true), nil) + f.event("127.0.0.1:4500", false) + if after := f.snapshot(); !after.APIBound || after.APIAddress != "127.0.0.1:4700" { + t.Fatal("explicit enable not observed", after) + } + }) + } +} + +func TestFirstAccountStartupEventBeforeAndAfterResponse(t *testing.T) { + for _, beforeResponse := range []bool{true, false} { + f := newObservationFixture(t) + var during func() + if beforeResponse { + during = func() { f.event("127.0.0.1:4500", true) } + } + f.selectAccount("127.0.0.1:4500", during) + if !beforeResponse { + f.event("127.0.0.1:4500", true) + } + if s := f.snapshot(); !s.APIBound || s.APIAddress != "127.0.0.1:4500" || s.AccountId != "account-a" || s.AccountTransition { + t.Fatal(s) + } + } +} + +func TestStartupAfterStopRejectsStaleEventsAndReportsAmbiguity(t *testing.T) { + for _, sameAddress := range []bool{false, true} { + f := newObservationFixture(t) + f.selectAccount("127.0.0.1:4500", nil) + f.event("127.0.0.1:4500", true) + f.r.Stopped() + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" { + t.Fatal("stopped listener resurrected", s) + } + address := "127.0.0.1:4600" + if sameAddress { + address = "127.0.0.1:4500" + } + f.selectAccount(address, nil) + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" { + t.Fatal("old address attributed to new startup", s) + } + f.event(address, true) + if s := f.snapshot(); sameAddress { + if s.APIBound || s.APIObservation != "unverified_event_order" { + t.Fatal("same-address event given invented freshness", s) + } + } else if !s.APIBound || s.APIAddress != address { + t.Fatal("unambiguous later startup not observed", s) + } + // A fresh server process has no delayed events from the previous sender. + var err error + f.r, err = runtimeinfo.New(f.file, "config", "data") + if err != nil { + t.Fatal(err) + } + f.interceptor = observationInterceptor(f.r) + f.selectAccount("127.0.0.1:4500", nil) + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); !s.APIBound { + t.Fatal("fresh-process startup blocked", s) + } + } +} + +func TestSameAccountRecoveryRetainsDisableAgainstOldEvents(t *testing.T) { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + f.rebind("", &pb.RpcAccountChangeJsonApiAddrResponse{}, nil) + f.r.WalletTransition() + f.selectAccount("127.0.0.1:4500", nil) + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" || s.APIObservation != "rpc_response" { + t.Fatal("recovery lost authoritative disable", s) + } +} + +func TestConcurrentRebindHandlersAreNotQueuedAndRemainUnverified(t *testing.T) { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + entered := make(chan struct{}) + release := make(chan struct{}) + finished := make(chan struct{}) + go func() { + defer close(finished) + _, _ = f.interceptor(context.Background(), &pb.RpcAccountChangeJsonApiAddrRequest{ListenAddr: "127.0.0.1:4500"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountChangeJsonApiAddr"}, func(context.Context, any) (any, error) { + close(entered) + <-release + return bindResponse("127.0.0.1:4500", true), nil + }) + }() + <-entered + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + called := false + _, err := f.interceptor(ctx, &pb.RpcAccountChangeJsonApiAddrRequest{}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountChangeJsonApiAddr"}, func(context.Context, any) (any, error) { + called = true + return &pb.RpcAccountChangeJsonApiAddrResponse{}, nil + }) + close(release) + <-finished + if err != nil || !called { + t.Fatal("observer prevented native handler entry", called, err) + } + if s := f.snapshot(); s.APIBound || s.APIAddress != "" || s.AccountId != "account-a" || s.AccountTransition || s.APIObservation != "concurrent_lifecycle" { + t.Fatal("overlap advertised unproven native effect order", s) + } + f.rebind("127.0.0.1:4600", bindResponse("127.0.0.1:4600", true), nil) + before := f.snapshot() + if !before.APIBound || before.AccountTransition { + t.Fatal("independent rebind did not restore evidence", before) + } + canceled, cancelNow := context.WithCancel(context.Background()) + cancelNow() + _, err = f.interceptor(canceled, &pb.RpcAccountStopRequest{}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountStop"}, func(context.Context, any) (any, error) { + t.Fatal("already canceled request reached handler") + return nil, nil + }) + if s := f.snapshot(); !errors.Is(err, context.Canceled) || s != before { + t.Fatal(s) + } +} + +func TestStopAndShutdownCancelInFlightStartWithoutWaitingForItsHandler(t *testing.T) { + for _, method := range []string{"AccountStop", "AppShutdown"} { + t.Run(method, func(t *testing.T) { + f := newObservationFixture(t) + startEntered := make(chan struct{}) + stopEntered := make(chan struct{}) + releaseOldResponse := make(chan struct{}) + startDone := make(chan struct{}) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { + defer close(startDone) + _, _ = f.interceptor(ctx, &pb.RpcAccountSelectRequest{Id: "account-a", JsonApiListenAddr: "127.0.0.1:4500"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountSelect"}, func(context.Context, any) (any, error) { + close(startEntered) + // Heart's recovery can only finish after stop enters and cancels it. + select { + case <-stopEntered: + case <-ctx.Done(): + return nil, ctx.Err() + } + <-releaseOldResponse + return &pb.RpcAccountSelectResponse{Account: &model.Account{Id: "account-a"}}, nil + }) + }() + <-startEntered + var request any = &pb.RpcAccountStopRequest{} + var response any = &pb.RpcAccountStopResponse{} + if method == "AppShutdown" { + request, response = &pb.RpcAppShutdownRequest{}, &pb.RpcAppShutdownResponse{} + } + _, err := f.interceptor(ctx, request, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/" + method}, func(context.Context, any) (any, error) { + close(stopEntered) + return response, nil + }) + close(releaseOldResponse) + <-startDone + if err != nil { + t.Fatal("cancellation could not reach Heart while start was blocked", err) + } + select { + case <-stopEntered: + default: + t.Fatal("cancellation handler never entered") + } + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.AccountId != "" || !s.AccountTransition || s.APIBound || s.APIObservation != "concurrent_lifecycle" { + t.Fatal("overlapping start/cancellation claimed a known outcome", s) + } + // A later independent stop can establish a known stopped outcome. + _, err = f.interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/" + method}, func(context.Context, any) (any, error) { return response, nil }) + if s := f.snapshot(); err != nil || s.AccountTransition || s.APIBound || s.APIObservation != "stopped" { + t.Fatal(s, err) + } + }) + } +} + +func TestNewAccountStartsSupersedeBlockedRecoveryWithoutObserverGate(t *testing.T) { + for _, method := range []string{"AccountSelect", "AccountCreate"} { + t.Run(method, func(t *testing.T) { + f := newObservationFixture(t) + firstEntered := make(chan struct{}) + newerEntered := make(chan struct{}) + firstDone := make(chan struct{}) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { + defer close(firstDone) + _, _ = f.interceptor(ctx, &pb.RpcAccountSelectRequest{Id: "account-a", JsonApiListenAddr: "127.0.0.1:4500"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountSelect"}, func(context.Context, any) (any, error) { + close(firstEntered) + select { + case <-newerEntered: + case <-ctx.Done(): + return nil, ctx.Err() + } + return nil, context.Canceled + }) + }() + <-firstEntered + var request any = &pb.RpcAccountSelectRequest{Id: "account-b", JsonApiListenAddr: "127.0.0.1:4600"} + var response any = &pb.RpcAccountSelectResponse{Account: &model.Account{Id: "account-b"}} + if method == "AccountCreate" { + request = &pb.RpcAccountCreateRequest{Name: "New account", JsonApiListenAddr: "127.0.0.1:4600"} + response = &pb.RpcAccountCreateResponse{Account: &model.Account{Id: "account-b"}} + } + called := false + _, err := f.interceptor(ctx, request, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/" + method}, func(context.Context, any) (any, error) { + called = true + close(newerEntered) + <-firstDone + return response, nil + }) + <-firstDone + if err != nil || !called { + t.Fatal("new start could not reach Heart to supersede recovery", err) + } + f.event("127.0.0.1:4600", true) + if s := f.snapshot(); s.APIBound || !s.AccountTransition || s.APIObservation != "concurrent_lifecycle" { + t.Fatal("overlap outcome was guessed", s) + } + f.selectAccount("127.0.0.1:4700", nil) + f.event("127.0.0.1:4700", true) + if s := f.snapshot(); !s.APIBound || s.AccountTransition || s.AccountId != "account-a" { + t.Fatal("independent account observation did not recover", s) + } + }) + } +} + +func TestFailedStopAndShutdownDiscardBindingBeforeHandler(t *testing.T) { + for _, method := range []string{"AccountStop", "AppShutdown"} { + for _, transportFailure := range []bool{false, true} { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + var request any = &pb.RpcAccountStopRequest{} + var response any = &pb.RpcAccountStopResponse{Error: &pb.RpcAccountStopResponseError{Code: pb.RpcAccountStopResponseError_FAILED_TO_STOP_NODE}} + if method == "AppShutdown" { + request = &pb.RpcAppShutdownRequest{} + response = &pb.RpcAppShutdownResponse{Error: &pb.RpcAppShutdownResponseError{Code: pb.RpcAppShutdownResponseErrorCode(101)}} + } + _, _ = f.interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/" + method}, func(context.Context, any) (any, error) { + if s := f.snapshot(); s.APIBound || !s.AccountTransition || s.APIObservation != "stop_pending" { + t.Fatal("stop entry retained binding", s) + } + if transportFailure { + return nil, errors.New("close outcome lost") + } + return response, nil + }) + if s := f.snapshot(); s.APIBound || !s.AccountTransition || s.APIObservation != "stop_outcome_unknown" { + t.Fatal("failed stop claimed a known state", s) + } + f.selectAccount("127.0.0.1:4500", func() { f.event("127.0.0.1:4500", false) }) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" || s.APIObservation != "unverified_event_order" { + t.Fatal("same-account restart revived pre-stop listener", s) + } + } + } +} + +func TestWildcardStartupMatchingAndHistoryShareAlias(t *testing.T) { + for _, addresses := range [][2]string{{"0.0.0.0:4500", "[::]:4500"}, {"[::]:4500", "0.0.0.0:4500"}} { + f := newObservationFixture(t) + f.selectAccount(addresses[0], nil) + f.event(addresses[1], true) + if s := f.snapshot(); !s.APIBound || s.APIAddress != addresses[1] { + t.Fatal("fresh wildcard event was not matched", s) + } + f.r.Stopped() + f.selectAccount(addresses[1], nil) + f.event(addresses[1], true) // prior startup's delayed success + f.event(addresses[1], false) // current bind failure cannot identify its generation + if s := f.snapshot(); s.APIBound || s.APIAddress != "" || s.APIObservation != "unverified_event_order" { + t.Fatal("wildcard spelling invented event freshness", s) + } + } +} + +func TestDifferentUnicastAddressesKeepDistinctStartupEvidence(t *testing.T) { + f := newObservationFixture(t) + f.selectAccount("127.0.0.1:4500", nil) + f.event("[::1]:4500", true) + if s := f.snapshot(); s.APIBound { + t.Fatal("unicast address mismatch accepted", s) + } + f.event("127.0.0.1:4500", true) + f.r.Stopped() + f.selectAccount("[::1]:4500", nil) + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound { + t.Fatal("old unicast event attributed to different endpoint", s) + } + f.event("[::1]:4500", true) + if s := f.snapshot(); !s.APIBound || s.APIAddress != "[::1]:4500" { + t.Fatal("unicast history incorrectly conflated", s) + } +} + +func TestInterruptedStartupCannotReuseEarlierBindingEvent(t *testing.T) { + f := newObservationFixture(t) + _, _ = f.interceptor(context.Background(), &pb.RpcAccountSelectRequest{Id: "account-a", JsonApiListenAddr: "127.0.0.1:4500"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountSelect"}, func(context.Context, any) (any, error) { + f.event("127.0.0.1:4500", true) + return nil, errors.New("account startup failed after API bind") + }) + f.selectAccount("127.0.0.1:4500", nil) + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound || s.APIAddress != "" || s.APIObservation != "unverified_event_order" { + t.Fatal(s) + } +} diff --git a/core/grpcserver/observation_test.go b/core/grpcserver/observation_test.go new file mode 100644 index 0000000..cdd1fbe --- /dev/null +++ b/core/grpcserver/observation_test.go @@ -0,0 +1,141 @@ +package grpcserver + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "google.golang.org/grpc" +) + +func TestObservationTransitionsAndEffectiveListener(t *testing.T) { + file := filepath.Join(t.TempDir(), "runtime.json") + recorder, err := runtimeinfo.New(file, "config", "data") + if err != nil { + t.Fatal(err) + } + interceptor := observationInterceptor(recorder) + _, err = interceptor(context.Background(), &pb.RpcAccountCreateRequest{JsonApiListenAddr: "127.0.0.1:4500"}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountCreate"}, func(context.Context, any) (any, error) { + return &pb.RpcAccountCreateResponse{Account: &model.Account{Id: "account", Info: &model.AccountInfo{ProfileObjectId: "profile", TechSpaceId: "tech", MetaDataKey: "secret"}}}, nil + }) + if err != nil { + t.Fatal(err) + } + stream := &observationStream{recorder: recorder} + if err = stream.Send(&pb.Event{Messages: []*pb.EventMessage{{Value: &pb.EventMessageValueOfAccountJsonApiStatus{AccountJsonApiStatus: &pb.EventAccountJsonApiStatus{Success: true, ListenAddr: "127.0.0.1:4500", Error: "must-not-persist"}}}}}); err != nil { + t.Fatal(err) + } + var snapshot runtimeinfo.Snapshot + if err = localstate.ReadJSON(file, &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.AccountId != "account" || snapshot.AccountTransition || !snapshot.APIBound || snapshot.APIAddress != "127.0.0.1:4500" { + t.Fatal(snapshot) + } + _, err = interceptor(context.Background(), &pb.RpcWalletRecoverRequest{}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/WalletRecover"}, func(context.Context, any) (any, error) { return &pb.RpcWalletRecoverResponse{}, nil }) + if err != nil { + t.Fatal(err) + } + snapshot = runtimeinfo.Snapshot{} + if err = localstate.ReadJSON(file, &snapshot); err != nil { + t.Fatal(err) + } + if !snapshot.AccountTransition || snapshot.AccountId != "" || snapshot.APIBound { + t.Fatal("wallet transition advertised stale identity", snapshot) + } +} + +func TestSuccessfulAPIDisableClearsBindingWithoutStatusEvent(t *testing.T) { + for _, outcome := range []string{"success", "protobuf_error", "transport_error", "nonempty_request"} { + t.Run(outcome, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "runtime.json") + r, err := runtimeinfo.New(file, "config", "data") + if err != nil { + t.Fatal(err) + } + r.Account(&model.Account{Id: "account-a"}) + r.API("127.0.0.1:4500", true) + request := &pb.RpcAccountChangeJsonApiAddrRequest{} + if outcome == "nonempty_request" { + request.ListenAddr = "127.0.0.1:4600" + } + _, _ = observationInterceptor(r)(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountChangeJsonApiAddr"}, func(context.Context, any) (any, error) { + response := &pb.RpcAccountChangeJsonApiAddrResponse{} + if outcome == "protobuf_error" { + response.Error = &pb.RpcAccountChangeJsonApiAddrResponseError{Code: pb.RpcAccountChangeJsonApiAddrResponseErrorCode(101)} + } + if outcome == "transport_error" { + return response, errors.New("failed") + } + return response, nil + }) + var snapshot runtimeinfo.Snapshot + if err = localstate.ReadJSON(file, &snapshot); err != nil { + t.Fatal(err) + } + if outcome == "success" { + if snapshot.APIBound || snapshot.APIAddress != "" || snapshot.AccountId != "account-a" { + t.Fatal(snapshot) + } + } else if snapshot.APIBound || snapshot.APIAddress != "" || snapshot.APIObservation != "rpc_outcome_unknown" { + t.Fatal("unproven rebind retained binding claim", snapshot) + } + }) + } +} +func TestPartialAccountCreateRemainsUncertain(t *testing.T) { + file := filepath.Join(t.TempDir(), "runtime.json") + recorder, err := runtimeinfo.New(file, "config", "data") + if err != nil { + t.Fatal(err) + } + _, err = observationInterceptor(recorder)(context.Background(), &pb.RpcAccountCreateRequest{}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountCreate"}, func(context.Context, any) (any, error) { + return &pb.RpcAccountCreateResponse{Account: &model.Account{Id: "partial"}, Error: &pb.RpcAccountCreateResponseError{Code: pb.RpcAccountCreateResponseErrorCode(101)}}, nil + }) + if err != nil { + t.Fatal(err) + } + var snapshot runtimeinfo.Snapshot + if err = localstate.ReadJSON(file, &snapshot); err != nil { + t.Fatal(err) + } + if !snapshot.AccountTransition || snapshot.AccountId != "" { + t.Fatal(snapshot) + } +} + +func TestSameAccountWalletRecoverSelectRetainsObservedBinding(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "runtime.json") + data := filepath.Join(dir, "data") + recorder, err := runtimeinfo.New(file, "cfg", data) + if err != nil { + t.Fatal(err) + } + recorder.Account(&model.Account{Id: "account-a"}) + recorder.API("127.0.0.1:4500", true) + interceptor := observationInterceptor(recorder) + _, err = interceptor(context.Background(), &pb.RpcWalletRecoverRequest{RootPath: data}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/WalletRecover"}, func(context.Context, any) (any, error) { return &pb.RpcWalletRecoverResponse{}, nil }) + if err != nil { + t.Fatal(err) + } + // Heart's already-selected-account fast path emits no new API event. + _, err = interceptor(context.Background(), &pb.RpcAccountSelectRequest{Id: "account-a", RootPath: data}, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/AccountSelect"}, func(context.Context, any) (any, error) { + return &pb.RpcAccountSelectResponse{Account: &model.Account{Id: "account-a"}}, nil + }) + if err != nil { + t.Fatal(err) + } + var snapshot runtimeinfo.Snapshot + if err = localstate.ReadJSON(file, &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.AccountId != "account-a" || !snapshot.APIBound || snapshot.APIAddress != "127.0.0.1:4500" || snapshot.AccountTransition { + t.Fatal(snapshot) + } +} diff --git a/core/grpcserver/server.go b/core/grpcserver/server.go index 1d58242..3cd9e48 100644 --- a/core/grpcserver/server.go +++ b/core/grpcserver/server.go @@ -12,6 +12,8 @@ import ( "os" "time" + "github.com/anyproto/anytype-cli/core/runtimeinfo" + "github.com/anyproto/any-sync/app" "github.com/anyproto/anytype-heart/core/api" "github.com/grpc-ecosystem/go-grpc-middleware" @@ -33,11 +35,19 @@ var log = logging.Logger("anytype-heart") const grpcWebStartedMessagePrefix = "gRPC Web proxy started at: " type Server struct { - mw *core.Middleware - grpcServer *grpc.Server - webServer *http.Server - grpcListener net.Listener - webListener net.Listener + mw *core.Middleware + grpcServer *grpc.Server + webServer *http.Server + grpcListener net.Listener + webListener net.Listener + observation *runtimeinfo.Recorder + observationSender *event.GrpcSender + observationSession *event.SessionServer +} + +// AccountActivity reads this process's observation, never a cached runtime file. +func (s *Server) AccountActivity() bool { + return s.observation != nil && s.observation.AccountActivity() } func NewServer() *Server { @@ -53,7 +63,8 @@ func (s *Server) Start(grpcAddr, grpcWebAddr string) error { log.Info("Starting anytype-heart...") s.mw = core.New() - s.mw.SetEventSender(event.NewGrpcSender()) + s.observationSender = event.NewGrpcSender() + s.mw.SetEventSender(s.observationSender) var err error s.grpcListener, err = net.Listen("tcp", grpcAddr) @@ -66,6 +77,13 @@ func (s *Server) Start(grpcAddr, grpcWebAddr string) error { s.grpcListener.Close() return fmt.Errorf("failed to listen on %s: %w", grpcWebAddr, err) } + s.observation, err = runtimeinfo.NewDefault() + if err != nil { + s.grpcListener.Close() + s.webListener.Close() + return fmt.Errorf("initialize runtime observation: %w", err) + } + s.observationSession = s.observationSender.SetSessionServer("cli-observation-"+s.observation.Nonce(), &observationStream{recorder: s.observation}) var unaryInterceptors []grpc.UnaryServerInterceptor @@ -90,6 +108,7 @@ func (s *Server) Start(grpcAddr, grpcWebAddr string) error { "/anytype.ClientCommands/AccountLocalLinkNewChallenge", )) unaryInterceptors = append(unaryInterceptors, originInterceptor()) + unaryInterceptors = append(unaryInterceptors, observationInterceptor(s.observation)) s.grpcServer = grpc.NewServer( grpc.MaxRecvMsgSize(20*1024*1024), @@ -150,6 +169,12 @@ func (s *Server) Stop() error { defer cancel() _ = s.mw.AppShutdown(ctx, &pb.RpcAppShutdownRequest{}) } + if s.observationSession != nil { + s.observationSender.CloseSessionInstance(s.observationSession) + } + if s.observation != nil { + s.observation.Stopped() + } log.Info("Servers stopped") return nil diff --git a/core/keyring.go b/core/keyring.go index d595e2b..f70bcd0 100644 --- a/core/keyring.go +++ b/core/keyring.go @@ -2,9 +2,11 @@ package core import ( "errors" + "os" "github.com/anyproto/anytype-cli/core/config" - "github.com/zalando/go-keyring" + "github.com/anyproto/anytype-cli/core/credentials" + "github.com/anyproto/anytype-cli/core/localstate" ) const ( @@ -16,107 +18,33 @@ const ( var ( keyringUnavailable = false ErrNotFound = errors.New("credentials not found") + credentialPaths = func() (string, string) { return localstate.Dir(), config.GetConfigFilePath() } ) -// isKeyringAvailable checks if the keyring is accessible -func isKeyringAvailable() bool { +func credentialStore() *credentials.Store { + dir, path := credentialPaths() + s := credentials.New(dir, path) if keyringUnavailable { - return false + s.GetKeyring = func(string, string) (string, error) { return "", os.ErrNotExist } + s.SetKeyring = func(string, string, string) error { return os.ErrPermission } + s.DeleteKeyring = func(string, string) error { return os.ErrPermission } } - - err := keyring.Set(keyringService, "test", "test") - if err != nil { - keyringUnavailable = true - return false - } - _ = keyring.Delete(keyringService, "test") - return true -} - -// SaveSessionToken saves the session token to the keyring if available, otherwise to the config file. -// Returns true if saved to keyring, false if saved to config file. -func SaveSessionToken(token string) (bool, error) { - if isKeyringAvailable() { - return true, keyring.Set(keyringService, keyringTokenUser, token) - } - - return false, config.SetSessionTokenToConfig(token) -} - -// GetStoredSessionToken retrieves the session token from keyring if available, otherwise from config file. -// Returns the token, true if retrieved from keyring (false if from config file), and any error. -func GetStoredSessionToken() (string, bool, error) { - if isKeyringAvailable() { - token, err := keyring.Get(keyringService, keyringTokenUser) - if err == nil { - return token, true, nil - } - if !errors.Is(err, keyring.ErrNotFound) { - keyringUnavailable = true - } - } - - token, _ := config.GetSessionTokenFromConfig() - if token == "" { - return "", false, ErrNotFound - } - return token, false, nil -} - -func DeleteStoredSessionToken() error { - var keyringErr error - if isKeyringAvailable() { - keyringErr = keyring.Delete(keyringService, keyringTokenUser) - } - - configErr := config.SetSessionTokenToConfig("") - - if keyringErr != nil && configErr != nil { - return configErr - } - return nil + return s } - -// SaveAccountKey saves the account key to the keyring if available, otherwise to the config file. -// Returns true if saved to keyring, false if saved to config file. -func SaveAccountKey(accountKey string) (bool, error) { - if isKeyringAvailable() { - return true, keyring.Set(keyringService, keyringAccountKeyUser, accountKey) - } - - return false, config.SetAccountKeyToConfig(accountKey) +func saveCredential(name, value string) (bool, error) { + storage, err := credentialStore().Set(name, value) + return storage == "system_keyring", err } - -// GetStoredAccountKey retrieves the account key from keyring if available, otherwise from config file. -// Returns the account key, true if retrieved from keyring (false if from config file), and any error. -func GetStoredAccountKey() (string, bool, error) { - if isKeyringAvailable() { - key, err := keyring.Get(keyringService, keyringAccountKeyUser) - if err == nil { - return key, true, nil - } - if !errors.Is(err, keyring.ErrNotFound) { - keyringUnavailable = true - } - } - - key, _ := config.GetAccountKeyFromConfig() - if key == "" { +func getCredential(name string) (string, bool, error) { + value, storage, err := credentialStore().Get(name) + if errors.Is(err, os.ErrNotExist) || err == nil && value == "" { return "", false, ErrNotFound } - return key, false, nil -} - -func DeleteStoredAccountKey() error { - var keyringErr error - if isKeyringAvailable() { - keyringErr = keyring.Delete(keyringService, keyringAccountKeyUser) - } - - configErr := config.SetAccountKeyToConfig("") - - if keyringErr != nil && configErr != nil { - return configErr - } - return nil + return value, storage == "system_keyring", err } +func SaveSessionToken(token string) (bool, error) { return saveCredential(keyringTokenUser, token) } +func SaveAccountKey(key string) (bool, error) { return saveCredential(keyringAccountKeyUser, key) } +func GetStoredSessionToken() (string, bool, error) { return getCredential(keyringTokenUser) } +func GetStoredAccountKey() (string, bool, error) { return getCredential(keyringAccountKeyUser) } +func DeleteStoredSessionToken() error { return credentialStore().Delete(keyringTokenUser) } +func DeleteStoredAccountKey() error { return credentialStore().Delete(keyringAccountKeyUser) } diff --git a/core/keyring_test.go b/core/keyring_test.go index d94a907..217aa30 100644 --- a/core/keyring_test.go +++ b/core/keyring_test.go @@ -3,196 +3,64 @@ package core import ( "errors" "os" + "path/filepath" "testing" - - "github.com/anyproto/anytype-cli/core/config" ) -func setupTestHome(t *testing.T) string { +func isolatedCredentials(t *testing.T) string { t.Helper() - tempDir, err := os.MkdirTemp("", "anytype-keyring-test") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { os.RemoveAll(tempDir) }) - t.Setenv("HOME", tempDir) - return tempDir -} - -func TestKeyringFallback(t *testing.T) { - setupTestHome(t) - + dir := t.TempDir() + oldPaths := credentialPaths + oldUnavailable := keyringUnavailable + credentialPaths = func() (string, string) { return filepath.Join(dir, "automation"), filepath.Join(dir, "config.json") } keyringUnavailable = true - defer func() { keyringUnavailable = false }() - - testAccountKey := "test-account-key-12345" - - savedToKeyring, err := SaveAccountKey(testAccountKey) - if err != nil { - t.Fatalf("SaveAccountKey failed: %v", err) - } - if savedToKeyring { - t.Error("Expected SaveAccountKey to return false (config fallback) when keyring unavailable, got true") - } - - configMgr := config.GetConfigManager() - err = configMgr.Load() - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } - - cfg := configMgr.Get() - if cfg.AccountKey != testAccountKey { - t.Errorf("Expected account key %q, got %q", testAccountKey, cfg.AccountKey) - } - - retrievedKey, fromKeyring, err := GetStoredAccountKey() - if err != nil { - t.Fatalf("GetStoredAccountKey failed: %v", err) - } - - if retrievedKey != testAccountKey { - t.Errorf("Expected retrieved key %q, got %q", testAccountKey, retrievedKey) - } - - if fromKeyring { - t.Error("Expected fromKeyring to be false (config fallback), got true") - } - - err = DeleteStoredAccountKey() - if err != nil { - t.Fatalf("DeleteStoredAccountKey failed: %v", err) - } - - err = configMgr.Load() - if err != nil { - t.Fatalf("Failed to load config after delete: %v", err) - } - - cfg = configMgr.Get() - if cfg.AccountKey != "" { - t.Errorf("Expected empty account key after delete, got %q", cfg.AccountKey) - } - - _, _, err = GetStoredAccountKey() - if !errors.Is(err, ErrNotFound) { - t.Errorf("Expected ErrNotFound after delete, got %v", err) - } + t.Cleanup(func() { credentialPaths = oldPaths; keyringUnavailable = oldUnavailable }) + return dir } - -func TestTokenFallback(t *testing.T) { - setupTestHome(t) - - keyringUnavailable = true - defer func() { keyringUnavailable = false }() - - testToken := "test-session-token-67890" - - savedToKeyring, err := SaveSessionToken(testToken) - if err != nil { - t.Fatalf("SaveSessionToken failed: %v", err) - } - if savedToKeyring { - t.Error("Expected SaveSessionToken to return false (config fallback) when keyring unavailable, got true") - } - - configMgr := config.GetConfigManager() - err = configMgr.Load() - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } - - cfg := configMgr.Get() - if cfg.SessionToken != testToken { - t.Errorf("Expected session token %q, got %q", testToken, cfg.SessionToken) - } - - retrievedToken, fromKeyring, err := GetStoredSessionToken() - if err != nil { - t.Fatalf("GetStoredSessionToken failed: %v", err) - } - - if retrievedToken != testToken { - t.Errorf("Expected retrieved token %q, got %q", testToken, retrievedToken) - } - - if fromKeyring { - t.Error("Expected fromKeyring to be false (config fallback), got true") - } - - err = DeleteStoredSessionToken() - if err != nil { - t.Fatalf("DeleteStoredSessionToken failed: %v", err) - } - - err = configMgr.Load() - if err != nil { - t.Fatalf("Failed to load config after delete: %v", err) - } - - cfg = configMgr.Get() - if cfg.SessionToken != "" { - t.Errorf("Expected empty session token after delete, got %q", cfg.SessionToken) - } - - _, _, err = GetStoredSessionToken() - if !errors.Is(err, ErrNotFound) { - t.Errorf("Expected ErrNotFound after delete, got %v", err) +func TestCredentialFallbackRoundTrip(t *testing.T) { + for _, name := range []string{keyringAccountKeyUser, keyringTokenUser} { + t.Run(name, func(t *testing.T) { + isolatedCredentials(t) + inKeyring, err := saveCredential(name, "private-fixture") + if err != nil || inKeyring { + t.Fatalf("save: %v %v", inKeyring, err) + } + value, inKeyring, err := getCredential(name) + if err != nil || value != "private-fixture" || inKeyring { + t.Fatalf("read: %v %v", inKeyring, err) + } + if err = credentialStore().Delete(name); err != nil { + t.Fatal(err) + } + _, _, err = getCredential(name) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("delete: %v", err) + } + }) } } - -func TestConfigFilePermissions(t *testing.T) { - setupTestHome(t) - - keyringUnavailable = true - defer func() { keyringUnavailable = false }() - - testAccountKey := "test-account-key-permissions" - savedToKeyring, err := SaveAccountKey(testAccountKey) - if err != nil { - t.Fatalf("SaveAccountKey failed: %v", err) - } - if savedToKeyring { - t.Error("Expected SaveAccountKey to return false (config fallback) when keyring unavailable, got true") - } - - configMgr := config.GetConfigManager() - configPath := configMgr.GetFilePath() - if configPath == "" { - t.Skip("Could not determine config file path") +func TestCredentialFilePermissions(t *testing.T) { + dir := isolatedCredentials(t) + if _, err := SaveAccountKey("fixture"); err != nil { + t.Fatal(err) } - - fileInfo, err := os.Stat(configPath) + entries, err := os.ReadDir(filepath.Join(dir, "automation/secrets")) if err != nil { - t.Fatalf("Failed to stat config file: %v", err) + t.Fatal(err) } - - mode := fileInfo.Mode().Perm() - expectedMode := os.FileMode(0600) - if mode != expectedMode { - t.Errorf("Expected file permissions %v, got %v", expectedMode, mode) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("permissions %v", info.Mode()) + } } } - func TestEmptyCredentialRetrieval(t *testing.T) { - setupTestHome(t) - - keyringUnavailable = true - defer func() { keyringUnavailable = false }() - - configMgr := config.GetConfigManager() - err := configMgr.Delete() - if err != nil && !os.IsNotExist(err) { - t.Fatalf("Failed to delete config: %v", err) - } - - _, _, err = GetStoredAccountKey() - if !errors.Is(err, ErrNotFound) { - t.Errorf("Expected ErrNotFound for non-existent account key, got %v", err) - } - - _, _, err = GetStoredSessionToken() - if !errors.Is(err, ErrNotFound) { - t.Errorf("Expected ErrNotFound for non-existent token, got %v", err) + isolatedCredentials(t) + if _, _, err := GetStoredAccountKey(); !errors.Is(err, ErrNotFound) { + t.Fatal(err) } } diff --git a/core/localstate/files_unix.go b/core/localstate/files_unix.go new file mode 100644 index 0000000..04992c8 --- /dev/null +++ b/core/localstate/files_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package localstate + +import ( + "os" + "path/filepath" +) + +func createPrivateTemp(dir string) (*os.File, error) { return os.CreateTemp(dir, ".write-*") } + +func replaceFile(from, to string) error { + if err := os.Rename(from, to); err != nil { + return err + } + d, err := os.Open(filepath.Dir(to)) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +// CheckPrivateFile applies POSIX permission semantics to an opened regular file. +func CheckPrivateFile(f *os.File) error { + info, err := f.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { + return os.ErrPermission + } + return nil +} diff --git a/core/localstate/files_windows.go b/core/localstate/files_windows.go new file mode 100644 index 0000000..acdb286 --- /dev/null +++ b/core/localstate/files_windows.go @@ -0,0 +1,109 @@ +package localstate + +import ( + "crypto/rand" + "encoding/hex" + "os" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +func createPrivateTemp(dir string) (*os.File, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, err + } + // Supply the protected DACL at creation, before any secret is written. + // Inherited group/everyone grants must not reach credential files. + sid := user.User.Sid.String() + sd, err := windows.SecurityDescriptorFromString("O:" + sid + "D:P(A;;FA;;;" + sid + ")(A;;FA;;;SY)") + if err != nil { + return nil, err + } + sa := &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd} + for range 10 { + var nonce [16]byte + if _, err = rand.Read(nonce[:]); err != nil { + return nil, err + } + name := filepath.Join(dir, ".write-"+hex.EncodeToString(nonce[:])) + path, err := windows.UTF16PtrFromString(name) + if err != nil { + return nil, err + } + h, err := windows.CreateFile(path, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.FILE_SHARE_READ|windows.FILE_SHARE_DELETE, sa, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err == windows.ERROR_FILE_EXISTS { + continue + } + if err != nil { + return nil, err + } + return os.NewFile(uintptr(h), name), nil + } + return nil, os.ErrExist +} + +func replaceFile(from, to string) error { + source, err := windows.UTF16PtrFromString(from) + if err != nil { + return err + } + target, err := windows.UTF16PtrFromString(to) + if err != nil { + return err + } + // Sync on the writable file already succeeded. Windows cannot flush a + // read-only directory handle; use its write-through replacement primitive. + return windows.MoveFileEx(source, target, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} + +// CheckPrivateFile verifies the opened file's owner and DACL, not Windows' fake +// POSIX mode bits. Only the current user and LocalSystem may have allow ACEs. +func CheckPrivateFile(f *os.File) error { + info, err := f.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return os.ErrPermission + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return err + } + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil || owner == nil || !owner.Equals(user.User.Sid) { + return os.ErrPermission + } + dacl, _, err := sd.DACL() + if err != nil || dacl == nil { + return os.ErrPermission + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return err + } + for i := uint32(0); i < uint32(dacl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err = windows.GetAce(dacl, i, &ace); err != nil { + return err + } + if ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return os.ErrPermission + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !sid.Equals(user.User.Sid) && !sid.Equals(system) { + return os.ErrPermission + } + } + return nil +} diff --git a/core/localstate/files_windows_test.go b/core/localstate/files_windows_test.go new file mode 100644 index 0000000..cdac0aa --- /dev/null +++ b/core/localstate/files_windows_test.go @@ -0,0 +1,34 @@ +package localstate + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func TestWindowsPrivateFileRejectsPublicACL(t *testing.T) { + file := filepath.Join(t.TempDir(), "credential.json") + if err := WriteJSON(file, map[string]string{"secret": "fixture"}); err != nil { + t.Fatal(err) + } + var value map[string]string + if err := ReadPrivateJSON(file, &value); err != nil { + t.Fatal("private ACL was rejected", err) + } + sd, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;WD)") + if err != nil { + t.Fatal(err) + } + dacl, _, err := sd.DACL() + if err != nil { + t.Fatal(err) + } + if err = windows.SetNamedSecurityInfo(file, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } + if err = ReadPrivateJSON(file, &value); !os.IsPermission(err) { + t.Fatal("public ACL accepted", err) + } +} diff --git a/core/localstate/state.go b/core/localstate/state.go new file mode 100644 index 0000000..c282b34 --- /dev/null +++ b/core/localstate/state.go @@ -0,0 +1,134 @@ +// Package localstate provides the default instance's durable coordination and +// private files. It deliberately has no dependency on RPCs or credential APIs. +package localstate + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + + "github.com/anyproto/anytype-cli/core/config" + "github.com/gofrs/flock" +) + +var ErrBusy = errors.New("another CLI operation is in progress") + +func Dir() string { return filepath.Join(config.GetConfigDir(), "automation") } + +func Lock(dir string) (func(), error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + f := flock.New(filepath.Join(dir, "instance.lock")) + ok, err := f.TryLock() + if err != nil { + return nil, err + } + if !ok { + _ = f.Close() + return nil, ErrBusy + } + return func() { _ = f.Unlock(); _ = f.Close() }, nil +} + +// WriteJSON replaces a private file without truncating the previous version. +// Contents are synced before a platform-specific durable replacement. +func WriteJSON(path string, value any) error { + b, err := json.Marshal(value) + if err != nil { + return err + } + dir := filepath.Dir(path) + if err = os.MkdirAll(dir, 0700); err != nil { + return err + } + f, err := createPrivateTemp(dir) + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(b); err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return replaceFile(f.Name(), path) +} + +// ReadPrivateJSON checks the opened file's privacy before reading secrets. +// Symlinks and replacement between lstat/open are rejected. +func ReadPrivateJSON(path string, value any) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return os.ErrPermission + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + opened, err := f.Stat() + if err != nil { + return err + } + if !os.SameFile(info, opened) { + return os.ErrPermission + } + if err = CheckPrivateFile(f); err != nil { + return err + } + b, err := io.ReadAll(f) + if err != nil { + return err + } + return json.Unmarshal(b, value) +} + +func ReadJSON(path string, value any) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(b, value) +} + +// HumanMutation identifies commands sharing mutable native instance state. Shell +// lines and ordinary process invocations use the same classification. +func HumanMutation(args []string) bool { + for _, a := range args { + if a == "--help" || a == "-h" { + return false + } + } + var words []string + for _, a := range args { + if !strings.HasPrefix(a, "-") { + words = append(words, a) + } + } + if len(words) < 2 { + return false + } + switch words[0] { + case "auth": + return words[1] != "status" && (len(words) < 3 || words[1] != "apikey" || words[2] != "list") + case "space": + return words[1] != "list" + case "service": + return words[1] != "status" + case "config": + return words[1] != "get" + } + return false +} diff --git a/core/localstate/state_test.go b/core/localstate/state_test.go new file mode 100644 index 0000000..dc4f6e9 --- /dev/null +++ b/core/localstate/state_test.go @@ -0,0 +1,37 @@ +package localstate + +import ( + "path/filepath" + "testing" +) + +func TestLockAndAtomicPrivateFiles(t *testing.T) { + dir := t.TempDir() + unlock, err := Lock(dir) + if err != nil { + t.Fatal(err) + } + if _, err = Lock(dir); err != ErrBusy { + t.Fatal(err) + } + unlock() + unlock2, err := Lock(dir) + if err != nil { + t.Fatal(err) + } + unlock2() + file := filepath.Join(dir, "state.json") + if err = WriteJSON(file, map[string]int{"version": 1}); err != nil { + t.Fatal(err) + } + if err = WriteJSON(file, map[string]int{"version": 2}); err != nil { + t.Fatal(err) + } + var v map[string]int + if err = ReadJSON(file, &v); err != nil || v["version"] != 2 { + t.Fatal(v, err) + } + if err = ReadPrivateJSON(file, &v); err != nil || v["version"] != 2 { + t.Fatal("private file roundtrip", v, err) + } +} diff --git a/core/runtimeinfo/runtime.go b/core/runtimeinfo/runtime.go new file mode 100644 index 0000000..def9d04 --- /dev/null +++ b/core/runtimeinfo/runtime.go @@ -0,0 +1,325 @@ +// Package runtimeinfo records sanitized observations made by the CLI's own +// embedded server. Consumers must match Nonce against the live gRPC header; +// a file alone is never evidence of a running or authenticated account. +package runtimeinfo + +import ( + "crypto/rand" + "encoding/hex" + "net" + "os" + "path/filepath" + "strconv" + "sync" + + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +const Header = "anytype-cli-runtime" + +type Snapshot struct { + SchemaVersion int `json:"schema_version"` + Nonce string `json:"nonce"` + PID int `json:"pid"` + ConfigPath string `json:"config_path"` + DataPath string `json:"data_path"` + AccountId string `json:"account_id,omitempty"` + ProfileId string `json:"profile_id,omitempty"` + TechSpaceId string `json:"tech_space_id,omitempty"` + NetworkId string `json:"network_id,omitempty"` + APIAddress string `json:"api_address,omitempty"` + APIBound bool `json:"api_bound"` + APIObservation string `json:"api_observation,omitempty"` + AccountTransition bool `json:"account_transition"` +} + +type Recorder struct { + mu sync.Mutex + path string + snapshot Snapshot + err error + // An in-process candidate only: never advertise it until the same account + // and data root have been observed again. An accepted API event, rebind, or + // stop invalidates it; ignored stale events cannot change retained evidence. + previousAPI *Snapshot + // Heart API events have no generation/account identity and may be delivered + // after newer RPC outcomes. Only an unambiguous new startup address may use + // an event as binding evidence; an RPC response always closes that window. + startupAPIAddress string + apiAddresses map[string]bool + apiUnknownAddress bool +} + +func Path() string { return filepath.Join(localstate.Dir(), "runtime.json") } + +func New(path, configPath, dataPath string) (*Recorder, error) { + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, err + } + r := &Recorder{path: path, snapshot: Snapshot{SchemaVersion: 1, Nonce: hex.EncodeToString(nonce[:]), PID: os.Getpid(), ConfigPath: configPath, DataPath: dataPath}} + r.err = localstate.WriteJSON(path, r.snapshot) + return r, r.err +} + +func NewDefault() (*Recorder, error) { + dataPath, err := filepath.Abs(config.GetDataDir()) + if err != nil { + return nil, err + } + return New(Path(), config.GetConfigFilePath(), dataPath) +} +func (r *Recorder) Nonce() string { + r.mu.Lock() + defer r.mu.Unlock() + if r.err != nil { + return "" + } + return r.snapshot.Nonce +} +func (r *Recorder) update(fn func(*Snapshot)) { + r.mu.Lock() + defer r.mu.Unlock() + fn(&r.snapshot) + r.err = localstate.WriteJSON(r.path, r.snapshot) +} +func (r *Recorder) beginTransition(s *Snapshot) { + r.startupAPIAddress = "" + if !s.AccountTransition { + r.previousAPI = nil + if s.AccountId != "" { + previous := *s + r.previousAPI = &previous + } + s.APIBound = false + s.APIAddress = "" + s.APIObservation = "account_transition" + } + s.AccountTransition = true +} +func (r *Recorder) Transition() { r.update(r.beginTransition) } + +// AccountSelection keeps an earlier binding only for a known continuation of +// the same account and data root. Heart can close the previous application +// while switching accounts, even when the selection later returns an error. +// Creation passes an empty identity: it always takes a destructive start path. +func (r *Recorder) AccountSelection(accountId string) { + r.update(func(s *Snapshot) { + r.beginTransition(s) + previous := r.previousAPI + if accountId == "" || previous == nil || previous.AccountId != accountId || previous.DataPath != s.DataPath { + r.previousAPI = nil + } + }) +} + +// A failed response does not identify whether Heart kept the old app or closed +// it before startup failed. Neither retained evidence nor later unversioned +// events may establish continuity across that unknown outcome. +func (r *Recorder) AccountStartUncertain() { + r.update(func(s *Snapshot) { + r.previousAPI = nil + r.startupAPIAddress = "" + s.APIAddress, s.APIBound = "", false + s.APIObservation = "account_start_outcome_unknown" + s.AccountTransition = true + }) +} + +// Stopping discards retained evidence before Heart closes components. A close +// error does not imply that the old app/listener remains available. +func (r *Recorder) Stopping() { + r.update(func(s *Snapshot) { + r.beginTransition(s) + r.previousAPI = nil + s.APIAddress, s.APIBound = "", false + s.APIObservation = "stop_pending" + }) +} + +func (r *Recorder) StopUncertain() { + r.update(func(s *Snapshot) { s.APIObservation = "stop_outcome_unknown" }) +} + +func (r *Recorder) LifecycleUncertain() { + r.update(func(s *Snapshot) { + r.previousAPI = nil + r.startupAPIAddress = "" + s.APIAddress, s.APIBound = "", false + s.APIObservation = "concurrent_lifecycle" + // Account/wallet calls already mark their own transition. Overlapping + // API-only rebinds leave a known account identity intact. + }) +} +func (r *Recorder) WalletTransition() { + r.update(func(s *Snapshot) { + r.beginTransition(s) + s.AccountId = "" + s.ProfileId = "" + s.TechSpaceId = "" + s.NetworkId = "" + }) +} +func (r *Recorder) Account(a *model.Account) { + r.update(func(s *Snapshot) { + if a == nil || a.Id == "" { + return + } + if previous := r.previousAPI; previous != nil && previous.AccountId == a.Id && previous.DataPath == s.DataPath { + s.APIAddress, s.APIBound = previous.APIAddress, previous.APIBound + s.APIObservation = previous.APIObservation + if !s.APIBound && (s.APIObservation == "" || s.APIObservation == "startup_pending") { + s.APIObservation = "unverified_event_order" + } + r.startupAPIAddress = "" + } else if !s.AccountTransition && s.AccountId != "" && s.AccountId != a.Id { + s.APIAddress, s.APIBound = "", false + s.APIObservation = "unverified_event_order" + } + r.previousAPI = nil + s.AccountId = a.Id + s.AccountTransition = false + if a.Info != nil { + s.ProfileId = a.Info.ProfileObjectId + s.TechSpaceId = a.Info.TechSpaceId + s.NetworkId = a.Info.NetworkId + } + }) +} +func (r *Recorder) Stopped() { + r.update(func(s *Snapshot) { + r.previousAPI = nil + r.startupAPIAddress = "" + s.AccountId = "" + s.ProfileId = "" + s.TechSpaceId = "" + s.NetworkId = "" + s.APIBound = false + s.APIAddress = "" + s.APIObservation = "stopped" + s.AccountTransition = false + }) +} +func (r *Recorder) API(address string, bound bool) { + r.update(func(s *Snapshot) { + r.previousAPI = nil + r.startupAPIAddress = "" + r.rememberAPIAddress(address) + s.APIAddress = address + s.APIBound = bound + s.APIObservation = "rpc_response" + }) +} + +// APIStarting opens a one-event window only when this address cannot identify +// an older startup/rebind in this process. Reusing an address without a retained +// same-account binding needs a direct rebind response or a fresh process. +func (r *Recorder) APIStarting(address string) { + r.update(func(s *Snapshot) { + r.startupAPIAddress = "" + s.APIAddress, s.APIBound = "", false + canonical := canonicalAPIAddress(address) + unambiguous := canonical != "" && !r.apiUnknownAddress && !r.apiAddresses[canonical] + r.rememberAPIAddress(address) + s.APIObservation = "unverified_event_order" + if address == "" { + s.APIObservation = "startup_disabled" + } else if unambiguous { + r.startupAPIAddress = canonical + s.APIObservation = "startup_pending" + } + }) +} + +// APIChanging invalidates prior evidence before a rebind can shut down the old +// listener. Even an error may follow shutdown, so a failed RPC stays unverified. +func (r *Recorder) APIChanging(address string) { + r.update(func(s *Snapshot) { + r.previousAPI = nil + r.startupAPIAddress = "" + r.rememberAPIAddress(address) + s.APIAddress, s.APIBound = "", false + s.APIObservation = "rpc_pending" + }) +} + +func (r *Recorder) APIChangeUncertain() { + r.update(func(s *Snapshot) { s.APIObservation = "rpc_outcome_unknown" }) +} + +func (r *Recorder) APIEvent(address string, bound bool) { + r.update(func(s *Snapshot) { + if r.startupAPIAddress == "" || canonicalAPIAddress(address) != r.startupAPIAddress { + return + } + r.startupAPIAddress = "" + r.previousAPI = nil + s.APIAddress, s.APIBound = address, bound + s.APIObservation = "startup_event" + }) +} + +func canonicalAPIAddress(address string) string { + host, port, err := net.SplitHostPort(address) + p, portErr := strconv.Atoi(port) + ip := net.ParseIP(host) + if err != nil || portErr != nil || ip == nil || p < 1 || p > 65535 { + return "" + } + if ip.IsUnspecified() { + // Go's dual-stack tcp listener may report [::] for 0.0.0.0. These + // wildcard spellings must share both event matching and history keys. + return net.JoinHostPort("::", strconv.Itoa(p)) + } + return net.JoinHostPort(ip.String(), strconv.Itoa(p)) +} + +// SameAPIAddress compares fixed numeric listener identities, including Go's +// dual-stack wildcard aliases. It does not select a client connection origin. +func SameAPIAddress(a, b string) bool { + canonical := canonicalAPIAddress(a) + return canonical != "" && canonical == canonicalAPIAddress(b) +} + +func (r *Recorder) rememberAPIAddress(address string) { + if address == "" { + return + } + canonical := canonicalAPIAddress(address) + if canonical == "" || len(r.apiAddresses) >= 1024 { + // Dynamic/hostname listeners do not provide an exact address to fence. + // Bound memory use under arbitrary native RPC callers conservatively. + r.apiUnknownAddress = true + return + } + if r.apiAddresses == nil { + r.apiAddresses = make(map[string]bool) + } + r.apiAddresses[canonical] = true +} + +// AccountActivity includes unresolved transitions. Auto-login must not race or +// override another caller's successful or interrupted account operation. +func (r *Recorder) AccountActivity() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.snapshot.AccountId != "" || r.snapshot.AccountTransition +} + +func (r *Recorder) DataPath(value string) { + if value != "" { + if absolute, err := filepath.Abs(value); err == nil { + r.update(func(s *Snapshot) { + if s.DataPath != absolute { + r.previousAPI = nil + r.startupAPIAddress = "" + s.APIAddress, s.APIBound = "", false + s.APIObservation = "unverified_event_order" + } + s.DataPath = absolute + }) + } + } +} diff --git a/core/runtimeinfo/runtime_test.go b/core/runtimeinfo/runtime_test.go new file mode 100644 index 0000000..eab8e87 --- /dev/null +++ b/core/runtimeinfo/runtime_test.go @@ -0,0 +1,167 @@ +package runtimeinfo + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/anyproto/anytype-cli/core/localstate" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" +) + +func TestRuntimeRecordSanitizedAndNonceChanges(t *testing.T) { + file := filepath.Join(t.TempDir(), "runtime.json") + r, err := New(file, "cfg", "data") + if err != nil { + t.Fatal(err) + } + first := r.Nonce() + r.Account(&model.Account{Id: "account", Info: &model.AccountInfo{TechSpaceId: "tech", ProfileObjectId: "profile", NetworkId: "network", MetaDataKey: "must-not-leak"}}) + r.API("127.0.0.1:1234", true) + var s Snapshot + if err = localstate.ReadJSON(file, &s); err != nil { + t.Fatal(err) + } + if s.AccountId != "account" || !s.APIBound || s.Nonce != first { + t.Fatal(s) + } + b, _ := os.ReadFile(file) + if strings.Contains(string(b), "must-not-leak") { + t.Fatal("sensitive metadata persisted") + } + r.Stopped() + s = Snapshot{} + if err = localstate.ReadJSON(file, &s); err != nil { + t.Fatal(err) + } + if s.AccountId != "" || s.APIBound { + t.Fatal("stop retained live identity") + } + r2, err := New(file, "cfg", "data") + if err != nil { + t.Fatal(err) + } + if r2.Nonce() == first { + t.Fatal("nonce reused") + } +} +func TestWriteFailureInvalidatesNonce(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "runtime.json") + r, err := New(file, "cfg", "data") + if err != nil { + t.Fatal(err) + } + if err = os.Remove(file); err != nil { + t.Fatal(err) + } + if err = os.Mkdir(file, 0700); err != nil { + t.Fatal(err) + } + r.Transition() + if r.Nonce() != "" { + t.Fatal("failed observation still advertised") + } +} + +func TestSameAccountBindingRecoveryAndInvalidation(t *testing.T) { + for _, test := range []struct { + name string + transition func(*Recorder, string) + account string + wantAddress string + wantBound bool + }{ + {"wallet recovery same account", func(r *Recorder, _ string) { r.WalletTransition(); r.Transition() }, "account-a", "127.0.0.1:4500", true}, + {"direct selection same account", func(r *Recorder, _ string) { r.Transition() }, "account-a", "127.0.0.1:4500", true}, + {"different account", func(r *Recorder, _ string) { r.WalletTransition(); r.Transition() }, "account-b", "", false}, + {"different data root", func(r *Recorder, dir string) { r.WalletTransition(); r.DataPath(filepath.Join(dir, "other")) }, "account-a", "", false}, + {"stop invalidates candidate", func(r *Recorder, _ string) { r.WalletTransition(); r.Stopped(); r.Transition() }, "account-a", "", false}, + {"failed binding replaces candidate", func(r *Recorder, _ string) { r.WalletTransition(); r.API("127.0.0.1:4600", false) }, "account-a", "127.0.0.1:4600", false}, + {"fresh binding replaces candidate", func(r *Recorder, _ string) { r.WalletTransition(); r.API("127.0.0.1:4600", true) }, "account-b", "127.0.0.1:4600", true}, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "runtime.json") + recorder, err := New(file, "cfg", filepath.Join(dir, "data")) + if err != nil { + t.Fatal(err) + } + if recorder.AccountActivity() { + t.Fatal("fresh server has account activity") + } + recorder.Account(&model.Account{Id: "account-a"}) + recorder.API("127.0.0.1:4500", true) + test.transition(recorder, dir) + if !recorder.AccountActivity() { + t.Fatal("transition lost account activity") + } + var before Snapshot + if err = localstate.ReadJSON(file, &before); err != nil { + t.Fatal(err) + } + if !before.AccountTransition { + t.Fatal("transition not recorded") + } + recorder.Account(&model.Account{Id: test.account}) + var after Snapshot + if err = localstate.ReadJSON(file, &after); err != nil { + t.Fatal(err) + } + if after.AccountId != test.account || after.APIAddress != test.wantAddress || after.APIBound != test.wantBound || after.AccountTransition { + t.Fatal(after) + } + }) + } +} + +func TestAPIListenerEquivalenceDoesNotConflateUnicastOrUnknownAddresses(t *testing.T) { + for _, tc := range []struct { + a, b string + same bool + }{ + {"0.0.0.0:4500", "[::]:4500", true}, + {"[::]:4500", "0.0.0.0:4500", true}, + {"0.0.0.0:4500", "[::]:4600", false}, + {"127.0.0.1:4500", "[::1]:4500", false}, + {"127.0.0.1:4500", "0.0.0.0:4500", false}, + {"", "", false}, + {"localhost:4500", "localhost:4500", false}, + } { + if got := SameAPIAddress(tc.a, tc.b); got != tc.same { + t.Fatal(tc, got) + } + } +} + +func TestSelectionRequiresKnownIdentityAndRootBeforeRetainingBinding(t *testing.T) { + for _, id := range []string{"account-a", "account-b", ""} { + for _, changedRoot := range []bool{false, true} { + dir := t.TempDir() + file := filepath.Join(dir, "runtime.json") + r, err := New(file, "config", filepath.Join(dir, "data")) + if err != nil { + t.Fatal(err) + } + r.Account(&model.Account{Id: "account-a"}) + r.API("127.0.0.1:4500", true) + r.WalletTransition() + if changedRoot { + r.DataPath(filepath.Join(dir, "other-data")) + } + r.AccountSelection(id) + // Even returning to the old identity cannot restore evidence after a + // different/unknown selection was allowed to enter the native handler. + r.Account(&model.Account{Id: "account-a"}) + var s Snapshot + if err = localstate.ReadJSON(file, &s); err != nil { + t.Fatal(err) + } + wantBound := id == "account-a" && !changedRoot + if s.APIBound != wantBound { + t.Fatal(id, changedRoot, s) + } + } + } +} diff --git a/core/serviceprogram/serviceprogram.go b/core/serviceprogram/serviceprogram.go index 276f3bb..031c2c6 100644 --- a/core/serviceprogram/serviceprogram.go +++ b/core/serviceprogram/serviceprogram.go @@ -12,6 +12,7 @@ import ( "github.com/anyproto/anytype-cli/core" "github.com/anyproto/anytype-cli/core/config" "github.com/anyproto/anytype-cli/core/grpcserver" + "github.com/anyproto/anytype-cli/core/localstate" "github.com/anyproto/anytype-cli/core/output" ) @@ -22,11 +23,14 @@ func GetService() (service.Service, error) { // GetServiceWithAddress creates a service instance with a custom API listen address. func GetServiceWithAddress(apiAddr string) (service.Service, error) { + return getServiceWithAddressAndLogs(apiAddr, config.GetLogsDir()) +} + +func getServiceWithAddressAndLogs(apiAddr, logDir string) (service.Service, error) { options := service.KeyValue{ "UserService": true, } - logDir := config.GetLogsDir() if logDir != "" { if err := os.MkdirAll(logDir, 0755); err == nil { options["LogDirectory"] = logDir @@ -73,6 +77,10 @@ type Program struct { // hasStoredAccount reports whether auto-login has a key to use; // replaceable in tests. hasStoredAccount func() bool + // accountActivity and lockInstance allow isolated auto-login lifecycle tests. + accountActivity func() bool + lockInstance func() (func(), error) + storedAccountKey func() (string, bool, error) } func New(apiListenAddr string) *Program { @@ -162,7 +170,40 @@ func (p *Program) run() { } func (p *Program) attemptAutoLogin() { - accountKey, _, err := core.GetStoredAccountKey() + lock := p.lockInstance + if lock == nil { + lock = func() (func(), error) { return localstate.Lock(localstate.Dir()) } + } + var unlock func() + for { + var err error + unlock, err = lock() + if err == nil { + break + } + select { + case <-p.ctx.Done(): + return + case <-time.After(200 * time.Millisecond): + } + } + defer unlock() + active := p.accountActivity + if active == nil { + active = p.server.AccountActivity + } + // Check after taking the shared lock: provisioning may have completed while + // this delayed auto-login was waiting for it. + if active() { + output.Info("Account operation already observed, skipping auto-login") + return + } + + readKey := p.storedAccountKey + if readKey == nil { + readKey = core.GetStoredAccountKey + } + accountKey, _, err := readKey() if err != nil || accountKey == "" { output.Info("No stored account key found, skipping auto-login") return diff --git a/core/serviceprogram/serviceprogram_test.go b/core/serviceprogram/serviceprogram_test.go index 8820b0d..a8b63e8 100644 --- a/core/serviceprogram/serviceprogram_test.go +++ b/core/serviceprogram/serviceprogram_test.go @@ -55,7 +55,7 @@ func TestNew(t *testing.T) { } func TestGetService(t *testing.T) { - svc, err := GetService() + svc, err := getServiceWithAddressAndLogs("", t.TempDir()) if err != nil { t.Fatalf("GetService() error = %v", err) } @@ -86,7 +86,7 @@ func TestGetServiceWithAddress(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, err := GetServiceWithAddress(tt.apiAddr) + svc, err := getServiceWithAddressAndLogs(tt.apiAddr, t.TempDir()) if err != nil { t.Fatalf("GetServiceWithAddress() error = %v", err) } @@ -211,3 +211,23 @@ func TestStartPrintsStartupBanner(t *testing.T) { }) } } + +func TestAutoLoginChecksAccountActivityAfterAcquiringLock(t *testing.T) { + for _, activity := range []bool{false, true} { + p := New(config.DefaultAPIAddress) + locked, unlocked, active, reads := false, false, false, 0 + p.lockInstance = func() (func(), error) { locked = true; active = activity; return func() { unlocked = true }, nil } + p.accountActivity = func() bool { + if !locked { + t.Fatal("activity checked before lock") + } + return active + } + // Even a regression must not reach the user's real credential store. + p.storedAccountKey = func() (string, bool, error) { reads++; return "", false, os.ErrNotExist } + p.attemptAutoLogin() + if !unlocked || activity && reads != 0 || !activity && reads != 1 { + t.Fatal(activity, reads, unlocked) + } + } +} diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md new file mode 100644 index 0000000..2646d10 --- /dev/null +++ b/docs/AUTOMATION.md @@ -0,0 +1,321 @@ +# Native CLI automation v1 + +The additive `--json` interface uses the existing CLI command paths. It never +prompts, prints colors/update hints, or interprets private input as shell text. +Human commands without `--json` retain their existing presentation and flags. +Machine help and parse errors are also single JSON objects on stdout. + +This interface manages **one default native instance**. A Hermes profile is not +an Anytype instance. `--instance default` is accepted; all other selections fail +with `unsupported_instance`. Config/data bindings are derived from the ordinary +CLI configuration and reported explicitly. Service automation supports launchd +user agents and systemd user services; Windows service automation is unsupported +in this first version. Arbitrary service environments, systemd drop-ins, custom +service definitions, and `DATA_PATH` service provisioning are not adopted. +Ordinary Windows CLI/service operation remains supported; the machine service +adapter limitation does not disable its runtime observation or credential store. + +## Envelope and exits + +Every machine command emits exactly one object: + +```json +{"schema_version":1,"state":"ready","changed":false,"result":{},"next_action":""} +``` + +Errors add `error: {code, message, retryable}`. Error messages are controlled CLI +text and typed protobuf code names; raw server descriptions, command arguments, +private inputs, and secret fragments are never copied into diagnostics. Result +fields use snake_case. Unknown fields should be ignored; unknown schema versions +must be rejected. `changed` is true when this invocation positively observed a +mutation; false is **not** a no-side-effect guarantee after an interrupted RPC. +Read the error and operation stage when a result is uncertain. + +| Exit | Meaning | +| --- | --- | +| 0 | Requested operation/inspection completed | +| 2 | Input or operator action required | +| 3 | Membership request sent, pending approval, or syncing | +| 4 | Temporary unavailability/deadline/instance contention | +| 1 | Incompatible or terminal outcome | + +States include `ready`, `needs_input`, `unavailable`, `incompatible`, +`request_sent`, `pending_approval`, and `syncing`. Typed errors include +`invalid_arguments`, `operation_id_required`, `operation_conflict`, +`operation_uncertain`, `instance_busy`, `unsupported_instance`, +`instance_mismatch`, `service_upgrade_required`, `account_mismatch`, +`account_transition`, `authentication_required`, `grant_mismatch`, and +`unverified_key_cleanup_uncertain`. Heart errors become `rpc_`. +An invitation-not-found code is not labeled expired: Heart does not distinguish +expiration, revocation, missing content, and every network failure reliably. + +All commands accept `--json`, `--non-interactive` (redundant), +`--instance default`, `--expect-account ID`, and `--timeout 45s` (1s–5m). +The caller's whole-command deadline is respected. Individual liveness and OS +status probes are bounded more tightly. CLI keyring operations use the platform +credential API. A stalled platform keyring can require caller process timeout. + +## Read-only discovery + +```text +anytype capabilities --json +anytype inspect --json +anytype service status --json +anytype auth status --json [--expect-account ID] +anytype space list --json [--expect-account ID] +anytype space status SPACE_ID --json [--expect-account ID] +anytype auth apikey list --json [--expect-account ID] +``` + +`capabilities` describes the **embedded build**, with no network/config/keyring +access. `inspect`, `service status`, and `auth status` share an inspection result: + +- `cli_version`, `capabilities`; +- `instance`: `id`, absolute `config_path`, `data_path`, `work_path`, fixed + `grpc_address`, `grpc_web_address`, and `isolated:false`; +- `service`: `name`, `manager`, `definition_path`, `definition_hash`, + `executable`, `installed`, `running`, `binding_verified`, `listen_address`, + supervisor `pid` and separately nonce-verified `runtime_pid` when available, and `health`; +- `account`: full `id`, observed `display_name`, `authenticated`, + `observation` (`live` or `configured`), and credential-storage backend; +- `network`: `id`, `config_path`, and `observation`; +- `api`: effective `origin`, `listen_address`, `bound`, `observation`, and + `ready:false`/`readiness:requires_integration_key_probe`, plus + `binding_observation` and an actionable `next_action` when unverified; +- `mcp`: effective `url`, bearer authentication, `token_source:same_api_key`, + `same_grant:true`, `ready:false`/`readiness:not_probed`. + +Configured facts remain available in `result` when the service is unavailable. +A successful supervisor start is independent of API binding: Heart starts the +HTTP API on account login. HTTP/MCP usability must be probed with the selected +integration credential. CLI inspection does not mint a temporary API key. + +The existing embedded service now publishes a sanitized private atomic +`~/.anytype/automation/runtime.json`. Its random per-launch nonce must match the +live `AppGetVersion` response metadata `anytype-cli-runtime`. A PID/file alone +is never evidence of liveness. Config/data/nonce mismatches fail closed; account +transitions invalidate authentication/readiness claims. Account name is read +from the authenticated native profile index, not guessed from config. +During wallet recovery, the previous API binding is held only in process memory. +It is restored only after successful selection of the same account and data +root, with no intervening stop, rebind, accepted API status event, account +creation, different/unknown-account selection, or uncertain account start. +Selecting another account can close the old application before returning an +error, so the old binding is discarded before that selection enters Heart. +A failed or incomplete create/select response discards continuity and closes +its startup-event window, reporting `account_start_outcome_unknown`; inspection +directs the caller to resume the account operation or explicitly restart the +native service. A new account never inherits the previous account's binding. +Delayed service auto-login skips any account activity already observed under the +shared instance lock, including an unresolved transition. + +Heart broadcasts API status asynchronously without an event generation or +account identity. Non-overlapping `AccountChangeJsonApiAddr` outcomes supersede +broadcasts, including a successful disable and a failed bind. An interrupted or +failed rebind without a status leaves the binding unverified, because shutdown +may already have happened. Stop/shutdown discard retained binding evidence when +they begin, even if closing components later reports an error; a failed stop +reports `stop_outcome_unknown` and cannot restore the old binding on selection. +The observer never queues native lifecycle handlers behind one another: newer +starts, stop, and shutdown reach Heart immediately to supersede/cancel recovery. +Overlapping lifecycle calls have no proven native effect order, so their batch +reports `concurrent_lifecycle` and suppresses positive account/API response and +event claims. A later independent account observation or explicit service restart +can resolve an account transition; an independent rebind can resolve uncertainty +from overlapping API-only rebinds. Inspection explains the applicable next action. +Delayed success/error events cannot replace established RPC outcomes. +Normal first startup accepts a matching event for its requested fixed numeric +address, treating `0.0.0.0:PORT` and `[::]:PORT` as aliases for Go's dual-stack +wildcard listener in both matching and address history. Distinct unicast +endpoints retain separate history. Same-account recovery retains prior binding +evidence. A distinct-address +startup rejects old-address events. Reusing an address after a stop in the same +process cannot distinguish a new event from an old one: inspection reports +`binding_observation:unverified_event_order`, `bound:false`, empty API/MCP origins, +and a next action to explicitly restart the native service or rebind through +`AccountChangeJsonApiAddr`. A fresh service process and an explicit successful +rebind can establish binding evidence again. Dynamic/hostname startup addresses +also remain unverified when their event cannot be tied to a unique requested +address. None of these observations prove HTTP/MCP usability without the +integration-key probes. + +**Previously running CLI binaries need an explicit upgrade/restart** before this +observation interface is available. Inspection returns `service_upgrade_required` +and never restarts/replaces them itself. There is no new daemon, listener, or +Hermes-specific RPC service. + +HTTP MCP in this embedded Heart uses `/mcp/full` (also `/mcp/small` and +`/mcp/large`) on the same actual API origin. It uses the same bearer API key and +space/read-write grant as REST v2, is stateless, and supports protocol versions +`2025-06-18` and `2025-11-25`. The full tool set excludes `auth_whoami`; REST +`GET /v2/auth/whoami` remains available. These build facts are not live readiness +proof for an arbitrary externally managed service. + +## Explicit service provisioning + +```text +anytype service install --json --operation-id SERVICE_OPERATION [--listen-address IP:PORT] +anytype service start --json --operation-id START_OPERATION [--listen-address IP:PORT] +``` + +A matching existing definition is reused. Definitions with different executable, +listener, environment, or unknown binding are not replaced. Existing account +network/ports are preserved. A reachable incompatible/mismatched native server +blocks installation. A definition without `--listen-address` inherits the saved +CLI address, as `serve` does. A launchd `Program` override is unverified and +blocks automatic adoption. Systemd must report a loaded unit, no required daemon +reload, and an effective `ExecStart` matching the supported on-disk definition. +Stale, missing, or unparseable supervisor evidence blocks adoption; the CLI does +not reload an externally changed unit. Result is `{operation_id, service, api_ready:false, +next_probe}`. Start may be retried against the same supervisor definition. An +ambiguous installation with no observable definition requires intervention. +The CLI invokes its existing service ownership commands with bounded subprocesses; +it does not write a separate Hermes service. + +## Accounts + +```text +anytype auth create 'Агент 🌿' --json --expect-account none --operation-id CREATE_OPERATION [--include-secret] [--network-config FILE] [--listen-address IP:PORT] +anytype auth login --json --expect-account none --operation-id LOGIN_OPERATION --key-stdin [--network-config FILE] [--listen-address IP:PORT] +anytype auth login --json --expect-account FULL_ACCOUNT_ID --operation-id LOGIN_OPERATION --key-stdin +``` + +Stdin is a pipe or owner-only regular file containing only the account key, optionally followed by a newline, then EOF. +No raw key argument/flag is accepted. `--expect-account none` proves the instance +has no live/configured account, saved key, or existing data before first creation +or first import. Login derives the full identity locally and binds it durably to +the operation; subsequent login against an existing instance requires its full +expected identity. Credentials/account/network are never implicitly replaced. +Saved account credentials, including legacy config keys, must also derive to the +expected identity before any wallet/session/account mutation. Unreadable +credential evidence blocks provisioning. The chosen YAML/default network must +match observed and configured network identity. Same-account reuse preserves +the live API listener, including when CLI config was not yet saved. + +Creation trims outer whitespace, rejects empty/invalid UTF-8/control characters, +and preserves internal whitespace and Unicode. The verified Heart account-create +and profile-details paths impose no name-specific maximum; the CLI advertises its +own 4096 UTF-8 byte input bound. Account rename is deliberately unsupported: +`auth rename NAME --json` returns `unsupported_operation` without mutation. + +Result is `{operation_id, account}`. With explicit `--include-secret`, create or +login/recovery may also return `account_key`. Secret output requires a pipe or owner-only regular file, +not a terminal. Capture it only in the private calling process; do not log it. +Omitting the flag never prints any key fragment. Resume repeats the same command +and operation ID; private stdin can be omitted once the credential is stored. +Network file paths are normalized before comparing effective settings; an exact +replay of a relative path remains valid after its absolute form was saved. +The selected file's content hash is also bound to the operation. + +Wallet recovery material is durably saved before account creation. Exact live +identity plus observed requested name can reconcile a lost create response. +After a confirmed account response, deterministic `AccountSelect` can recover a +restarted service without repeating `AccountCreate`. A sent-but-unconfirmed create +without such proof returns `operation_uncertain`. Partial protobuf errors retain +the recovery credential and the uncertain operation; they are not successes. + +## Native space membership + +```text +anytype space join --json --expect-account FULL_ACCOUNT_ID --operation-id JOIN_OPERATION --invite-stdin [--space-id EXPECTED_FULL_SPACE_ID] +anytype space join --json --expect-account FULL_ACCOUNT_ID --operation-id JOIN_OPERATION +anytype space status FULL_SPACE_ID --json --expect-account FULL_ACCOUNT_ID +``` + +First-call stdin is one invitation URL followed by EOF. The journal stores the +resolved space ID, never the invitation or its key. Resume can inspect the +recorded membership without stdin and without resubmission. Status/list read the +native membership index using the native session before any integration API key +exists. Full IDs are returned with name, raw local/account/participant status, +normalized `state`, and `http_ready:false`. + +States include `request_sent`, `pending_approval`, `joined`, `syncing`, `rejected`, +`removed`, and `unknown`. Missing participant status is not interpreted as joining. +Heart's terminal deleted account status overrides a stale joining participant +after rejection. Loaded owned/personal spaces with local `Ok` and account +`Unknown` are joined and may be selected for fixed-space grants. +`joined` means native membership/local load, not HTTP authorization or gateway +readiness. Rejected/removed membership returns exit 2. Join result is +`{operation_id, space}`; status result is the space object; list result is +`{account_id, spaces:[]}`. An interrupted request without an observable membership +is uncertain and is never automatically submitted again. + +## Integration credentials + +```text +anytype auth apikey create 'stable application name' --json --expect-account FULL_ACCOUNT_ID --operation-id KEY_OPERATION --space FULL_SPACE_ID --read-write --include-secret +anytype auth apikey create 'stable application name' --json --expect-account FULL_ACCOUNT_ID --operation-id KEY_OPERATION --all-spaces --read-only --include-secret +anytype auth apikey revoke CREDENTIAL_ID --json --expect-account FULL_ACCOUNT_ID --operation-id REVOKE_OPERATION +``` + +Exactly one space scope (`--space` repeated or `--all-spaces`) and permission +(`--read-only` or `--read-write`) is required. Exact unique space names are also +accepted, resolved to full IDs. Application names remain unchanged across +rotation: Heart's creation/deletion ownership depends on the exact name, and the +verified limit is 128 UTF-8 bytes. Names are not operation identities. + +Creation result contains `operation_id`, `account_id`, `credential_id` (Heart +AppHash), `application_name`, `scope`, `grant`, `created_at`, `expires_at`, and +`grant_verified:true`. Grant is `{all_spaces, space_ids, permission}` where +permission is `read` or `read_write`. `api_key` appears only with +`--include-secret` on private stdout. List returns `{account_id, credentials:[]}` +with the same non-secret credential fields; it never prints tokens. + +Creation verifies the actual persisted grant by native readback. Recovery uses an +exact persisted key receipt or credential ID, never an application-name match. +The resolved space IDs and permission are persisted before creation and reused +on every replay, even if a selected space name changes or is reused. Old sent or +completed operation receipts without that resolved grant return uncertainty; +they do not recreate/revoke a key using a newly resolved name. +If the response/receipt was lost before identity could be recorded, even a unique +new same-name key is insufficient proof: return uncertainty, not a duplicate. +A provably newly created credential with wrong scope/grant is revoked by its exact +identity, with cleanup recorded and verified. Cleanup failure is explicit. +Externally changed keys from previously completed operations are not revoked. +Rotation retains all old credentials unless explicitly revoked by ID. + +## Durable coordination and private storage + +Mutations require an 8–128 character operation ID (`[A-Za-z0-9][A-Za-z0-9_-]*`). +Non-secret operation records in `automation/operations/` bind command intent, +instance paths, expected/derived account, effective listener/network, stage and +resource identity. Reusing an ID with different intent fails. One OS advisory +instance lock coordinates machine mutations, this version's human CLI mutations, +and service auto-login. External native RPC clients or older CLI versions do not +honor that lock; identity/session/observation checks detect incompatible account +transitions, but no distributed coordination is claimed. + +Secrets are CLI-owned, using the existing `anytype-cli` system keyring when +available and owner-only atomic files in `automation/secrets/` otherwise. +Default lookup distinguishes an absent key from a denied or unavailable keyring; +the latter blocks account provisioning rather than claiming an empty instance. +For a fresh headless instance, explicitly set +`ANYTYPE_CLI_CREDENTIAL_BACKEND=private_file` on the provisioning invocation. +This selects private files and leaves unmarked legacy keyring entries untouched; +it does **not** prove those entries are absent. Use it only when deliberately +choosing file-backed credentials for this instance. The choice is saved on the +first credential write in `automation/credential-backend.json`, so later CLI +commands and service auto-login need no environment override. Existing per-secret +markers take precedence: system-keyring markers remain keyring-backed and cannot +be silently migrated by this selector or by keyring failures. Private-file +markers remain file-backed. Legacy config credentials still participate in +identity guards. Invalid selector values fail closed. Capabilities advertise +the selector, and account results report the actual credential backend. +On POSIX, files use mode 0600, file sync and directory sync after rename. On +Windows, files are created with a protected ACL allowing only the current user +and LocalSystem, checked through the opened file's owner/DACL, flushed, and +replaced with `MoveFileEx` write-through. Windows' synthetic POSIX mode bits and +unsupported read-only directory flush are not used. The Windows storage helpers +and their ACL/round-trip tests are cross-compiled in this checkpoint; Windows +runtime acceptance still needs a Windows host. +Read-only inspection never tests keyring availability by writing an entry, creates +no directory, and does not migrate legacy storage. Legacy config credentials are +read compatibly. Human CLI and service auto-login share this credential store. +Logout commits tombstones, attempts physical deletion of owned keyring entries, and removes legacy +config credentials. Completed operation recovery receipts are retained separately +for explicit private recovery; logout does not erase those recovery receipts. +Treat the entire CLI config directory and keyring as sensitive backups. + +Cancellation/failure never deletes accounts, leaves spaces, stops shared services, +or revokes older credentials. An uncertain mutation stays visible and requires +inspection/resume. There is no implicit cleanup merely because a caller exits. From 9c044e7b269d47ee61e73479b2732fcd71d8de64 Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Tue, 13 Oct 2026 14:28:40 +0200 Subject: [PATCH 3/5] GO-7383 Update embedded Heart to v0.51.6 --- go.mod | 14 +++++++------- go.sum | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 6eadd9f..0c39dff 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/anyproto/anytype-cli go 1.26.5 require ( - github.com/anyproto/any-sync v0.13.7 - github.com/anyproto/anytype-heart v0.51.6-nightly.20261008.1.0.20261009162250-92ed13917ebb + github.com/anyproto/any-sync v0.13.8 + github.com/anyproto/anytype-heart v0.51.6 github.com/cheggaaa/mb/v3 v3.0.3 github.com/chzyer/readline v1.5.1 + github.com/gofrs/flock v0.13.0 + github.com/gogo/protobuf v1.3.2 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 github.com/improbable-eng/grpc-web v0.15.0 @@ -15,6 +17,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/zalando/go-keyring v0.2.8 golang.org/x/mod v0.41.0 + golang.org/x/sys v0.48.0 google.golang.org/grpc v1.83.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -27,7 +30,7 @@ require ( github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/anyproto/any-block v0.0.0-20260919082013-9906a7c36b68 // indirect - github.com/anyproto/any-store v1.0.2 // indirect + github.com/anyproto/any-store v1.0.3 // indirect github.com/anyproto/anytype-publish-server/publishclient v0.0.0-20260925080338-0fb960f00458 // indirect github.com/anyproto/anytype-push-server/pushclient v0.0.0-20260407093150-0b14187dfd27 // indirect github.com/anyproto/go-bip39 v1.0.0 // indirect @@ -97,9 +100,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/googleapis v1.4.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/status v1.1.1 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect @@ -239,7 +240,6 @@ require ( golang.org/x/net v0.59.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sync v0.23.0 // indirect - golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.50.0 // indirect @@ -268,7 +268,7 @@ replace ( github.com/gogo/protobuf => github.com/anyproto/protobuf v1.3.3-0.20240201225420-6e325cf0ac38 github.com/ipfs/go-ds-flatfs => github.com/anyproto/go-ds-flatfs v0.0.0-20250828183910-d49f5b2d567f github.com/ipfs/go-log/v2 => github.com/anyproto/go-log/v2 v2.1.2-0.20220721095711-bcf09ff293b2 - github.com/libp2p/zeroconf/v2 => github.com/anyproto/zeroconf/v2 v2.2.1-0.20260709212715-528971bb5854 + github.com/libp2p/zeroconf/v2 => github.com/anyproto/zeroconf/v2 v2.2.1-0.20261012094456-27dfb04e233b github.com/multiformats/go-multiaddr => github.com/anyproto/go-multiaddr v0.8.1-0.20250307125826-51ba58e2ebc7 google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 gopkg.in/Graylog2/go-gelf.v2 => github.com/anyproto/go-gelf v0.0.0-20210418191311-774bd5b016e7 diff --git a/go.sum b/go.sum index 4b5c4da..5075b84 100644 --- a/go.sum +++ b/go.sum @@ -29,12 +29,12 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/anyproto/any-block v0.0.0-20260919082013-9906a7c36b68 h1:7EAL+fDiqZs/1R52STcE1tFcnGGwDYC7KeMk+4q/VKQ= github.com/anyproto/any-block v0.0.0-20260919082013-9906a7c36b68/go.mod h1:ctWUBba2Nm+mHwkh4bBej57avQHaW2HDzDZDSlbVchs= -github.com/anyproto/any-store v1.0.2 h1:vQvgKKDDmM0j9EheBWGq8bMVU7Oi3061DKJFG8D3sTQ= -github.com/anyproto/any-store v1.0.2/go.mod h1:8cqb52gjZSaYnlybugqpSqSG1RQygY96D2vWMbSJsLo= -github.com/anyproto/any-sync v0.13.7 h1:J5cEGYDSM1bmKPWjqmyPks1GqGz3Tceg9TERUt6twgk= -github.com/anyproto/any-sync v0.13.7/go.mod h1:d56Pxkt2zaK8K9SAEf8KkhBwJx/a6mEGeRY6h717x0w= -github.com/anyproto/anytype-heart v0.51.6-nightly.20261008.1.0.20261009162250-92ed13917ebb h1:bVmRsTMDuagLl7eW/pHJKFkrjKTg2ouS6We2sxGbXnU= -github.com/anyproto/anytype-heart v0.51.6-nightly.20261008.1.0.20261009162250-92ed13917ebb/go.mod h1:jDeXMTEpBDMOwQYhFlMra1VReUZAKS1aXFFcbwf/2lk= +github.com/anyproto/any-store v1.0.3 h1:lAlwJ2Gc/LXiA9zXVqGYAgo7h7CgjTvcVpe9FjJsGjM= +github.com/anyproto/any-store v1.0.3/go.mod h1:8cqb52gjZSaYnlybugqpSqSG1RQygY96D2vWMbSJsLo= +github.com/anyproto/any-sync v0.13.8 h1:VVcAX/8VHwIB3D3mEaO0v3ojyixrYvaGI1nwgLja3zc= +github.com/anyproto/any-sync v0.13.8/go.mod h1:d56Pxkt2zaK8K9SAEf8KkhBwJx/a6mEGeRY6h717x0w= +github.com/anyproto/anytype-heart v0.51.6 h1:JcI9makyD1pxzUmzI/OlsKEPOanPhOWNE88kKiTuCH4= +github.com/anyproto/anytype-heart v0.51.6/go.mod h1:b9UxwPzOivKOPVuqgLn/4W/uzc9dXnXjFMXGg1Nwa7c= github.com/anyproto/anytype-publish-server/publishclient v0.0.0-20260925080338-0fb960f00458 h1:kg/BHaimoLI2suFe4Kq1MfNxFvWYlFfPPkgVehU9+FE= github.com/anyproto/anytype-publish-server/publishclient v0.0.0-20260925080338-0fb960f00458/go.mod h1:lRppnNvn5vAw/ASBOYV6LTYPqEg+XC34AzloxK1E/Ac= github.com/anyproto/anytype-push-server/pushclient v0.0.0-20260407093150-0b14187dfd27 h1:O/n87PsZozTtfIbKOad8lS2safL/A1CuzFNrLbin18M= @@ -71,8 +71,8 @@ github.com/anyproto/ristretto v0.1.2-0.20240221153107-2b23839cc50c h1:GicoaTUyB2 github.com/anyproto/ristretto v0.1.2-0.20240221153107-2b23839cc50c/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/anyproto/tantivy-go v1.0.6 h1:8FI8otTeq5OFXK7Fw7vwG2vrwW1R1Yvcbiy4rO4YfoQ= github.com/anyproto/tantivy-go v1.0.6/go.mod h1:LtipOpRjGtcYMGcop6gQN7rVl1Pc6BlIs9BTMqeWMsk= -github.com/anyproto/zeroconf/v2 v2.2.1-0.20260709212715-528971bb5854 h1:9Hwnz/eHgLqchu42c0EjSRMgzabVJnbX4QOxGIs3frw= -github.com/anyproto/zeroconf/v2 v2.2.1-0.20260709212715-528971bb5854/go.mod h1:2KV4b7s+6Jfu0HM826dXIAh2Ln5EXcnpt/cW1TWFWBo= +github.com/anyproto/zeroconf/v2 v2.2.1-0.20261012094456-27dfb04e233b h1:GG/Ce6M15BZ34zhr1osMe+dgQQmxIC2zgCjrtp2QyqM= +github.com/anyproto/zeroconf/v2 v2.2.1-0.20261012094456-27dfb04e233b/go.mod h1:2KV4b7s+6Jfu0HM826dXIAh2Ln5EXcnpt/cW1TWFWBo= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= From c8ef2bb0d760ee8cb599cfca5d060d39255d937a Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Tue, 13 Oct 2026 14:35:33 +0200 Subject: [PATCH 4/5] GO-7383 Use tagged switch for membership status --- core/automation/spaces.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/automation/spaces.go b/core/automation/spaces.go index b94ee84..b9e9c53 100644 --- a/core/automation/spaces.go +++ b/core/automation/spaces.go @@ -57,13 +57,14 @@ func (e *Engine) spaces(ctx context.Context, tech string) ([]Space, error) { } // Heart's rejected join retains participant Joining while its account // status becomes SpaceDeleted. Terminal account state wins over it. - if account == model.SpaceStatus_SpaceDeleted { + switch account { + case model.SpaceStatus_SpaceDeleted: if s.ParticipantStatus == model.ParticipantStatus_Joining.String() || s.State == "rejected" { s.State = "rejected" } else { s.State = "removed" } - } else if account == model.SpaceStatus_SpaceRemoving { + case model.SpaceStatus_SpaceRemoving: s.State = "removed" } if s.State == "unknown" { From d23960a7a7f63543c917f3c19b15bc008f7887f4 Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Tue, 13 Oct 2026 15:42:10 +0200 Subject: [PATCH 5/5] GO-7383 Fix CLI automation review findings --- cmd/machine/machine_darwin_test.go | 39 +++++++++ core/automation/account.go | 2 +- core/automation/account_test.go | 30 +++++++ core/automation/engine.go | 2 +- core/automation/service.go | 13 +++ core/credentials/store.go | 9 +- core/credentials/store_test.go | 54 ++++++++++-- core/grpcserver/observation.go | 16 +++- .../grpcserver/observation_continuity_test.go | 82 +++++++++++++++++++ core/localstate/acl_darwin.go | 46 +++++++++++ core/localstate/acl_darwin_nocgo.go | 14 ++++ core/localstate/acl_darwin_test.go | 70 ++++++++++++++++ core/localstate/acl_posix.go | 9 ++ core/localstate/files_unix.go | 19 ++++- docs/AUTOMATION.md | 18 ++++ 15 files changed, 404 insertions(+), 19 deletions(-) create mode 100644 cmd/machine/machine_darwin_test.go create mode 100644 core/localstate/acl_darwin.go create mode 100644 core/localstate/acl_darwin_nocgo.go create mode 100644 core/localstate/acl_darwin_test.go create mode 100644 core/localstate/acl_posix.go diff --git a/cmd/machine/machine_darwin_test.go b/cmd/machine/machine_darwin_test.go new file mode 100644 index 0000000..5fec83f --- /dev/null +++ b/cmd/machine/machine_darwin_test.go @@ -0,0 +1,39 @@ +//go:build darwin && cgo + +package machine + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/anyproto/anytype-cli/core/automation" +) + +func TestSecretOutputRejectsInheritedReadACL(t *testing.T) { + dir := t.TempDir() + if out, err := exec.Command("/bin/chmod", "+a", "everyone allow read,search,file_inherit,directory_inherit", dir).CombinedOutput(); err != nil { + t.Fatalf("add fixture ACL: %v: %s", err, out) + } + file, err := os.OpenFile(filepath.Join(dir, "output.json"), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer file.Close() + r := new(recorder) + code := Execute(context.Background(), []string{"auth", "create", "Agent", "--json", "--include-secret"}, strings.NewReader(""), file, r) + if code != 2 || r.calls != 0 { + t.Fatal("shared ACL passed secret-output gate", code, r.calls) + } + if _, err = file.Seek(0, 0); err != nil { + t.Fatal(err) + } + var out automation.Envelope + if err = json.NewDecoder(file).Decode(&out); err != nil || out.Error == nil || out.Error.Code != "private_output_required" { + t.Fatal("missing actionable private-output failure", out, err) + } +} diff --git a/core/automation/account.go b/core/automation/account.go index 1833c90..d70ee0b 100644 --- a/core/automation/account.go +++ b/core/automation/account.go @@ -120,7 +120,7 @@ func (e *Engine) authMutation(ctx context.Context, r Request, op *operation, ret } op.Settings = settings // Installation arguments and account selection must agree about the listener. - svc, err := e.Service.Status(ctx) + svc, err := e.runtimeServiceStatus(ctx) if err != nil { return fail(err) } diff --git a/core/automation/account_test.go b/core/automation/account_test.go index 6798544..5c831af 100644 --- a/core/automation/account_test.go +++ b/core/automation/account_test.go @@ -11,6 +11,36 @@ import ( "github.com/anyproto/anytype-cli/core/runtimeinfo" ) +func TestWindowsForegroundAccountAutomation(t *testing.T) { + for _, command := range []string{"auth create", "auth login"} { + t.Run(command, func(t *testing.T) { + e, rpc, _ := fixture(t, false) + e.Service = &NativeService{Platform: "windows"} + r := createRequest() + if command == "auth login" { + r = Request{Command: command, OperationId: "login-windows", ExpectedAccount: "account-a", Secret: "account-private-fixture"} + } + if out := run(e, r); out.ExitCode() != 0 || rpc.createCalls+rpc.selectCalls != 1 { + t.Fatal("foreground account operation failed", out) + } + for _, inspect := range []string{"inspect", "auth status", "service status"} { + out := run(e, Request{Command: inspect, ExpectedAccount: "account-a"}) + if out.ExitCode() != 0 { + t.Fatal("foreground inspection failed", out) + } + result := out.Result.(map[string]any) + if !result["account"].(Account).Authenticated || result["service"].(ServiceStatus).Manager != "unsupported" { + t.Fatal("inspection hid service adapter limitation", out) + } + } + assertCode(t, run(e, Request{Command: "auth status", ExpectedAccount: "another-account"}), "account_mismatch") + for _, mutation := range []Request{{Command: "service install", OperationId: "windows-install"}, {Command: "service start", OperationId: "windows-start"}} { + assertCode(t, run(e, mutation), "unsupported_service_platform") + } + }) + } +} + func TestUnreadableSavedKeyBlocksAccountProvisioning(t *testing.T) { for _, command := range []string{"auth create", "auth login"} { e, rpc, _ := fixture(t, false) diff --git a/core/automation/engine.go b/core/automation/engine.go index ec178d2..c810f77 100644 --- a/core/automation/engine.go +++ b/core/automation/engine.go @@ -204,7 +204,7 @@ func (e *Engine) inspect(ctx context.Context, r Request) Envelope { if err != nil { return Failure(err) } - svc, svcErr := e.Service.Status(ctx) + svc, svcErr := e.runtimeServiceStatus(ctx) result := map[string]any{"cli_version": core.GetVersion(), "instance": map[string]any{"id": "default", "config_path": e.ConfigPath, "data_path": e.DataPath, "work_path": e.WorkPath, "grpc_address": config.DefaultGRPCAddress, "grpc_web_address": config.DefaultGRPCWebAddress, "isolated": false}, "service": svc, "capabilities": capabilities(), "account": Account{Id: c.AccountId, Observation: "configured"}, "network": map[string]any{"id": c.NetworkId, "config_path": c.NetworkConfigPath, "observation": "configured"}, "api": map[string]any{"configured_origin": apiOrigin(config.ResolveAPIListenAddr("", false, c.ApiListenAddr)), "ready": false, "observation": "configured"}} s, err := e.live(ctx) if err != nil { diff --git a/core/automation/service.go b/core/automation/service.go index ba313f1..7b51804 100644 --- a/core/automation/service.go +++ b/core/automation/service.go @@ -36,6 +36,19 @@ func runProcess(ctx context.Context, name string, args ...string) ([]byte, error defer cancel() return exec.CommandContext(ctx, name, args...).Output() } + +// A verified foreground runtime does not require an OS service adapter. +// Keep every other inspection error (including unavailable supported managers) +// so this cannot bypass an installed service's binding checks. +func (e *Engine) runtimeServiceStatus(ctx context.Context) (ServiceStatus, error) { + s, err := e.Service.Status(ctx) + var detail *Error + if s.Manager == "unsupported" && errors.As(err, &detail) && detail.Code == "unsupported_service_platform" { + return s, nil + } + return s, err +} + func (n *NativeService) Status(ctx context.Context) (ServiceStatus, error) { s := ServiceStatus{Name: "anytype", Health: "not_running"} switch n.Platform { diff --git a/core/credentials/store.go b/core/credentials/store.go index dd1efae..8e68e3f 100644 --- a/core/credentials/store.go +++ b/core/credentials/store.go @@ -60,11 +60,12 @@ func (s *Store) Get(name string) (string, string, error) { return "", "", err } var v string + var keyringErr error if selected != "private_file" { var err error v, err = s.GetKeyring("anytype-cli", name) if err != nil && !errors.Is(err, keyring.ErrNotFound) && !errors.Is(err, os.ErrNotExist) { - return "", "system_keyring", err + keyringErr = err } if err == nil && v != "" { return v, "system_keyring", nil @@ -85,6 +86,12 @@ func (s *Store) Get(name string) (string, string, error) { return "", "legacy_config_file", err } } + // Existing headless installs stored credentials in config when the keyring + // was unavailable. Read those without migration, but never treat an + // unreadable keyring with no legacy credential as an empty instance. + if keyringErr != nil { + return "", "system_keyring", keyringErr + } return "", "", os.ErrNotExist } func (s *Store) Set(name, value string) (string, error) { diff --git a/core/credentials/store_test.go b/core/credentials/store_test.go index 2c87d4f..4a12eb9 100644 --- a/core/credentials/store_test.go +++ b/core/credentials/store_test.go @@ -161,16 +161,52 @@ func TestKeyringMarkerRoundTrip(t *testing.T) { } } func TestReadsLegacyConfigWithoutMutation(t *testing.T) { - s := storeFixture(t) - if err := localstate.WriteJSON(s.ConfigPath, map[string]string{"accountKey": "legacy-fixture"}); err != nil { - t.Fatal(err) - } - value, backend, err := s.Get("account-key") - if err != nil || value != "legacy-fixture" || backend != "legacy_config_file" { - t.Fatal(backend, err) + for _, keyringErr := range []error{os.ErrNotExist, os.ErrPermission, errors.New("D-Bus unavailable")} { + s := storeFixture(t) + s.GetKeyring = func(string, string) (string, error) { return "", keyringErr } + if err := localstate.WriteJSON(s.ConfigPath, map[string]string{"accountKey": "legacy-key", "sessionToken": "legacy-token"}); err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"account-key": "legacy-key", "session-token": "legacy-token"} { + value, backend, err := s.Get(name) + if err != nil || value != want || backend != "legacy_config_file" { + t.Fatal(name, backend, err) + } + } + if _, err := os.Stat(s.Dir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("inspection migrated storage") + } + if _, _, err := s.Get("unmarked-api-key"); !errors.Is(err, keyringErr) { + t.Fatal("legacy credentials masked unrelated keyring failure", err) + } } - if _, err := os.Stat(s.Dir); !errors.Is(err, os.ErrNotExist) { - t.Fatal("inspection migrated storage") +} + +func TestLegacyCredentialsCannotOverrideMarkers(t *testing.T) { + for _, kind := range []string{"keyring", "private", "tombstone"} { + s := storeFixture(t) + if err := localstate.WriteJSON(s.ConfigPath, map[string]string{"accountKey": "stale-legacy-key"}); err != nil { + t.Fatal(err) + } + marker := storedValue{Secret: "current-key", Backend: "private_file"} + switch kind { + case "keyring": + marker = storedValue{Backend: "system_keyring"} + case "tombstone": + marker.Secret = "" + } + if err := localstate.WriteJSON(s.file("account-key"), marker); err != nil { + t.Fatal(err) + } + s.GetKeyring = func(string, string) (string, error) { return "", os.ErrPermission } + value, backend, err := s.Get("account-key") + if kind == "keyring" { + if !errors.Is(err, os.ErrPermission) || value != "" || backend != "system_keyring" { + t.Fatal("legacy value bypassed keyring marker", backend, err) + } + } else if err != nil || value != marker.Secret || backend != "private_file" { + t.Fatal("legacy value bypassed file marker", kind, backend, err) + } } } func TestDeleteRemovesReachableKeyringAndLegacySecret(t *testing.T) { diff --git a/core/grpcserver/observation.go b/core/grpcserver/observation.go index 8339356..228ce4a 100644 --- a/core/grpcserver/observation.go +++ b/core/grpcserver/observation.go @@ -32,7 +32,7 @@ func observationInterceptor(r *runtimeinfo.Recorder) grpc.UnaryServerInterceptor return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { method := path.Base(info.FullMethod) switch method { - case "WalletCreate", "WalletRecover", "AccountCreate", "AccountSelect", "AccountStop", "AppShutdown", "AccountChangeJsonApiAddr": + case "WalletCreate", "WalletRecover", "AccountCreate", "AccountSelect", "AccountStop", "AppShutdown", "AccountChangeJsonApiAddr", "AccountMove", "AccountChangeNetworkConfigAndRestart": default: resp, err := handler(ctx, req) if method == "AppGetVersion" { @@ -48,7 +48,7 @@ func observationInterceptor(r *runtimeinfo.Recorder) grpc.UnaryServerInterceptor r.Transition() case "AccountCreate": r.AccountSelection("") - case "AccountStop", "AppShutdown": + case "AccountStop", "AppShutdown", "AccountMove", "AccountChangeNetworkConfigAndRestart": r.Stopping() } switch v := req.(type) { @@ -116,6 +116,11 @@ func observeResponse(r *runtimeinfo.Recorder, method string, req, resp any, err r.Stopped() stopObserved = true } + case *pb.RpcAccountMoveResponse: + if v != nil && v.GetError().GetCode() == pb.RpcAccountMoveResponseError_NULL { + r.Stopped() + stopObserved = true + } case *pb.RpcAccountChangeJsonApiAddrResponse: if v != nil && v.GetError().GetCode() == pb.RpcAccountChangeJsonApiAddrResponseError_NULL && v.Status != nil { r.API(v.Status.ListenAddr, v.Status.Success) @@ -131,10 +136,13 @@ func observeResponse(r *runtimeinfo.Recorder, method string, req, resp any, err if method == "AccountChangeJsonApiAddr" && !apiOutcomeObserved { r.APIChangeUncertain() } - if (method == "AccountStop" || method == "AppShutdown") && !stopObserved { + if (method == "AccountStop" || method == "AppShutdown" || method == "AccountMove") && !stopObserved { r.StopUncertain() } - if (method == "AccountCreate" || method == "AccountSelect") && !accountObserved { + // Network restart returns neither an account nor an authoritative API bind + // result. Even success cannot prove listener continuity. A later selection + // can establish account identity; an explicit rebind establishes the API. + if (method == "AccountCreate" || method == "AccountSelect" || method == "AccountChangeNetworkConfigAndRestart") && !accountObserved { r.AccountStartUncertain() } } diff --git a/core/grpcserver/observation_continuity_test.go b/core/grpcserver/observation_continuity_test.go index 5a93634..0b524b1 100644 --- a/core/grpcserver/observation_continuity_test.go +++ b/core/grpcserver/observation_continuity_test.go @@ -96,3 +96,85 @@ func TestAccountCreateNeverRetainsPriorAccountBinding(t *testing.T) { } } } + +func TestNativeMoveAndNetworkRestartInvalidateBinding(t *testing.T) { + for _, method := range []string{"AccountMove", "AccountChangeNetworkConfigAndRestart"} { + for _, outcome := range []string{"success", "protobuf_error", "transport_error"} { + t.Run(method+"/"+outcome, func(t *testing.T) { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + var request any = &pb.RpcAccountMoveRequest{NewPath: "/fixture/new-data"} + var response any = &pb.RpcAccountMoveResponse{} + if method == "AccountChangeNetworkConfigAndRestart" { + request = &pb.RpcAccountChangeNetworkConfigAndRestartRequest{} + response = &pb.RpcAccountChangeNetworkConfigAndRestartResponse{} + } + _, _ = f.interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/" + method}, func(context.Context, any) (any, error) { + if s := f.snapshot(); s.APIBound || !s.AccountTransition { + t.Fatal("pre-close binding survived handler entry", s) + } + // Heart may close the old app before an error. A successful + // network restart does not imply a successful HTTP bind. + f.event("127.0.0.1:4500", false) + if outcome == "transport_error" { + return nil, errors.New("native result lost") + } + if outcome == "protobuf_error" { + if method == "AccountMove" { + return &pb.RpcAccountMoveResponse{Error: &pb.RpcAccountMoveResponseError{Code: pb.RpcAccountMoveResponseError_UNKNOWN_ERROR}}, nil + } + return &pb.RpcAccountChangeNetworkConfigAndRestartResponse{Error: &pb.RpcAccountChangeNetworkConfigAndRestartResponseError{Code: pb.RpcAccountChangeNetworkConfigAndRestartResponseError_UNKNOWN_ERROR}}, nil + } + return response, nil + }) + f.event("127.0.0.1:4500", true) + s := f.snapshot() + if s.APIBound || s.APIAddress != "" { + t.Fatal("closed binding revived by delayed event", s) + } + if method == "AccountMove" && outcome == "success" { + if s.AccountId != "" || s.AccountTransition || s.APIObservation != "stopped" { + t.Fatal("successful move did not observe stopped app", s) + } + } else if !s.AccountTransition { + t.Fatal("unverified account lifecycle presented as ready", s) + } + f.selectAccount("127.0.0.1:4500", func() { f.event("127.0.0.1:4500", false) }) + if s = f.snapshot(); s.APIBound || s.AccountTransition { + t.Fatal("selection revived pre-close evidence", s) + } + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + if s = f.snapshot(); !s.APIBound || s.AccountTransition { + t.Fatal("explicit selection and rebind did not recover", s) + } + }) + } + } +} + +func TestNativeMoveAndRestartParticipateInOverlapTracking(t *testing.T) { + for _, method := range []string{"AccountMove", "AccountChangeNetworkConfigAndRestart"} { + t.Run(method, func(t *testing.T) { + f := newObservationFixture(t) + f.r.Account(&model.Account{Id: "account-a"}) + entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) + go func() { + defer close(finished) + _, _ = f.interceptor(context.Background(), nil, &grpc.UnaryServerInfo{FullMethod: "/anytype.ClientCommands/" + method}, func(context.Context, any) (any, error) { + close(entered) + <-release + return nil, errors.New("native result lost") + }) + }() + <-entered + f.rebind("127.0.0.1:4500", bindResponse("127.0.0.1:4500", true), nil) + close(release) + <-finished + f.event("127.0.0.1:4500", true) + if s := f.snapshot(); s.APIBound || !s.AccountTransition || s.APIObservation != "concurrent_lifecycle" { + t.Fatal("overlapping response invented lifecycle order", s) + } + }) + } +} diff --git a/core/localstate/acl_darwin.go b/core/localstate/acl_darwin.go new file mode 100644 index 0000000..7f1104d --- /dev/null +++ b/core/localstate/acl_darwin.go @@ -0,0 +1,46 @@ +//go:build darwin && cgo + +package localstate + +/* +#include +#include + +static int private_acl(int fd) { + acl_t acl = acl_get_fd_np(fd, ACL_TYPE_EXTENDED); + // An open descriptor with no extended ACL reports ENOENT on macOS. + if (acl == NULL) return errno == ENOENT ? 0 : -1; + acl_entry_t entry; + int result = -1; + int id = ACL_FIRST_ENTRY; + for (;;) { + // Darwin returns -1/EINVAL at the end, unlike POSIX's zero. + if (acl_get_entry(acl, id, &entry) != 0) { + if (errno == EINVAL) result = 0; + break; + } + acl_tag_t tag; + // Deny entries cannot broaden access. Reject all granting entries, + // rather than guessing membership or evaluating ACL ordering. + if (acl_get_tag_type(entry, &tag) != 0 || tag != ACL_EXTENDED_DENY) break; + id = ACL_NEXT_ENTRY; + } + acl_free(acl); + return result; +} +*/ +import "C" + +import ( + "os" + "runtime" +) + +func checkPrivateACL(f *os.File) error { + result := C.private_acl(C.int(f.Fd())) + runtime.KeepAlive(f) + if result != 0 { + return os.ErrPermission + } + return nil +} diff --git a/core/localstate/acl_darwin_nocgo.go b/core/localstate/acl_darwin_nocgo.go new file mode 100644 index 0000000..6af225a --- /dev/null +++ b/core/localstate/acl_darwin_nocgo.go @@ -0,0 +1,14 @@ +//go:build darwin && !cgo + +package localstate + +import ( + "errors" + "os" +) + +// The CLI already requires cgo for Heart. A standalone non-cgo consumer must +// not silently downgrade macOS ACL checks to mode bits. +func checkPrivateACL(_ *os.File) error { + return errors.New("macOS private file checks require a cgo build") +} diff --git a/core/localstate/acl_darwin_test.go b/core/localstate/acl_darwin_test.go new file mode 100644 index 0000000..c3737ea --- /dev/null +++ b/core/localstate/acl_darwin_test.go @@ -0,0 +1,70 @@ +//go:build darwin && cgo + +package localstate + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func addTestACL(t *testing.T, path, entry string) { + t.Helper() + if out, err := exec.Command("/bin/chmod", "+a", entry, path).CombinedOutput(); err != nil { + t.Fatalf("add fixture ACL: %v: %s", err, out) + } +} + +func TestPrivateFileChecksMacOSACLsOnOpenedDescriptor(t *testing.T) { + for _, kind := range []string{"none", "deny", "read_allow", "deny_then_allow"} { + t.Run(kind, func(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "private") + if err != nil { + t.Fatal(err) + } + defer file.Close() + // Rename after opening to prove the ACL check uses the descriptor. + path := file.Name() + ".moved" + if err = os.Rename(file.Name(), path); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if out, err := exec.Command("/bin/chmod", "-N", path).CombinedOutput(); err != nil { + t.Errorf("remove fixture ACL: %v: %s", err, out) + } + }) + if kind == "deny" || kind == "deny_then_allow" { + addTestACL(t, path, "everyone deny delete") + } + if kind == "read_allow" || kind == "deny_then_allow" { + addTestACL(t, path, "everyone allow read") + } + err = CheckPrivateFile(file) + if wantDenied := kind == "read_allow" || kind == "deny_then_allow"; wantDenied != errors.Is(err, os.ErrPermission) || !wantDenied && err != nil { + t.Fatal(kind, err) + } + }) + } +} + +func TestWriteJSONRejectsInheritedGrantBeforeWriting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + if err := WriteJSON(path, map[string]string{"value": "previous"}); err != nil { + t.Fatal(err) + } + addTestACL(t, dir, "everyone allow read,search,file_inherit,directory_inherit") + if err := WriteJSON(path, map[string]string{"secret": "must-not-be-written"}); !errors.Is(err, os.ErrPermission) { + t.Fatal("inherited grant accepted", err) + } + var result map[string]string + if err := ReadPrivateJSON(path, &result); err != nil || result["value"] != "previous" { + t.Fatal("failed replacement changed original", result, err) + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 { + t.Fatal("failed write left temporary files", entries, err) + } +} diff --git a/core/localstate/acl_posix.go b/core/localstate/acl_posix.go new file mode 100644 index 0000000..40a8e98 --- /dev/null +++ b/core/localstate/acl_posix.go @@ -0,0 +1,9 @@ +//go:build !darwin && !windows + +package localstate + +import "os" + +// POSIX ACL effective group/named-user permissions are bounded by the mode's +// group mask, already rejected by CheckPrivateFile when nonzero. +func checkPrivateACL(_ *os.File) error { return nil } diff --git a/core/localstate/files_unix.go b/core/localstate/files_unix.go index 04992c8..ef83d26 100644 --- a/core/localstate/files_unix.go +++ b/core/localstate/files_unix.go @@ -7,7 +7,20 @@ import ( "path/filepath" ) -func createPrivateTemp(dir string) (*os.File, error) { return os.CreateTemp(dir, ".write-*") } +func createPrivateTemp(dir string) (*os.File, error) { + f, err := os.CreateTemp(dir, ".write-*") + if err != nil { + return nil, err + } + // Check inherited permissions before writing any private data. In + // particular, chmod(0600) does not remove inherited macOS ACL grants. + if err = CheckPrivateFile(f); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return nil, err + } + return f, nil +} func replaceFile(from, to string) error { if err := os.Rename(from, to); err != nil { @@ -21,7 +34,7 @@ func replaceFile(from, to string) error { return d.Sync() } -// CheckPrivateFile applies POSIX permission semantics to an opened regular file. +// CheckPrivateFile checks the opened file's mode and platform ACL semantics. func CheckPrivateFile(f *os.File) error { info, err := f.Stat() if err != nil { @@ -30,5 +43,5 @@ func CheckPrivateFile(f *os.File) error { if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { return os.ErrPermission } - return nil + return checkPrivateACL(f) } diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 2646d10..1aee1c9 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -14,6 +14,10 @@ in this first version. Arbitrary service environments, systemd drop-ins, custom service definitions, and `DATA_PATH` service provisioning are not adopted. Ordinary Windows CLI/service operation remains supported; the machine service adapter limitation does not disable its runtime observation or credential store. +Windows JSON account create/login and inspection can use a nonce-verified +foreground `serve`; inspection reports `service.manager:unsupported` without +claiming a verified service definition. Machine service install/start still +return `unsupported_service_platform`. ## Envelope and exits @@ -106,6 +110,12 @@ A failed or incomplete create/select response discards continuity and closes its startup-event window, reporting `account_start_outcome_unknown`; inspection directs the caller to resume the account operation or explicitly restart the native service. A new account never inherits the previous account's binding. +Native `AccountMove` and `AccountChangeNetworkConfigAndRestart` also invalidate +binding continuity before entering Heart and participate in overlap tracking. +A successful move observes a stopped account. Network restart has no account or +API status in its response, so it leaves `account_start_outcome_unknown` even on +success; a later account selection establishes identity, and an explicit API +rebind (or native service restart) establishes binding again. Delayed service auto-login skips any account activity already observed under the shared instance lock, including an unresolved transition. @@ -302,6 +312,11 @@ markers remain file-backed. Legacy config credentials still participate in identity guards. Invalid selector values fail closed. Capabilities advertise the selector, and account results report the actual credential backend. On POSIX, files use mode 0600, file sync and directory sync after rename. On +macOS, opened-file ACLs are also checked: deny-only ACLs are accepted, granting +ACL entries are conservatively rejected. This applies to private stdin/stdout +and stored secrets. Inherited grants reject a temporary file before private +data is written; use a pipe or a private directory without granting ACLs. +macOS ACL checks require the CLI's normal cgo build. On Windows, files are created with a protected ACL allowing only the current user and LocalSystem, checked through the opened file's owner/DACL, flushed, and replaced with `MoveFileEx` write-through. Windows' synthetic POSIX mode bits and @@ -311,6 +326,9 @@ runtime acceptance still needs a Windows host. Read-only inspection never tests keyring availability by writing an entry, creates no directory, and does not migrate legacy storage. Legacy config credentials are read compatibly. Human CLI and service auto-login share this credential store. +An unmarked legacy config credential remains readable when the keyring is +unavailable. Without a legacy value, that keyring error remains an error rather +than evidence of an empty instance. Existing markers and tombstones always win. Logout commits tombstones, attempts physical deletion of owned keyring entries, and removes legacy config credentials. Completed operation recovery receipts are retained separately for explicit private recovery; logout does not erase those recovery receipts.