Merge pull request #1774 from entireio/fix/1773-warn-logged-out-import · Entire

Merge pull request #1774 from entireio/fix/1773-warn-logged-out-import

6e06e26→main

karthik-rameshkumar·21h ago·4 files·+171 added/-0 removed

feat(import): warn when importing agent history while logged out

Changes

78 unmodified lines

79
80
81
82
83
84
85
86
87
88

78 unmodified lines

}
        fmt.Fprintf(c.OutOrStdout(), "%s %d turn(s) from %d session(s) (%d already imported).
",
            verb, res.TurnsImported, res.SessionsScanned, res.TurnsSkipped)
        // A dry run writes nothing locally, so there is nothing to sync.
        if !dryRun {
            warnIfImportNotSynced(c.OutOrStdout(), res.TurnsImported > 0 || res.TurnsSkipped > 0)
        }
        return nil
    },
}

Mcmd/entire/cli/import_cmd.go+4

package cli

import (
    "fmt"
    "io"
    "os"

"github.com/entireio/cli/cmd/entire/cli/auth"
)

// Local auth reads, as package vars so the login heuristic's branching is
// testable without a real keyring or config dir. Production wiring is the real
// auth functions.
var (
    importListContexts    = auth.Contexts
    importTokenForContext = auth.LoginTokenForContext
)

// importLoggedIn reports whether there is an active login the imported history
// could sync under: an ENTIRE_TOKEN env token, or a current stored login context
// that still has a token in the token store. It is local-only (env, contexts.json,
// and a token-store read) and never makes a network call, so it is safe on the
// import path.
//
// This is a presence check, not a liveness check. LoginTokenForContext returns a
// present-but-expired token without error, so a dead-but-not-removed login can
// still read as "logged in": confirming a token is actually usable needs a
// network refresh, which we deliberately avoid here (same reason the pre-push
// hook avoids ls-remote — no surprise auth prompts mid-command). That narrow
// residual false-negative (expired token → notice suppressed) is accepted to
// keep the check local and prompt-free; the common broken case this guards
// against — no context, or a context whose token was removed — is handled.
//
// Package var so tests can force the whole outcome (see #1773 review thread).
var importLoggedIn = func() bool {
    if os.Getenv(auth.EnvTokenVar) != "" {
        return true
    }
    ctxs, current, err := importListContexts()
    if err != nil || current == "" {
        return false
    }
    for _, c := range ctxs {
        if c.Name == current {
            tok, terr := importTokenForContext(c)
            return terr == nil && tok != ""
        }
    }
    return false
}

// warnIfImportNotSynced prints a one-time notice, when the user is not logged
// in, that imported agent history is stored locally only and will not appear in
// the Entire dashboard. It is a no-op when logged in or when nothing local was
// imported.
//
// Import writes read-only checkpoints to the local entire/checkpoints/v1 store
// and never syncs on its own; sync happens later via the git pre-push hook once
// logged in. Importing while logged out therefore succeeds locally but silently
// never reaches the dashboard — this notice surfaces that instead of leaving the
// user to discover an empty dashboard (see issue #1773).
func warnIfImportNotSynced(w io.Writer, importedLocalHistory bool) {
    if !importedLocalHistory || importLoggedIn() {
        return
    }
    fmt.Fprintln(w, "Note: you're not logged in, so this history was imported locally only and won't appear in your Entire dashboard.")
    fmt.Fprintln(w, "Log in with 'entire login' before importing to have your history synced.")
}

Acmd/entire/cli/import_sync_notice.go+68

package cli

import (
    "bytes"
    "errors"
    "strings"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/auth"
    "github.com/entireio/cli/internal/entireclient/contexts"
)

