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_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/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..d70ee0b --- /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.runtimeServiceStatus(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..5c831af --- /dev/null +++ b/core/automation/account_test.go @@ -0,0 +1,233 @@ +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 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) + 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..c810f77 --- /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.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 { + 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..7b51804 --- /dev/null +++ b/core/automation/service.go @@ -0,0 +1,387 @@ +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() +} + +// 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 { + 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..b9e9c53 --- /dev/null +++ b/core/automation/spaces.go @@ -0,0 +1,223 @@ +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. + switch account { + case model.SpaceStatus_SpaceDeleted: + if s.ParticipantStatus == model.ParticipantStatus_Joining.String() || s.State == "rejected" { + s.State = "rejected" + } else { + s.State = "removed" + } + case 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..8e68e3f --- /dev/null +++ b/core/credentials/store.go @@ -0,0 +1,190 @@ +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 + 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) { + keyringErr = 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 + } + } + // 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) { + 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..4a12eb9 --- /dev/null +++ b/core/credentials/store_test.go @@ -0,0 +1,229 @@ +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) { + 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) + } + } +} + +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) { + 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..228ce4a --- /dev/null +++ b/core/grpcserver/observation.go @@ -0,0 +1,148 @@ +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", "AccountMove", "AccountChangeNetworkConfigAndRestart": + 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", "AccountMove", "AccountChangeNetworkConfigAndRestart": + 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.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) + 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" || method == "AccountMove") && !stopObserved { + r.StopUncertain() + } + // 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 new file mode 100644 index 0000000..0b524b1 --- /dev/null +++ b/core/grpcserver/observation_continuity_test.go @@ -0,0 +1,180 @@ +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) + } + } +} + +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/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/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 new file mode 100644 index 0000000..ef83d26 --- /dev/null +++ b/core/localstate/files_unix.go @@ -0,0 +1,47 @@ +//go:build !windows + +package localstate + +import ( + "os" + "path/filepath" +) + +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 { + return err + } + d, err := os.Open(filepath.Dir(to)) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +// CheckPrivateFile checks the opened file's mode and platform ACL semantics. +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 checkPrivateACL(f) +} 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..1aee1c9 --- /dev/null +++ b/docs/AUTOMATION.md @@ -0,0 +1,339 @@ +# 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. +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 + +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. +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. + +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 +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 +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. +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. +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. diff --git a/go.mod b/go.mod index b08dbc6..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.5 - github.com/anyproto/anytype-heart v0.51.4 + 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 @@ -152,7 +153,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 +193,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 @@ -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 4b803f9..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.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-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= @@ -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=