diff --git a/cmd/config/init.go b/cmd/config/init.go index 635474519d..a6bcef20a2 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -32,6 +32,7 @@ type ConfigInitOptions struct { AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) Brand string New bool + Restore bool Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang langExplicit bool // true when --lang was explicitly passed @@ -90,6 +91,9 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) * RunE: func(cmd *cobra.Command, args []string) error { opts.Ctx = cmd.Context() opts.langExplicit = cmd.Flags().Changed("lang") + if err := validateRestoreFlags(cmd, opts); err != nil { + return err + } if err := validateInitLang(opts); err != nil { return err } @@ -104,6 +108,8 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) * } cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)") + cmd.Flags().BoolVar(&opts.Restore, "restore", false, + "re-register the app already in config to recover a lost app secret") cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") @@ -178,7 +184,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error { // hasAnyNonInteractiveFlag returns true if any non-interactive flag is set. func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { - return o.New || o.AppID != "" || o.AppSecretStdin + return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin } // cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. @@ -369,6 +375,14 @@ func configInitRun(opts *ConfigInitOptions) error { } } + if opts.Restore { + existing, err := core.LoadOrNotConfigured() + if err != nil { + return err + } + return runRestoreFlow(opts, existing, f, getInitMsg(opts.UILang)) + } + existing, err := core.LoadMultiAppConfig() if err != nil { existing = nil // treat as empty @@ -416,7 +430,7 @@ func configInitRun(opts *ConfigInitOptions) error { // Mode 3: Create new app directly (--new) if opts.New { - result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg) + result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg, "") if err != nil { return err } diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index 5c9625cafa..6a64dcdc98 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -56,7 +56,7 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init return runExistingAppForm(f, msg) } - return runCreateAppFlow(ctx, f, "", msg) + return runCreateAppFlow(ctx, f, "", msg, "") } // runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand. @@ -150,7 +150,13 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er // runCreateAppFlow runs the "create new app" flow via OpenClaw device flow. // If brandOverride is non-empty, skip the interactive brand selection. -func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) { +func runCreateAppFlow( + ctx context.Context, + f *cmdutil.Factory, + brandOverride core.LarkBrand, + msg *initMsg, + appID string, +) (*configInitResult, error) { var larkBrand core.LarkBrand if brandOverride != "" { larkBrand = brandOverride @@ -182,7 +188,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor // Registration is platform traffic, so it must use the provider-aware // transport as well as the shared proxy configuration. httpClient := transport.NewHTTPClient(0) - authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut) + authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, appID, f.IOStreams.ErrOut) if err != nil { return nil, classifyRegistrationBeginError(err) } @@ -222,7 +228,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor } fmt.Fprintln(f.IOStreams.ErrOut) - output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID)) + if appID == "" { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID)) + } return &configInitResult{ Mode: "create", diff --git a/cmd/config/init_restore.go b/cmd/config/init_restore.go new file mode 100644 index 0000000000..5af15fad87 --- /dev/null +++ b/cmd/config/init_restore.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +func validateRestoreFlags(cmd *cobra.Command, opts *ConfigInitOptions) error { + if !opts.Restore { + return nil + } + for _, name := range []string{"new", "app-id", "app-secret-stdin", "brand", "lang", "name"} { + if cmd.Flags().Changed(name) { + flag := "--" + name + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "%s cannot be used with --restore", flag).WithParam(flag) + } + } + return nil +} + +func runRestoreFlow( + opts *ConfigInitOptions, + existing *core.MultiAppConfig, + f *cmdutil.Factory, + msg *initMsg, +) error { + app, err := existing.RequireAppConfig(f.Invocation.Profile, f.Invocation.ProfileSource) + if err != nil { + return err + } + if app.AppId == "" { + return errs.NewConfigError(errs.SubtypeInvalidConfig, + "app selected for restore has an empty app ID") + } + + result, err := runCreateAppFlow(opts.Ctx, f, core.ParseBrand(string(app.Brand)), msg, app.AppId) + if err != nil { + return err + } + if result == nil || result.AppID != app.AppId || result.AppSecret == "" { + return errs.NewConfigError(errs.SubtypeInvalidClient, + "app restore returned invalid credentials for the configured app") + } + + secret, err := core.ForStorage(app.AppId, core.PlainSecret(result.AppSecret), f.Keychain) + if err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) + } + app.AppSecret = secret + app.Brand = result.Brand + if err := core.SaveMultiAppConfig(existing); err != nil { + return wrapSaveConfigError(err) + } + + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, app.AppId)) + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "appId": app.AppId, "appSecret": "****", "brand": app.Brand, + }) + return runProbe(opts.Ctx, f, app.AppId, result.AppSecret, app.Brand) +} diff --git a/cmd/config/init_restore_test.go b/cmd/config/init_restore_test.go new file mode 100644 index 0000000000..d62f3e3a58 --- /dev/null +++ b/cmd/config/init_restore_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +type restoreRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f restoreRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type recordingKeychain struct { + setCalls int + account string + value string +} + +func (k *recordingKeychain) Get(_, _ string) (string, error) { return "", nil } +func (k *recordingKeychain) Remove(_, _ string) error { return nil } +func (k *recordingKeychain) Set(_, account, value string) error { + k.setCalls++ + k.account = account + k.value = value + return nil +} + +func TestConfigInitRestoreUpdatesSelectedProfile(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARK_CLI_NO_PROXY", "") + original := &core.MultiAppConfig{ + CurrentApp: "other", + Apps: []core.AppConfig{ + {Name: "target", AppId: "cli_target", AppSecret: core.PlainSecret("old-secret"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_target", UserName: "Target"}}}, + {Name: "other", AppId: "cli_other", AppSecret: core.PlainSecret("other-secret"), Brand: core.BrandLark}, + }, + } + if err := core.SaveMultiAppConfig(original); err != nil { + t.Fatal(err) + } + + var beginAppID string + replaceRestoreDefaultTransport(t, restoreRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if err := req.ParseForm(); err != nil { + t.Fatal(err) + } + switch req.Form.Get("action") { + case "begin": + beginAppID = req.Form.Get("app_id") + return restoreJSONResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":30,"interval":0}`), nil + case "poll": + return restoreJSONResponse(`{"client_id":"cli_target","client_secret":"restored-secret"}`), nil + default: + t.Fatalf("unexpected action %q", req.Form.Get("action")) + return nil, nil + } + })) + + kc := &recordingKeychain{} + rt := &fakeRT{} + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + f.Keychain = kc + f.Invocation = cmdutil.InvocationContext{Profile: "target", ProfileSource: core.ProfileFromFlag} + f.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: rt}, nil } + cmd := NewCmdConfigInit(f, nil) + cmd.SetArgs([]string{"--restore"}) + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatal(err) + } + + if beginAppID != "cli_target" { + t.Fatalf("begin app_id = %q, want cli_target", beginAppID) + } + if kc.setCalls != 1 || kc.account != "appsecret:cli_target" || kc.value != "restored-secret" { + t.Fatalf("keychain set = calls:%d account:%q value:%q", kc.setCalls, kc.account, kc.value) + } + after, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatal(err) + } + if after.Apps[0].AppSecret.Ref == nil || after.Apps[0].AppSecret.Ref.ID != "appsecret:cli_target" { + t.Fatalf("target secret = %#v", after.Apps[0].AppSecret) + } + if !reflect.DeepEqual(after.Apps[0].Users, original.Apps[0].Users) || !reflect.DeepEqual(after.Apps[1], original.Apps[1]) { + t.Fatalf("restore changed unrelated config: %#v", after) + } + var output map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("stdout is not JSON: %v", err) + } + if output["appSecret"] != "****" || strings.Contains(stdout.String(), "restored-secret") { + t.Fatalf("stdout leaked restored secret: %q", stdout.String()) + } +} + +func TestConfigInitRestoreRejectsMismatchedAppIDWithoutPersisting(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARK_CLI_NO_PROXY", "") + original := &core.MultiAppConfig{Apps: []core.AppConfig{{ + AppId: "cli_target", AppSecret: core.PlainSecret("old-secret"), Brand: core.BrandFeishu, + }}} + if err := core.SaveMultiAppConfig(original); err != nil { + t.Fatal(err) + } + replaceRestoreDefaultTransport(t, restoreRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if err := req.ParseForm(); err != nil { + t.Fatal(err) + } + if req.Form.Get("action") == "begin" { + return restoreJSONResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":30,"interval":0}`), nil + } + return restoreJSONResponse(`{"client_id":"cli_other","client_secret":"returned-secret"}`), nil + })) + + kc := &recordingKeychain{} + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + f.Keychain = kc + cmd := NewCmdConfigInit(f, nil) + cmd.SetArgs([]string{"--restore"}) + err := cmd.ExecuteContext(context.Background()) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient { + t.Fatalf("problem = %#v, err = %v", problem, err) + } + if kc.setCalls != 0 || stdout.Len() != 0 { + t.Fatalf("restore persisted mismatched credentials: keychain calls=%d stdout=%q", kc.setCalls, stdout.String()) + } + after, loadErr := core.LoadMultiAppConfig() + if loadErr != nil { + t.Fatal(loadErr) + } + if !reflect.DeepEqual(after, original) { + t.Fatalf("config changed: got %#v, want %#v", after, original) + } +} + +func restoreJSONResponse(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +func replaceRestoreDefaultTransport(t *testing.T, rt http.RoundTripper) { + t.Helper() + original := http.DefaultTransport + http.DefaultTransport = rt + t.Cleanup(func() { http.DefaultTransport = original }) +} diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 44d5c3af95..0936dbc686 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -88,7 +88,7 @@ func appRegistrationEndpoint(brand core.LarkBrand) string { // RequestAppRegistration initiates the device flow. The registration protocol // always bootstraps on Feishu; brand selects the user-facing verification host. // The request is bounded by ctx and a begin timeout. -func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) { +func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID string, errOut io.Writer) (*AppRegistrationResponse, error) { if errOut == nil { errOut = io.Discard } @@ -104,6 +104,9 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand form.Set("archetype", "PersonalAgent") form.Set("auth_method", "client_secret") form.Set("request_user_info", "open_id tenant_brand") + if appID != "" { + form.Set("app_id", appID) + } req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode())) if err != nil { diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go index 64993e9ccb..98248af2ea 100644 --- a/internal/auth/app_registration_test.go +++ b/internal/auth/app_registration_test.go @@ -66,21 +66,30 @@ func TestAppRegistrationEndpoint(t *testing.T) { func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) { cases := []struct { + name string brand core.LarkBrand verificationHost string + appID string }{ - {core.BrandFeishu, "open.feishu.cn"}, - {core.BrandLark, "open.larksuite.com"}, + {name: "feishu", brand: core.BrandFeishu, verificationHost: "open.feishu.cn"}, + {name: "lark", brand: core.BrandLark, verificationHost: "open.larksuite.com"}, + {name: "restore", brand: core.BrandFeishu, verificationHost: "open.feishu.cn", appID: "cli_restore"}, } for _, c := range cases { - t.Run(string(c.brand), func(t *testing.T) { + t.Run(c.name, func(t *testing.T) { client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if got, want := r.URL.Host, "accounts.feishu.cn"; got != want { t.Errorf("begin host = %q, want bootstrap host %q", got, want) } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if got := r.Form.Get("app_id"); got != c.appID { + t.Errorf("begin app_id = %q, want %q", got, c.appID) + } return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil })} - resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard) + resp, err := RequestAppRegistration(context.Background(), client, c.brand, c.appID, io.Discard) if err != nil { t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err) } @@ -115,7 +124,7 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) { t.Errorf("unexpected host polled: %s", r.URL.Host) return jsonResponse(`{}`), nil })} - resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard) + resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, "", io.Discard) if err != nil { t.Fatalf("RequestAppRegistration error = %v", err) } @@ -286,7 +295,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err := RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, "", io.Discard) if err != nil { t.Fatalf("begin error = %v", err) } @@ -295,7 +304,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err = RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, "", io.Discard) if err != nil { t.Fatalf("legacy begin error = %v", err) } @@ -304,7 +313,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err = RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, "", io.Discard) if err != nil { t.Fatalf("defaults begin error = %v", err) } @@ -313,7 +322,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } if _, err := RequestAppRegistration(context.Background(), - serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil { + serve(`{"interval":5}`), core.BrandFeishu, "", io.Discard); err == nil { t.Error("missing device_code: expected error, got nil") } } @@ -394,7 +403,7 @@ func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) { Header: make(http.Header), }, nil })} - _, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard) + _, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, "", io.Discard) if !errors.Is(err, context.Canceled) { t.Errorf("err = %v, want a context.Canceled cause", err) }