Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 59 additions & 0 deletions internal/app/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ type credentials struct {
Role string `json:"role"`
}

// credentialsPathOverride allows tests to redirect the credentials path.
var credentialsPathOverride string

func credentialsPath() string {
if credentialsPathOverride != "" {
return credentialsPathOverride
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".mscli", "credentials.json")
}
Expand Down Expand Up @@ -155,6 +161,59 @@ func (a *Application) ensureProjectService() bool {
return true
}

func (a *Application) cmdLogout() {
if a.issueService == nil {
if _, err := loadCredentials(); err != nil {
a.EventCh <- model.Event{Type: model.AgentReply, Message: "not logged in."}
return
}
}
if err := os.Remove(credentialsPath()); err != nil && !os.IsNotExist(err) {
a.EventCh <- model.Event{
Type: model.AgentReply,
Message: fmt.Sprintf("logout failed: %v", err),
}
return
}
// If using mscli-provided model, clear config.json and in-session preset
// state — ModelToken is the same server token; leaving it would cause
// auto-relogin on next startup, and the preset would remain active in UI.
if cfg, err := loadAppConfig(); err == nil && cfg.ModelMode == modelModeMSCLIProvided {
if err := saveAppConfig(&appConfig{}); err != nil {
a.emitToolError("config", "logout: failed to clear model config: %v", err)
}
// Always reset in-session preset state regardless of disk write outcome.
startupPreset := a.activeModelPresetID != "" && a.modelBeforePreset == nil
a.restoreModelConfigFromPreset() // restores Config.Model if switched mid-session
if startupPreset && a.Config != nil {
// Startup-restored preset: modelBeforePreset was never captured so
// restoreModelConfigFromPreset() was a no-op and Config.Model still
// holds preset values. Clear them so the session reflects no model.
a.Config.Model.Key = ""
a.Config.Model.URL = ""
a.Config.Model.Model = ""
}
a.activeModelPresetID = ""
a.modelBeforePreset = nil
a.savedModelToken = ""
a.llmReady = false
// Notify UI so the model bar reflects the updated (cleared) state.
if a.Config != nil {
a.EventCh <- model.Event{
Type: model.ModelUpdate,
Message: a.Config.Model.Model,
CtxMax: a.Config.Context.Window,
}
}
}
a.issueService = nil
a.projectService = nil
a.issueUser = ""
a.issueRole = ""
a.EventCh <- model.Event{Type: model.IssueUserUpdate, Message: ""}
a.EventCh <- model.Event{Type: model.AgentReply, Message: "logged out. Run /login <token> to log in again."}
}

