diff --git a/cmd/browser_import_managed_auth.go b/cmd/browser_import_managed_auth.go index 9a7081b4..0541ecd7 100644 --- a/cmd/browser_import_managed_auth.go +++ b/cmd/browser_import_managed_auth.go @@ -15,6 +15,8 @@ import ( ) type managedAuthCapacity struct { + maximum int + used int remaining int unlimited bool } @@ -39,10 +41,17 @@ func decodeManagedAuthCapacity(raw string) (managedAuthCapacity, error) { maxRaw, hasMax := fields["max_auth_connections"] usedRaw, hasUsed := fields["auth_connections_used"] if !hasMax || !hasUsed { - return managedAuthCapacity{}, fmt.Errorf("Kernel API does not expose Managed Auth capacity; deploy the organization entitlements API first") + return managedAuthCapacity{}, fmt.Errorf("Kernel API does not expose Managed Auth capacity through organization limits") } if string(maxRaw) == "null" { - return managedAuthCapacity{unlimited: true}, nil + var usedConnections int + if err := json.Unmarshal(usedRaw, &usedConnections); err != nil { + return managedAuthCapacity{}, fmt.Errorf("decode used auth connections: %w", err) + } + if usedConnections < 0 { + return managedAuthCapacity{}, fmt.Errorf("Kernel API returned invalid Managed Auth capacity") + } + return managedAuthCapacity{used: usedConnections, unlimited: true}, nil } var maxConnections, usedConnections int if err := json.Unmarshal(maxRaw, &maxConnections); err != nil { @@ -54,7 +63,11 @@ func decodeManagedAuthCapacity(raw string) (managedAuthCapacity, error) { if maxConnections < 0 || usedConnections < 0 { return managedAuthCapacity{}, fmt.Errorf("Kernel API returned invalid Managed Auth capacity") } - return managedAuthCapacity{remaining: max(0, maxConnections-usedConnections)}, nil + return managedAuthCapacity{ + maximum: maxConnections, + used: usedConnections, + remaining: max(0, maxConnections-usedConnections), + }, nil } type managedAuthProvisioner interface { @@ -62,6 +75,10 @@ type managedAuthProvisioner interface { Existing(context.Context, string, []passwordmanager.Candidate) (map[string]bool, error) } +type crossProfileManagedAuthFinder interface { + ExistingProfiles(context.Context, string, []passwordmanager.Candidate) (map[string][]string, error) +} + type kernelManagedAuthProvisioner struct { credentials interface { New(context.Context, kernel.CredentialNewParams, ...option.RequestOption) (*kernel.Credential, error) @@ -155,6 +172,34 @@ func (p kernelManagedAuthProvisioner) Existing(ctx context.Context, profileName return result, nil } +func (p kernelManagedAuthProvisioner) ExistingProfiles(ctx context.Context, profileName string, candidates []passwordmanager.Candidate) (map[string][]string, error) { + profilesByCredential := make(map[string][]string) + const pageSize = 100 + for offset := int64(0); ; offset += pageSize { + page, err := p.connections.List(ctx, kernel.AuthConnectionListParams{Limit: kernel.Opt(int64(pageSize)), Offset: kernel.Opt(offset)}) + if err != nil { + return nil, err + } + if page == nil { + break + } + for _, connection := range page.Items { + if connection.ProfileName != profileName { + profilesByCredential[connection.Credential.Name] = append(profilesByCredential[connection.Credential.Name], connection.ProfileName) + } + } + if len(page.Items) < pageSize { + break + } + } + result := make(map[string][]string, len(candidates)) + for _, candidate := range candidates { + name := importedCredentialNameFor(candidate.Provider, candidateImportID(candidate), candidate.Domain) + result[candidateKey(candidate)] = profilesByCredential[name] + } + return result, nil +} + type connectionLookup struct { match *kernel.ManagedAuth conflict *kernel.ManagedAuth diff --git a/cmd/browser_import_managed_auth_test.go b/cmd/browser_import_managed_auth_test.go index 89b9b4bd..0bc2b8aa 100644 --- a/cmd/browser_import_managed_auth_test.go +++ b/cmd/browser_import_managed_auth_test.go @@ -160,6 +160,24 @@ func TestManagedAuthExistingUsesOnePasswordVaultIdentity(t *testing.T) { assert.True(t, existing[candidateKey(candidate)]) } +func TestManagedAuthExistingProfilesFindsSameAccountOnAnotherProfile(t *testing.T) { + candidate := passwordmanager.Candidate{Provider: "bitwarden", ID: "item", Domain: "google.com"} + name := importedCredentialNameFor("bitwarden", "item", "google.com") + provisioner := kernelManagedAuthProvisioner{connections: fakeImportedConnections{ + listFunc: func(params kernel.AuthConnectionListParams) (*pagination.OffsetPagination[kernel.ManagedAuth], error) { + require.False(t, params.ProfileName.Valid()) + return &pagination.OffsetPagination[kernel.ManagedAuth]{Items: []kernel.ManagedAuth{ + {ProfileName: "helium-you", Credential: kernel.ManagedAuthCredential{Name: name}}, + {ProfileName: "helium-you-2", Credential: kernel.ManagedAuthCredential{Name: "another-account"}}, + }}, nil + }, + }} + + profiles, err := provisioner.ExistingProfiles(t.Context(), "helium-you-2", []passwordmanager.Candidate{candidate}) + require.NoError(t, err) + assert.Equal(t, []string{"helium-you"}, profiles[candidateKey(candidate)]) +} + func TestManagedAuthProvisionFindsMatchingConnectionAfterSiblingAccount(t *testing.T) { record := passwordmanager.Record{Provider: "bitwarden", ID: "item", Domain: "example.com", Username: "me"} name := importedCredentialName(record) diff --git a/cmd/browser_import_profile_data.go b/cmd/browser_import_profile_data.go index 6dd22991..49a729c8 100644 --- a/cmd/browser_import_profile_data.go +++ b/cmd/browser_import_profile_data.go @@ -236,7 +236,7 @@ func warnUnavailableBrowserData(category string, err error) { } } -func (c ProfilesImportLocalCmd) confirmBrowserImport(targetName string, cookies cookieImportSelection, cookieSites []localbrowser.Site, profileData localProfileDataSelection, logins pendingManagedAuth) (bool, error) { +func (c ProfilesImportLocalCmd) confirmBrowserImport(targetName string, cookies cookieImportSelection, cookieSites []localbrowser.Site, profileData localProfileDataSelection) (bool, error) { pterm.Println() pterm.Printf("Ready to import into profile %q\n\n", targetName) if cookies.all { @@ -253,13 +253,6 @@ func (c ProfilesImportLocalCmd) confirmBrowserImport(targetName string, cookies if profileData.storage { pterm.Printf(" Local storage — %s across %d origins\n", formatBinaryBytes(profileData.storageBytes), len(profileData.storageSites)) } - loginCount := 0 - for _, provider := range logins.providers { - loginCount += len(provider.candidates) - } - if loginCount > 0 { - pterm.Printf(" Managed Auth connections — %d\n", loginCount) - } pterm.Println() return c.prompter.ConfirmDefault("import browser data", "Proceed?", true) } @@ -359,12 +352,14 @@ func buildSelectedProfileData(ctx context.Context, profile localbrowser.Profile, counts["history"] = len(history) } if selection.storage { - storage, err := localbrowser.ExportLocalStorage(ctx, profile, selection.storageSites) + exported, err := localbrowser.ExportLocalStorage(ctx, profile, selection.storageSites) if err != nil { return localbrowser.ProfileData{}, nil, err } - data.Storage = storage - counts["storage"] = len(storage) + data.Storage = exported.Records + data.StorageRecordsSkipped = exported.SkippedRecords + data.StorageOriginsSkipped = exported.SkippedOrigins + counts["storage"] = len(exported.Records) } return data, counts, nil } @@ -410,6 +405,30 @@ func importedStorageOriginCount(records []localbrowser.StorageRecord) int { return len(origins) } +type storageImportSummary struct { + importedOrigins int + importedEntries int + skippedOrigins int + skippedEntries int +} + +func effectiveStorageImportSummary(applied localbrowser.AppliedProfile, requestedEntries, requestedOrigins int) storageImportSummary { + if applied.StorageEntriesImported == nil || applied.StorageOriginsImported == nil { + return storageImportSummary{importedOrigins: requestedOrigins, importedEntries: requestedEntries} + } + summary := storageImportSummary{ + importedOrigins: *applied.StorageOriginsImported, + importedEntries: *applied.StorageEntriesImported, + } + if applied.StorageOriginsSkipped != nil { + summary.skippedOrigins = *applied.StorageOriginsSkipped + } + if applied.StorageEntriesSkipped != nil { + summary.skippedEntries = *applied.StorageEntriesSkipped + } + return summary +} + func formatBinaryBytes(bytes int64) string { if bytes < 1<<20 { return fmt.Sprintf("%.1f KiB", float64(bytes)/(1<<10)) diff --git a/cmd/browser_import_profile_job.go b/cmd/browser_import_profile_job.go new file mode 100644 index 00000000..428d4c5c --- /dev/null +++ b/cmd/browser_import_profile_job.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + localbrowser "github.com/kernel/cli/internal/browserimport" + "github.com/pterm/pterm" +) + +type profileImportStage int32 + +const ( + profileImportStagePreparing profileImportStage = iota + profileImportStageUploading + profileImportStageApplying + profileImportStageReady +) + +type browserProfileImportClient interface { + SubmitInventory(context.Context, string, string, localbrowser.Inventory) (localbrowser.Status, error) + SubmitSelection(context.Context, string, localbrowser.Selection) (localbrowser.Status, error) + Upload(context.Context, string, string, []byte) (localbrowser.Status, error) + Wait(context.Context, string, time.Duration) (localbrowser.Status, error) + WaitForProfile(context.Context, string, time.Duration) (localbrowser.Status, error) +} + +type profileImportRequest struct { + importID string + helperToken string + dashboardHandoff bool + inventory localbrowser.Inventory + selection localbrowser.Selection + bundle []byte + waitTimeout time.Duration +} + +type profileImportResult struct { + status localbrowser.Status + duration time.Duration +} + +type profileImportJob struct { + cancel context.CancelFunc + stage atomic.Int32 + done chan struct{} + + mu sync.Mutex + result profileImportResult + err error +} + +func startProfileImport(ctx context.Context, client browserProfileImportClient, request profileImportRequest) *profileImportJob { + jobCtx, cancel := context.WithCancel(ctx) + job := &profileImportJob{cancel: cancel, done: make(chan struct{})} + job.stage.Store(int32(profileImportStagePreparing)) + go func() { + defer close(job.done) + result, err := runProfileImport(jobCtx, client, request, &job.stage) + job.mu.Lock() + job.result = result + job.err = err + job.mu.Unlock() + }() + return job +} + +func runProfileImport(ctx context.Context, client browserProfileImportClient, request profileImportRequest, stage *atomic.Int32) (profileImportResult, error) { + startedAt := time.Now() + status, err := client.SubmitInventory(ctx, request.importID, request.helperToken, request.inventory) + if err != nil { + return profileImportResult{}, browserImportProgressError(request.importID, status.Phase, time.Since(startedAt), err) + } + status, err = client.SubmitSelection(ctx, request.importID, request.selection) + if err != nil { + return profileImportResult{}, browserImportProgressError(request.importID, status.Phase, time.Since(startedAt), err) + } + stage.Store(int32(profileImportStageUploading)) + status, err = client.Upload(ctx, request.importID, request.helperToken, request.bundle) + if err != nil { + return profileImportResult{}, browserImportProgressError(request.importID, status.Phase, time.Since(startedAt), err) + } + stage.Store(int32(profileImportStageApplying)) + waitCtx, cancel := context.WithTimeout(ctx, request.waitTimeout) + defer cancel() + if request.dashboardHandoff { + status, err = client.WaitForProfile(waitCtx, request.importID, 2*time.Second) + } else { + status, err = client.Wait(waitCtx, request.importID, 2*time.Second) + } + if err != nil { + return profileImportResult{}, fmt.Errorf("browser import %s did not complete: %w; check it with: kernel profiles import-status %s", request.importID, err, request.importID) + } + stage.Store(int32(profileImportStageReady)) + return profileImportResult{status: status, duration: time.Since(startedAt)}, nil +} + +func (j *profileImportJob) Stage() profileImportStage { + return profileImportStage(j.stage.Load()) +} + +func (j *profileImportJob) Wait(ctx context.Context) (profileImportResult, error) { + select { + case <-ctx.Done(): + return profileImportResult{}, ctx.Err() + case <-j.done: + j.mu.Lock() + defer j.mu.Unlock() + return j.result, j.err + } +} + +func (j *profileImportJob) Cancel() { + j.cancel() +} + +func waitForProfileImport(ctx context.Context, job *profileImportJob, targetName string, humanOutput bool) (profileImportResult, error) { + if !humanOutput { + return job.Wait(ctx) + } + current := job.Stage() + progress, _ := pterm.DefaultProgressbar. + WithTotal(len(profileImportProgressStages)). + WithCurrent(int(current)). + WithTitle(fmt.Sprintf("%s: %q", profileImportProgressStages[current], targetName)). + WithShowElapsedTime(). + Start() + defer progress.Stop() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return profileImportResult{}, ctx.Err() + case <-job.done: + result, err := job.Wait(ctx) + if err == nil { + progress.Current = len(profileImportProgressStages) + progress.UpdateTitle(fmt.Sprintf("%s: %q", profileImportProgressStages[profileImportStageReady], targetName)) + } + return result, err + case <-ticker.C: + next := job.Stage() + if next == current { + continue + } + current = next + progress.Current = int(current) + progress.UpdateTitle(fmt.Sprintf("%s: %q", profileImportProgressStages[current], targetName)) + } + } +} diff --git a/cmd/browser_import_profile_job_test.go b/cmd/browser_import_profile_job_test.go new file mode 100644 index 00000000..cc3f655b --- /dev/null +++ b/cmd/browser_import_profile_job_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "context" + "sync" + "testing" + "time" + + localbrowser "github.com/kernel/cli/internal/browserimport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type blockingProfileImportClient struct { + mu sync.Mutex + calls []string + waitCalled chan struct{} + release chan struct{} +} + +func (c *blockingProfileImportClient) record(call string) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls = append(c.calls, call) +} + +func (c *blockingProfileImportClient) SubmitInventory(context.Context, string, string, localbrowser.Inventory) (localbrowser.Status, error) { + c.record("inventory") + return localbrowser.Status{Phase: "awaiting_selection"}, nil +} + +func (c *blockingProfileImportClient) SubmitSelection(context.Context, string, localbrowser.Selection) (localbrowser.Status, error) { + c.record("selection") + return localbrowser.Status{Phase: "awaiting_upload"}, nil +} + +func (c *blockingProfileImportClient) Upload(context.Context, string, string, []byte) (localbrowser.Status, error) { + c.record("upload") + return localbrowser.Status{Phase: "applying"}, nil +} + +func (c *blockingProfileImportClient) Wait(context.Context, string, time.Duration) (localbrowser.Status, error) { + return c.wait() +} + +func (c *blockingProfileImportClient) WaitForProfile(context.Context, string, time.Duration) (localbrowser.Status, error) { + return c.wait() +} + +func (c *blockingProfileImportClient) wait() (localbrowser.Status, error) { + c.record("wait") + close(c.waitCalled) + <-c.release + return localbrowser.Status{Phase: "awaiting_client_completion", Applied: &localbrowser.Applied{Profiles: []localbrowser.AppliedProfile{{ProfileID: "prof_1"}}}}, nil +} + +func TestProfileImportRunsWhileManagedAuthIsSelected(t *testing.T) { + client := &blockingProfileImportClient{waitCalled: make(chan struct{}), release: make(chan struct{})} + job := startProfileImport(t.Context(), client, profileImportRequest{ + importID: "bri_1", helperToken: "grant", dashboardHandoff: true, + inventory: localbrowser.Inventory{}, selection: localbrowser.Selection{}, bundle: []byte("bundle"), waitTimeout: time.Minute, + }) + + select { + case <-client.waitCalled: + assert.Equal(t, profileImportStageApplying, job.Stage()) + case <-time.After(time.Second): + t.Fatal("profile import did not reach server-side apply while the caller remained interactive") + } + + client.mu.Lock() + assert.Equal(t, []string{"inventory", "selection", "upload", "wait"}, client.calls) + client.mu.Unlock() + close(client.release) + + result, err := job.Wait(t.Context()) + require.NoError(t, err) + assert.Equal(t, "prof_1", result.status.Applied.Profiles[0].ProfileID) + assert.Equal(t, profileImportStageReady, job.Stage()) +} diff --git a/cmd/connector.go b/cmd/connector.go index cc66d607..47808631 100644 --- a/cmd/connector.go +++ b/cmd/connector.go @@ -56,6 +56,7 @@ func runConnectorOpen(cmd *cobra.Command, args []string) error { input := ProfilesImportLocalInput{ Days: 30, ProjectID: link.ProjectID, + ImportID: link.ImportID, Version: metadata.Version, WaitTimeout: 30 * time.Minute, DashboardLaunch: true, diff --git a/cmd/profiles_import_local.go b/cmd/profiles_import_local.go index 01f1ed08..2bb61cde 100644 --- a/cmd/profiles_import_local.go +++ b/cmd/profiles_import_local.go @@ -28,6 +28,13 @@ import ( "github.com/spf13/cobra" ) +var profileImportProgressStages = []string{ + "Preparing import", + "Uploading encrypted browser data", + "Applying and saving browser profile", + "Profile ready", +} + type ProfilesImportLocalInput struct { BrowserProfile string ProfileName string @@ -36,6 +43,7 @@ type ProfilesImportLocalInput struct { SkipConfirm bool Output string ProjectID string + ImportID string Version string WaitTimeout time.Duration PasswordManager string @@ -46,12 +54,20 @@ type ProfilesImportLocalInput struct { } type ProfilesImportLocalCmd struct { - prompter interactive.Prompter - homeDir func() (string, error) - now func() time.Time - providers func() []passwordmanager.Provider - provisioner managedAuthProvisioner - managedAuthCapacity func(context.Context) (managedAuthCapacity, error) + prompter interactive.Prompter + homeDir func() (string, error) + now func() time.Time + providers func() []passwordmanager.Provider + provisioner managedAuthProvisioner + managedAuthCapacity func(context.Context) (managedAuthCapacity, error) + profileLookup func(context.Context, string) (kernelProfileReference, bool, error) + selectProfileTarget func(string, []string, string) (string, error) + selectManagedAuthAccount func(string, []string, string) (string, error) +} + +type kernelProfileReference struct { + ID string + Name string } type pendingManagedAuth struct { @@ -90,7 +106,7 @@ func dashboardProjectUnauthorized(err error) bool { return errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnauthorized } -func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalInput) error { +func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalInput) (returnErr error) { startedAt := time.Now() timings := make(map[string]time.Duration) if err := validateJSONOutput(in.Output); err != nil { @@ -118,6 +134,53 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI } humanOutput := in.Output != "json" nonInteractive := in.SkipConfirm || !humanOutput + dashboardHandoff := in.DashboardLaunch && in.ImportID != "" + var handoffClient *localbrowser.Client + clientCompletion := localbrowser.ClientCompletion{ + Outcome: "failed", ManagedAuthConnections: make([]localbrowser.ManagedAuthConnection, 0), + } + clientFailureStage := "local" + clientCompletionReported := false + if dashboardHandoff { + token, err := auth.BearerToken(ctx) + if err != nil { + return err + } + handoffClient, err = localbrowser.NewClient(util.GetBaseURL(), token, in.ProjectID) + if err != nil { + return err + } + status, err := handoffClient.Status(ctx, in.ImportID) + if err != nil { + return fmt.Errorf("check browser import before starting local work: %w", err) + } + if message, handled := completedDashboardImportMessage(status.Phase); handled { + if humanOutput { + pterm.Info.Println(message) + } + clientCompletionReported = true + return nil + } + if status.Phase == "failed" { + return fmt.Errorf("browser import %s has already failed; start a new import from Kernel", in.ImportID) + } + defer func() { + if returnErr == nil || clientCompletionReported { + return + } + reportCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + status, statusErr := handoffClient.Status(reportCtx, in.ImportID) + if statusErr == nil && status.Phase == "failed" { + return + } + clientCompletion.Outcome = "failed" + clientCompletion.Failure = &localbrowser.ClientFailure{Stage: clientFailureStage, Message: "Local browser import did not finish."} + if _, reportErr := handoffClient.SubmitClientCompletion(reportCtx, in.ImportID, clientCompletion); reportErr != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("report browser import failure: %w", reportErr)) + } + }() + } if humanOutput { pterm.Info.Println("Looking for local browser profiles...") } @@ -139,12 +202,19 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI pterm.Success.Printf("Found %s\n", profile.DisplayName()) } targetName := in.ProfileName + targetProfileID := "" if targetName == "" { targetName = defaultImportedProfileName(profile) } if targetName == "" || len(targetName) > 255 || profileIDNameCharacters.MatchString(targetName) || cuidLikeProfileName.MatchString(targetName) { return fmt.Errorf("profile name must be 1-255 letters, numbers, dots, underscores, or hyphens and cannot be a cuid-like string") } + if c.profileLookup != nil { + targetName, targetProfileID, err = c.chooseImportedProfileTarget(ctx, profile, targetName, nonInteractive) + if err != nil { + return err + } + } explicitSites := in.Sites if len(explicitSites) > 0 { explicitSites, err = normalizeSites(explicitSites) @@ -191,24 +261,21 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI if err != nil { return err } - pendingLogins := pendingManagedAuth{} - if managedAuthImportRequested(in.PasswordManager, nonInteractive) { - phaseStarted = time.Now() - loginSites := rankedManagedAuthSites(cookieSites, cookieSelection.sites, managedAuthSiteLimit) - availableLoginSites := selectedSiteMetadata(cookieSites, cookieSelection.sites) - pendingLogins, err = c.chooseManagedAuthLogins(ctx, targetName, loginSites, availableLoginSites, in.PasswordManager, nonInteractive, humanOutput) - timings["password_manager_discovery"] = time.Since(phaseStarted) - if err != nil { - return err - } - } if !nonInteractive { - proceed, err := c.confirmBrowserImport(targetName, cookieSelection, cookieSites, profileDataSelection, pendingLogins) + proceed, err := c.confirmBrowserImport(targetName, cookieSelection, cookieSites, profileDataSelection) if err != nil { return err } if !proceed { pterm.Info.Println("Browser import canceled; no Kernel resources were changed") + if dashboardHandoff { + clientCompletion.Outcome = "canceled" + clientCompletion.Failure = &localbrowser.ClientFailure{Stage: "local", Message: "Browser import was canceled locally."} + if _, err := handoffClient.SubmitClientCompletion(ctx, in.ImportID, clientCompletion); err != nil { + return fmt.Errorf("report browser import cancellation: %w", err) + } + clientCompletionReported = true + } return nil } } @@ -261,6 +328,14 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI } if !proceed { pterm.Info.Println("Browser import canceled; no Kernel resources were changed") + if dashboardHandoff { + clientCompletion.Outcome = "canceled" + clientCompletion.Failure = &localbrowser.ClientFailure{Stage: "local", Message: "Browser import was canceled locally."} + if _, err := handoffClient.SubmitClientCompletion(ctx, in.ImportID, clientCompletion); err != nil { + return fmt.Errorf("report browser import cancellation: %w", err) + } + clientCompletionReported = true + } return nil } } @@ -268,50 +343,97 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI itemCounts = fit.itemCounts bundle := fit.bundle categories := selectedProfileCategories(itemCounts) - token, err := auth.BearerToken(ctx) - if err != nil { - return err + clientCompletion.Counts = localbrowser.ClientCounts{ + Cookies: itemCounts["cookies"], Bookmarks: itemCounts["bookmarks"], History: itemCounts["history"], StorageOrigins: importedStorageOriginCount(profileData.Storage), } - client, err := localbrowser.NewClient(util.GetBaseURL(), token, in.ProjectID) - if err != nil { - return err + pendingLogins := pendingManagedAuth{} + var managedAuthSelectionErr error + managedAuthRequested := managedAuthImportRequested(in.PasswordManager, nonInteractive) + if managedAuthRequested && nonInteractive { + phaseStarted = time.Now() + loginSites := rankedManagedAuthSites(cookieSites, cookieSelection.sites, managedAuthSiteLimit) + availableLoginSites := selectedSiteMetadata(cookieSites, cookieSelection.sites) + pendingLogins, managedAuthSelectionErr = c.chooseManagedAuthLogins(ctx, targetName, loginSites, availableLoginSites, in.PasswordManager, true, humanOutput) + timings["password_manager_discovery"] = time.Since(phaseStarted) + if managedAuthSelectionErr != nil { + return fmt.Errorf("select Managed Auth setup before creating profile: %w", managedAuthSelectionErr) + } } - if humanOutput { - pterm.Info.Printf("Creating Kernel profile %q...\n", targetName) + + client := handoffClient + if client == nil { + token, err := auth.BearerToken(ctx) + if err != nil { + return err + } + client, err = localbrowser.NewClient(util.GetBaseURL(), token, in.ProjectID) + if err != nil { + return err + } } - phaseStarted = time.Now() - created, err := client.Create(ctx) - if err != nil { - return err + importID := in.ImportID + helperToken := "" + if dashboardHandoff { + grant, err := client.AcquireHelperGrant(ctx, importID) + if err != nil { + status, statusErr := client.Status(ctx, importID) + if statusErr == nil { + if message, handled := completedDashboardImportMessage(status.Phase); handled { + if humanOutput { + pterm.Info.Println(message) + } + clientCompletionReported = true + return nil + } + } + return fmt.Errorf("get scoped browser import grant: %w", err) + } + helperToken = grant.HelperToken + } else { + created, err := client.Create(ctx) + if err != nil { + return err + } + importID = created.ID + helperToken = created.HelperToken } + clientFailureStage = "profile" inventory := localbrowser.Inventory{Sources: []localbrowser.Source{{ ID: profile.ID, Kind: "browser", Name: profile.DisplayName(), Browser: profile.Browser.ID, DataTypes: categories, ItemCounts: itemCounts, }}} - status, err := client.SubmitInventory(ctx, created.ID, created.HelperToken, inventory) - if err != nil { - return browserImportProgressError(created.ID, status.Phase, time.Since(phaseStarted), err) - } - selection := localbrowser.Selection{Profiles: []localbrowser.ProfileSelection{{SourceID: profile.ID, TargetName: targetName, Categories: categories}}, CredentialSources: make([]string, 0)} - status, err = client.SubmitSelection(ctx, created.ID, selection) - if err != nil { - return browserImportProgressError(created.ID, status.Phase, time.Since(phaseStarted), err) - } - status, err = client.Upload(ctx, created.ID, created.HelperToken, bundle) - if err != nil { - return browserImportProgressError(created.ID, status.Phase, time.Since(phaseStarted), err) + selection := localbrowser.Selection{Profiles: []localbrowser.ProfileSelection{{SourceID: profile.ID, TargetName: targetName, TargetProfileID: targetProfileID, Categories: categories}}, CredentialSources: make([]string, 0)} + profileJob := startProfileImport(ctx, client, profileImportRequest{ + importID: importID, helperToken: helperToken, dashboardHandoff: dashboardHandoff, + inventory: inventory, selection: selection, bundle: bundle, waitTimeout: in.WaitTimeout, + }) + defer profileJob.Cancel() + + if managedAuthRequested && !nonInteractive { + phaseStarted = time.Now() + loginSites := rankedManagedAuthSites(cookieSites, cookieSelection.sites, managedAuthSiteLimit) + availableLoginSites := selectedSiteMetadata(cookieSites, cookieSelection.sites) + pendingLogins, managedAuthSelectionErr = c.chooseManagedAuthLogins(ctx, targetName, loginSites, availableLoginSites, in.PasswordManager, false, humanOutput) + timings["password_manager_discovery"] = time.Since(phaseStarted) } - waitCtx, cancelWait := context.WithTimeout(ctx, in.WaitTimeout) - defer cancelWait() - status, err = client.Wait(waitCtx, created.ID, 2*time.Second) - timings["upload_and_apply"] = time.Since(phaseStarted) + + profileResult, err := waitForProfileImport(ctx, profileJob, targetName, humanOutput) + timings["upload_and_apply"] = profileResult.duration if err != nil { - return fmt.Errorf("browser import %s did not complete: %w; check it with: kernel profiles import-status %s", created.ID, err, created.ID) + return err } + status := profileResult.status if status.Applied == nil || len(status.Applied.Profiles) == 0 { return fmt.Errorf("browser import completed without a profile") } - profileID := status.Applied.Profiles[0].ProfileID + if managedAuthSelectionErr != nil { + return fmt.Errorf("profile %s is ready, but Managed Auth setup could not be selected: %w", targetName, managedAuthSelectionErr) + } + appliedProfile := status.Applied.Profiles[0] + profileID := appliedProfile.ProfileID + storageSummary := effectiveStorageImportSummary(appliedProfile, itemCounts["storage"], importedStorageOriginCount(profileData.Storage)) + clientCompletion.Counts.StorageOrigins = storageSummary.importedOrigins + clientFailureStage = "managed_auth" if humanOutput { pterm.Success.Printf("Imported %d cookies from %d websites\n", len(cookies), importedCookieSites) if count := itemCounts["bookmarks"]; count > 0 { @@ -320,13 +442,23 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI if count := itemCounts["history"]; count > 0 { pterm.Success.Printf("Imported %d history entries\n", count) } - if count := itemCounts["storage"]; count > 0 { - pterm.Success.Printf("Imported %d local storage keys from %d origins\n", count, importedStorageOriginCount(profileData.Storage)) + if storageSummary.importedEntries > 0 { + pterm.Success.Printf("Imported %d local storage keys from %d origins\n", storageSummary.importedEntries, storageSummary.importedOrigins) + } + if storageSummary.skippedEntries > 0 { + pterm.Warning.Printf("Skipped %d local storage keys from %d origins that could not be restored\n", storageSummary.skippedEntries, storageSummary.skippedOrigins) + } + if profileData.StorageRecordsSkipped > 0 { + pterm.Warning.Printf("Skipped %d oversized local storage keys from %d origins (1 MiB maximum per key)\n", profileData.StorageRecordsSkipped, profileData.StorageOriginsSkipped) } } connectionIDs := make([]string, 0) approvedLogins := make([]passwordmanager.Record, 0) + approvedCredentialCount := pendingCredentialCount(pendingLogins) if len(pendingLogins.providers) > 0 { + if humanOutput { + pterm.Info.Println(approvedCredentialReadMessage(pendingLogins)) + } phaseStarted = time.Now() for _, pendingProvider := range pendingLogins.providers { records, revealErr := pendingProvider.provider.Reveal(ctx, pendingProvider.candidates) @@ -336,6 +468,9 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI approvedLogins = append(approvedLogins, records...) } timings["password_manager_reveal"] = time.Since(phaseStarted) + if skipped := approvedCredentialCount - len(approvedLogins); skipped > 0 && humanOutput { + pterm.Warning.Printf("Skipped %d approved password-manager item%s without a usable username or password\n", skipped, pluralSuffix(skipped)) + } } if len(approvedLogins) > 0 { if c.provisioner == nil { @@ -347,6 +482,7 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI } phaseStarted = time.Now() connectionIDs, err = c.provisioner.Provision(ctx, targetName, approvedLogins) + clientCompletion.ManagedAuthConnections = managedAuthCompletionConnections(connectionIDs, approvedLogins) timings["managed_auth"] = time.Since(phaseStarted) if err != nil { if humanOutput { @@ -365,6 +501,7 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI installedSkills := 0 skillWarning := "" if len(connectionIDs) > 0 { + clientFailureStage = "agent_skills" phaseStarted = time.Now() installedSkills, err = c.offerAgentSkills(home, in.InstallAgentSkills, nonInteractive, humanOutput) timings["agent_skills"] = time.Since(phaseStarted) @@ -372,6 +509,23 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI skillWarning = err.Error() } } + if dashboardHandoff { + clientCompletion.Outcome = "completed" + clientCompletion.Failure = nil + if humanOutput { + pterm.Info.Println("Opening Kernel to finish authentication...") + } + if _, err := client.SubmitClientCompletion(ctx, importID, clientCompletion); err != nil { + return fmt.Errorf("report browser import completion: %w", err) + } + clientCompletionReported = true + ackCtx, cancelAck := context.WithTimeout(ctx, in.WaitTimeout) + defer cancelAck() + status, err = client.Wait(ackCtx, importID, 2*time.Second) + if err != nil { + return fmt.Errorf("dashboard did not acknowledge browser import %s: %w; reopen Kernel to finish setup", importID, err) + } + } if in.Output == "json" { data, err := json.MarshalIndent(map[string]any{"profile_id": profileID, "profile_name": targetName, "sites": cookieSelection.sites, "cookies_imported": itemCounts["cookies"], "browser_data_imported": itemCounts, "managed_auth_connections": connectionIDs, "agent_skills_installed": installedSkills, "agent_skill_warning": skillWarning, "duration_ms": time.Since(startedAt).Milliseconds(), "timings_ms": durationMilliseconds(timings)}, "", " ") if err != nil { @@ -393,6 +547,49 @@ func (c ProfilesImportLocalCmd) Run(ctx context.Context, in ProfilesImportLocalI return nil } +func completedDashboardImportMessage(phase string) (string, bool) { + switch phase { + case "staged", "applying", "awaiting_client_completion": + return "This browser import is already running. Return to Kernel for progress.", true + case "awaiting_dashboard_ack", "finishing_managed_auth", "completed": + return "This browser import has already finished. Return to Kernel to continue.", true + default: + return "", false + } +} + +func approvedCredentialReadMessage(pending pendingManagedAuth) string { + count := pendingCredentialCount(pending) + providers := make([]string, 0, len(pending.providers)) + for _, pendingProvider := range pending.providers { + providers = append(providers, pendingProvider.provider.Name()) + } + credential := "credentials" + if count == 1 { + credential = "credential" + } + return fmt.Sprintf("Reading %d approved %s from %s...", count, credential, strings.Join(providers, " and ")) +} + +func pendingCredentialCount(pending pendingManagedAuth) int { + count := 0 + for _, pendingProvider := range pending.providers { + count += len(pendingProvider.candidates) + } + return count +} + +func managedAuthCompletionConnections(ids []string, records []passwordmanager.Record) []localbrowser.ManagedAuthConnection { + connections := make([]localbrowser.ManagedAuthConnection, 0, min(len(ids), len(records))) + for index, id := range ids { + if index >= len(records) { + break + } + connections = append(connections, localbrowser.ManagedAuthConnection{ID: id, Domain: records[index].Domain}) + } + return connections +} + func (c ProfilesImportLocalCmd) offerAgentSkills(home string, installRequested, nonInteractive, humanOutput bool) (int, error) { workingDirectory, err := os.Getwd() if err != nil { @@ -406,7 +603,7 @@ func (c ProfilesImportLocalCmd) offerAgentSkills(home string, installRequested, if nonInteractive { return 0, nil } - approved, err := c.prompter.Confirm("install agent skill", "Install the Kernel Managed Auth skill for your local agents?") + approved, err := c.prompter.ConfirmDefault("install agent skill", "Install the Kernel Managed Auth skill for your local agents?", true) if err != nil || !approved { return 0, err } @@ -512,10 +709,6 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro capacityHint, capacityHintErr = c.managedAuthCapacity(ctx) capacityLoaded = capacityHintErr == nil } - sites, err := c.chooseManagedAuthSites(sites, availableSites, nonInteractive, capacityHint, capacityLoaded && capacityHintErr == nil) - if err != nil { - return pendingManagedAuth{}, err - } if len(sites) == 0 { return pendingManagedAuth{}, nil } @@ -566,7 +759,7 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro allCandidates = append(allCandidates, sourcedPasswordManagerCandidate{provider: provider, candidate: candidate}) } } - if len(allCandidates) == 0 { + if len(allCandidates) == 0 && nonInteractive { return pendingManagedAuth{}, nil } if c.provisioner == nil { @@ -586,20 +779,39 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro } return pendingManagedAuth{}, nil } + otherProfiles := make(map[string][]string) + if finder, ok := c.provisioner.(crossProfileManagedAuthFinder); ok { + otherProfiles, err = finder.ExistingProfiles(ctx, profileName, candidates) + if err != nil { + return pendingManagedAuth{}, fmt.Errorf("check Managed Auth connections on other profiles: %w", err) + } + } hasExisting := false hasNew := false for _, candidate := range candidates { - if existing[candidateKey(candidate)] { + key := candidateKey(candidate) + if existing[key] || len(otherProfiles[key]) > 0 { hasExisting = true - } else { + } + if !existing[key] { hasNew = true } } availableConnections := 0 capacityKnown := !hasNew + displayCapacity := capacityHint + displayCapacityKnown := capacityLoaded var capacityLookupErr error if !hasNew { // Existing imports refresh their credential and do not consume quota. + // Interactive discovery may still add another website, so retain the + // known capacity for that choice without making refreshes depend on it. + if !nonInteractive && capacityLoaded { + availableConnections = capacityHint.remaining + if capacityHint.unlimited { + availableConnections = len(availableSites) + } + } } else if c.managedAuthCapacity == nil { if !hasExisting { return pendingManagedAuth{}, fmt.Errorf("managed auth capacity is unavailable") @@ -619,11 +831,11 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro } } else { capacityKnown = true + displayCapacity = capacity + displayCapacityKnown = true availableConnections = capacity.remaining if capacity.unlimited { availableConnections = len(sites) - } else if humanOutput { - pterm.Info.Printf("Your organization has %d Managed Auth connection slot%s available\n", availableConnections, pluralSuffix(availableConnections)) } } } @@ -638,7 +850,7 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro if availableConnections == 0 && hasNew && requested != "" && nonInteractive { return pendingManagedAuth{}, fmt.Errorf("your organization has no Managed Auth connection slots available for new logins; delete a connection or upgrade your plan, then retry") } - if capacityKnown && availableConnections == 0 && !hasExisting { + if hasNew && capacityKnown && availableConnections == 0 && !hasExisting && nonInteractive { if requested != "" { return pendingManagedAuth{}, fmt.Errorf("your organization has no Managed Auth connection slots available; delete a connection or upgrade your plan, then retry") } @@ -666,6 +878,15 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro } return left.candidate.Name < right.candidate.Name }) + if !nonInteractive { + sites, allCandidates, err = c.chooseManagedAuthWebsites(ctx, profileName, sites, availableSites, chosenProviders, allCandidates, existing, availableConnections, displayCapacity, displayCapacityKnown, humanOutput) + if err != nil { + return pendingManagedAuth{}, err + } + if len(sites) == 0 { + return pendingManagedAuth{}, nil + } + } domainCounts := make(map[string]int, len(sites)) for _, sourced := range allCandidates { domainCounts[sourced.candidate.Domain]++ @@ -706,7 +927,7 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro approvedCandidates = append(approvedCandidates, sourced) } } else { - approvedCandidates, err = c.chooseManagedAuthAccountsByWebsite(sites, allCandidates, existing, availableConnections) + approvedCandidates, err = c.chooseManagedAuthAccountsByWebsite(profileName, sites, allCandidates, existing, otherProfiles, availableConnections, 0, displayCapacity, displayCapacityKnown) if err != nil { return pendingManagedAuth{}, err } @@ -730,14 +951,14 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthLogins(ctx context.Context, pro return pending, nil } -func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool, availableConnections int) ([]sourcedPasswordManagerCandidate, error) { +func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(profileName string, sites []string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool, otherProfiles map[string][]string, availableConnections, alreadySelectedNew int, capacity managedAuthCapacity, capacityKnown bool) ([]sourcedPasswordManagerCandidate, error) { byDomain := make(map[string][]sourcedPasswordManagerCandidate, len(sites)) for _, candidate := range candidates { byDomain[candidate.candidate.Domain] = append(byDomain[candidate.candidate.Domain], candidate) } pterm.Println() - pterm.Println("Choose one login per website for Managed Auth:") + pterm.Println(managedAuthAccountHeader(capacity, capacityKnown)) pterm.Println() domains := make([]string, 0, len(sites)) for _, domain := range sites { @@ -749,10 +970,10 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin for domainIndex := 0; domainIndex < len(domains); { domain := domains[domainIndex] domainCandidates := byDomain[domain] - if len(domainCandidates) == 1 { + if len(domainCandidates) == 1 && len(otherProfiles[candidateKey(domainCandidates[0].candidate)]) == 0 { chosen := domainCandidates[0] delete(choices, domain) - if !existing[candidateKey(chosen.candidate)] && managedAuthNewChoiceCount(choices, existing) >= availableConnections { + if !existing[candidateKey(chosen.candidate)] && managedAuthCapacityReached(alreadySelectedNew+managedAuthNewChoiceCount(choices, existing), availableConnections, capacity) { pterm.Warning.Printf("Skipping %s: no Managed Auth connection slots remain\n", domain) domainIndex++ continue @@ -762,12 +983,38 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin domainIndex++ continue } - labels := make([]string, 0, len(domainCandidates)) + labels := make([]string, 0, len(domainCandidates)*2) byLabel := make(map[string]sourcedPasswordManagerCandidate, len(domainCandidates)) + keepExisting := make(map[string]bool) existingLabels := make([]string, 0, len(domainCandidates)) - labels = groupedLoginLabels(domainCandidates) + candidateLabels := groupedLoginLabels(domainCandidates) + accountPrompt := domain + " — ↑/↓ move, Enter chooses" + if len(domainCandidates) == 1 { + profiles := otherProfiles[candidateKey(domainCandidates[0].candidate)] + if len(profiles) > 0 && !existing[candidateKey(domainCandidates[0].candidate)] { + pterm.Printf("%s\nAlready managed on %q with this account\n\n", domain, profiles[0]) + accountPrompt = "Choose how to use this account — ↑/↓ move, Enter chooses" + } + } for index, sourced := range domainCandidates { - label := labels[index] + label := candidateLabels[index] + profiles := otherProfiles[candidateKey(sourced.candidate)] + if len(profiles) > 0 && !existing[candidateKey(sourced.candidate)] { + keepLabel := fmt.Sprintf("Keep this account managed on %q · 0 new slots", profiles[0]) + alsoLabel := fmt.Sprintf("Also manage this account on %q · uses 1 slot", profileName) + if len(domainCandidates) > 1 { + keepLabel = fmt.Sprintf("Keep %s on %q · 0 new slots", label, profiles[0]) + alsoLabel = fmt.Sprintf("Also manage %s on %q · uses 1 slot", label, profileName) + } + labels = append(labels, keepLabel, alsoLabel) + keepExisting[keepLabel] = true + byLabel[alsoLabel] = sourced + continue + } + if existing[candidateKey(sourced.candidate)] { + label += " ✓ existing · no new slot" + } + labels = append(labels, label) byLabel[label] = sourced if existing[candidateKey(sourced.candidate)] { existingLabels = append(existingLabels, label) @@ -788,10 +1035,16 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin } } else if len(existingLabels) == 1 { defaultOption = existingLabels[0] - } else if managedAuthNewChoiceCount(choices, existing) >= availableConnections { + } else if managedAuthCapacityReached(alreadySelectedNew+managedAuthNewChoiceCount(choices, existing), availableConnections, capacity) { defaultOption = "Skip this website" } - selected, err := c.prompter.SelectDefault("login for "+domain, "use arrow keys and press Enter", domain+" — ↑/↓ move, Enter chooses", options, defaultOption) + var selected string + var err error + if c.selectManagedAuthAccount != nil { + selected, err = c.selectManagedAuthAccount(domain, options, defaultOption) + } else { + selected, err = c.prompter.SelectDefault("login for "+domain, "use arrow keys and press Enter", accountPrompt, options, defaultOption) + } if err != nil { return nil, err } @@ -811,10 +1064,15 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin domainIndex++ continue } + if keepExisting[selected] { + delete(choices, domain) + domainIndex++ + continue + } chosen := byLabel[selected] previous, hadPrevious := choices[domain] delete(choices, domain) - if !existing[candidateKey(chosen.candidate)] && managedAuthNewChoiceCount(choices, existing) >= availableConnections { + if !existing[candidateKey(chosen.candidate)] && managedAuthCapacityReached(alreadySelectedNew+managedAuthNewChoiceCount(choices, existing), availableConnections, capacity) { if hadPrevious { choices[domain] = previous } @@ -831,140 +1089,262 @@ func (c ProfilesImportLocalCmd) chooseManagedAuthAccountsByWebsite(sites []strin approved = append(approved, chosen) } } + selectedNew := alreadySelectedNew + managedAuthNewChoiceCount(choices, existing) + if capacity.unlimited { + pterm.Printf("New connections selected: %d · unlimited plan\n", selectedNew) + } else { + pterm.Printf("New connections selected: %d of %d\n", selectedNew, availableConnections) + } return approved, nil } -func previousAmbiguousDomainIndex(domains []string, candidates map[string][]sourcedPasswordManagerCandidate, current int) int { - for index := current - 1; index >= 0; index-- { - if len(candidates[domains[index]]) > 1 { - return index +func (c ProfilesImportLocalCmd) chooseManagedAuthWebsites( + ctx context.Context, + profileName string, + sites []string, + availableSites []localbrowser.Site, + providers []passwordmanager.Provider, + candidates []sourcedPasswordManagerCandidate, + existing map[string]bool, + availableConnections int, + capacity managedAuthCapacity, + capacityKnown bool, + humanOutput bool, +) ([]string, []sourcedPasswordManagerCandidate, error) { + searchedSites := append([]string(nil), sites...) + selectedSites := defaultManagedAuthWebsites(sites, candidates, existing, availableConnections, capacity) + const findAnother = "+ Find another website" + for { + matchedSites := managedAuthMatchedSites(searchedSites, candidates) + labels, byLabel := managedAuthWebsiteOptions(matchedSites, candidates, existing) + labels = append(labels, findAnother) + selectedSet := make(map[string]struct{}, len(selectedSites)) + for _, site := range selectedSites { + selectedSet[site] = struct{}{} + } + defaults := make([]string, 0, len(selectedSites)) + for _, label := range labels { + if _, selected := selectedSet[byLabel[label]]; selected { + defaults = append(defaults, label) + } } - } - return -1 -} - -func managedAuthNewChoiceCount(choices map[string]sourcedPasswordManagerCandidate, existing map[string]bool) int { - count := 0 - for _, choice := range choices { - if !existing[candidateKey(choice.candidate)] { - count++ + prompt := managedAuthWebsiteHeader(capacity, capacityKnown) + "\nSpace toggles websites · Enter continues" + selectedLabels, err := c.prompter.MultiSelect("Managed Auth websites", "select websites or continue without Managed Auth", prompt, labels, defaults) + if err != nil { + return nil, nil, err } - } - return count -} - -func (c ProfilesImportLocalCmd) chooseManagedAuthSites(sites []string, availableSites []localbrowser.Site, nonInteractive bool, capacity managedAuthCapacity, capacityKnown bool) ([]string, error) { - if nonInteractive || len(sites) == 0 { - return sites, nil - } - prompt, defaultDomains := managedAuthSitePrompt(sites, capacity, capacityKnown) - recommendedSet := make(map[string]struct{}, len(sites)) - for _, domain := range sites { - recommendedSet[domain] = struct{}{} - } - selected := append([]string(nil), defaultDomains...) - const findAnotherOption = "+ Find another website" - for { - labels, byLabel := managedAuthRecommendationOptions(sites, availableSites) - for _, domain := range selected { - if _, recommended := recommendedSet[domain]; recommended { + selectedSites = selectedSites[:0] + findRequested := false + for _, label := range selectedLabels { + if label == findAnother { + findRequested = true continue } - label := managedAuthSiteOption(len(labels), domain, siteMetadata(availableSites, domain)) - labels = append(labels, label) - byLabel[label] = domain + selectedSites = append(selectedSites, byLabel[label]) } - labels = append(labels, findAnotherOption) - currentSet := make(map[string]struct{}, len(selected)) - for _, domain := range selected { - currentSet[domain] = struct{}{} + if !capacity.unlimited && managedAuthNewWebsiteCount(selectedSites, candidates, existing) > availableConnections { + pterm.Warning.Printf("Choose at most %d website%s that need a new connection; existing connections do not use a slot\n", availableConnections, pluralSuffix(availableConnections)) + continue } - selectedDefaults := make([]string, 0, len(selected)) - for _, label := range labels { - if _, checked := currentSet[byLabel[label]]; checked { - selectedDefaults = append(selectedDefaults, label) + if !findRequested { + selected := make(map[string]struct{}, len(selectedSites)) + for _, site := range selectedSites { + selected[site] = struct{}{} } + filtered := make([]sourcedPasswordManagerCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if _, ok := selected[candidate.candidate.Domain]; ok { + filtered = append(filtered, candidate) + } + } + return selectedSites, filtered, nil } - sectionPrompt := prompt + "\nSpace toggles websites · check + Find another website to search · Enter continues" - selectedLabels, err := c.prompter.MultiSelect("Managed Auth websites", "pass --yes to use the suggested websites", sectionPrompt, labels, selectedDefaults) + options, domains := managedAuthSearchOptions(availableSites, searchedSites) + if len(options) == 0 { + pterm.Info.Println("Every browser website has already been searched") + continue + } + browseOptions := managedAuthBrowseOptions(options) + selected, err := c.prompter.MultiSelect( + "Managed Auth websites", + "select websites", + "Choose browser websites to add\nScroll with ↑/↓ · Space toggles websites · Enter continues", + browseOptions, + nil, + ) if err != nil { - return nil, err + return nil, nil, err } - result := make([]string, 0, len(selectedLabels)) - findAnother := false - for _, label := range selectedLabels { - if label == findAnotherOption { - findAnother = true + selected, searchRequested := managedAuthBrowseSelection(selected) + if searchRequested { + query, err := c.prompter.TextInput("browser website search", "type a website domain", "Search browser websites (leave blank to go back)") + if err != nil { + return nil, nil, err + } + if strings.TrimSpace(query) != "" { + filtered := filterManagedAuthSearchOptions(options, domains, query) + if len(filtered) == 0 { + pterm.Info.Printf("No browser website matched %q\n", strings.TrimSpace(query)) + } else { + searched, err := c.prompter.MultiSelect( + "Managed Auth websites", + "select websites", + "Choose matching websites to add\nSpace toggles websites · Enter continues", + filtered, + nil, + ) + if err != nil { + return nil, nil, err + } + selected = append(selected, searched...) + } + } + } + for _, chosen := range selected { + domain := domains[chosen] + searchedSites = append(searchedSites, domain) + matches := make([]sourcedPasswordManagerCandidate, 0) + for _, provider := range providers { + discovered, err := discoverProviderCandidates(ctx, provider, []string{domain}, false, humanOutput) + if err != nil { + return nil, nil, err + } + for _, candidate := range discovered { + matches = append(matches, sourcedPasswordManagerCandidate{provider: provider, candidate: candidate}) + } + } + if len(matches) == 0 { + pterm.Info.Printf("No password-manager login matched %s\n", domain) continue } - result = append(result, byLabel[label]) + candidateRecords := make([]passwordmanager.Candidate, 0, len(matches)) + for _, match := range matches { + candidateRecords = append(candidateRecords, match.candidate) + } + domainExisting, err := c.provisioner.Existing(ctx, profileName, candidateRecords) + if err != nil { + return nil, nil, fmt.Errorf("check existing Managed Auth connection for %s: %w", domain, err) + } + for key, value := range domainExisting { + existing[key] = value + } + candidates = append(candidates, matches...) + if domainHasExistingManagedAuth(domain, candidates, existing) || capacity.unlimited || managedAuthNewWebsiteCount(selectedSites, candidates, existing) < availableConnections { + selectedSites = append(selectedSites, domain) + } } - selected = result - if !findAnother { - return selected, nil + } +} + +const searchManagedAuthWebsites = "+ Search websites" + +func managedAuthBrowseOptions(options []string) []string { + return append(append([]string(nil), options...), searchManagedAuthWebsites) +} + +func managedAuthBrowseSelection(selected []string) ([]string, bool) { + websites := make([]string, 0, len(selected)) + seen := make(map[string]struct{}, len(selected)) + searchRequested := false + for _, label := range selected { + if label == searchManagedAuthWebsites { + searchRequested = true + continue } - options, domains := managedAuthSearchOptions(availableSites, selected) - if len(options) == 1 { - pterm.Info.Println("Every browser website is already selected") + if _, exists := seen[label]; exists { continue } - chosen, err := c.prompter.Select("Managed Auth website", "select a website", "Find another website — type to search, Enter adds, Back returns", options) - if err != nil { - return nil, err + seen[label] = struct{}{} + websites = append(websites, label) + } + return websites, searchRequested +} + +func managedAuthMatchedSites(sites []string, candidates []sourcedPasswordManagerCandidate) []string { + matched := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + matched[candidate.candidate.Domain] = struct{}{} + } + result := make([]string, 0, len(matched)) + for _, site := range sites { + if _, ok := matched[site]; ok { + result = append(result, site) + } + } + return result +} + +func domainHasExistingManagedAuth(domain string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool) bool { + for _, candidate := range candidates { + if candidate.candidate.Domain == domain && existing[candidateKey(candidate.candidate)] { + return true } - if chosen != backOption && !containsString(selected, domains[chosen]) { - selected = append(selected, domains[chosen]) + } + return false +} + +func managedAuthNewWebsiteCount(sites []string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool) int { + count := 0 + for _, site := range sites { + if !domainHasExistingManagedAuth(site, candidates, existing) { + count++ } } + return count } -func managedAuthSitePrompt(sites []string, capacity managedAuthCapacity, capacityKnown bool) (string, []string) { - defaults := sites - prompt := "Choose recent websites to find Managed Auth logins" - if capacityKnown && !capacity.unlimited { - prompt = fmt.Sprintf("Choose recent websites to find Managed Auth logins (%d new connection slot%s available)", capacity.remaining, pluralSuffix(capacity.remaining)) +func defaultManagedAuthWebsites(sites []string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool, availableConnections int, capacity managedAuthCapacity) []string { + defaults := make([]string, 0, len(sites)) + newConnections := 0 + for _, site := range managedAuthMatchedSites(sites, candidates) { + if domainHasExistingManagedAuth(site, candidates, existing) { + defaults = append(defaults, site) + continue + } + if capacity.unlimited || newConnections < availableConnections { + defaults = append(defaults, site) + newConnections++ + } } - return prompt, defaults + return defaults } -func managedAuthRecommendationOptions(sites []string, available []localbrowser.Site) ([]string, map[string]string) { - metadata := make(map[string]localbrowser.Site, len(available)) - for _, site := range available { - metadata[site.Domain] = site +func managedAuthWebsiteOptions(sites []string, candidates []sourcedPasswordManagerCandidate, existing map[string]bool) ([]string, map[string]string) { + counts := make(map[string]int, len(sites)) + for _, candidate := range candidates { + counts[candidate.candidate.Domain]++ } options := make([]string, 0, len(sites)) byOption := make(map[string]string, len(sites)) - for index, domain := range sites { - label := managedAuthSiteOption(index, domain, metadata[domain]) + for index, site := range sites { + label := fmt.Sprintf("%2d %-28s %d matching login%s", index+1, compactField(site, 28), counts[site], pluralSuffix(counts[site])) + if domainHasExistingManagedAuth(site, candidates, existing) { + label += " · existing connection" + } options = append(options, label) - byOption[label] = domain + byOption[label] = site } return options, byOption } -func managedAuthSiteOption(index int, domain string, site localbrowser.Site) string { - label := fmt.Sprintf("%d %s", index+1, domain) - if site.Visits > 0 { - label += fmt.Sprintf(" %s visits", boundedCount(site.Visits)) - } - return label +func managedAuthWebsiteHeader(capacity managedAuthCapacity, capacityKnown bool) string { + header := managedAuthAccountHeader(capacity, capacityKnown) + return strings.Replace(header, "Choose accounts to make available to agents:", "Choose websites to make available to agents:", 1) } -func siteMetadata(sites []localbrowser.Site, domain string) localbrowser.Site { - for _, site := range sites { - if site.Domain == domain { - return site - } - } - return localbrowser.Site{Domain: domain} +func managedAuthCapacityReached(selected, available int, capacity managedAuthCapacity) bool { + return !capacity.unlimited && selected >= available } func managedAuthSearchOptions(available []localbrowser.Site, selected []string) ([]string, map[string]string) { - options := make([]string, 0, len(available)+1) - options = append(options, backOption) + selectedSet := make(map[string]struct{}, len(selected)) + for _, domain := range selected { + selectedSet[domain] = struct{}{} + } + options := make([]string, 0, len(available)) byOption := make(map[string]string, len(available)) for index, site := range available { - if containsString(selected, site.Domain) { + if _, exists := selectedSet[site.Domain]; exists { continue } label := fmt.Sprintf("%d %-28s %s visits", index+1, compactField(site.Domain, 28), boundedCount(site.Visits)) @@ -975,13 +1355,52 @@ func managedAuthSearchOptions(available []localbrowser.Site, selected []string) return options, byOption } -func containsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true +func filterManagedAuthSearchOptions(options []string, domains map[string]string, query string) []string { + terms := strings.Fields(strings.ToLower(strings.TrimSpace(query))) + filtered := make([]string, 0, len(options)) + for _, option := range options { + domain := strings.ToLower(domains[option]) + matches := true + for _, term := range terms { + if !strings.Contains(domain, term) { + matches = false + break + } + } + if matches { + filtered = append(filtered, option) } } - return false + return filtered +} + +func previousAmbiguousDomainIndex(domains []string, candidates map[string][]sourcedPasswordManagerCandidate, current int) int { + for index := current - 1; index >= 0; index-- { + if len(candidates[domains[index]]) > 1 { + return index + } + } + return -1 +} + +func managedAuthNewChoiceCount(choices map[string]sourcedPasswordManagerCandidate, existing map[string]bool) int { + count := 0 + for _, choice := range choices { + if !existing[candidateKey(choice.candidate)] { + count++ + } + } + return count +} + +func managedAuthAccountHeader(capacity managedAuthCapacity, capacityKnown bool) string { + capacityLine := "Connection capacity will be checked before creating new connections" + if capacityKnown && capacity.unlimited { + capacityLine = fmt.Sprintf("Unlimited connections · %d currently used", capacity.used) + } else if capacityKnown { + capacityLine = fmt.Sprintf("%d of %d connections used · %d new connections available", capacity.used, capacity.maximum, capacity.remaining) + } + return fmt.Sprintf("Managed Auth\n%s\n\nChoose accounts to make available to agents:", capacityLine) } func managedAuthDiscoveryFailure(requested, action string, err error) error { @@ -1044,6 +1463,73 @@ func browserImportProgressError(importID, phase string, elapsed time.Duration, e return fmt.Errorf("browser import %s stopped after %s in phase %s: %w; check it with: kernel profiles import-status %s", importID, elapsed.Round(time.Millisecond), phase, err, importID) } +func resolveImportedProfileName(ctx context.Context, requested string, explicit bool, exists func(context.Context, string) (bool, error)) (string, bool, error) { + found, err := exists(ctx, requested) + if err != nil { + return "", false, fmt.Errorf("check Kernel profile name %q: %w", requested, err) + } + if !found { + return requested, false, nil + } + if explicit { + return "", false, fmt.Errorf("Kernel profile %q already exists; choose a different --profile-name", requested) + } + for suffixNumber := 2; suffixNumber < 10000; suffixNumber++ { + suffix := fmt.Sprintf("-%d", suffixNumber) + base := requested + if len(base)+len(suffix) > 255 { + base = base[:255-len(suffix)] + } + candidate := base + suffix + found, err = exists(ctx, candidate) + if err != nil { + return "", false, fmt.Errorf("check Kernel profile name %q: %w", candidate, err) + } + if !found { + return candidate, true, nil + } + } + return "", false, fmt.Errorf("could not find an available Kernel profile name based on %q; use --profile-name", requested) +} + +func (c ProfilesImportLocalCmd) chooseImportedProfileTarget(ctx context.Context, source localbrowser.Profile, requested string, nonInteractive bool) (string, string, error) { + existing, found, err := c.profileLookup(ctx, requested) + if err != nil { + return "", "", fmt.Errorf("check Kernel profile name %q: %w", requested, err) + } + if !found { + return requested, "", nil + } + if nonInteractive { + return "", "", fmt.Errorf("Kernel profile %q already exists; run interactively to update it or choose a different --profile-name", requested) + } + + separateName, _, err := resolveImportedProfileName(ctx, requested, false, func(ctx context.Context, name string) (bool, error) { + _, found, err := c.profileLookup(ctx, name) + return found, err + }) + if err != nil { + return "", "", err + } + updateOption := fmt.Sprintf("Update %q (recommended; keeps existing Managed Auth connections)", existing.Name) + separateOption := fmt.Sprintf("Create a separate profile %q", separateName) + prompt := fmt.Sprintf("An earlier %s import already exists", source.Browser.Name) + options := []string{updateOption, separateOption} + var choice string + if c.selectProfileTarget != nil { + choice, err = c.selectProfileTarget(prompt, options, updateOption) + } else { + choice, err = c.prompter.SelectDefault("profile import destination", "choose whether to update or create a separate profile", prompt, options, updateOption) + } + if err != nil { + return "", "", err + } + if choice == updateOption { + return existing.Name, existing.ID, nil + } + return separateName, "", nil +} + func durationMilliseconds(values map[string]time.Duration) map[string]int64 { result := make(map[string]int64, len(values)) for name, duration := range values { @@ -1337,6 +1823,11 @@ func compactField(value string, limit int) string { return ansi.Truncate(strings.Join(strings.Fields(value), " "), limit, "…") } +func paddedCompactField(value string, width int) string { + value = compactField(value, width) + return value + strings.Repeat(" ", max(0, width-ansi.StringWidth(value))) +} + func groupedLoginCandidateLabel(provider string, candidate passwordmanager.Candidate) string { idSuffix := candidate.ID if len(idSuffix) > 6 { @@ -1529,11 +2020,23 @@ func runProfilesImportLocalWithInput(cmd *cobra.Command, input ProfilesImportLoc credentials := projectClient.Credentials connections := projectClient.Auth.Connections limits := projectClient.Organization.Limits + profiles := projectClient.Profiles c := ProfilesImportLocalCmd{ prompter: interactive.NewPrompter(), providers: passwordmanager.Detect, provisioner: kernelManagedAuthProvisioner{credentials: &credentials, connections: &connections}, managedAuthCapacity: func(ctx context.Context) (managedAuthCapacity, error) { return loadManagedAuthCapacity(ctx, &limits) }, + profileLookup: func(ctx context.Context, name string) (kernelProfileReference, bool, error) { + profile, err := profiles.Get(ctx, name) + if err == nil { + return kernelProfileReference{ID: profile.ID, Name: profile.Name}, true, nil + } + var apiErr *kernel.Error + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return kernelProfileReference{}, false, nil + } + return kernelProfileReference{}, false, util.CleanedUpSdkError{Err: err} + }, } input.ProjectID = project.ID if input.Version == "" { diff --git a/cmd/profiles_import_local_test.go b/cmd/profiles_import_local_test.go index 9392f917..669ea2a2 100644 --- a/cmd/profiles_import_local_test.go +++ b/cmd/profiles_import_local_test.go @@ -2,9 +2,15 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" "testing" "github.com/charmbracelet/x/ansi" @@ -101,6 +107,36 @@ func TestGroupedLoginLabelsUseIDsOnlyToResolveCollisions(t *testing.T) { assert.NotContains(t, labels[0], "1 BW") } +func TestManagedAuthAccountOnAnotherProfileDefaultsToKeepingExistingConnection(t *testing.T) { + candidate := passwordmanager.Candidate{Provider: "bitwarden", ID: "google", Domain: "google.com", Username: "me@example.com"} + sourced := sourcedPasswordManagerCandidate{provider: fakePasswordManager{name: "Bitwarden"}, candidate: candidate} + command := ProfilesImportLocalCmd{selectManagedAuthAccount: func(domain string, options []string, defaultOption string) (string, error) { + assert.Equal(t, "google.com", domain) + require.Len(t, options, 3) + assert.Contains(t, options[0], `Keep this account managed on "helium-you"`) + assert.Contains(t, options[1], `Also manage this account on "helium-you-2"`) + assert.Equal(t, options[0], defaultOption) + return defaultOption, nil + }} + + approved, err := command.chooseManagedAuthAccountsByWebsite("helium-you-2", []string{"google.com"}, []sourcedPasswordManagerCandidate{sourced}, map[string]bool{}, map[string][]string{candidateKey(candidate): []string{"helium-you"}}, 1, 0, managedAuthCapacity{maximum: 2, remaining: 1}, true) + require.NoError(t, err) + assert.Empty(t, approved) +} + +func TestManagedAuthAccountOnAnotherProfileCanUseNewSlot(t *testing.T) { + candidate := passwordmanager.Candidate{Provider: "bitwarden", ID: "google", Domain: "google.com", Username: "me@example.com"} + sourced := sourcedPasswordManagerCandidate{provider: fakePasswordManager{name: "Bitwarden"}, candidate: candidate} + command := ProfilesImportLocalCmd{selectManagedAuthAccount: func(_ string, options []string, _ string) (string, error) { + return options[1], nil + }} + + approved, err := command.chooseManagedAuthAccountsByWebsite("helium-you-2", []string{"google.com"}, []sourcedPasswordManagerCandidate{sourced}, map[string]bool{}, map[string][]string{candidateKey(candidate): []string{"helium-you"}}, 1, 0, managedAuthCapacity{maximum: 2, remaining: 1}, true) + require.NoError(t, err) + require.Len(t, approved, 1) + assert.Equal(t, candidateKey(candidate), candidateKey(approved[0].candidate)) +} + func TestManagedAuthNewChoiceCountDoesNotChargeExistingConnections(t *testing.T) { provider := fakePasswordManager{name: "Bitwarden"} existingCandidate := passwordmanager.Candidate{Provider: "bitwarden", ID: "existing", Domain: "one.com"} @@ -153,6 +189,14 @@ type fakePasswordManager struct { err error } +type lockedFakePasswordManager struct{ fakePasswordManager } + +func (lockedFakePasswordManager) AuthorizationRequired(context.Context) (bool, error) { + return true, nil +} + +func (lockedFakePasswordManager) Authorize(context.Context) error { return nil } + type fakeManagedAuthProvisioner struct { existing map[string]bool err error @@ -308,47 +352,133 @@ func TestManagedAuthUsesExplicitCookieSitesWithoutHistoryRanking(t *testing.T) { assert.Equal(t, selected, rankedManagedAuthSites([]localbrowser.Site{{Domain: "github.com"}, {Domain: "google.com"}}, selected, 10)) } -func TestManagedAuthWebsiteDiscoveryDefaultsStaySelectedAtLimitedCapacity(t *testing.T) { - sites := []string{"one.com", "two.com", "three.com"} - prompt, defaults := managedAuthSitePrompt(sites, managedAuthCapacity{remaining: 2}, true) +func TestManagedAuthAccountHeaderExplainsConnectionCapacity(t *testing.T) { + assert.Equal(t, `Managed Auth +3 of 5 connections used · 2 new connections available + +Choose accounts to make available to agents:`, managedAuthAccountHeader(managedAuthCapacity{maximum: 5, used: 3, remaining: 2}, true)) + assert.Equal(t, `Managed Auth +Unlimited connections · 7 currently used + +Choose accounts to make available to agents:`, managedAuthAccountHeader(managedAuthCapacity{used: 7, unlimited: true}, true)) + assert.Equal(t, `Managed Auth +Connection capacity will be checked before creating new connections + +Choose accounts to make available to agents:`, managedAuthAccountHeader(managedAuthCapacity{}, false)) +} + +func TestCompletedDashboardImportMessage(t *testing.T) { + for _, phase := range []string{"staged", "applying", "awaiting_client_completion"} { + message, handled := completedDashboardImportMessage(phase) + assert.True(t, handled, phase) + assert.Contains(t, message, "already running", phase) + } + for _, phase := range []string{"awaiting_dashboard_ack", "finishing_managed_auth", "completed"} { + message, handled := completedDashboardImportMessage(phase) + assert.True(t, handled, phase) + assert.Contains(t, message, "already finished", phase) + } + for _, phase := range []string{"awaiting_inventory", "awaiting_selection", "awaiting_bundle", "failed"} { + message, handled := completedDashboardImportMessage(phase) + assert.False(t, handled, phase) + assert.Empty(t, message, phase) + } +} + +func TestManagedAuthWebsiteDefaultsRespectCapacityWithoutRemovingChoice(t *testing.T) { + candidates := []sourcedPasswordManagerCandidate{ + {candidate: passwordmanager.Candidate{Provider: "bitwarden", ID: "existing", Domain: "existing.com"}}, + {candidate: passwordmanager.Candidate{Provider: "bitwarden", ID: "one", Domain: "one.com"}}, + {candidate: passwordmanager.Candidate{Provider: "bitwarden", ID: "two", Domain: "two.com"}}, + {candidate: passwordmanager.Candidate{Provider: "bitwarden", ID: "three", Domain: "three.com"}}, + } + existing := map[string]bool{candidateKey(candidates[0].candidate): true} + sites := []string{"existing.com", "one.com", "two.com", "three.com"} - assert.Contains(t, prompt, "2 new connection slots available") - assert.Equal(t, sites, defaults) + assert.Equal(t, []string{"existing.com", "one.com", "two.com"}, defaultManagedAuthWebsites(sites, candidates, existing, 2, managedAuthCapacity{remaining: 2})) + assert.Equal(t, []string{"existing.com"}, defaultManagedAuthWebsites(sites, candidates, existing, 0, managedAuthCapacity{})) + assert.Equal(t, sites, defaultManagedAuthWebsites(sites, candidates, existing, 0, managedAuthCapacity{unlimited: true})) + options, _ := managedAuthWebsiteOptions(sites, candidates, existing) + assert.Contains(t, options[0], "existing connection") } -func TestManagedAuthWebsiteDefaultsStayOpenWhenCapacityIsUnknownOrUnlimited(t *testing.T) { - sites := []string{"one.com", "two.com", "three.com"} - _, unknownDefaults := managedAuthSitePrompt(sites, managedAuthCapacity{}, false) - _, unlimitedDefaults := managedAuthSitePrompt(sites, managedAuthCapacity{unlimited: true}, true) +func TestApprovedCredentialReadMessageNamesProviders(t *testing.T) { + pending := pendingManagedAuth{providers: []pendingProviderLogins{ + {provider: fakePasswordManager{name: "Bitwarden"}, candidates: []passwordmanager.Candidate{{ID: "one"}, {ID: "two"}}}, + {provider: fakePasswordManager{name: "1Password"}, candidates: []passwordmanager.Candidate{{ID: "three"}}}, + }} - assert.Equal(t, sites, unknownDefaults) - assert.Equal(t, sites, unlimitedDefaults) + assert.Equal(t, 3, pendingCredentialCount(pending)) + assert.Equal(t, "Reading 3 approved credentials from Bitwarden and 1Password...", approvedCredentialReadMessage(pending)) } -func TestManagedAuthRecommendationOptionsShowRecentUse(t *testing.T) { - options, domains := managedAuthRecommendationOptions( - []string{"github.com", "example.com"}, - []localbrowser.Site{{Domain: "github.com", Visits: 1475}}, - ) +func TestManagedAuthSearchOptionsExcludeWebsitesAlreadySearched(t *testing.T) { + options, domains := managedAuthSearchOptions([]localbrowser.Site{ + {Domain: "google.com", Visits: 20}, + {Domain: "github.com", Visits: 10}, + }, []string{"google.com"}) - require.Len(t, options, 2) + require.Len(t, options, 1) assert.Contains(t, options[0], "github.com") - assert.Contains(t, options[0], "1475 visits") assert.Equal(t, "github.com", domains[options[0]]) - assert.NotContains(t, options[1], "visits") } -func TestManagedAuthSearchOptionsExcludeSelectedWebsites(t *testing.T) { +func TestFilterManagedAuthSearchOptionsSupportsMultipleTerms(t *testing.T) { options, domains := managedAuthSearchOptions([]localbrowser.Site{ - {Domain: "google.com", Visits: 20}, + {Domain: "dashboard-git-browser-import.example", Visits: 9}, {Domain: "github.com", Visits: 10}, - }, []string{"google.com"}) + {Domain: "example.com", Visits: 20}, + }, nil) - require.Len(t, options, 2) - assert.Equal(t, backOption, options[0]) - assert.NotContains(t, domains, backOption) - assert.Contains(t, options[1], "github.com") - assert.Equal(t, "github.com", domains[options[1]]) + filtered := filterManagedAuthSearchOptions(options, domains, "git browser") + require.Len(t, filtered, 1) + assert.Equal(t, "dashboard-git-browser-import.example", domains[filtered[0]]) + assert.Empty(t, filterManagedAuthSearchOptions(options, domains, "missing")) +} + +func TestManagedAuthBrowseOptionsKeepScrollableSitesAndOptionalSearch(t *testing.T) { + options := []string{"1 reddit.com 149 visits", "2 openai.com 99 visits"} + + browseOptions := managedAuthBrowseOptions(options) + + assert.Equal(t, []string{ + "1 reddit.com 149 visits", + "2 openai.com 99 visits", + searchManagedAuthWebsites, + }, browseOptions) + assert.Equal(t, []string{"1 reddit.com 149 visits", "2 openai.com 99 visits"}, options) +} + +func TestManagedAuthBrowseSelectionSeparatesSearchAction(t *testing.T) { + selected, searchRequested := managedAuthBrowseSelection([]string{ + "1 reddit.com 149 visits", + searchManagedAuthWebsites, + "2 openai.com 99 visits", + "1 reddit.com 149 visits", + }) + + assert.True(t, searchRequested) + assert.Equal(t, []string{"1 reddit.com 149 visits", "2 openai.com 99 visits"}, selected) +} + +func TestEffectiveStorageImportSummaryUsesAppliedCounts(t *testing.T) { + importedOrigins, importedEntries := 4, 8 + skippedOrigins, skippedEntries := 2, 3 + + summary := effectiveStorageImportSummary(localbrowser.AppliedProfile{ + StorageOriginsImported: &importedOrigins, + StorageEntriesImported: &importedEntries, + StorageOriginsSkipped: &skippedOrigins, + StorageEntriesSkipped: &skippedEntries, + }, 11, 6) + + require.Equal(t, storageImportSummary{importedOrigins: 4, importedEntries: 8, skippedOrigins: 2, skippedEntries: 3}, summary) +} + +func TestEffectiveStorageImportSummaryFallsBackForOlderAPI(t *testing.T) { + summary := effectiveStorageImportSummary(localbrowser.AppliedProfile{}, 11, 6) + + require.Equal(t, storageImportSummary{importedOrigins: 6, importedEntries: 11}, summary) } func TestSelectedSiteMetadataPreservesRankAndExplicitSites(t *testing.T) { @@ -366,24 +496,33 @@ func TestDecodeManagedAuthCapacity(t *testing.T) { t.Run("remaining", func(t *testing.T) { capacity, err := decodeManagedAuthCapacity(`{"max_auth_connections":5,"auth_connections_used":3}`) require.NoError(t, err) - assert.Equal(t, managedAuthCapacity{remaining: 2}, capacity) + assert.Equal(t, managedAuthCapacity{maximum: 5, used: 3, remaining: 2}, capacity) }) t.Run("at limit", func(t *testing.T) { capacity, err := decodeManagedAuthCapacity(`{"max_auth_connections":3,"auth_connections_used":4}`) require.NoError(t, err) - assert.Equal(t, managedAuthCapacity{}, capacity) + assert.Equal(t, managedAuthCapacity{maximum: 3, used: 4}, capacity) }) t.Run("unlimited", func(t *testing.T) { capacity, err := decodeManagedAuthCapacity(`{"max_auth_connections":null,"auth_connections_used":329}`) require.NoError(t, err) - assert.Equal(t, managedAuthCapacity{unlimited: true}, capacity) + assert.Equal(t, managedAuthCapacity{used: 329, unlimited: true}, capacity) }) t.Run("old API", func(t *testing.T) { _, err := decodeManagedAuthCapacity(`{"max_concurrent_sessions":10}`) - require.ErrorContains(t, err, "deploy the organization entitlements API first") + require.ErrorContains(t, err, "does not expose Managed Auth capacity through organization limits") }) } +func TestProfileImportProgressStagesDescribeCompletedServerMilestones(t *testing.T) { + assert.Equal(t, []string{ + "Preparing import", + "Uploading encrypted browser data", + "Applying and saving browser profile", + "Profile ready", + }, profileImportProgressStages) +} + func TestChooseManagedAuthLoginsRejectsExplicitBatchAboveRemainingConnections(t *testing.T) { command := managedAuthTestCommand(func() []passwordmanager.Provider { return []passwordmanager.Provider{fakePasswordManager{candidates: []passwordmanager.Candidate{ @@ -513,12 +652,126 @@ func TestDefaultImportedProfileName(t *testing.T) { assert.Equal(t, "chrome-ilyaas-personal", defaultImportedProfileName(profile)) } +func TestResolveImportedProfileNameUsesFirstAvailableSuffix(t *testing.T) { + existing := map[string]bool{"helium-you": true, "helium-you-2": true} + name, renamed, err := resolveImportedProfileName(t.Context(), "helium-you", false, func(_ context.Context, name string) (bool, error) { + return existing[name], nil + }) + require.NoError(t, err) + assert.True(t, renamed) + assert.Equal(t, "helium-you-3", name) +} + +func TestResolveImportedProfileNameRejectsExplicitDuplicate(t *testing.T) { + _, _, err := resolveImportedProfileName(t.Context(), "helium-you", true, func(context.Context, string) (bool, error) { + return true, nil + }) + require.EqualError(t, err, `Kernel profile "helium-you" already exists; choose a different --profile-name`) +} + +func TestResolveImportedProfileNamePreservesMaximumLength(t *testing.T) { + requested := strings.Repeat("a", 255) + name, renamed, err := resolveImportedProfileName(t.Context(), requested, false, func(_ context.Context, name string) (bool, error) { + return name == requested, nil + }) + require.NoError(t, err) + assert.True(t, renamed) + assert.Len(t, name, 255) + assert.True(t, strings.HasSuffix(name, "-2")) +} + +func TestChooseImportedProfileTargetDefaultsToUpdatingExistingProfile(t *testing.T) { + command := ProfilesImportLocalCmd{ + profileLookup: func(_ context.Context, name string) (kernelProfileReference, bool, error) { + if name == "helium-you" { + return kernelProfileReference{ID: "profile-1", Name: name}, true, nil + } + return kernelProfileReference{}, false, nil + }, + selectProfileTarget: func(_ string, options []string, defaultOption string) (string, error) { + require.Len(t, options, 2) + assert.Contains(t, options[0], `Update "helium-you"`) + assert.Contains(t, options[1], `"helium-you-2"`) + assert.Equal(t, options[0], defaultOption) + return defaultOption, nil + }, + } + name, profileID, err := command.chooseImportedProfileTarget(t.Context(), localbrowser.Profile{Browser: localbrowser.Browser{Name: "Helium"}}, "helium-you", false) + require.NoError(t, err) + assert.Equal(t, "helium-you", name) + assert.Equal(t, "profile-1", profileID) +} + +func TestChooseImportedProfileTargetCanCreateSeparateProfile(t *testing.T) { + command := ProfilesImportLocalCmd{ + profileLookup: func(_ context.Context, name string) (kernelProfileReference, bool, error) { + if name == "helium-you" { + return kernelProfileReference{ID: "profile-1", Name: name}, true, nil + } + return kernelProfileReference{}, false, nil + }, + selectProfileTarget: func(_ string, options []string, _ string) (string, error) { return options[1], nil }, + } + name, profileID, err := command.chooseImportedProfileTarget(t.Context(), localbrowser.Profile{Browser: localbrowser.Browser{Name: "Helium"}}, "helium-you", false) + require.NoError(t, err) + assert.Equal(t, "helium-you-2", name) + assert.Empty(t, profileID) +} + +func TestChooseImportedProfileTargetRequiresInteractiveDuplicateDecision(t *testing.T) { + command := ProfilesImportLocalCmd{profileLookup: func(_ context.Context, name string) (kernelProfileReference, bool, error) { + return kernelProfileReference{ID: "profile-1", Name: name}, true, nil + }} + _, _, err := command.chooseImportedProfileTarget(t.Context(), localbrowser.Profile{}, "helium-you", true) + require.EqualError(t, err, `Kernel profile "helium-you" already exists; run interactively to update it or choose a different --profile-name`) +} + func TestProfilesImportLocalRejectsUnsupportedOutputBeforeDiscovery(t *testing.T) { command := ProfilesImportLocalCmd{prompter: interactive.NewPrompterWithTerminal(false)} err := command.Run(t.Context(), ProfilesImportLocalInput{Output: "yaml", Days: 30}) assert.EqualError(t, err, `unsupported --output value "yaml"; use "json" or omit --output for human-readable output`) } +func TestProfilesImportLocalChecksExplicitPasswordManagerBeforeRemoteMutation(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("local browser import is macOS-only") + } + home := t.TempDir() + root := filepath.Join(home, "Library/Application Support/net.imput.helium") + profilePath := filepath.Join(root, "Default") + require.NoError(t, os.MkdirAll(filepath.Join(profilePath, "Network"), 0o755)) + state, err := json.Marshal(map[string]any{"profile": map[string]any{"info_cache": map[string]any{"Default": map[string]string{"name": "Personal"}}}}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(root, "Local State"), state, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(profilePath, "History"), nil, 0o600)) + createCookies := exec.Command("/usr/bin/sqlite3", filepath.Join(profilePath, "Network", "Cookies"), ` +CREATE TABLE cookies ( + host_key TEXT, path TEXT, name TEXT, value TEXT, encrypted_value BLOB, + expires_utc INTEGER, is_httponly INTEGER, is_secure INTEGER, samesite INTEGER +); +INSERT INTO cookies VALUES ('.google.com', '/', 'session', 'secret', X'', 0, 1, 1, 1); +`) + output, err := createCookies.CombinedOutput() + require.NoError(t, err, string(output)) + + t.Setenv("KERNEL_API_KEY", "test-api-key") + t.Setenv("KERNEL_BASE_URL", "http://127.0.0.1:1") + command := ProfilesImportLocalCmd{ + prompter: interactive.NewPrompterWithTerminal(false), + homeDir: func() (string, error) { return home, nil }, + providers: func() []passwordmanager.Provider { + return []passwordmanager.Provider{lockedFakePasswordManager{fakePasswordManager{name: "Bitwarden"}}} + }, + } + err = command.Run(t.Context(), ProfilesImportLocalInput{ + BrowserProfile: "Helium / Personal", ProfileName: "test-profile", Sites: []string{"google.com"}, + Days: 30, SkipConfirm: true, PasswordManager: "bitwarden", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "select Managed Auth setup before creating profile") + assert.Contains(t, err.Error(), "Bitwarden is locked") +} + func TestProfilesImportStatusRejectsUnsupportedOutputBeforeAuthentication(t *testing.T) { profilesImportStatusCmd.Flags().Set("output", "yaml") t.Cleanup(func() { _ = profilesImportStatusCmd.Flags().Set("output", "") }) @@ -526,6 +779,21 @@ func TestProfilesImportStatusRejectsUnsupportedOutputBeforeAuthentication(t *tes assert.EqualError(t, err, `unsupported --output value "yaml"; use "json" or omit --output for human-readable output`) } +func TestManagedAuthCompletionConnectionsPreserveProvisionedPrefix(t *testing.T) { + connections := managedAuthCompletionConnections( + []string{"ma_google", "ma_github"}, + []passwordmanager.Record{ + {Domain: "google.com"}, + {Domain: "github.com"}, + {Domain: "x.com"}, + }, + ) + assert.Equal(t, []localbrowser.ManagedAuthConnection{ + {ID: "ma_google", Domain: "google.com"}, + {ID: "ma_github", Domain: "github.com"}, + }, connections) +} + func TestChooseProfileRejectsDuplicateFriendlyName(t *testing.T) { profiles := []localbrowser.Profile{ {ID: "one", Name: "Personal", Browser: localbrowser.Browser{Name: "Google Chrome"}}, diff --git a/internal/browserimport/chromium.go b/internal/browserimport/chromium.go index 28d67105..e0ccaaca 100644 --- a/internal/browserimport/chromium.go +++ b/internal/browserimport/chromium.go @@ -440,19 +440,19 @@ func LocalStorageSites(ctx context.Context, profile Profile) ([]StorageSite, err return sites, nil } -func ExportLocalStorage(ctx context.Context, profile Profile, selectedOrigins []string) ([]StorageRecord, error) { +func ExportLocalStorage(ctx context.Context, profile Profile, selectedOrigins []string) (StorageExport, error) { database, cleanup, err := levelDBSnapshot(ctx, filepath.Join(profile.Path, "Local Storage", "leveldb")) if errors.Is(err, os.ErrNotExist) { - return nil, nil + return StorageExport{}, nil } if err != nil { - return nil, fmt.Errorf("snapshot browser local storage: %w", err) + return StorageExport{}, fmt.Errorf("snapshot browser local storage: %w", err) } defer cleanup() db, err := leveldb.OpenFile(database, nil) if err != nil { - return nil, fmt.Errorf("open browser local storage: %w", err) + return StorageExport{}, fmt.Errorf("open browser local storage: %w", err) } defer db.Close() @@ -461,6 +461,8 @@ func ExportLocalStorage(ctx context.Context, profile Profile, selectedOrigins [] selected[origin] = struct{}{} } records := make([]StorageRecord, 0) + skippedOrigins := make(map[string]struct{}) + skippedRecords := 0 encodedBytes := 0 iterator := db.NewIterator(util.BytesPrefix([]byte("_")), nil) defer iterator.Release() @@ -490,22 +492,24 @@ func ExportLocalStorage(ctx context.Context, profile Profile, selectedOrigins [] record := StorageRecord{Origin: origin, Kind: StorageKindLocal, Key: scriptKey, Value: value} encoded, err := json.Marshal(record) if err != nil { - return nil, fmt.Errorf("encode browser local storage: %w", err) + return StorageExport{}, fmt.Errorf("encode browser local storage: %w", err) } if len(encoded)+1 > maxStorageRecord { - return nil, fmt.Errorf("local storage key %q for %s exceeds the 1 MiB record limit", scriptKey, origin) + skippedRecords++ + skippedOrigins[origin] = struct{}{} + continue } encodedBytes += len(encoded) + 1 if encodedBytes > maxStorageBytes { - return nil, fmt.Errorf("browser local storage exceeds the 64 MiB import limit; choose fewer websites") + return StorageExport{}, fmt.Errorf("browser local storage exceeds the 64 MiB import limit; choose fewer websites") } records = append(records, record) if len(records) > maxStorageCount { - return nil, fmt.Errorf("browser local storage exceeds the 100000-record import limit; choose fewer websites") + return StorageExport{}, fmt.Errorf("browser local storage exceeds the 100000-record import limit; choose fewer websites") } } if err := iterator.Error(); err != nil { - return nil, fmt.Errorf("read browser local storage: %w", err) + return StorageExport{}, fmt.Errorf("read browser local storage: %w", err) } sort.Slice(records, func(left, right int) bool { if records[left].Origin == records[right].Origin { @@ -513,7 +517,7 @@ func ExportLocalStorage(ctx context.Context, profile Profile, selectedOrigins [] } return records[left].Origin < records[right].Origin }) - return records, nil + return StorageExport{Records: records, SkippedRecords: skippedRecords, SkippedOrigins: len(skippedOrigins)}, nil } func portableStorageOrigin(raw string) (string, bool) { diff --git a/internal/browserimport/chromium_test.go b/internal/browserimport/chromium_test.go index 52759d45..30cb9205 100644 --- a/internal/browserimport/chromium_test.go +++ b/internal/browserimport/chromium_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" "unicode/utf16" @@ -290,14 +291,35 @@ func TestLocalStorageSitesAndExportUseLivePortableRecords(t *testing.T) { require.Equal(t, "https://other.example", sites[1].Origin) require.Positive(t, sites[1].Bytes) - records, err := ExportLocalStorage(t.Context(), profile, []string{"https://example.com"}) + exported, err := ExportLocalStorage(t.Context(), profile, []string{"https://example.com"}) require.NoError(t, err) - require.Equal(t, []StorageRecord{{Origin: "https://example.com", Kind: StorageKindLocal, Key: "theme", Value: "dark"}}, records) + require.Equal(t, []StorageRecord{{Origin: "https://example.com", Kind: StorageKindLocal, Key: "theme", Value: "dark"}}, exported.Records) - records, err = ExportLocalStorage(t.Context(), profile, nil) + exported, err = ExportLocalStorage(t.Context(), profile, nil) require.NoError(t, err) - require.Len(t, records, 2) - require.Equal(t, "hello 世界", records[1].Value) + require.Len(t, exported.Records, 2) + require.Equal(t, "hello 世界", exported.Records[1].Value) +} + +func TestExportLocalStorageSkipsOversizedRecords(t *testing.T) { + profile := sqliteProfileFixture(t) + databasePath := filepath.Join(profile.Path, "Local Storage", "leveldb") + require.NoError(t, os.MkdirAll(filepath.Dir(databasePath), 0o755)) + database, err := leveldb.OpenFile(databasePath, nil) + require.NoError(t, err) + require.NoError(t, database.Put(localStorageRecordKeyFixture("https://openai.com", "theme"), chromiumStorageStringFixture("dark"), nil)) + require.NoError(t, database.Put( + localStorageRecordKeyFixture("https://openai.com", "statsig.cached.evaluations.2419098204"), + chromiumStorageStringFixture(strings.Repeat("x", maxStorageRecord)), + nil, + )) + require.NoError(t, database.Close()) + + exported, err := ExportLocalStorage(t.Context(), profile, nil) + require.NoError(t, err) + require.Equal(t, []StorageRecord{{Origin: "https://openai.com", Kind: StorageKindLocal, Key: "theme", Value: "dark"}}, exported.Records) + require.Equal(t, 1, exported.SkippedRecords) + require.Equal(t, 1, exported.SkippedOrigins) } func TestSelectedCookieFilterEscapesInput(t *testing.T) { diff --git a/internal/browserimport/client.go b/internal/browserimport/client.go index 9fbce15a..0588bf47 100644 --- a/internal/browserimport/client.go +++ b/internal/browserimport/client.go @@ -84,11 +84,17 @@ func (c *Client) Create(ctx context.Context) (CreateResponse, error) { return CreateResponse{}, lastErr } +func (c *Client) AcquireHelperGrant(ctx context.Context, id string) (HelperGrant, error) { + var result HelperGrant + err := c.doJSON(ctx, http.MethodPost, "/browser-imports/"+url.PathEscape(id)+"/helper-grant", c.token, nil, &result) + return result, err +} + func (c *Client) SubmitInventory(ctx context.Context, id, helperToken string, inventory Inventory) (Status, error) { var result Status err := c.doJSON(ctx, http.MethodPost, "/browser-imports/"+url.PathEscape(id)+"/inventory", helperToken, inventory, &result) if err != nil { - return c.reconcile(ctx, id, err, "awaiting_selection", "awaiting_bundle", "staged", "applying", "completed") + return c.reconcile(ctx, id, err, "awaiting_selection", "awaiting_bundle", "staged", "applying", "awaiting_client_completion", "awaiting_dashboard_ack", "completed") } return result, err } @@ -97,7 +103,7 @@ func (c *Client) SubmitSelection(ctx context.Context, id string, selection Selec var result Status err := c.doJSON(ctx, http.MethodPost, "/browser-imports/"+url.PathEscape(id)+"/selection", c.token, selection, &result) if err != nil { - return c.reconcile(ctx, id, err, "awaiting_bundle", "staged", "applying", "completed") + return c.reconcile(ctx, id, err, "awaiting_bundle", "staged", "applying", "awaiting_client_completion", "awaiting_dashboard_ack", "completed") } return result, err } @@ -111,12 +117,21 @@ func (c *Client) Upload(ctx context.Context, id, helperToken string, bundle []by request.Header.Set("Content-Type", "application/octet-stream") response, err := c.http.Do(request) if err != nil { - return c.reconcile(ctx, id, err, "staged", "applying", "completed", "failed") + return c.reconcile(ctx, id, err, "staged", "applying", "awaiting_client_completion", "awaiting_dashboard_ack", "completed", "failed") } defer response.Body.Close() var result Status if err := decodeResponse(response, &result); err != nil { - return c.reconcile(ctx, id, err, "staged", "applying", "completed", "failed") + return c.reconcile(ctx, id, err, "staged", "applying", "awaiting_client_completion", "awaiting_dashboard_ack", "completed", "failed") + } + return result, nil +} + +func (c *Client) SubmitClientCompletion(ctx context.Context, id string, completion ClientCompletion) (Status, error) { + var result Status + err := c.doJSON(ctx, http.MethodPost, "/browser-imports/"+url.PathEscape(id)+"/client-completion", c.token, completion, &result) + if err != nil { + return c.reconcile(ctx, id, err, "awaiting_dashboard_ack", "finishing_managed_auth", "completed", "failed") } return result, nil } @@ -141,6 +156,14 @@ func (c *Client) Status(ctx context.Context, id string) (Status, error) { } func (c *Client) Wait(ctx context.Context, id string, interval time.Duration) (Status, error) { + return c.wait(ctx, id, interval, false) +} + +func (c *Client) WaitForProfile(ctx context.Context, id string, interval time.Duration) (Status, error) { + return c.wait(ctx, id, interval, true) +} + +func (c *Client) wait(ctx context.Context, id string, interval time.Duration, returnWhenAwaitingClient bool) (Status, error) { if interval <= 0 { interval = time.Second } @@ -157,8 +180,12 @@ func (c *Client) Wait(ctx context.Context, id string, interval time.Duration) (S } else { consecutiveErrors = 0 switch status.Phase { - case "completed": + case "completed", "finishing_managed_auth": return status, nil + case "awaiting_client_completion": + if returnWhenAwaitingClient { + return status, nil + } case "failed": if status.Applied != nil && status.Applied.Failure != nil { return status, fmt.Errorf("browser import failed during %s: %s", status.Applied.Failure.Stage, status.Applied.Failure.Message) diff --git a/internal/browserimport/client_test.go b/internal/browserimport/client_test.go index 75675bb7..7783dc53 100644 --- a/internal/browserimport/client_test.go +++ b/internal/browserimport/client_test.go @@ -90,6 +90,48 @@ func TestCreateRetriesWithSameIdempotencyKey(t *testing.T) { assert.EqualValues(t, 2, calls.Load()) } +func TestClientRunsDashboardHandoffWithFreshScopedGrant(t *testing.T) { + var statusCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.Method + " " + request.URL.Path { + case "POST /browser-imports/bri_1/helper-grant": + assert.Equal(t, "Bearer user-token", request.Header.Get("Authorization")) + response.WriteHeader(http.StatusCreated) + fmt.Fprint(response, `{"helper_token":"fresh-helper","helper_token_expires_at":"2030-01-01T00:00:00Z"}`) + case "POST /browser-imports/bri_1/client-completion": + assert.Equal(t, "Bearer user-token", request.Header.Get("Authorization")) + response.WriteHeader(http.StatusAccepted) + fmt.Fprint(response, `{"id":"bri_1","phase":"awaiting_dashboard_ack"}`) + case "GET /browser-imports/bri_1": + phase := "awaiting_client_completion" + if statusCalls.Add(1) > 1 { + phase = "finishing_managed_auth" + } + fmt.Fprintf(response, `{"id":"bri_1","phase":%q}`, phase) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + client, err := NewClient(server.URL, "user-token", "proj_test") + require.NoError(t, err) + grant, err := client.AcquireHelperGrant(context.Background(), "bri_1") + require.NoError(t, err) + assert.Equal(t, "fresh-helper", grant.HelperToken) + status, err := client.WaitForProfile(context.Background(), "bri_1", time.Millisecond) + require.NoError(t, err) + assert.Equal(t, "awaiting_client_completion", status.Phase) + _, err = client.SubmitClientCompletion(context.Background(), "bri_1", ClientCompletion{ + Outcome: "completed", ManagedAuthConnections: []ManagedAuthConnection{{ID: "auth-1", Domain: "github.com"}}, + }) + require.NoError(t, err) + waitCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + status, err = client.Wait(waitCtx, "bri_1", time.Millisecond) + require.NoError(t, err) + assert.Equal(t, "finishing_managed_auth", status.Phase) +} + func TestClientRejectsUntrustedPlaintextAPI(t *testing.T) { _, err := NewClient("http://api.example.com", "token", "") assert.EqualError(t, err, "Kernel API URL must use HTTPS or local development") diff --git a/internal/browserimport/types.go b/internal/browserimport/types.go index aeb15a47..8c529e75 100644 --- a/internal/browserimport/types.go +++ b/internal/browserimport/types.go @@ -87,11 +87,20 @@ type StorageSite struct { Bytes int64 } +// StorageExport contains portable records and records excluded by size limits. +type StorageExport struct { + Records []StorageRecord + SkippedRecords int + SkippedOrigins int +} + type ProfileData struct { - Cookies []Cookie - Storage []StorageRecord - Bookmarks *BookmarkDocument - History []HistoryRecord + Cookies []Cookie + Storage []StorageRecord + StorageRecordsSkipped int + StorageOriginsSkipped int + Bookmarks *BookmarkDocument + History []HistoryRecord } type Source struct { @@ -108,9 +117,10 @@ type Inventory struct { } type ProfileSelection struct { - SourceID string `json:"source_id"` - TargetName string `json:"target_name"` - Categories []string `json:"categories"` + SourceID string `json:"source_id"` + TargetName string `json:"target_name"` + TargetProfileID string `json:"target_profile_id,omitempty"` + Categories []string `json:"categories"` } type Selection struct { @@ -119,9 +129,13 @@ type Selection struct { } type AppliedProfile struct { - SourceID string `json:"source_id"` - ProfileID string `json:"profile_id"` - TargetName string `json:"target_name"` + SourceID string `json:"source_id"` + ProfileID string `json:"profile_id"` + TargetName string `json:"target_name"` + StorageOriginsImported *int `json:"storage_origins_imported,omitempty"` + StorageEntriesImported *int `json:"storage_entries_imported,omitempty"` + StorageOriginsSkipped *int `json:"storage_origins_skipped,omitempty"` + StorageEntriesSkipped *int `json:"storage_entries_skipped,omitempty"` } type ApplyFailure struct { @@ -137,11 +151,12 @@ type Applied struct { } type Status struct { - ID string `json:"id"` - Phase string `json:"phase"` - Inventory *Inventory `json:"inventory,omitempty"` - Selection *Selection `json:"selection,omitempty"` - Applied *Applied `json:"applied,omitempty"` + ID string `json:"id"` + Phase string `json:"phase"` + Inventory *Inventory `json:"inventory,omitempty"` + Selection *Selection `json:"selection,omitempty"` + Applied *Applied `json:"applied,omitempty"` + Client *ClientCompletion `json:"client,omitempty"` } type CreateResponse struct { @@ -149,3 +164,32 @@ type CreateResponse struct { HelperToken string `json:"helper_token"` HelperTokenExpiresAt time.Time `json:"helper_token_expires_at"` } + +type HelperGrant struct { + HelperToken string `json:"helper_token"` + HelperTokenExpiresAt time.Time `json:"helper_token_expires_at"` +} + +type ManagedAuthConnection struct { + ID string `json:"id"` + Domain string `json:"domain"` +} + +type ClientCounts struct { + Cookies int `json:"cookies"` + Bookmarks int `json:"bookmarks"` + History int `json:"history"` + StorageOrigins int `json:"storage_origins"` +} + +type ClientFailure struct { + Stage string `json:"stage"` + Message string `json:"message"` +} + +type ClientCompletion struct { + Outcome string `json:"outcome"` + Counts ClientCounts `json:"counts"` + ManagedAuthConnections []ManagedAuthConnection `json:"managed_auth_connections"` + Failure *ClientFailure `json:"failure,omitempty"` +} diff --git a/internal/connector/connector.go b/internal/connector/connector.go index c0810500..2abde278 100644 --- a/internal/connector/connector.go +++ b/internal/connector/connector.go @@ -25,12 +25,16 @@ const ( connectorName = "Kernel Connector.app" ) -var projectIDPattern = regexp.MustCompile(`^[a-z0-9]{24}$`) +var ( + projectIDPattern = regexp.MustCompile(`^[a-z0-9]{24}$`) + importIDPattern = regexp.MustCompile(`^bri_[a-z0-9]{8,64}$`) +) // BrowserImportLink is the trusted, non-secret input carried by a dashboard // deep link. The CLI still authenticates and authorizes the project itself. type BrowserImportLink struct { ProjectID string + ImportID string } // ParseBrowserImportLink validates a Kernel browser-import deep link. @@ -43,22 +47,32 @@ func ParseBrowserImportLink(raw string) (BrowserImportLink, error) { return BrowserImportLink{}, errors.New("invalid Kernel browser import link") } query, err := url.ParseQuery(parsed.RawQuery) - if err != nil || len(query) != 1 || len(query["project_id"]) != 1 { + if err != nil || len(query) < 1 || len(query) > 2 || len(query["project_id"]) != 1 { return BrowserImportLink{}, errors.New("invalid Kernel browser import link") } projectID := query.Get("project_id") if !projectIDPattern.MatchString(projectID) { return BrowserImportLink{}, errors.New("invalid Kernel project ID") } - return BrowserImportLink{ProjectID: projectID}, nil + importID := query.Get("import_id") + if len(query) == 2 && (len(query["import_id"]) != 1 || !importIDPattern.MatchString(importID)) { + return BrowserImportLink{}, errors.New("invalid Kernel browser import ID") + } + return BrowserImportLink{ProjectID: projectID, ImportID: importID}, nil } // URL returns the canonical browser-import deep link for a project. -func URL(projectID string) (string, error) { +func URL(projectID string, importID ...string) (string, error) { if !projectIDPattern.MatchString(projectID) { return "", errors.New("invalid Kernel project ID") } query := url.Values{"project_id": []string{projectID}} + if len(importID) > 1 || (len(importID) == 1 && !importIDPattern.MatchString(importID[0])) { + return "", errors.New("invalid Kernel browser import ID") + } + if len(importID) == 1 { + query.Set("import_id", importID[0]) + } return Scheme + "://" + ImportHost + "?" + query.Encode(), nil } @@ -224,12 +238,23 @@ func macOSAppleScript(executable string) string { return `on «event GURLGURL» incomingURL set kernelExecutable to "` + appleScriptString(executable) + `" set commandText to "for variable in KERNEL_BASE_URL KERNEL_API_KEY KERNEL_AUTH_BASE_URL; do value=$(/bin/launchctl getenv \"$variable\"); if [[ -n \"$value\" ]]; then export \"$variable=$value\"; fi; done; exec " & quoted form of kernelExecutable & " connector open " & quoted form of incomingURL -set scriptPath to «event sysoexec» "/usr/bin/mktemp /tmp/kernel-connector.XXXXXX" -set scriptFile to «event rdwropen» POSIX file scriptPath with «class perm» -«event rdwrwrit» "#!/bin/zsh" & linefeed & "rm -f " & quoted form of scriptPath & linefeed & "if [[ ! -x " & quoted form of kernelExecutable & " ]]; then echo 'Kernel CLI was removed. Reinstall it with: brew install kernel/tap/kernel'; read -k 1 '?Press any key to close'; exit 1; fi" & linefeed & "exec /bin/zsh -lic " & quoted form of commandText & linefeed given «class refn»:scriptFile +set launcherPath to «event sysoexec» "/usr/bin/mktemp /tmp/kernel-connector.XXXXXX" +set startedPath to launcherPath & ".started" +set successPath to launcherPath & ".success" +set donePath to launcherPath & ".done" +set scriptFile to «event rdwropen» POSIX file launcherPath with «class perm» +«event rdwrwrit» "#!/bin/zsh" & linefeed & "rm -f " & quoted form of launcherPath & " " & quoted form of startedPath & " " & quoted form of successPath & " " & quoted form of donePath & linefeed & "commandStatus=1" & linefeed & "finishConnector() { commandStatus=$?; if [[ $commandStatus -eq 0 ]]; then /usr/bin/touch " & quoted form of successPath & "; fi; /usr/bin/touch " & quoted form of donePath & "; }" & linefeed & "trap finishConnector EXIT" & linefeed & "/usr/bin/touch " & quoted form of startedPath & linefeed & "if [[ ! -x " & quoted form of kernelExecutable & " ]]; then echo 'Kernel CLI was removed. Reinstall it with: brew install kernel/tap/kernel'; read -k 1 '?Press any key to close'; exit 1; fi" & linefeed & "/bin/zsh -lc " & quoted form of commandText & linefeed & "commandStatus=$?" & linefeed & "exit $commandStatus" & linefeed given «class refn»:scriptFile «event rdwrclos» scriptFile -«event sysoexec» "/bin/chmod 700 " & quoted form of scriptPath -«event sysoexec» "/usr/bin/open -a Terminal " & quoted form of scriptPath +«event sysoexec» "/bin/chmod 700 " & quoted form of launcherPath +tell application "Terminal" +activate +set connectorTab to do script (quoted form of launcherPath & "; exit") +set connectorWindow to front window +set connectorWindowID to id of connectorWindow +end tell +set closeTerminalWindow to "tell application \"Terminal\" to close (first window whose id is " & connectorWindowID & ") saving no" +set monitorCommand to "for i in {1..57600}; do if [[ -f " & quoted form of donePath & " ]]; then break; fi; /bin/sleep 0.5; done; if [[ -f " & quoted form of successPath & " ]]; then /bin/sleep 1; /usr/bin/osascript -e " & quoted form of closeTerminalWindow & "; fi; /bin/rm -f " & quoted form of startedPath & " " & quoted form of successPath & " " & quoted form of donePath +«event sysoexec» "/usr/bin/nohup /bin/zsh -c " & quoted form of monitorCommand & " >/dev/null 2>&1 &" end «event GURLGURL»` } diff --git a/internal/connector/connector_darwin_test.go b/internal/connector/connector_darwin_test.go index 72ce5e8a..f1f39ba4 100644 --- a/internal/connector/connector_darwin_test.go +++ b/internal/connector/connector_darwin_test.go @@ -12,7 +12,8 @@ import ( func TestMacOSAppleScriptCompiles(t *testing.T) { app := filepath.Join(t.TempDir(), "Kernel Connector.app") - command := exec.Command("/usr/bin/osacompile", "-o", app, "-e", macOSAppleScript("/opt/homebrew/bin/kernel")) + script := macOSAppleScript("/opt/homebrew/bin/kernel") + command := exec.Command("/usr/bin/osacompile", "-o", app, "-e", script) output, err := command.CombinedOutput() require.NoError(t, err, string(output)) } diff --git a/internal/connector/connector_test.go b/internal/connector/connector_test.go index f626d3cf..92294b17 100644 --- a/internal/connector/connector_test.go +++ b/internal/connector/connector_test.go @@ -25,6 +25,14 @@ func TestParseBrowserImportLink(t *testing.T) { assert.Equal(t, testProjectID, link.ProjectID) } +func TestParseBrowserImportLinkWithDashboardImport(t *testing.T) { + t.Parallel() + link, err := ParseBrowserImportLink("kernel://browser-import?project_id=" + testProjectID + "&import_id=bri_12345678abcdef") + require.NoError(t, err) + assert.Equal(t, testProjectID, link.ProjectID) + assert.Equal(t, "bri_12345678abcdef", link.ImportID) +} + func TestParseBrowserImportLinkRejectsUntrustedShapes(t *testing.T) { t.Parallel() for _, raw := range []string{ @@ -33,6 +41,7 @@ func TestParseBrowserImportLinkRejectsUntrustedShapes(t *testing.T) { "kernel://browser-import/path?project_id=" + testProjectID, "kernel://browser-import?project_id=" + testProjectID + "&next=https://evil.test", "kernel://browser-import?project_id=" + testProjectID + "&project_id=" + testProjectID, + "kernel://browser-import?project_id=" + testProjectID + "&import_id=bad", "kernel://browser-import?project_id=not-a-project", "kernel://browser-import?project_id=" + testProjectID + "#fragment", } { @@ -54,6 +63,15 @@ func TestURLRoundTrip(t *testing.T) { assert.Equal(t, testProjectID, link.ProjectID) } +func TestDashboardURLRoundTrip(t *testing.T) { + t.Parallel() + raw, err := URL(testProjectID, "bri_12345678abcdef") + require.NoError(t, err) + link, err := ParseBrowserImportLink(raw) + require.NoError(t, err) + assert.Equal(t, "bri_12345678abcdef", link.ImportID) +} + func TestInstallMacOSBuildsAndRegistersUserApp(t *testing.T) { home := t.TempDir() executable := filepath.Join(home, "bin", "kernel") @@ -131,15 +149,27 @@ func TestMacOSAppleScriptShellQuotesURLAtRuntime(t *testing.T) { script := macOSAppleScript(`/opt/homebrew/bin/kernel`) assert.Contains(t, script, `quoted form of incomingURL`) assert.Contains(t, script, `" connector open "`) - assert.Contains(t, script, `/usr/bin/open -a Terminal`) - assert.Contains(t, script, `/bin/zsh -lic`) + assert.Contains(t, script, `set connectorTab to do script (quoted form of launcherPath & "; exit")`) + assert.Contains(t, script, `set connectorWindow to front window`) + assert.Contains(t, script, `set connectorWindowID to id of connectorWindow`) + assert.Contains(t, script, `close (first window whose id is `) + assert.NotContains(t, script, `close connectorTab`) + assert.Contains(t, script, `set startedPath to launcherPath & ".started"`) + assert.Contains(t, script, `set donePath to launcherPath & ".done"`) + assert.Contains(t, script, `trap finishConnector EXIT`) + assert.Contains(t, script, `set monitorCommand to`) + assert.Contains(t, script, `/usr/bin/nohup /bin/zsh -c`) + assert.Contains(t, script, `if [[ -f " & quoted form of donePath`) + assert.Contains(t, script, `if [[ -f " & quoted form of successPath`) + assert.NotContains(t, script, `repeat 50 times`) + assert.Contains(t, script, `/bin/zsh -lc`) + assert.NotContains(t, script, `/bin/zsh -lic`) assert.Contains(t, script, `/usr/bin/mktemp /tmp/kernel-connector.XXXXXX`) assert.Contains(t, script, `/bin/launchctl getenv`) assert.Contains(t, script, `KERNEL_BASE_URL KERNEL_API_KEY KERNEL_AUTH_BASE_URL`) assert.NotContains(t, script, `XXXXXX.command`) assert.Contains(t, script, `Kernel CLI was removed`) assert.Contains(t, script, `«event GURLGURL»`) - assert.NotContains(t, script, `tell application "Terminal"`) assert.NotContains(t, script, "project_id=") escaped := macOSAppleScript(`/Applications/a\"b/kernel`) diff --git a/internal/passwordmanager/bitwarden.go b/internal/passwordmanager/bitwarden.go index 49e8f081..3f4721fc 100644 --- a/internal/passwordmanager/bitwarden.go +++ b/internal/passwordmanager/bitwarden.go @@ -151,16 +151,23 @@ func (p *bitwardenProvider) Reveal(ctx context.Context, selected []Candidate) ([ if err != nil { return nil, err } - records := make([]Record, 0, len(selected)) - for _, candidate := range selected { + items, err := fetchBitwardenApprovedItems(ctx, selected, func(ctx context.Context, candidate Candidate) (bitwardenItem, error) { output, err := command(ctx, p.path, environment, "get", "item", candidate.ID) if err != nil { - return nil, fmt.Errorf("read approved Bitwarden login %q: %w", candidate.Name, err) + return bitwardenItem{}, err } var item bitwardenItem if err := json.Unmarshal(output, &item); err != nil { - return nil, fmt.Errorf("decode approved Bitwarden login: %w", err) + return bitwardenItem{}, fmt.Errorf("decode approved Bitwarden login: %w", err) } + return item, nil + }) + if err != nil { + return nil, err + } + records := make([]Record, 0, len(selected)) + for index, candidate := range selected { + item := items[index] if item.Login == nil || item.OrganizationID != "" { continue } @@ -169,6 +176,40 @@ func (p *bitwardenProvider) Reveal(ctx context.Context, selected []Candidate) ([ return deduplicate(records), nil } +func fetchBitwardenApprovedItems(ctx context.Context, selected []Candidate, fetch func(context.Context, Candidate) (bitwardenItem, error)) ([]bitwardenItem, error) { + unique := make([]Candidate, 0, len(selected)) + indices := make(map[string]int, len(selected)) + for _, candidate := range selected { + if _, exists := indices[candidate.ID]; exists { + continue + } + indices[candidate.ID] = len(unique) + unique = append(unique, candidate) + } + items := make([]bitwardenItem, len(unique)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(min(4, len(unique))) + for index, candidate := range unique { + index, candidate := index, candidate + group.Go(func() error { + item, err := fetch(groupCtx, candidate) + if err != nil { + return fmt.Errorf("read approved Bitwarden login %q: %w", candidate.Name, err) + } + items[index] = item + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + ordered := make([]bitwardenItem, len(selected)) + for index, candidate := range selected { + ordered[index] = items[indices[candidate.ID]] + } + return ordered, nil +} + func (p *bitwardenProvider) authorizedEnvironment(ctx context.Context) (map[string]string, error) { environment := map[string]string(nil) if p.session != "" { diff --git a/internal/passwordmanager/passwordmanager_test.go b/internal/passwordmanager/passwordmanager_test.go index c0aeaa83..4e96e268 100644 --- a/internal/passwordmanager/passwordmanager_test.go +++ b/internal/passwordmanager/passwordmanager_test.go @@ -162,6 +162,52 @@ func TestBitwardenCandidateQueriesAreBoundedOrderedAndCanceled(t *testing.T) { } } +func TestBitwardenApprovedItemReadsAreBoundedOrderedDeduplicatedAndCanceled(t *testing.T) { + selected := []Candidate{ + {ID: "one", Name: "One"}, {ID: "two", Name: "Two"}, {ID: "three", Name: "Three"}, + {ID: "four", Name: "Four"}, {ID: "five", Name: "Five"}, {ID: "one", Name: "One again"}, + } + var active, peak, oneReads atomic.Int32 + items, err := fetchBitwardenApprovedItems(t.Context(), selected, func(_ context.Context, candidate Candidate) (bitwardenItem, error) { + current := active.Add(1) + defer active.Add(-1) + if candidate.ID == "one" { + oneReads.Add(1) + } + for { + previous := peak.Load() + if current <= previous || peak.CompareAndSwap(previous, current) { + break + } + } + time.Sleep(time.Duration(len(selected)-stringIndex([]string{"one", "two", "three", "four", "five"}, candidate.ID)) * time.Millisecond) + return bitwardenItem{ID: candidate.ID}, nil + }) + require.NoError(t, err) + assert.LessOrEqual(t, peak.Load(), int32(4)) + assert.Equal(t, int32(1), oneReads.Load()) + for index, candidate := range selected { + assert.Equal(t, candidate.ID, items[index].ID) + } + + canceled := make(chan struct{}, 1) + _, err = fetchBitwardenApprovedItems(t.Context(), []Candidate{{ID: "fail"}, {ID: "slow"}}, func(ctx context.Context, candidate Candidate) (bitwardenItem, error) { + if candidate.ID == "fail" { + time.Sleep(10 * time.Millisecond) + return bitwardenItem{}, assert.AnError + } + <-ctx.Done() + canceled <- struct{}{} + return bitwardenItem{}, ctx.Err() + }) + require.Error(t, err) + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("sibling approved-item read was not canceled") + } +} + func stringIndex(values []string, target string) int { for index, value := range values { if value == target {