Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0806fac
Hand browser imports back to the dashboard
IlyaasK Aug 17, 2026
fdd80d5
Align managed auth website choices
IlyaasK Aug 17, 2026
6140a3d
Report skipped browser storage imports
IlyaasK Aug 17, 2026
14be1fc
fix connector terminal completion lifecycle
IlyaasK Aug 17, 2026
6aa9cb1
improve browser import progress and defaults
IlyaasK Aug 17, 2026
1941f52
wait for connector terminal tab before closing
IlyaasK Aug 17, 2026
89b74b1
close the connector terminal window reliably
IlyaasK Aug 18, 2026
7f4202e
avoid blocking repeated connector launches
IlyaasK Aug 18, 2026
5ee94b3
skip oversized browser storage records
IlyaasK Aug 18, 2026
adddc7d
clarify managed auth capacity error
IlyaasK Aug 18, 2026
d9f709a
show browser import capacity and progress
IlyaasK Aug 19, 2026
6da1e38
Clarify Managed Auth capacity during website search
IlyaasK Aug 19, 2026
594e9bc
Overlap profile import with Managed Auth setup
IlyaasK Aug 19, 2026
d1f3396
Speed up approved Bitwarden credential reads
IlyaasK Aug 19, 2026
21afc9b
Choose Managed Auth websites before accounts
IlyaasK Aug 19, 2026
b5b6731
Keep website search selection keys consistent
IlyaasK Aug 19, 2026
d8ad0b1
Stop stale browser import relaunches early
IlyaasK Aug 19, 2026
6b6130c
Browse websites before Managed Auth search
IlyaasK Aug 19, 2026
5b239a5
Validate explicit vaults before profile import
IlyaasK Aug 20, 2026
af14e12
Handle repeat browser imports clearly
IlyaasK Aug 20, 2026
a2326b4
avoid duplicate imported profiles and auth
IlyaasK Aug 20, 2026
a823b0a
Remove obsolete credential import status
IlyaasK Aug 20, 2026
f68d104
Honor local API URL during authentication
IlyaasK Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions cmd/browser_import_managed_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
)

type managedAuthCapacity struct {
maximum int
used int
remaining int
unlimited bool
}
Expand All @@ -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 {
Expand All @@ -54,14 +63,22 @@ 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 {
Provision(context.Context, string, []passwordmanager.Record) ([]string, error)
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)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions cmd/browser_import_managed_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 30 additions & 11 deletions cmd/browser_import_profile_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand 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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
Expand Down
155 changes: 155 additions & 0 deletions cmd/browser_import_profile_job.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Loading
Loading