func (a *Application) ensureAdmin() bool {
if a.issueRole == "" {
if !a.ensureIssueService() {
Expand Down
139 changes: 139 additions & 0 deletions internal/app/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package app

import (
"encoding/json"
"os"
"path/filepath"
"testing"

issuepkg "github.com/mindspore-lab/mindspore-cli/internal/issues"
"github.com/mindspore-lab/mindspore-cli/configs"
"github.com/mindspore-lab/mindspore-cli/ui/model"
)

func TestCmdLogoutClearsState(t *testing.T) {
tmp := t.TempDir()
credentialsPathOverride = filepath.Join(tmp, "credentials.json")
appConfigPathOverride = filepath.Join(tmp, "config.json")
defer func() { credentialsPathOverride = ""; appConfigPathOverride = "" }()

_ = os.WriteFile(credentialsPathOverride,
[]byte(`{"server_url":"http://x","token":"tok","user":"alice","role":"admin"}`), 0o600)
_ = saveAppConfig(&appConfig{ModelMode: modelModeMSCLIProvided, ModelPresetID: "kimi-k2.5-free", ModelToken: "tok"})

app := &Application{
EventCh: make(chan model.Event, 8),
issueService: issuepkg.NewService(&fakeAppIssueStore{}),
issueUser: "alice",
issueRole: "admin",
}
app.cmdLogout()

if app.issueService != nil {
t.Error("issueService should be nil")
}
if app.issueUser != "" || app.issueRole != "" {
t.Errorf("user/role should be empty, got %q/%q", app.issueUser, app.issueRole)
}
ev := drainUntilEventType(t, app, model.IssueUserUpdate)
if ev.Message != "" {
t.Errorf("IssueUserUpdate.Message should be empty, got %q", ev.Message)
}
if _, err := os.Stat(credentialsPathOverride); !os.IsNotExist(err) {
t.Error("credentials file should be deleted")
}
// config.json should have ModelToken and ModelMode cleared
data, _ := os.ReadFile(appConfigPathOverride)
var cfg appConfig
_ = json.Unmarshal(data, &cfg)
if cfg.ModelToken != "" {
t.Errorf("config.json ModelToken should be cleared, got %q", cfg.ModelToken)
}
if cfg.ModelMode != "" {
t.Errorf("config.json ModelMode should be cleared, got %q", cfg.ModelMode)
}
}

func TestCmdLogoutOwnModelKeepsConfig(t *testing.T) {
tmp := t.TempDir()
credentialsPathOverride = filepath.Join(tmp, "credentials.json")
appConfigPathOverride = filepath.Join(tmp, "config.json")
defer func() { credentialsPathOverride = ""; appConfigPathOverride = "" }()

_ = os.WriteFile(credentialsPathOverride,
[]byte(`{"server_url":"http://x","token":"tok","user":"bob","role":"user"}`), 0o600)
_ = saveAppConfig(&appConfig{ModelMode: modelModeOwn})

app := &Application{
EventCh: make(chan model.Event, 8),
issueService: issuepkg.NewService(&fakeAppIssueStore{}),
issueUser: "bob",
issueRole: "user",
}
app.cmdLogout()

// config.json should be untouched (own model, no server token in it)
data, _ := os.ReadFile(appConfigPathOverride)
var cfg appConfig
_ = json.Unmarshal(data, &cfg)
if cfg.ModelMode != modelModeOwn {
t.Errorf("config.json ModelMode should remain %q, got %q", modelModeOwn, cfg.ModelMode)
}
}

func TestCmdLogoutStartupPresetClearsModel(t *testing.T) {
tmp := t.TempDir()
credentialsPathOverride = filepath.Join(tmp, "credentials.json")
appConfigPathOverride = filepath.Join(tmp, "config.json")
defer func() { credentialsPathOverride = ""; appConfigPathOverride = "" }()

_ = os.WriteFile(credentialsPathOverride,
[]byte(`{"server_url":"http://x","token":"tok","user":"alice","role":"admin"}`), 0o600)
_ = saveAppConfig(&appConfig{ModelMode: modelModeMSCLIProvided, ModelPresetID: "kimi-k2.5-free", ModelToken: "tok"})

cfg := configs.DefaultConfig()
cfg.Model.Model = "kimi-k2.5"
cfg.Model.URL = "https://preset.api/"
cfg.Model.Key = "sk-preset"

// Simulate startup-restored preset: activeModelPresetID is set but modelBeforePreset is nil.
app := &Application{
EventCh: make(chan model.Event, 16),
issueService: issuepkg.NewService(&fakeAppIssueStore{}),
issueUser: "alice",
issueRole: "admin",
Config: cfg,
activeModelPresetID: "kimi-k2.5-free",
modelBeforePreset: nil,
}
app.cmdLogout()

if app.Config.Model.Model != "" {
t.Errorf("Config.Model.Model should be cleared, got %q", app.Config.Model.Model)
}
if app.Config.Model.Key != "" {
t.Errorf("Config.Model.Key should be cleared, got %q", app.Config.Model.Key)
}
if app.Config.Model.URL != "" {
t.Errorf("Config.Model.URL should be cleared, got %q", app.Config.Model.URL)
}
// ModelUpdate should carry empty model name.
ev := drainUntilEventType(t, app, model.ModelUpdate)
if ev.Message != "" {
t.Errorf("ModelUpdate.Message should be empty, got %q", ev.Message)
}
}

func TestCmdLogoutWhenNotLoggedIn(t *testing.T) {
tmp := t.TempDir()
credentialsPathOverride = filepath.Join(tmp, "credentials.json")
defer func() { credentialsPathOverride = "" }()
// No credentials file, no in-memory service.
app := &Application{EventCh: make(chan model.Event, 4)}
app.cmdLogout()

ev := drainUntilEventType(t, app, model.AgentReply)
if ev.Message != "not logged in." {
t.Errorf("unexpected message: %q", ev.Message)
}
}
2 changes: 2 additions & 0 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func (a *Application) handleCommand(input string) {
a.cmdProjectInput(cmd.Remainder)
case "/login":
a.cmdLogin(args)
case "/logout":
a.cmdLogout()
case "/feedback":
expanded, err := a.expandReportInput(cmd.Remainder)
if err != nil {
Expand Down
8 changes: 7 additions & 1 deletion ui/slash/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,16 @@ func (r *Registry) registerDefaults() {

r.Register(Command{
Name: "/login",
Description: "Log in to the bug server",
Description: "Log in to the issue server",
Usage: "/login <token>",
})

r.Register(Command{
Name: "/logout",
Description: "Log out from the issue server",
Usage: "/logout",
})

r.Register(Command{
Name: "/feedback",
Description: "Report a bug or issue",
Expand Down