diff --git a/desktop/backend/services.go b/desktop/backend/services.go index 484f067..b8fd89f 100644 --- a/desktop/backend/services.go +++ b/desktop/backend/services.go @@ -560,8 +560,11 @@ func (s *AntigravityService) UpdateProfile(ctx context.Context, req UpdateAntigr return result, err } -func (s *AntigravityService) SaveCurrent(ctx context.Context) (antigravity.AntigravityProfileSaveResult, error) { - result, err := s.application.Antigravity().SaveActiveProfile(ctx) +func (s *AntigravityService) SaveCurrent(ctx context.Context, expectedProfileID string) (antigravity.AntigravityProfileSaveResult, error) { + if expectedProfileID == "" { + return antigravity.AntigravityProfileSaveResult{}, apperror.New(apperror.ProfileChanged, "selected Profile is missing") + } + result, err := s.application.Antigravity().SaveActiveProfileFor(ctx, expectedProfileID) s.notifyMutationResult(DesktopChangeAntigravityProfileChanged, "antigravity.saveCurrent", agyconfig.ProviderID, result.Summary.Profile.ID, result.OperationID, err) return result, err } @@ -606,8 +609,11 @@ func (s *ClaudeCodeService) UpdateProfile(ctx context.Context, req UpdateClaudeC return result, err } -func (s *ClaudeCodeService) SaveCurrent(ctx context.Context, confirmShared bool) (claudecode.ClaudeCodeProfileSaveResult, error) { - result, err := s.application.ClaudeCode().SaveActiveProfile(ctx, claudecode.SaveActiveClaudeCodeProfileRequest{ConfirmShared: confirmShared}) +func (s *ClaudeCodeService) SaveCurrent(ctx context.Context, expectedProfileID string, confirmShared bool) (claudecode.ClaudeCodeProfileSaveResult, error) { + if expectedProfileID == "" { + return claudecode.ClaudeCodeProfileSaveResult{}, apperror.New(apperror.ProfileChanged, "selected Profile is missing") + } + result, err := s.application.ClaudeCode().SaveActiveProfile(ctx, claudecode.SaveActiveClaudeCodeProfileRequest{ExpectedProfileID: expectedProfileID, ConfirmShared: confirmShared}) s.notifyMutationResult(DesktopChangeClaudeCodeProfileChanged, "claude-code.saveCurrent", claudecodeconfig.ProviderID, result.Summary.Profile.ID, result.OperationID, err) return result, err } @@ -697,8 +703,11 @@ func (s *CodexService) ForkProfile(ctx context.Context, req ForkCodexProfileRequ return result, err } -func (s *CodexService) SaveActiveProfileState(ctx context.Context) (codex.CodexProfileStateSaveResult, error) { - result, err := s.application.Codex().SaveActiveProfileState(ctx) +func (s *CodexService) SaveActiveProfileState(ctx context.Context, expectedProfileID string, expectedCredentialReferences, expectedConfigReferences int) (codex.CodexProfileStateSaveResult, error) { + if expectedProfileID == "" { + return codex.CodexProfileStateSaveResult{}, apperror.New(apperror.ProfileChanged, "selected Profile is missing") + } + result, err := s.application.Codex().SaveActiveProfileStateFor(ctx, expectedProfileID, expectedCredentialReferences, expectedConfigReferences) s.notifyMutationResult(DesktopChangeCodexProfileChanged, "codex.saveActiveProfileState", codexconfig.ProviderID, result.ProfileID, result.OperationID, err) return result, err } @@ -832,8 +841,11 @@ func (s *GrokBuildService) ForkProfile(ctx context.Context, req ForkGrokBuildPro return result, err } -func (s *GrokBuildService) SaveActiveProfileState(ctx context.Context) (grokbuild.ProfileStateSaveResult, error) { - result, err := s.application.GrokBuild().SaveActiveProfileState(ctx) +func (s *GrokBuildService) SaveActiveProfileState(ctx context.Context, expectedProfileID string, expectedCredentialReferences, expectedConfigReferences int) (grokbuild.ProfileStateSaveResult, error) { + if expectedProfileID == "" { + return grokbuild.ProfileStateSaveResult{}, apperror.New(apperror.ProfileChanged, "selected Profile is missing") + } + result, err := s.application.GrokBuild().SaveActiveProfileStateFor(ctx, expectedProfileID, expectedCredentialReferences, expectedConfigReferences) s.notifyMutationResult(DesktopChangeGrokBuildProfileChanged, "grok-build.saveActiveProfileState", grokconfig.ProviderID, result.ProfileID, result.OperationID, err) return result, err } diff --git a/desktop/backend/services_test.go b/desktop/backend/services_test.go index aeb6e95..f5f8d52 100644 --- a/desktop/backend/services_test.go +++ b/desktop/backend/services_test.go @@ -355,6 +355,24 @@ func assertDesktopServiceErrorCode(t *testing.T, err error, code apperror.Code) } } +func TestDesktopSaveCurrentRejectsMissingSelectedProfile(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + save func() error + }{ + {"Antigravity", func() error { _, err := (&AntigravityService{}).SaveCurrent(ctx, ""); return err }}, + {"Claude Code", func() error { _, err := (&ClaudeCodeService{}).SaveCurrent(ctx, "", false); return err }}, + {"Codex", func() error { _, err := (&CodexService{}).SaveActiveProfileState(ctx, "", 0, 0); return err }}, + {"Grok Build", func() error { _, err := (&GrokBuildService{}).SaveActiveProfileState(ctx, "", 0, 0); return err }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertDesktopServiceErrorCode(t, test.save(), apperror.ProfileChanged) + }) + } +} + func TestAntigravityServiceCreatesSafeDashboardProfile(t *testing.T) { keyring.MockInit() t.Cleanup(keyring.MockInit) @@ -1030,7 +1048,7 @@ func TestCodexSaveActiveProfileStateReadsCurrentFilesBehindDesktopBoundary(t *te } writeDesktopCodexFiles(t, codexDir, `model = "gpt-5.1-codex"`+"\n", `{"tokens":{"account_id":"updated","access_token":"changed"}}`) - if _, err := services.Codex.SaveActiveProfileState(ctx); err != nil { + if _, err := services.Codex.SaveActiveProfileState(ctx, "work", 1, 1); err != nil { t.Fatalf("expected save-current to read current Codex files, got %v", err) } diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/antigravityservice.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/antigravityservice.ts index 96f8083..729eec4 100644 --- a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/antigravityservice.ts +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/antigravityservice.ts @@ -29,8 +29,8 @@ export function ReadProfileQuota(profileID: string): $CancellablePromise { - return $Call.ByID(1314540511); +export function SaveCurrent(expectedProfileID: string): $CancellablePromise { + return $Call.ByID(1314540511, expectedProfileID); } export function ShowProfile(profileID: string): $CancellablePromise { diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/claudecodeservice.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/claudecodeservice.ts index ced4410..681a3ed 100644 --- a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/claudecodeservice.ts +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/claudecodeservice.ts @@ -29,8 +29,8 @@ export function ListProfiles(): $CancellablePromise { - return $Call.ByID(1016544372, confirmShared); +export function SaveCurrent(expectedProfileID: string, confirmShared: boolean): $CancellablePromise { + return $Call.ByID(1016544372, expectedProfileID, confirmShared); } export function ShowProfile(profileID: string): $CancellablePromise { diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/codexservice.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/codexservice.ts index 1c58783..20e84ee 100644 --- a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/codexservice.ts +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/codexservice.ts @@ -60,8 +60,8 @@ export function ReadProfileQuota(profileID: string): $CancellablePromise { - return $Call.ByID(2732837523); +export function SaveActiveProfileState(expectedProfileID: string, expectedCredentialReferences: number, expectedConfigReferences: number): $CancellablePromise { + return $Call.ByID(2732837523, expectedProfileID, expectedCredentialReferences, expectedConfigReferences); } export function SetProfileConfig(req: $models.UpdateCodexProfileConfigSetRequest): $CancellablePromise { diff --git a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/grokbuildservice.ts b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/grokbuildservice.ts index 094143f..c22c80f 100644 --- a/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/grokbuildservice.ts +++ b/desktop/frontend/bindings/github.com/strahe/profiledeck/desktop/backend/grokbuildservice.ts @@ -56,8 +56,8 @@ export function ReadProfileQuota(profileID: string): $CancellablePromise { - return $Call.ByID(1134372713); +export function SaveActiveProfileState(expectedProfileID: string, expectedCredentialReferences: number, expectedConfigReferences: number): $CancellablePromise { + return $Call.ByID(1134372713, expectedProfileID, expectedCredentialReferences, expectedConfigReferences); } export function SetProfileConfig(req: $models.UpdateGrokBuildProfileConfigSetRequest): $CancellablePromise { diff --git a/desktop/frontend/src/features/profiles/AntigravityProfiles.svelte b/desktop/frontend/src/features/profiles/AntigravityProfiles.svelte index aa458dd..3bb8024 100644 --- a/desktop/frontend/src/features/profiles/AntigravityProfiles.svelte +++ b/desktop/frontend/src/features/profiles/AntigravityProfiles.svelte @@ -12,6 +12,7 @@ import PencilIcon from "@lucide/svelte/icons/pencil"; import PlusIcon from "@lucide/svelte/icons/plus"; import RefreshCwIcon from "@lucide/svelte/icons/refresh-cw"; + import SaveIcon from "@lucide/svelte/icons/save"; import Trash2Icon from "@lucide/svelte/icons/trash-2"; import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert"; @@ -109,6 +110,7 @@ let editName = $state(""); let editDescription = $state(""); let saveCurrentOpen = $state(false); + let saveCurrentProfileID = $state(""); let saveCurrentReferenceCount = $state(1); let deleteOpen = $state(false); let deleteTarget = $state(null); @@ -244,7 +246,8 @@ showNotice(translate("profileDelete.deletedTitle"), translate("profileDelete.deletedDescription")); } - function openSaveCurrent(referenceCount: number) { + function openSaveCurrent(profileID: string, referenceCount: number) { + saveCurrentProfileID = profileID; saveCurrentReferenceCount = Math.max(1, referenceCount); saveCurrentOpen = true; } @@ -270,7 +273,16 @@ async function saveCurrent() { await runAction("antigravity-save-current", async () => { if (!isSourceReady(await refreshDetect())) return; - const result = await track("antigravity-save-current", AntigravityService.SaveCurrent()); + let result; + try { + result = await track("antigravity-save-current", AntigravityService.SaveCurrent(saveCurrentProfileID)); + } catch (error) { + if (isDesktopErrorCode(error, "PROFILE_CHANGED")) { + saveCurrentOpen = false; + await refreshProfiles(); + } + throw error; + } saveCurrentOpen = false; await Promise.all([refreshProfiles(), detail ? loadDetail(detail.summary.profile.id) : Promise.resolve()]); showWarnings(result.warnings); @@ -533,7 +545,7 @@ push(`/antigravity/profiles/${encodeURIComponent(summary.profile.id)}`)}>{$_("actions.details")} - {#if summary.active} openSaveCurrent(summary.credential_reference_count)}>{$_("antigravity.actions.updateCurrent")}{/if} + {#if summary.active} openSaveCurrent(summary.profile.id, summary.credential_reference_count)}>{$_("antigravity.actions.updateCurrent")}{/if} openProfileDelete({ id: summary.profile.id, name: summary.profile.name || $_("profile.unnamed") })}>{$_("actions.deleteProfile")} @@ -640,7 +652,7 @@ - {#if detail.summary.active}{/if} + {#if detail.summary.active}{/if} diff --git a/desktop/frontend/src/features/profiles/ClaudeCodeProfiles.svelte b/desktop/frontend/src/features/profiles/ClaudeCodeProfiles.svelte index 5e047b0..7068054 100644 --- a/desktop/frontend/src/features/profiles/ClaudeCodeProfiles.svelte +++ b/desktop/frontend/src/features/profiles/ClaudeCodeProfiles.svelte @@ -12,6 +12,7 @@ import PencilIcon from "@lucide/svelte/icons/pencil"; import PlusIcon from "@lucide/svelte/icons/plus"; import RefreshCwIcon from "@lucide/svelte/icons/refresh-cw"; + import SaveIcon from "@lucide/svelte/icons/save"; import Trash2Icon from "@lucide/svelte/icons/trash-2"; import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert"; @@ -82,6 +83,7 @@ let editName = $state(""); let editDescription = $state(""); let saveCurrentOpen = $state(false); + let saveCurrentProfileID = $state(""); let saveCurrentReferenceCount = $state(1); let deleteOpen = $state(false); let deleteTarget = $state(null); @@ -206,7 +208,8 @@ }); } - function openSaveCurrent(referenceCount: number) { + function openSaveCurrent(profileID: string, referenceCount: number) { + saveCurrentProfileID = profileID; saveCurrentReferenceCount = Math.max(1, referenceCount); saveCurrentOpen = true; } @@ -214,7 +217,15 @@ async function saveCurrent() { await runAction("claude-code-save-current", async () => { if (!isSourceReady(await refreshDetect())) return; - await track("claude-code-save-current", ClaudeCodeService.SaveCurrent(saveCurrentReferenceCount > 1)); + try { + await track("claude-code-save-current", ClaudeCodeService.SaveCurrent(saveCurrentProfileID, saveCurrentReferenceCount > 1)); + } catch (error) { + if (isDesktopErrorCode(error, "PROFILE_CHANGED")) { + saveCurrentOpen = false; + await refreshProfiles(); + } + throw error; + } saveCurrentOpen = false; await Promise.all([refreshProfiles(), detail ? loadDetail(detail.summary.profile.id) : Promise.resolve()]); showNotice(translate("claudeCode.notice.savedTitle"), translate("claudeCode.notice.savedDescription")); @@ -344,7 +355,7 @@
{#if summary.active}{$_("status.current")}{/if}{$_(`claudeCode.status.${summary.credential_status}`)} {#if !summary.active}{/if} - {#snippet child({ props })}{/snippet} push(`/claude-code/profiles/${encodeURIComponent(summary.profile.id)}`)}>{$_("actions.details")} openProfileDelete({ id: summary.profile.id, name: summary.profile.name || summary.profile.id })}>{$_("actions.deleteProfile")} + {#snippet child({ props })}{/snippet} push(`/claude-code/profiles/${encodeURIComponent(summary.profile.id)}`)}>{$_("actions.details")}{#if summary.active} openSaveCurrent(summary.profile.id, summary.credential_reference_count)}>{$_("claudeCode.actions.updateCurrent")}{/if} openProfileDelete({ id: summary.profile.id, name: summary.profile.name || summary.profile.id })}>{$_("actions.deleteProfile")}
{$_("claudeCode.detail.expiry")}: {formatExpiry(summary.expires_at_unix_ms)}
{#if summary.warnings?.length}{$_("claudeCode.notice.profileWarning")}{/if} {#if index < profiles.length - 1}{/if} @@ -369,7 +380,7 @@ {detail.summary.profile.name || detail.summary.profile.id}{detail.summary.profile.description || $_("profile.noDescription")}{#if detail.summary.active}{$_("status.current")}{/if}
{$_("profilePages.form.profileID")}
{detail.summary.profile.id}
{$_("claudeCode.detail.status")}
{$_(`claudeCode.status.${detail.summary.credential_status}`)}
{$_("claudeCode.detail.expiry")}
{formatExpiry(detail.summary.expires_at_unix_ms)}
{$_("claudeCode.detail.references")}
{detail.summary.credential_reference_count}
{#if detail.summary.warnings?.length}{$_("claudeCode.notice.profileWarning")}{/if}
- {#snippet child({ props })}{/snippet}{$_("actions.editDetails")} openProfileDelete({ id: detail!.summary.profile.id, name: detail!.summary.profile.name || detail!.summary.profile.id })}>{$_("actions.deleteProfile")}{#if detail.summary.active}{/if} + {#snippet child({ props })}{/snippet}{$_("actions.editDetails")} openProfileDelete({ id: detail!.summary.profile.id, name: detail!.summary.profile.name || detail!.summary.profile.id })}>{$_("actions.deleteProfile")}{#if detail.summary.active}{/if}
{/if} diff --git a/desktop/frontend/src/features/profiles/CodexProfiles.svelte b/desktop/frontend/src/features/profiles/CodexProfiles.svelte index 9ddb5b5..85fd117 100644 --- a/desktop/frontend/src/features/profiles/CodexProfiles.svelte +++ b/desktop/frontend/src/features/profiles/CodexProfiles.svelte @@ -118,6 +118,8 @@ let editName = $state(""); let editDescription = $state(""); let saveCurrentOpen = $state(false); + let saveCurrentProfileID = $state(""); + let saveCurrentSummary = $state(null); let saveCurrentSourceError = $state(""); let setConfigOpen = $state(false); let selectedConfigSetID = $state(""); @@ -395,6 +397,8 @@ } async function saveCurrent() { + const summary = saveCurrentSummary; + if (!summary) return; await runAction("profile-save-current", async () => { const source = await refreshDetect(); if (!isSourceReady(source)) { @@ -402,7 +406,16 @@ return; } saveCurrentSourceError = ""; - const result = await track("profile-save-current", CodexService.SaveActiveProfileState()); + let result; + try { + result = await track("profile-save-current", CodexService.SaveActiveProfileState(saveCurrentProfileID, summary.credential_reference_count, summary.config_set_reference_count)); + } catch (error) { + if (isDesktopErrorCode(error, "PROFILE_CHANGED") || isDesktopErrorCode(error, "PROFILE_SHARING_CHANGED")) { + saveCurrentOpen = false; + await Promise.all([refreshProfiles(), detail ? loadDetail(detail.summary.profile.id) : Promise.resolve()]); + } + throw error; + } saveCurrentOpen = false; await Promise.all([refreshProfiles(), refreshConfigSets(), detail ? loadDetail(detail.summary.profile.id) : Promise.resolve()]); if (result.warnings?.length) toast.warning(translate("notice.profileWarnings.title"), { description: joinUserMessages(result.warnings, profileChangeWarningMessage) }); @@ -410,9 +423,20 @@ }); } - function openSaveCurrent() { - saveCurrentSourceError = ""; - saveCurrentOpen = true; + async function openSaveCurrent(profileID: string) { + await runAction("profile-save-prepare", async () => { + saveCurrentSummary = null; + const current = await track("profile-save-prepare", CodexService.ShowProfile(profileID)); + if (!current.summary.active) { + await refreshProfiles(); + showError({ code: "PROFILE_CHANGED" }); + return; + } + saveCurrentProfileID = profileID; + saveCurrentSummary = current.summary; + saveCurrentSourceError = ""; + saveCurrentOpen = true; + }); } async function openSetConfig() { @@ -704,10 +728,10 @@ } push("/codex/profiles/new"); }} - aria-label={$_("actions.saveAsNewProfile")} + aria-label={$_("actions.newProfile")} > - {$_("actions.saveCurrentShort")} + {$_("actions.newProfile")} {/snippet} @@ -737,10 +761,13 @@ error={profileError} busy={!!busyAction || useBuilding || useApplying} canCreate={sourceReady} + createLabel={$_("actions.newProfile")} + saveCurrentLabel={$_("actions.updateFromCurrent")} onNew={() => push("/codex/profiles/new")} onDelete={openProfileDelete} onRefreshQuota={(profile) => runtime.readQuota(profile.id)} onUse={openUse} + onSaveCurrent={(profile) => openSaveCurrent(profile.id)} onDetails={(profile) => push(`/codex/profiles/${encodeURIComponent(profile.id)}`)} onFork={(profile) => push(`/codex/profiles/${encodeURIComponent(profile.id)}/fork`)} onRetrySource={() => { void Promise.all([refreshDetect(), refreshProfiles()]); }} @@ -765,7 +792,7 @@ onUse={() => openUse(profileListItem(detail!.summary))} onFork={() => push(`/codex/profiles/${encodeURIComponent(detail!.summary.profile.id)}/fork`)} onEdit={openEdit} - onSaveCurrent={openSaveCurrent} + onSaveCurrent={() => openSaveCurrent(detail!.summary.profile.id)} onSetConfig={openSetConfig} onDelete={() => openProfileDelete({ id: detail!.summary.profile.id, name: detail!.summary.profile.name || translate("profile.unnamed") })} /> @@ -808,17 +835,17 @@ {saveCurrentSourceError} {/if} - {#if (detail?.login?.reference_count ?? 0) > 1 || (detail?.config_set?.reference_count ?? 0) > 1} + {#if (saveCurrentSummary?.credential_reference_count ?? 0) > 1 || (saveCurrentSummary?.config_set_reference_count ?? 0) > 1} {$_("profilePages.saveCurrent.sharedTitle")} - {#if (detail?.login?.reference_count ?? 0) > 1 && (detail?.config_set?.reference_count ?? 0) > 1} - {$_("profilePages.saveCurrent.sharedBothDescription", { values: { loginCount: detail?.login?.reference_count ?? 0, configCount: detail?.config_set?.reference_count ?? 0 } })} - {:else if (detail?.login?.reference_count ?? 0) > 1} - {$_("profilePages.saveCurrent.sharedLoginDescription", { values: { count: detail?.login?.reference_count ?? 0 } })} + {#if (saveCurrentSummary?.credential_reference_count ?? 0) > 1 && (saveCurrentSummary?.config_set_reference_count ?? 0) > 1} + {$_("profilePages.saveCurrent.sharedBothDescription", { values: { loginCount: saveCurrentSummary?.credential_reference_count ?? 0, configCount: saveCurrentSummary?.config_set_reference_count ?? 0 } })} + {:else if (saveCurrentSummary?.credential_reference_count ?? 0) > 1} + {$_("profilePages.saveCurrent.sharedLoginDescription", { values: { count: saveCurrentSummary?.credential_reference_count ?? 0 } })} {:else} - {$_("profilePages.saveCurrent.sharedConfigDescription", { values: { count: detail?.config_set?.reference_count ?? 0 } })} + {$_("profilePages.saveCurrent.sharedConfigDescription", { values: { count: saveCurrentSummary?.config_set_reference_count ?? 0 } })} {/if} diff --git a/desktop/frontend/src/features/profiles/GrokBuildProfiles.svelte b/desktop/frontend/src/features/profiles/GrokBuildProfiles.svelte index 7f7b990..f522a06 100644 --- a/desktop/frontend/src/features/profiles/GrokBuildProfiles.svelte +++ b/desktop/frontend/src/features/profiles/GrokBuildProfiles.svelte @@ -154,6 +154,8 @@ let editName = $state(""); let editDescription = $state(""); let saveCurrentOpen = $state(false); + let saveCurrentProfileID = $state(""); + let saveCurrentSummary = $state(null); let saveCurrentSourceError = $state(""); let setConfigOpen = $state(false); let selectedConfigSetID = $state(""); @@ -465,6 +467,8 @@ } async function saveCurrent() { + const summary = saveCurrentSummary; + if (!summary) return; await runAction("profile-save-current", async () => { const source = await refreshDetect(); if (!isSaveCurrentSourceReady(source)) { @@ -472,7 +476,16 @@ return; } saveCurrentSourceError = ""; - const result = await track("profile-save-current", GrokBuildService.SaveActiveProfileState()); + let result; + try { + result = await track("profile-save-current", GrokBuildService.SaveActiveProfileState(saveCurrentProfileID, summary.credential_reference_count, summary.config_set_reference_count)); + } catch (error) { + if (isDesktopErrorCode(error, "PROFILE_CHANGED") || isDesktopErrorCode(error, "PROFILE_SHARING_CHANGED")) { + saveCurrentOpen = false; + await Promise.all([refreshProfiles(), detail ? loadDetail(detail.summary.profile.id) : Promise.resolve()]); + } + throw error; + } saveCurrentOpen = false; await Promise.all([ refreshProfiles(), @@ -488,11 +501,22 @@ }); } - function openSaveCurrent() { - saveCurrentSourceError = isSaveCurrentSourceReady(detectResult) - ? "" - : saveCurrentSourceDescription(detectResult); - saveCurrentOpen = true; + async function openSaveCurrent(profileID: string) { + await runAction("profile-save-prepare", async () => { + saveCurrentSummary = null; + const current = await track("profile-save-prepare", GrokBuildService.ShowProfile(profileID)); + if (!current.summary.active) { + await refreshProfiles(); + showError({ code: "PROFILE_CHANGED" }); + return; + } + saveCurrentProfileID = profileID; + saveCurrentSummary = current.summary; + saveCurrentSourceError = isSaveCurrentSourceReady(detectResult) + ? "" + : saveCurrentSourceDescription(detectResult); + saveCurrentOpen = true; + }); } async function retryCreateSource() { @@ -882,10 +906,10 @@ } push("/grok-build/profiles/new"); }} - aria-label={$_("grokBuild.actions.saveAsNewProfile")} + aria-label={$_("grokBuild.actions.newProfile")} > - {$_("grokBuild.actions.saveCurrentShort")} + {$_("grokBuild.actions.newProfile")} {/snippet} @@ -931,11 +955,13 @@ error={profileError} busy={!!busyAction || useBuilding || useApplying} canCreate={defaultCreateSourceReady} + saveCurrentLabel={$_("grokBuild.actions.updateFromCurrent")} emptyDescription={$_("grokBuild.profilePages.list.emptyDescription")} - createLabel={$_("grokBuild.actions.saveAsNewProfile")} + createLabel={$_("grokBuild.actions.newProfile")} onNew={() => push("/grok-build/profiles/new")} onDelete={openProfileDelete} onUse={openUse} + onSaveCurrent={(profile) => openSaveCurrent(profile.id)} onDetails={(profile) => push(`${basePath}/${encodeURIComponent(profile.id)}`)} onFork={(profile) => push(`${basePath}/${encodeURIComponent(profile.id)}/fork`)} onRetrySource={() => { void Promise.all([refreshDetect(), refreshProfiles()]); }} @@ -1026,7 +1052,7 @@ onUse={() => openUse(profileListItem(detail!.summary))} onFork={() => push(`${basePath}/${encodeURIComponent(detail!.summary.profile.id)}/fork`)} onEdit={openEdit} - onSaveCurrent={openSaveCurrent} + onSaveCurrent={() => openSaveCurrent(detail!.summary.profile.id)} onSetConfig={openSetConfig} onDelete={() => openProfileDelete({ id: detail!.summary.profile.id, @@ -1167,25 +1193,25 @@ {saveCurrentSourceError} {/if} - {#if (detail?.login?.reference_count ?? 0) > 1 || (detail?.config_set?.reference_count ?? 0) > 1} + {#if (saveCurrentSummary?.credential_reference_count ?? 0) > 1 || (saveCurrentSummary?.config_set_reference_count ?? 0) > 1} {$_("profilePages.saveCurrent.sharedTitle")} - {#if (detail?.login?.reference_count ?? 0) > 1 && (detail?.config_set?.reference_count ?? 0) > 1} + {#if (saveCurrentSummary?.credential_reference_count ?? 0) > 1 && (saveCurrentSummary?.config_set_reference_count ?? 0) > 1} {$_("profilePages.saveCurrent.sharedBothDescription", { values: { - loginCount: detail?.login?.reference_count ?? 0, - configCount: detail?.config_set?.reference_count ?? 0, + loginCount: saveCurrentSummary?.credential_reference_count ?? 0, + configCount: saveCurrentSummary?.config_set_reference_count ?? 0, }, })} - {:else if (detail?.login?.reference_count ?? 0) > 1} + {:else if (saveCurrentSummary?.credential_reference_count ?? 0) > 1} {$_("profilePages.saveCurrent.sharedLoginDescription", { - values: { count: detail?.login?.reference_count ?? 0 }, + values: { count: saveCurrentSummary?.credential_reference_count ?? 0 }, })} {:else} {$_("profilePages.saveCurrent.sharedConfigDescription", { - values: { count: detail?.config_set?.reference_count ?? 0 }, + values: { count: saveCurrentSummary?.config_set_reference_count ?? 0 }, })} {/if} diff --git a/desktop/frontend/src/features/profiles/ProfileList.svelte b/desktop/frontend/src/features/profiles/ProfileList.svelte index e427e75..e877451 100644 --- a/desktop/frontend/src/features/profiles/ProfileList.svelte +++ b/desktop/frontend/src/features/profiles/ProfileList.svelte @@ -7,6 +7,7 @@ import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal"; import PlusIcon from "@lucide/svelte/icons/plus"; import RefreshCwIcon from "@lucide/svelte/icons/refresh-cw"; + import SaveIcon from "@lucide/svelte/icons/save"; import Trash2Icon from "@lucide/svelte/icons/trash-2"; import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert"; @@ -36,10 +37,12 @@ canCreate = false, emptyDescription = "", createLabel = "", + saveCurrentLabel = "", onNew, onUse, onDetails, onFork, + onSaveCurrent, onDelete, onRefreshQuota, onRetrySource, @@ -53,10 +56,12 @@ canCreate?: boolean; emptyDescription?: string; createLabel?: string; + saveCurrentLabel?: string; onNew?: () => void; onUse: (profile: ManagedProfileListItem) => void; onDetails: (profile: ManagedProfileListItem) => void; onFork: (profile: ManagedProfileListItem) => void; + onSaveCurrent?: (profile: ManagedProfileListItem) => void; onDelete: (profile: ManagedProfileListItem) => void; onRefreshQuota?: (profile: ManagedProfileListItem) => void; onRetrySource?: () => void; @@ -160,7 +165,8 @@ onDetails(profile)}>{$_("actions.details")} - onFork(profile)}>{$_("actions.fork")} + onFork(profile)}>{$_("actions.fork")} + {#if profile.summary.active && onSaveCurrent} onSaveCurrent(profile)}>{saveCurrentLabel}{/if} onDelete(profile)}>{$_("actions.deleteProfile")} diff --git a/desktop/frontend/src/lib/desktop-errors.ts b/desktop/frontend/src/lib/desktop-errors.ts index efce967..0a8e865 100644 --- a/desktop/frontend/src/lib/desktop-errors.ts +++ b/desktop/frontend/src/lib/desktop-errors.ts @@ -31,6 +31,8 @@ const userErrorKeys: Record = { PROFILE_IN_USE: "errors.profileInUse", PROFILE_INVALID: "errors.profileInvalid", PROFILE_NOT_FOUND: "errors.profileNotFound", + PROFILE_CHANGED: "errors.profileChanged", + PROFILE_SHARING_CHANGED: "errors.profileSharingChanged", PLAN_BUILD_FAILED: "errors.switchPreviewFailed", ADAPTER_NOT_FOUND: "errors.providerInvalid", TARGET_INVALID: "errors.switchUnsupported", diff --git a/desktop/frontend/src/lib/i18n.ts b/desktop/frontend/src/lib/i18n.ts index e82cb94..071ab04 100644 --- a/desktop/frontend/src/lib/i18n.ts +++ b/desktop/frontend/src/lib/i18n.ts @@ -31,8 +31,8 @@ const messages = { refreshQuota: "Refresh limits", refreshProfileQuota: "Refresh limits for {profile}", saveAsNewProfile: "Save Current Codex as a New Profile", - saveCurrentShort: "Save Current", - updateFromCurrent: "Update from Current Codex", + newProfile: "New Profile", + updateFromCurrent: "Save Current Login and Settings", fork: "Fork", details: "Details", cancel: "Cancel", @@ -218,8 +218,8 @@ const messages = { grokBuild: { actions: { saveAsNewProfile: "Save Current Grok Build as a New Profile", - saveCurrentShort: "Save Current", - updateFromCurrent: "Update from Current Grok Build", + newProfile: "New Profile", + updateFromCurrent: "Save Current Login and Settings", }, profilePages: { new: { @@ -254,9 +254,9 @@ const messages = { changeConfigDescription: "Choose which saved Grok Build settings this inactive Profile will use.", }, saveCurrent: { - title: "Update from Current Grok Build?", - description: "Update this Profile with the active login and settings in Grok Build.", - configMissing: "Grok Build config.toml is missing. Restore or recreate it before updating this Profile. ProfileDeck did not change the saved login or settings.", + title: "Save Current Grok Build Login and Settings?", + description: "Save the login and settings currently used by Grok Build to the active Profile.", + configMissing: "Grok Build config.toml is missing. Restore or recreate it before saving. ProfileDeck did not change the saved login or settings.", }, }, quota: { @@ -354,9 +354,9 @@ const messages = { }, antigravity: { actions: { - newProfile: "Save Current Login", + newProfile: "New Profile", saveProfile: "Save Profile", - updateCurrent: "Update from Current Antigravity", + updateCurrent: "Save Current Login", }, list: { title: "Antigravity Profiles", @@ -415,10 +415,10 @@ const messages = { description: "Change this Profile's name or description. Its saved Antigravity login will not change.", }, saveCurrent: { - title: "Update from Current Antigravity?", - description: "Update this Profile with the active login in Antigravity.", - sharedTitle: "Update Shared Login?", - sharedDescription: "This login is shared with {count} other Profiles. Updating it will change it for all of them.", + title: "Save Current Antigravity Login?", + description: "Save the login currently used by Antigravity to the active Profile.", + sharedTitle: "Save Shared Login?", + sharedDescription: "This login is used by {count} Profiles. Saving it will change it for all of them.", }, use: { description: "Review the Antigravity login change. ProfileDeck creates a private recovery point before continuing.", @@ -447,7 +447,7 @@ const messages = { }, }, claudeCode: { - actions: { newProfile: "Save Current Login", saveProfile: "Save Profile", updateCurrent: "Save Current Claude Code Login" }, + actions: { newProfile: "New Profile", saveProfile: "Save Profile", updateCurrent: "Save Current Login" }, list: { title: "Claude Code Profiles", description: "Save and switch Claude Code logins. Claude Desktop is not affected.", @@ -476,8 +476,8 @@ const messages = { }, edit: { description: "Change this Profile's name or description. Its saved Claude Code login will not change." }, saveCurrent: { - title: "Save Current Claude Code Login?", description: "Update the active Profile with the login currently used by Claude Code.", - sharedTitle: "Update Shared Claude Code Login?", sharedDescription: "This saved login is used by {count} Profiles. Updating it changes all of them.", + title: "Save Current Claude Code Login?", description: "Save the login currently used by Claude Code to the active Profile.", + sharedTitle: "Save Shared Claude Code Login?", sharedDescription: "This saved login is used by {count} Profiles. Saving it changes all of them.", }, use: { description: "Review the Claude Code login change. ProfileDeck creates a private recovery point before continuing.", @@ -553,12 +553,12 @@ const messages = { newDescription: "Save a separate copy so this Profile's Codex settings can change independently.", }, saveCurrent: { - title: "Update from Current Codex?", - description: "Update this Profile with the active login and settings in Codex.", - sharedTitle: "Update Shared Settings or Login?", - sharedLoginDescription: "This login is shared with {count} other Profiles. Updating it will change it for all of them.", - sharedConfigDescription: "These settings are shared with {count} other Profiles. Updating them will change them for all of them.", - sharedBothDescription: "The login (shared by {loginCount} Profiles) and settings (shared by {configCount} Profiles) are shared. Updating them will change them for all associated Profiles.", + title: "Save Current Codex Login and Settings?", + description: "Save the login and settings currently used by Codex to the active Profile.", + sharedTitle: "Save Shared Login or Settings?", + sharedLoginDescription: "This login is used by {count} Profiles. Saving it will change it for all of them.", + sharedConfigDescription: "These settings are used by {count} Profiles. Saving them will change them for all of them.", + sharedBothDescription: "The login is used by {loginCount} Profiles and the settings by {configCount} Profiles. Saving them will change them for all associated Profiles.", }, form: { profile: "Profile", @@ -605,7 +605,7 @@ const messages = { notLoadedTitle: "Usage limits not checked", notLoadedDescription: "Refresh to check the current limits for this Profile.", authRequired: "Login refresh required", - authRequiredDescription: "Use this Profile in Codex and sign in again, then choose Update from Current Codex and retry.", + authRequiredDescription: "Use this Profile in Codex and sign in again, then save the current login and settings and retry.", unsupported: "Unsupported login", unsupportedDescription: "Limits are available for ChatGPT sign-ins and compatible API Key services.", unavailable: "Limits unavailable", @@ -1133,6 +1133,8 @@ const messages = { profileInUse: "This Profile is still in use and cannot be removed.", profileInvalid: "Check the Profile details and try again.", profileNotFound: "The selected Profile no longer exists.", + profileChanged: "The current Profile changed, so nothing was saved. Check the Profile list and try again.", + profileSharingChanged: "Sharing changed, so nothing was saved. Reopen the save confirmation to review the updated warning.", switchPreviewFailed: "ProfileDeck could not prepare this switch. Check the Profile and the tool's login or settings, then try again.", switchUnsupported: "This Profile includes a change that ProfileDeck cannot apply.", fileAlreadyManaged: "This file is already managed by another ProfileDeck item.", @@ -1193,8 +1195,8 @@ const messages = { refreshQuota: "刷新限额", refreshProfileQuota: "刷新 {profile} 的限额", saveAsNewProfile: "将当前 Codex 配置保存为新 Profile", - saveCurrentShort: "保存当前", - updateFromCurrent: "从当前 Codex 更新", + newProfile: "新建 Profile", + updateFromCurrent: "保存当前登录与设置", fork: "Fork", details: "详情", cancel: "取消", @@ -1380,8 +1382,8 @@ const messages = { grokBuild: { actions: { saveAsNewProfile: "将当前 Grok Build 保存为新 Profile", - saveCurrentShort: "保存当前状态", - updateFromCurrent: "从当前 Grok Build 更新", + newProfile: "新建 Profile", + updateFromCurrent: "保存当前登录与设置", }, profilePages: { new: { @@ -1416,9 +1418,9 @@ const messages = { changeConfigDescription: "选择此非活动 Profile 要使用的 Grok Build 设置。", }, saveCurrent: { - title: "从当前 Grok Build 更新?", - description: "使用 Grok Build 当前的登录和设置更新此 Profile。", - configMissing: "找不到 Grok Build config.toml。请先恢复或重新创建该文件,再更新此 Profile。ProfileDeck 未更改已保存的登录或设置。", + title: "保存当前 Grok Build 登录与设置?", + description: "将 Grok Build 当前使用的登录与设置保存到活动 Profile。", + configMissing: "找不到 Grok Build config.toml。请先恢复或重新创建该文件,再保存当前状态。ProfileDeck 未更改已保存的登录或设置。", }, }, quota: { @@ -1516,9 +1518,9 @@ const messages = { }, antigravity: { actions: { - newProfile: "保存当前登录", + newProfile: "新建 Profile", saveProfile: "保存 Profile", - updateCurrent: "从当前 Antigravity 更新", + updateCurrent: "保存当前登录", }, list: { title: "Antigravity Profiles", @@ -1577,10 +1579,10 @@ const messages = { description: "更改此 Profile 的名称或描述,不会改变已保存的 Antigravity 登录。", }, saveCurrent: { - title: "从当前 Antigravity 更新?", - description: "使用 Antigravity 当前的登录账号更新此 Profile。", - sharedTitle: "更新共享的登录?", - sharedDescription: "此 Profile 的登录账号与其他 {count} 个 Profile 共享。更新将同步应用到所有这些 Profile。", + title: "保存当前 Antigravity 登录?", + description: "将 Antigravity 当前使用的登录保存到活动 Profile。", + sharedTitle: "保存共享的登录?", + sharedDescription: "此登录由 {count} 个 Profile 共用,保存后会影响所有这些 Profile。", }, use: { description: "请审核 Antigravity 登录更改。ProfileDeck 会先创建私有恢复点,再继续操作。", @@ -1609,7 +1611,7 @@ const messages = { }, }, claudeCode: { - actions: { newProfile: "保存当前登录", saveProfile: "保存 Profile", updateCurrent: "保存当前 Claude Code 登录" }, + actions: { newProfile: "新建 Profile", saveProfile: "保存 Profile", updateCurrent: "保存当前登录" }, list: { title: "Claude Code Profiles", description: "保存并切换 Claude Code 登录,不会影响 Claude Desktop。", @@ -1638,8 +1640,8 @@ const messages = { }, edit: { description: "更改此 Profile 的名称或描述,不会改变已保存的 Claude Code 登录。" }, saveCurrent: { - title: "保存当前 Claude Code 登录?", description: "使用 Claude Code 当前登录更新活动 Profile。", - sharedTitle: "更新共享的 Claude Code 登录?", sharedDescription: "此登录由 {count} 个 Profile 共用,更新会同时影响它们。", + title: "保存当前 Claude Code 登录?", description: "将 Claude Code 当前使用的登录保存到活动 Profile。", + sharedTitle: "保存共享的 Claude Code 登录?", sharedDescription: "此登录由 {count} 个 Profile 共用,保存后会影响所有这些 Profile。", }, use: { description: "请审核 Claude Code 登录更改。ProfileDeck 会先创建私有恢复点,再继续操作。", @@ -1715,12 +1717,12 @@ const messages = { newDescription: "保存独立副本,让此 Profile 的 Codex 设置可以单独修改。", }, saveCurrent: { - title: "从当前 Codex 更新?", - description: "使用 Codex 当前的登录账号与设置更新此 Profile。", - sharedTitle: "确认更新共享的资源?", - sharedLoginDescription: "此 Profile 的登录账号与其他 {count} 个 Profile 共享。更新将同步应用到所有这些 Profile。", - sharedConfigDescription: "此 Profile 的设置与其他 {count} 个 Profile 共享。更新将同步应用到所有这些 Profile。", - sharedBothDescription: "此 Profile 的登录账号({loginCount} 个引用)和设置({configCount} 个引用)与其他 Profile 共享,更新将同步应用到所有关联的 Profile。", + title: "保存当前 Codex 登录与设置?", + description: "将 Codex 当前使用的登录与设置保存到活动 Profile。", + sharedTitle: "保存共享的登录或设置?", + sharedLoginDescription: "此登录由 {count} 个 Profile 共用,保存后会影响所有这些 Profile。", + sharedConfigDescription: "此设置由 {count} 个 Profile 共用,保存后会影响所有这些 Profile。", + sharedBothDescription: "此登录由 {loginCount} 个 Profile 共用,设置由 {configCount} 个 Profile 共用;保存后会影响所有相关 Profile。", }, form: { profile: "Profile", @@ -1767,7 +1769,7 @@ const messages = { notLoadedTitle: "尚未检查使用限额", notLoadedDescription: "点击刷新,检查此 Profile 当前的使用限额。", authRequired: "需要刷新登录状态", - authRequiredDescription: "请在 Codex 中使用此 Profile 并重新登录,然后选择“从当前 Codex 更新”并重试。", + authRequiredDescription: "请在 Codex 中使用此 Profile 并重新登录,然后保存当前登录与设置,再重试。", unsupported: "此登录方式不支持", unsupportedDescription: "通过 ChatGPT 登录或使用兼容 API Key 服务的 Profile 可以读取限额。", unavailable: "暂时无法读取限额", @@ -2295,6 +2297,8 @@ const messages = { profileInUse: "此 Profile 仍在使用,无法删除。", profileInvalid: "请检查 Profile 详情后重试。", profileNotFound: "所选 Profile 已不存在。", + profileChanged: "当前 Profile 已变更,未保存任何内容。请查看 Profile 列表后重试。", + profileSharingChanged: "共享情况已变化,未保存任何内容。请重新打开保存确认提示后重试。", switchPreviewFailed: "ProfileDeck 无法准备此次切换。请检查 Profile 以及工具登录或设置后重试。", switchUnsupported: "此 Profile 包含 ProfileDeck 无法应用的更改。", fileAlreadyManaged: "此文件已由另一个 ProfileDeck 项目管理。", diff --git a/desktop/frontend/tests/grok-build-profile-components.test.ts b/desktop/frontend/tests/grok-build-profile-components.test.ts index 76a7332..dc7d27d 100644 --- a/desktop/frontend/tests/grok-build-profile-components.test.ts +++ b/desktop/frontend/tests/grok-build-profile-components.test.ts @@ -1,4 +1,5 @@ import { render, screen, within } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "svelte"; import { tick } from "svelte"; import { locale } from "svelte-i18n"; @@ -54,6 +55,36 @@ function renderList(overrides: Partial> = {}) } describe("Grok Build managed Profile components", () => { + it("saves from the current Profile menu", async () => { + const user = userEvent.setup(); + const onSaveCurrent = vi.fn(); + const current = { + ...profile, + id: "current", + name: "Current", + summary: { ...profile.summary, profile: { ...profile.summary.profile, id: "current", name: "Current" }, active: true }, + }; + renderList({ profiles: [current, profile], onSaveCurrent, saveCurrentLabel: "Save Current Login and Settings" }); + const menus = screen.getAllByRole("button", { name: "More actions" }); + await user.click(menus[0]); + await user.click(await screen.findByText("Save Current Login and Settings")); + expect(onSaveCurrent).toHaveBeenCalledWith(current); + }); + + it("hides save in an inactive Profile menu", async () => { + const user = userEvent.setup(); + renderList({ profiles: [profile], onSaveCurrent: vi.fn(), saveCurrentLabel: "Save Current Login and Settings" }); + await user.click(screen.getByRole("button", { name: "More actions" })); + expect(screen.queryByRole("menuitem", { name: "Save Current Login and Settings" })).not.toBeInTheDocument(); + }); + + it("disables current Profile save while busy", async () => { + const user = userEvent.setup(); + renderList({ profiles: [{ ...profile, summary: { ...profile.summary, active: true } }], busy: true, onSaveCurrent: vi.fn(), saveCurrentLabel: "Save Current Login and Settings" }); + await user.click(screen.getByRole("button", { name: "More actions" })); + expect(await screen.findByText("Save Current Login and Settings")).toHaveAttribute("data-disabled"); + }); + it("covers loading, empty, error, warning, and populated list states", () => { let view = render(ProfileList, { profiles: [], diff --git a/desktop/frontend/tests/managed-profile-mutation-guards.test.ts b/desktop/frontend/tests/managed-profile-mutation-guards.test.ts index 0d22a9e..bc37709 100644 --- a/desktop/frontend/tests/managed-profile-mutation-guards.test.ts +++ b/desktop/frontend/tests/managed-profile-mutation-guards.test.ts @@ -20,7 +20,10 @@ const backend = vi.hoisted(() => ({ createGrokBuildProfile: vi.fn(), forkCodexProfile: vi.fn(), forkGrokBuildProfile: vi.fn(), + saveCodexProfile: vi.fn(), saveGrokBuildProfile: vi.fn(), + listCodexConfigSets: vi.fn(), + listGrokBuildConfigSets: vi.fn(), showCodexProfile: vi.fn(), showGrokBuildProfile: vi.fn(), listProfiles: vi.fn(), @@ -32,11 +35,14 @@ vi.mock("../bindings/github.com/strahe/profiledeck/desktop/backend", () => ({ CreateProfile: backend.createCodexProfile, ForkProfile: backend.forkCodexProfile, ShowProfile: backend.showCodexProfile, + SaveActiveProfileState: backend.saveCodexProfile, + ListConfigSets: backend.listCodexConfigSets, }, GrokBuildService: { CreateProfile: backend.createGrokBuildProfile, ForkProfile: backend.forkGrokBuildProfile, SaveActiveProfileState: backend.saveGrokBuildProfile, + ListConfigSets: backend.listGrokBuildConfigSets, ShowProfile: backend.showGrokBuildProfile, }, ProfileService: { @@ -171,6 +177,12 @@ function cancellableResolved(value: T): Promise & { cancel: () => void } { return promise; } +function cancellableRejected(error: unknown): Promise & { cancel: () => void } { + const promise = Promise.reject(error) as Promise & { cancel: () => void }; + promise.cancel = vi.fn(); + return promise; +} + function grokBuildQuotaProps() { return { quotaForSummary: (_summary: GrokBuildProfileSummary) => null, @@ -188,11 +200,162 @@ function grokBuildQuotaProps() { beforeEach(async () => { vi.clearAllMocks(); + backend.listCodexConfigSets.mockImplementation(() => cancellableResolved({ config_sets: [] })); + backend.listGrokBuildConfigSets.mockImplementation(() => cancellableResolved({ config_sets: [] })); locale.set("en"); await tick(); }); describe("managed Profile mutation guards", () => { + it("saves the active Codex Profile from its list and shows shared settings", async () => { + const user = userEvent.setup(); + backend.showCodexProfile.mockReturnValue(cancellableResolved(codexDetail)); + backend.saveCodexProfile.mockImplementation(() => cancellableResolved({ warnings: [] })); + render(CodexProfiles, { + route: { kind: "list", profileID: "" }, + profiles: [{ ...codexDetail.summary, config_set_reference_count: 1 }], + dashboardConfigSets: [], + detectResult: validCodexDetect, + detectError: "", + activeProfileID: "work", + loadingProfiles: false, + profileError: "", + useRequest: null, + refreshDetect: vi.fn().mockResolvedValue(validCodexDetect), + refreshProfiles: vi.fn().mockResolvedValue(undefined), + cancelDetect: vi.fn(), + onUseRequestHandled: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + }, { wrapper: ProfileTestProviders }); + await user.click(screen.getAllByRole("button", { name: "More actions" }).at(-1)!); + await user.click(await screen.findByText("Save Current Login and Settings")); + const dialog = await screen.findByRole("alertdialog"); + expect(within(dialog).getByText("These settings are used by 2 Profiles. Saving them will change them for all of them.")).toBeInTheDocument(); + expect(backend.showCodexProfile).toHaveBeenCalledWith("work"); + await user.click(within(dialog).getByRole("button", { name: "Save Current Login and Settings" })); + await waitFor(() => expect(backend.saveCodexProfile).toHaveBeenCalledWith("work", 1, 2)); + }); + + it("shows Grok Build shared settings from the active list row", async () => { + const user = userEvent.setup(); + backend.showGrokBuildProfile.mockReturnValue(cancellableResolved(grokBuildDetail)); + render(GrokBuildProfiles, { + ...grokBuildQuotaProps(), + route: { kind: "list", profileID: "" }, + profiles: [{ ...grokBuildDetail.summary, config_set_reference_count: 1 }], + dashboardConfigSets: [], + detectResult: validGrokBuildDetect, + detectError: "", + activeProfileID: "work", + loadingProfiles: false, + profileError: "", + useRequest: null, + refreshDetect: vi.fn().mockResolvedValue(validGrokBuildDetect), + refreshProfiles: vi.fn().mockResolvedValue(undefined), + cancelDetect: vi.fn(), + onUseRequestHandled: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + }, { wrapper: ProfileTestProviders }); + await user.click(screen.getAllByRole("button", { name: "More actions" }).at(-1)!); + await user.click(await screen.findByText("Save Current Login and Settings")); + expect(within(await screen.findByRole("alertdialog")).getByText("These settings are used by 2 Profiles. Saving them will change them for all of them.")).toBeInTheDocument(); + expect(backend.showGrokBuildProfile).toHaveBeenCalledWith("work"); + }); + + it("refreshes sharing before confirming from a Codex detail page", async () => { + const user = userEvent.setup(); + const staleDetail = { ...codexDetail, summary: { ...codexDetail.summary, config_set_reference_count: 1 } }; + backend.showCodexProfile + .mockReturnValueOnce(cancellableResolved(staleDetail)) + .mockReturnValueOnce(cancellableResolved(codexDetail)); + render(CodexProfiles, { + route: { kind: "detail", profileID: "work" }, + profiles: [staleDetail.summary], + dashboardConfigSets: [], + detectResult: validCodexDetect, + detectError: "", + activeProfileID: "work", + loadingProfiles: false, + profileError: "", + useRequest: null, + refreshDetect: vi.fn().mockResolvedValue(validCodexDetect), + refreshProfiles: vi.fn().mockResolvedValue(undefined), + cancelDetect: vi.fn(), + onUseRequestHandled: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + }, { wrapper: ProfileTestProviders }); + await screen.findByRole("heading", { name: "Work" }); + await user.click(screen.getByRole("button", { name: "More actions" })); + await user.click(await screen.findByText("Save Current Login and Settings")); + expect(within(await screen.findByRole("alertdialog")).getByText("These settings are used by 2 Profiles. Saving them will change them for all of them.")).toBeInTheDocument(); + expect(backend.showCodexProfile).toHaveBeenCalledTimes(2); + }); + + it("does not open the save confirmation when current sharing cannot be read", async () => { + const user = userEvent.setup(); + const showError = vi.fn(); + backend.showCodexProfile.mockImplementation(() => cancellableRejected({ code: "STORE_STATUS_FAILED" })); + render(CodexProfiles, { + route: { kind: "list", profileID: "" }, + profiles: [codexDetail.summary], + dashboardConfigSets: [], + detectResult: validCodexDetect, + detectError: "", + activeProfileID: "work", + loadingProfiles: false, + profileError: "", + useRequest: null, + refreshDetect: vi.fn().mockResolvedValue(validCodexDetect), + refreshProfiles: vi.fn().mockResolvedValue(undefined), + cancelDetect: vi.fn(), + onUseRequestHandled: vi.fn(), + showError, + showNotice: vi.fn(), + }, { wrapper: ProfileTestProviders }); + await user.click(screen.getAllByRole("button", { name: "More actions" }).at(-1)!); + await user.click(await screen.findByText("Save Current Login and Settings")); + await waitFor(() => expect(showError).toHaveBeenCalledWith({ code: "STORE_STATUS_FAILED" })); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(backend.saveCodexProfile).not.toHaveBeenCalled(); + }); + + it("closes the save confirmation when sharing changes before the commit", async () => { + const user = userEvent.setup(); + const showError = vi.fn(); + const refreshProfiles = vi.fn().mockResolvedValue(undefined); + backend.showGrokBuildProfile.mockImplementation(() => cancellableResolved(grokBuildDetail)); + backend.saveGrokBuildProfile.mockImplementation(() => cancellableRejected({ code: "PROFILE_SHARING_CHANGED" })); + render(GrokBuildProfiles, { + ...grokBuildQuotaProps(), + route: { kind: "list", profileID: "" }, + profiles: [grokBuildDetail.summary], + dashboardConfigSets: [], + detectResult: validGrokBuildDetect, + detectError: "", + activeProfileID: "work", + loadingProfiles: false, + profileError: "", + useRequest: null, + refreshDetect: vi.fn().mockResolvedValue(validGrokBuildDetect), + refreshProfiles, + cancelDetect: vi.fn(), + onUseRequestHandled: vi.fn(), + showError, + showNotice: vi.fn(), + }, { wrapper: ProfileTestProviders }); + await user.click(screen.getAllByRole("button", { name: "More actions" }).at(-1)!); + await user.click(await screen.findByText("Save Current Login and Settings")); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: "Save Current Login and Settings" })); + await waitFor(() => expect(showError).toHaveBeenCalledWith({ code: "PROFILE_SHARING_CHANGED" })); + expect(refreshProfiles).toHaveBeenCalled(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(backend.saveGrokBuildProfile).toHaveBeenCalledWith("work", 1, 2); + }); + it("shows Grok Build create recheck failures and does not create a Profile", async () => { const user = userEvent.setup(); const invalid = { ...validGrokBuildDetect, auth_status: "invalid" }; @@ -484,12 +647,12 @@ describe("managed Profile mutation guards", () => { it("blocks Grok Build save-current when config.toml is missing", async () => { const user = userEvent.setup(); const missingConfig = { ...validGrokBuildDetect, config_status: "missing" }; - backend.showGrokBuildProfile.mockReturnValue(cancellableResolved(grokBuildDetail)); const refreshDetect = vi.fn().mockResolvedValue(missingConfig); + backend.showGrokBuildProfile.mockReturnValue(cancellableResolved(grokBuildDetail)); render(GrokBuildProfiles, { ...grokBuildQuotaProps(), - route: { kind: "detail", profileID: "work" }, + route: { kind: "list", profileID: "" }, profiles: [grokBuildDetail.summary], dashboardConfigSets: [grokBuildDetail.config_set!], detectResult: missingConfig, @@ -506,16 +669,16 @@ describe("managed Profile mutation guards", () => { showNotice: vi.fn(), }, { wrapper: ProfileTestProviders }); - await screen.findByRole("heading", { name: "Work" }); - await user.click(screen.getByRole("button", { name: "More actions" })); - await user.click(await screen.findByText("Update from Current Grok Build")); + await user.click(screen.getAllByRole("button", { name: "More actions" }).at(-1)!); + await user.click(await screen.findByText("Save Current Login and Settings")); const dialog = await screen.findByRole("alertdialog"); - const message = "Grok Build config.toml is missing. Restore or recreate it before updating this Profile. ProfileDeck did not change the saved login or settings."; + const message = "Grok Build config.toml is missing. Restore or recreate it before saving. ProfileDeck did not change the saved login or settings."; expect(within(dialog).getByText(message)).toBeInTheDocument(); - await user.click(within(dialog).getByRole("button", { name: "Update from Current Grok Build" })); - await waitFor(() => expect(refreshDetect).toHaveBeenCalledOnce()); + const detectCallsBeforeSave = refreshDetect.mock.calls.length; + await user.click(within(dialog).getByRole("button", { name: "Save Current Login and Settings" })); + await waitFor(() => expect(refreshDetect).toHaveBeenCalledTimes(detectCallsBeforeSave + 1)); expect(within(dialog).getByText(message)).toBeInTheDocument(); expect(backend.saveGrokBuildProfile).not.toHaveBeenCalled(); }); diff --git a/docs/antigravity/profiles.md b/docs/antigravity/profiles.md index bd136db..8d02c62 100644 --- a/docs/antigravity/profiles.md +++ b/docs/antigravity/profiles.md @@ -12,7 +12,7 @@ Legacy Antigravity storage is not supported. ## Save a Profile in the Desktop app 1. Open **Antigravity** in the ProfileDeck sidebar. -2. Select **Save Current Login**. +2. Select **New Profile**. 3. Enter a permanent Profile ID and a display name, then select **Save Profile**. The new Profile becomes the current Antigravity Profile. ProfileDeck never displays its access or refresh tokens. @@ -59,7 +59,7 @@ Antigravity may refresh its login while it runs. The short-lived access-token ex profiledeck-cli antigravity profile save-current ``` -In the Desktop app, open the current Profile and select **Update from Current Antigravity**. +In the Desktop app, open the current Profile's **…** menu and select **Save Current Login**. ## Delete a Profile diff --git a/docs/claude-code/profiles.md b/docs/claude-code/profiles.md index 6d1bd91..8c9ba43 100644 --- a/docs/claude-code/profiles.md +++ b/docs/claude-code/profiles.md @@ -13,7 +13,7 @@ ProfileDeck only saves the account login from `/login`. It does not save API key 1. Select **Claude Code → Profiles**. 2. If macOS permission is required, choose **Authorize** and allow ProfileDeck to read the Claude Code login from Keychain. -3. Choose **Save Current Login**, then enter a permanent Profile ID and a display name. +3. Choose **New Profile**, then enter a permanent Profile ID and a display name. 4. Run `/login` in Claude Code for another account, return to ProfileDeck, and save another Profile. The first saved Profile becomes current. Saving another Profile does not change Claude Code settings. @@ -53,7 +53,7 @@ If Claude Code refreshed the current login, ProfileDeck saves a valid update bef ## Save a refreshed login -Use **Save Current Claude Code Login** on the current Profile in Desktop, or run: +Open the current Profile's **…** menu and choose **Save Current Login** in Desktop, or run: ```bash profiledeck-cli claude-code profile save-current diff --git a/docs/codex/profiles.md b/docs/codex/profiles.md index b58e03d..2c0b320 100644 --- a/docs/codex/profiles.md +++ b/docs/codex/profiles.md @@ -36,7 +36,7 @@ ProfileDeck also requires a valid `config.toml`. CLI commands resolve the Codex ## Save a Profile in Desktop 1. Select **Codex → Profiles**. -2. Choose **Save Current**. +2. Choose **New Profile**. 3. Enter a permanent Profile ID and a display name. 4. For the first Profile, save the current Codex settings in the default `shared` Config Set. @@ -107,7 +107,7 @@ profiledeck-cli codex profile fork work client-config \ Codex continues to use normal `auth.json` and `config.toml` files. Before switching away, ProfileDeck preserves valid changes made to the current login or settings. -Use **Update from Current Codex** in Desktop, or run the following command, before signing in to a different account or replacing the current files when you want to save explicitly: +To save before signing in to a different account or replacing these files, open the current Profile's **…** menu and choose **Save Current Login and Settings**, or run: ```bash profiledeck-cli codex profile save-current diff --git a/docs/grok-build/profiles.md b/docs/grok-build/profiles.md index a8754ba..0de9a4a 100644 --- a/docs/grok-build/profiles.md +++ b/docs/grok-build/profiles.md @@ -23,7 +23,7 @@ End active Grok sessions before an operation that changes files, then start a ne ## Save a Profile in Desktop 1. Select **Grok Build → Profiles**. -2. Choose **Save Current**. +2. Choose **New Profile**. 3. Enter a permanent Profile ID and a display name. 4. For the first Profile, use the default `shared` Config Set. ProfileDeck creates it from the current settings only if it does not already exist. @@ -99,7 +99,7 @@ profiledeck-cli grok-build profile fork work client-config \ ## Save changes and switch -Grok Build continues to use normal `auth.json` and `config.toml` files. Before switching away, ProfileDeck preserves valid changes made to the current login or settings. Use **Update from Current Grok Build** in Desktop, or run: +Grok Build continues to use normal `auth.json` and `config.toml` files. Before switching away, ProfileDeck preserves valid changes made to the current login or settings. Open the current Profile's **…** menu and choose **Save Current Login and Settings** in Desktop, or run: ```bash profiledeck-cli grok-build profile save-current diff --git a/docs/zh/antigravity/profiles.md b/docs/zh/antigravity/profiles.md index bd608b9..2df9dd6 100644 --- a/docs/zh/antigravity/profiles.md +++ b/docs/zh/antigravity/profiles.md @@ -12,7 +12,7 @@ ProfileDeck 不支持旧版 Antigravity 存储方式。 ## 在桌面端保存 Profile 1. 在 ProfileDeck 侧边栏中打开 **Antigravity**。 -2. 选择**保存当前登录**。 +2. 选择**新建 Profile**。 3. 输入创建后不会改变的 Profile ID 和用于显示的名称,然后选择**保存 Profile**。 新 Profile 会成为 Antigravity 的当前 Profile。ProfileDeck 不会显示其中的访问令牌或刷新令牌。 @@ -59,7 +59,7 @@ Antigravity 运行时可能刷新登录。短期访问令牌的到期时间不 profiledeck-cli antigravity profile save-current ``` -在桌面端打开当前 Profile,然后选择**从当前 Antigravity 更新**。 +在桌面端打开当前 Profile 的 **…** 菜单,然后选择**保存当前登录**。 ## 删除 Profile diff --git a/docs/zh/claude-code/profiles.md b/docs/zh/claude-code/profiles.md index d9647c9..d105663 100644 --- a/docs/zh/claude-code/profiles.md +++ b/docs/zh/claude-code/profiles.md @@ -13,7 +13,7 @@ ProfileDeck 只保存 `/login` 得到的账号登录,不保存 API Key 或 Con 1. 选择 **Claude Code → Profiles**。 2. 如果 macOS 要求权限,请选择**授权读取**,允许 ProfileDeck 从 Keychain 读取 Claude Code 登录。 -3. 选择**保存当前登录**,然后输入创建后不会改变的 Profile ID 和用于显示的名称。 +3. 选择**新建 Profile**,然后输入创建后不会改变的 Profile ID 和用于显示的名称。 4. 在 Claude Code 中为另一个账号运行 `/login`,返回 ProfileDeck,再保存一个 Profile。 第一个保存的 Profile 会成为当前 Profile。保存其他 Profile 不会修改 Claude Code 设置。 @@ -53,7 +53,7 @@ profiledeck-cli switch claude-code personal --yes ## 保存刷新后的登录 -在桌面端当前 Profile 中使用**保存当前 Claude Code 登录**,或运行: +在桌面端打开当前 Profile 的 **…** 菜单,选择**保存当前登录**,或运行: ```bash profiledeck-cli claude-code profile save-current diff --git a/docs/zh/codex/profiles.md b/docs/zh/codex/profiles.md index d2fe136..1a41cf6 100644 --- a/docs/zh/codex/profiles.md +++ b/docs/zh/codex/profiles.md @@ -36,7 +36,7 @@ ProfileDeck 还需要有效的 `config.toml`。CLI 命令按以下顺序查找 C ## 在桌面端保存 Profile 1. 选择 **Codex → Profiles**。 -2. 选择**保存当前**。 +2. 选择**新建 Profile**。 3. 输入创建后不会改变的 Profile ID,以及用于显示的名称。 4. 保存第一个 Profile 时,把当前 Codex 设置保存到默认的 `shared` 配置集。 @@ -107,7 +107,7 @@ profiledeck-cli codex profile fork work client-config \ Codex 继续使用普通的 `auth.json` 和 `config.toml` 文件。离开当前 Profile 前,ProfileDeck 会保留当前登录或设置中的有效更改。 -如果准备登录其他账号或替换当前文件,并希望先明确保存当前内容,请在桌面端使用**从当前 Codex 更新**,或运行: +如果要在登录其他账号或替换当前文件前保存,请打开当前 Profile 的 **…** 菜单,选择**保存当前登录与设置**,或运行: ```bash profiledeck-cli codex profile save-current diff --git a/docs/zh/grok-build/profiles.md b/docs/zh/grok-build/profiles.md index f87bd84..0f82f05 100644 --- a/docs/zh/grok-build/profiles.md +++ b/docs/zh/grok-build/profiles.md @@ -23,7 +23,7 @@ CLI 命令按以下顺序查找 Grok Home: ## 在桌面端保存 Profile 1. 选择 **Grok Build → Profiles**。 -2. 选择**保存当前**。 +2. 选择**新建 Profile**。 3. 输入创建后不会改变的 Profile ID,以及用于显示的名称。 4. 保存第一个 Profile 时使用默认的 `shared` 配置集;只有它尚不存在时,ProfileDeck 才会根据当前设置创建。 @@ -99,7 +99,7 @@ profiledeck-cli grok-build profile fork work client-config \ ## 保存更改并切换 -Grok Build 继续使用普通的 `auth.json` 和 `config.toml` 文件。离开当前 Profile 前,ProfileDeck 会保留当前登录或设置中的有效更改。请在桌面端使用**从当前 Grok Build 更新**,或运行: +Grok Build 继续使用普通的 `auth.json` 和 `config.toml` 文件。离开当前 Profile 前,ProfileDeck 会保留当前登录或设置中的有效更改。请打开当前 Profile 的 **…** 菜单,选择**保存当前登录与设置**,或运行: ```bash profiledeck-cli grok-build profile save-current diff --git a/internal/antigravity/service.go b/internal/antigravity/service.go index 54d7dfb..222f6e4 100644 --- a/internal/antigravity/service.go +++ b/internal/antigravity/service.go @@ -296,6 +296,14 @@ func (service *Service) UpdateProfile(ctx context.Context, req UpdateAntigravity } func (service *Service) SaveActiveProfile(ctx context.Context) (AntigravityProfileSaveResult, error) { + return service.saveActiveProfile(ctx, "") +} + +func (service *Service) SaveActiveProfileFor(ctx context.Context, expectedProfileID string) (AntigravityProfileSaveResult, error) { + return service.saveActiveProfile(ctx, expectedProfileID) +} + +func (service *Service) saveActiveProfile(ctx context.Context, expectedProfileID string) (AntigravityProfileSaveResult, error) { if err := service.requireAccess(ctx); err != nil { return AntigravityProfileSaveResult{}, err } @@ -325,11 +333,17 @@ func (service *Service) SaveActiveProfile(ctx context.Context) (AntigravityProfi } active, err := tx.GetActiveState(ctx, agyconfig.ProviderID) if errors.Is(err, store.ErrNotFound) { + if expectedProfileID != "" { + return apperror.New(apperror.ProfileChanged, "active Antigravity profile changed") + } return apperror.New(apperror.ProfileNotFound, "no active Antigravity profile") } if err != nil { return apperror.Wrap(apperror.StoreStatusFailed, "failed to read active Antigravity profile", err) } + if expectedProfileID != "" && active.ProfileID != expectedProfileID { + return apperror.New(apperror.ProfileChanged, "active Antigravity profile changed") + } profileID = active.ProfileID binding, err := tx.GetProfileCredentialBinding(ctx, profileID, agyconfig.ProviderID, agyconfig.CredentialSlot) if err != nil { diff --git a/internal/antigravity/service_test.go b/internal/antigravity/service_test.go index 4a628e5..e7c9bc8 100644 --- a/internal/antigravity/service_test.go +++ b/internal/antigravity/service_test.go @@ -451,6 +451,41 @@ func TestAntigravityCreateAndSaveRequireCurrentConsumerOAuthLogin(t *testing.T) assertErrorCode(t, err, apperror.AntigravityInvalid) } +func TestAntigravitySaveRejectsChangedActiveProfile(t *testing.T) { + ctx := context.Background() + configDir := t.TempDir() + if _, err := initAntigravityTestRuntime(ctx, configDir); err != nil { + t.Fatal(err) + } + client := &fakeKeyringClient{value: testAgyPayload("original", "refresh"), exists: true} + environment := newAntigravityTestEnvironment(t, configDir, client) + created, err := environment.antigravity.CreateProfile(ctx, CreateAntigravityProfileRequest{ProfileID: "work"}) + if err != nil { + t.Fatal(err) + } + db, err := openHealthyStore(ctx, configDir, true) + if err != nil { + t.Fatal(err) + } + before, err := db.GetProviderCredential(ctx, created.Summary.CredentialID) + if err != nil { + t.Fatal(err) + } + _ = db.Close() + client.value = testAgyPayload("changed", "refresh") + _, err = environment.antigravity.SaveActiveProfileFor(ctx, "other") + assertErrorCode(t, err, apperror.ProfileChanged) + db, err = openHealthyStore(ctx, configDir, true) + if err != nil { + t.Fatal(err) + } + defer db.Close() + after, err := db.GetProviderCredential(ctx, created.Summary.CredentialID) + if err != nil || after.PayloadJSON != before.PayloadJSON { + t.Fatalf("saved login changed after rejected save: credential=%#v err=%v", after, err) + } +} + func TestAntigravitySaveCurrentWarnsWhenLoginIsShared(t *testing.T) { ctx := context.Background() configDir := t.TempDir() diff --git a/internal/apperror/error.go b/internal/apperror/error.go index 8b22467..5279a4b 100644 --- a/internal/apperror/error.go +++ b/internal/apperror/error.go @@ -33,6 +33,8 @@ const ( ProfileInUse Code = "PROFILE_IN_USE" ProfileInvalid Code = "PROFILE_INVALID" ProfileNotFound Code = "PROFILE_NOT_FOUND" + ProfileChanged Code = "PROFILE_CHANGED" + ProfileSharingChanged Code = "PROFILE_SHARING_CHANGED" PlanBuildFailed Code = "PLAN_BUILD_FAILED" AdapterNotFound Code = "ADAPTER_NOT_FOUND" AgentDisabled Code = "agent_disabled" @@ -157,6 +159,8 @@ func KnownCode(code Code) bool { ProfileInUse, ProfileInvalid, ProfileNotFound, + ProfileChanged, + ProfileSharingChanged, PlanBuildFailed, AdapterNotFound, AgentDisabled, diff --git a/internal/claudecode/service.go b/internal/claudecode/service.go index 08a2b20..873e985 100644 --- a/internal/claudecode/service.go +++ b/internal/claudecode/service.go @@ -69,7 +69,8 @@ type UpdateClaudeCodeProfileRequest struct { Description *string `json:"description,omitempty"` } type SaveActiveClaudeCodeProfileRequest struct { - ConfirmShared bool `json:"confirm_shared"` + ExpectedProfileID string `json:"expected_profile_id,omitempty"` + ConfirmShared bool `json:"confirm_shared"` } type ClaudeCodeProfileSaveResult struct { OperationID string `json:"operation_id"` @@ -323,11 +324,17 @@ func (service *Service) SaveActiveProfile(ctx context.Context, req SaveActiveCla } active, err := tx.GetActiveState(ctx, claudecodeconfig.ProviderID) if errors.Is(err, store.ErrNotFound) { + if req.ExpectedProfileID != "" { + return apperror.New(apperror.ProfileChanged, "active Claude Code Profile changed") + } return apperror.New(apperror.ProfileNotFound, "no active Claude Code Profile") } if err != nil { return apperror.Wrap(apperror.StoreStatusFailed, "failed to read active Claude Code Profile", err) } + if req.ExpectedProfileID != "" && active.ProfileID != req.ExpectedProfileID { + return apperror.New(apperror.ProfileChanged, "active Claude Code Profile changed") + } profileID = active.ProfileID if _, err := claudeCodeProfileSummary(ctx, tx, profileID); err != nil { return err diff --git a/internal/claudecode/service_test.go b/internal/claudecode/service_test.go index 4d653fe..8017d3f 100644 --- a/internal/claudecode/service_test.go +++ b/internal/claudecode/service_test.go @@ -335,6 +335,43 @@ func TestClaudeCodeExpiredWorkingCopyDoesNotAutoOverwriteAndSharedSaveRequiresCo } } +func TestClaudeCodeSaveRejectsChangedActiveProfile(t *testing.T) { + ctx := context.Background() + configDir := t.TempDir() + credentialPath := filepath.Join(t.TempDir(), claudecodeconfig.CredentialsFile) + if _, err := initClaudeCodeTestRuntime(ctx, configDir); err != nil { + t.Fatal(err) + } + seedClaudeCodeFileProvider(t, ctx, configDir, credentialPath) + original := testClaudeCodePayload("original", "refresh", 4102444800000) + writeClaudeCodeCredential(t, credentialPath, original) + created, err := newClaudeCodeTestEnvironment(t, configDir).claudeCode.CreateProfile(ctx, CreateClaudeCodeProfileRequest{ProfileID: "work"}) + if err != nil { + t.Fatal(err) + } + db, err := openHealthyStore(ctx, configDir, true) + if err != nil { + t.Fatal(err) + } + before, err := db.GetProviderCredential(ctx, created.Summary.CredentialID) + if err != nil { + t.Fatal(err) + } + _ = db.Close() + writeClaudeCodeCredential(t, credentialPath, testClaudeCodePayload("changed", "refresh", 4102444800000)) + _, err = newClaudeCodeTestEnvironment(t, configDir).claudeCode.SaveActiveProfile(ctx, SaveActiveClaudeCodeProfileRequest{ExpectedProfileID: "other"}) + assertErrorCode(t, err, apperror.ProfileChanged) + db, err = openHealthyStore(ctx, configDir, true) + if err != nil { + t.Fatal(err) + } + defer db.Close() + credential, err := db.GetProviderCredential(ctx, created.Summary.CredentialID) + if err != nil || credential.PayloadJSON != before.PayloadJSON { + t.Fatalf("saved login changed after rejected save: credential=%#v err=%v", credential, err) + } +} + func TestClaudeCodeUnboundKnownCredentialDoesNotOverwriteActiveCredential(t *testing.T) { ctx := context.Background() configDir := t.TempDir() diff --git a/internal/codex/service_mutation.go b/internal/codex/service_mutation.go index 37b57b5..7da44dd 100644 --- a/internal/codex/service_mutation.go +++ b/internal/codex/service_mutation.go @@ -384,6 +384,14 @@ func (service *Service) UpdateProfileConfigSet(ctx context.Context, req UpdateCo } func (service *Service) SaveActiveProfileState(ctx context.Context) (CodexProfileStateSaveResult, error) { + return service.saveActiveProfileState(ctx, "", 0, 0) +} + +func (service *Service) SaveActiveProfileStateFor(ctx context.Context, expectedProfileID string, expectedCredentialReferences, expectedConfigReferences int) (CodexProfileStateSaveResult, error) { + return service.saveActiveProfileState(ctx, expectedProfileID, expectedCredentialReferences, expectedConfigReferences) +} + +func (service *Service) saveActiveProfileState(ctx context.Context, expectedProfileID string, expectedCredentialReferences, expectedConfigReferences int) (CodexProfileStateSaveResult, error) { if err := service.requireAccess(ctx); err != nil { return CodexProfileStateSaveResult{}, err } @@ -416,8 +424,14 @@ func (service *Service) SaveActiveProfileState(ctx context.Context) (CodexProfil return err } if !exists { + if expectedProfileID != "" { + return apperror.New(apperror.ProfileChanged, "active Codex profile changed") + } return apperror.New(apperror.ProfileNotFound, "no active Codex profile") } + if expectedProfileID != "" && active.ProfileID != expectedProfileID { + return apperror.New(apperror.ProfileChanged, "active Codex profile changed") + } profileID = active.ProfileID targets, err := codexBindingTargets(ctx, txStore, profileID, home) if err != nil { @@ -443,6 +457,19 @@ func (service *Service) SaveActiveProfileState(ctx context.Context) (CodexProfil if err != nil { return err } + if expectedProfileID != "" { + credentialReferences, err := codexCredentialBindingCount(ctx, txStore, credentialID) + if err != nil { + return err + } + configReferences, err := codexConfigSetBindingCount(ctx, txStore, configSet.ID) + if err != nil { + return err + } + if credentialReferences != expectedCredentialReferences || configReferences != expectedConfigReferences { + return apperror.New(apperror.ProfileSharingChanged, "Codex profile sharing changed before save") + } + } configSet, err = upsertCodexConfigSet(ctx, txStore, configSet.ID, configSet.Name, configSet.Description, payload.ConfigContent) if err != nil { return err diff --git a/internal/codex/service_mutation_test.go b/internal/codex/service_mutation_test.go index 6ca2cdd..57c5c7c 100644 --- a/internal/codex/service_mutation_test.go +++ b/internal/codex/service_mutation_test.go @@ -343,6 +343,77 @@ func TestSaveActiveCodexProfileStateRejectsDifferentCodexHome(t *testing.T) { assertErrorCode(t, err, apperror.CodexInvalid) } +func TestSaveActiveCodexProfileStateRejectsChangedActiveProfile(t *testing.T) { + ctx := context.Background() + configDir := t.TempDir() + codexDir := t.TempDir() + if _, err := initCodexTestRuntime(ctx, configDir); err != nil { + t.Fatal(err) + } + originalAuth := `{"tokens":{"access_token":"original"}}` + writeCodexProfileFixture(t, codexDir, "model = \"original\"\n", originalAuth) + created, err := newCodexTestEnvironment(t, configDir, codexDir).codex.CreateProfile(ctx, CreateCodexProfileRequest{ProfileID: "work"}) + if err != nil { + t.Fatal(err) + } + writeCodexProfileFixture(t, codexDir, "model = \"changed\"\n", `{"tokens":{"access_token":"changed"}}`) + _, err = newCodexTestEnvironment(t, configDir, codexDir).codex.SaveActiveProfileStateFor(ctx, "other", 1, 1) + assertErrorCode(t, err, apperror.ProfileChanged) + detail, err := newCodexTestEnvironment(t, configDir, codexDir).codex.GetProfile(ctx, "work") + if err != nil || detail.Summary.Model != "original" { + t.Fatalf("saved settings changed after rejected save: detail=%#v err=%v", detail, err) + } + db, err := openHealthyStore(ctx, configDir, true) + if err != nil { + t.Fatal(err) + } + defer db.Close() + credential, err := db.GetProviderCredential(ctx, created.Summary.CredentialID) + if err != nil || credential.PayloadJSON != originalAuth { + t.Fatalf("saved login changed after rejected save: credential=%#v err=%v", credential, err) + } +} + +func TestSaveActiveCodexProfileStateRejectsChangedSharing(t *testing.T) { + ctx := context.Background() + configDir := t.TempDir() + codexDir := t.TempDir() + if _, err := initCodexTestRuntime(ctx, configDir); err != nil { + t.Fatal(err) + } + originalAuth := `{"tokens":{"access_token":"original"}}` + writeCodexProfileFixture(t, codexDir, "model = \"original\"\n", originalAuth) + created, err := newCodexTestEnvironment(t, configDir, codexDir).codex.CreateProfile(ctx, CreateCodexProfileRequest{ProfileID: "work"}) + if err != nil { + t.Fatal(err) + } + if _, err := newCodexTestEnvironment(t, configDir, codexDir).codex.ForkProfile(ctx, ForkCodexProfileRequest{ + SourceProfileID: "work", ProfileID: "shared", + CredentialBinding: CodexForkBindingCopyNew, ConfigBinding: CodexForkBindingShareParent, + }); err != nil { + t.Fatal(err) + } + writeCodexProfileFixture(t, codexDir, "model = \"changed\"\n", `{"tokens":{"access_token":"changed"}}`) + _, err = newCodexTestEnvironment(t, configDir, codexDir).codex.SaveActiveProfileStateFor(ctx, "work", 1, 1) + assertErrorCode(t, err, apperror.ProfileSharingChanged) + detail, err := newCodexTestEnvironment(t, configDir, codexDir).codex.GetProfile(ctx, "work") + if err != nil || detail.Summary.Model != "original" { + t.Fatalf("saved settings changed after rejected save: detail=%#v err=%v", detail, err) + } + db, err := openHealthyStore(ctx, configDir, true) + if err != nil { + t.Fatal(err) + } + defer db.Close() + credential, err := db.GetProviderCredential(ctx, created.Summary.CredentialID) + if err != nil || credential.PayloadJSON != originalAuth { + t.Fatalf("saved login changed after rejected save: credential=%#v err=%v", credential, err) + } + if _, err := newCodexTestEnvironment(t, configDir, codexDir).codex.SaveActiveProfileStateFor(ctx, "work", 1, 2); err != nil { + t.Fatalf("save with current sharing failed: %v", err) + } +} + func createTestCodexConfigSet(t *testing.T, ctx context.Context, db *store.Store, id, content string) { t.Helper() if _, err := upsertCodexConfigSet(ctx, db, id, id, "", content); err != nil { diff --git a/internal/grokbuild/service_integration_test.go b/internal/grokbuild/service_integration_test.go index f24dacc..86a7c33 100644 --- a/internal/grokbuild/service_integration_test.go +++ b/internal/grokbuild/service_integration_test.go @@ -178,6 +178,52 @@ func TestManagedProfilesSwitchExactWorkingCopiesWithoutPublicBodies(t *testing.T } } +func TestSaveActiveProfileStateRejectsChangedActiveProfile(t *testing.T) { + ctx := context.Background() + home := t.TempDir() + originalAuth := syntheticAuth("work", "ORIGINAL_AUTH") + writePrivateFile(t, filepath.Join(home, grokconfig.AuthFileName), originalAuth) + application := newApplication(t, home) + created, err := application.GrokBuild().CreateProfile(ctx, grokbuild.CreateProfileRequest{ProfileID: "work"}) + if err != nil { + t.Fatal(err) + } + writePrivateFile(t, filepath.Join(home, grokconfig.AuthFileName), syntheticAuth("work", "CHANGED_AUTH")) + writePrivateFile(t, filepath.Join(home, grokconfig.ConfigFileName), "# changed\n") + _, err = application.GrokBuild().SaveActiveProfileStateFor(ctx, "other", 1, 1) + assertErrorCode(t, err, apperror.ProfileChanged) + assertStoredCredential(t, ctx, application, created.Summary.CredentialID, originalAuth) + assertStoredConfig(t, ctx, application, created.ConfigSet.ID, "") +} + +func TestSaveActiveProfileStateRejectsChangedSharing(t *testing.T) { + ctx := context.Background() + home := t.TempDir() + originalAuth := syntheticAuth("work", "ORIGINAL_AUTH") + writePrivateFile(t, filepath.Join(home, grokconfig.AuthFileName), originalAuth) + application := newApplication(t, home) + created, err := application.GrokBuild().CreateProfile(ctx, grokbuild.CreateProfileRequest{ProfileID: "work"}) + if err != nil { + t.Fatal(err) + } + if _, err := application.GrokBuild().ForkProfile(ctx, grokbuild.ForkProfileRequest{ + SourceProfileID: "work", ProfileID: "shared", + CredentialBinding: grokbuild.ForkBindingShareParent, ConfigBinding: grokbuild.ForkBindingCopyNew, + NewConfigSetID: "shared-settings", + }); err != nil { + t.Fatal(err) + } + writePrivateFile(t, filepath.Join(home, grokconfig.AuthFileName), syntheticAuth("work", "CHANGED_AUTH")) + writePrivateFile(t, filepath.Join(home, grokconfig.ConfigFileName), "# changed\n") + _, err = application.GrokBuild().SaveActiveProfileStateFor(ctx, "work", 1, 1) + assertErrorCode(t, err, apperror.ProfileSharingChanged) + assertStoredCredential(t, ctx, application, created.Summary.CredentialID, originalAuth) + assertStoredConfig(t, ctx, application, created.ConfigSet.ID, "") + if _, err := application.GrokBuild().SaveActiveProfileStateFor(ctx, "work", 2, 1); err != nil { + t.Fatalf("save with current sharing failed: %v", err) + } +} + func TestValidationErrorsDoNotExposeManagedFileBodies(t *testing.T) { ctx := context.Background() home := t.TempDir() diff --git a/internal/grokbuild/service_mutation.go b/internal/grokbuild/service_mutation.go index 1805134..20badab 100644 --- a/internal/grokbuild/service_mutation.go +++ b/internal/grokbuild/service_mutation.go @@ -375,6 +375,14 @@ func (service *Service) UpdateProfileConfigSet(ctx context.Context, req UpdatePr } func (service *Service) SaveActiveProfileState(ctx context.Context) (ProfileStateSaveResult, error) { + return service.saveActiveProfileState(ctx, "", 0, 0) +} + +func (service *Service) SaveActiveProfileStateFor(ctx context.Context, expectedProfileID string, expectedCredentialReferences, expectedConfigReferences int) (ProfileStateSaveResult, error) { + return service.saveActiveProfileState(ctx, expectedProfileID, expectedCredentialReferences, expectedConfigReferences) +} + +func (service *Service) saveActiveProfileState(ctx context.Context, expectedProfileID string, expectedCredentialReferences, expectedConfigReferences int) (ProfileStateSaveResult, error) { if err := service.requireAccess(ctx); err != nil { return ProfileStateSaveResult{}, err } @@ -406,8 +414,14 @@ func (service *Service) SaveActiveProfileState(ctx context.Context) (ProfileStat return err } if !exists { + if expectedProfileID != "" { + return apperror.New(apperror.ProfileChanged, "active Grok Build Profile changed") + } return apperror.New(apperror.ProfileNotFound, "no active Grok Build Profile") } + if expectedProfileID != "" && active.ProfileID != expectedProfileID { + return apperror.New(apperror.ProfileChanged, "active Grok Build Profile changed") + } profileID = active.ProfileID targets, err := grokprofile.BindingTargets(ctx, tx, profileID, home) if err != nil { @@ -425,6 +439,27 @@ func (service *Service) SaveActiveProfileState(ctx context.Context) (ProfileStat if err != nil { return err } + credentialID, err = grokprofile.CredentialIDFromTarget(authTarget) + if err != nil { + return err + } + credential, err := grokprofile.RequireAuthCredential(ctx, tx, credentialID) + if err != nil { + return err + } + if expectedProfileID != "" { + credentialReferences, err := grokprofile.CredentialBindingCount(ctx, tx, credentialID) + if err != nil { + return err + } + configReferences, err := grokprofile.ConfigSetBindingCount(ctx, tx, configSet.ID) + if err != nil { + return err + } + if credentialReferences != expectedCredentialReferences || configReferences != expectedConfigReferences { + return apperror.New(apperror.ProfileSharingChanged, "Grok Build Profile sharing changed before save") + } + } // A missing working file must never erase the database-owned Config Set // or allow the login to be updated without its settings. if working.ConfigMissing { @@ -439,14 +474,6 @@ func (service *Service) SaveActiveProfileState(ctx context.Context) (ProfileStat if err != nil { return err } - credentialID, err = grokprofile.CredentialIDFromTarget(authTarget) - if err != nil { - return err - } - credential, err := grokprofile.RequireAuthCredential(ctx, tx, credentialID) - if err != nil { - return err - } if _, err := tx.UpsertProviderCredential(ctx, store.UpsertProviderCredentialParams{ ID: credential.ID, ProviderID: credential.ProviderID, CredentialKind: credential.CredentialKind, PayloadJSON: working.AuthPayload, PayloadSHA256: switchtarget.SHA256String(working.AuthPayload),