func TestWarnIfImportNotSynced(t *testing.T) {
    // Mutates the package-level importLoggedIn seam, so it cannot run in
    // parallel with other tests that read it.
    orig := importLoggedIn
    t.Cleanup(func() { importLoggedIn = orig })

cases := []struct {
        name       string
        loggedIn   bool
        imported   bool
        wantNotice bool
    }{
        {name: "logged out with imported history warns", loggedIn: false, imported: true, wantNotice: true},
        {name: "logged in does not warn", loggedIn: true, imported: true, wantNotice: false},
        {name: "nothing imported does not warn", loggedIn: false, imported: false, wantNotice: false},
        {name: "logged in and nothing imported does not warn", loggedIn: true, imported: false, wantNotice: false},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            importLoggedIn = func() bool { return tc.loggedIn }
            var buf bytes.Buffer
            warnIfImportNotSynced(&buf, tc.imported)
            got := buf.String()
            hasNotice := strings.Contains(got, "not logged in") && strings.Contains(got, "entire login")
            if hasNotice != tc.wantNotice {
                t.Fatalf("warnIfImportNotSynced(logged_in=%v, imported=%v): notice=%v, want %v; output=%q",
                                                tc.loggedIn, tc.imported, hasNotice, tc.wantNotice, got)
            }
        })
    }
}

// TestImportLoggedIn exercises the default login heuristic's branching via the
// local-read seams. The key case (#1773 review): a current context that exists
// but has no stored token must NOT count as logged in, so the sync notice still
// fires. Mutates package-level seams, so no t.Parallel.
func TestImportLoggedIn(t *testing.T) {
    origCtx, origTok := importListContexts, importTokenForContext
    t.Cleanup(func() { importListContexts, importTokenForContext = origCtx, origTok })
    // Ensure no env token leaks in from the environment for the context cases.
    t.Setenv(auth.EnvTokenVar, "")

withCurrent := func() ([]*contexts.Context, string, error) {
        return []*contexts.Context{{Name: "prod"}}, "prod", nil
    }

t.Run("current context with a stored token is logged in", func(t *testing.T) {
        importListContexts = withCurrent
        importTokenForContext = func(*contexts.Context) (string, error) { return "stored-token", nil }
        if !importLoggedIn() {
            t.Fatal("context with a token should count as logged in")
        }
    })

t.Run("current context with a missing token is NOT logged in", func(t *testing.T) {
        importListContexts = withCurrent
        importTokenForContext = func(*contexts.Context) (string, error) {
            return "", errors.New("no token stored")
        }
        if importLoggedIn() {
            t.Fatal("context present but token missing must not count as logged in")
        }
    })

t.Run("no current context is not logged in", func(t *testing.T) {
        importListContexts = func() ([]*contexts.Context, string, error) { return nil, "", nil }
        importTokenForContext = func(*contexts.Context) (string, error) { return "stored-token", nil }
        if importLoggedIn() {
            t.Fatal("no current context should not count as logged in")
        }
    })

t.Run("env token counts as logged in even with no context", func(t *testing.T) {
        t.Setenv(auth.EnvTokenVar, "env-token")
        importListContexts = func() ([]*contexts.Context, string, error) { return nil, "", nil }
        if !importLoggedIn() {
            t.Fatal("ENTIRE_TOKEN should count as logged in")
        }
    })
}

Acmd/entire/cli/import_sync_notice_test.go+92

// import_cmd.go; without it only always-on secret scanning would run.
strategy.EnsureRedactionConfigured()

var importedLocalHistory bool
for _, e := range selected {
    res, err := agentimport.Run(ctx, repo, e.imp, agentimport.Options{
        RepoRoot: repoRoot,
        fmt.Fprintf(w, "Note: could not import %s history: %v\n", e.displayName, err)
        continue
    }
    if res.TurnsImported > 0 || res.TurnsSkipped > 0 {
        importedLocalHistory = true
    }
    fmt.Fprintf(w, "Imported %d turn(s) from %d session(s) (%d already imported).\n",
        res.TurnsImported, res.SessionsScanned, res.TurnsSkipped)
}
// Enable often runs before the user has logged in; surface once that a
// logged-out import stays local and won't reach the dashboard (issue #1773).
warnIfImportNotSynced(w, importedLocalHistory)

// pluralSessions renders a session count with correct pluralization.