diff --git a/cmd/msgvault/cmd/person_provider.go b/cmd/msgvault/cmd/person_provider.go index ecac33135..6b850e8ae 100644 --- a/cmd/msgvault/cmd/person_provider.go +++ b/cmd/msgvault/cmd/person_provider.go @@ -102,6 +102,13 @@ type personProviderRemoveOutput struct { DaemonRestartRequired bool `json:"daemon_restart_required"` } +type personProviderSetOutput struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + Checked bool `json:"checked"` + DaemonRestartRequired bool `json:"daemon_restart_required"` +} + type personProviderCodexIsolationStatus struct { Available bool `json:"available"` ExecutionBoundary string `json:"execution_boundary"` @@ -143,6 +150,11 @@ type personProviderRevokeAllOutput struct { Profiles []personProviderStatusOutput `json:"profiles"` } +type personProviderRevokeFingerprintOutput struct { + Fingerprint string `json:"fingerprint"` + Revoked bool `json:"revoked"` +} + type personSemanticProviderStatusOutput struct { Profile vector.SemanticPersonEmbeddingProfile `json:"profile"` Consent store.PersonSemanticEmbeddingConsentStatus `json:"consent"` @@ -301,6 +313,7 @@ func newPersonProviderCommand(deps personProviderCommandDeps) *cobra.Command { } provider.AddCommand( newPersonProviderAddCommand(deps), + newPersonProviderSetCommand(deps), newPersonProviderRemoveCommand(deps), newPersonProviderListCommand(deps), newPersonProviderUseCommand(deps), @@ -515,6 +528,193 @@ func writePersonProviderDaemonRestartNotice(w io.Writer) { "A running daemon keeps the people sweep config it started with; run `msgvault daemon restart` so scheduled sweeps observe this change.") } +func runPersonProviderSet( + command *cobra.Command, + deps personProviderCommandDeps, + name string, + options personProviderSetOptions, +) error { + if err := rejectRemotePersonProviderMutation(deps, "set"); err != nil { + return err + } + if !options.confirmed { + return errors.New("people provider set requires --yes after reviewing the final values") + } + if !personProviderSetHasMutableChanges(command) { + return errors.New("people provider set requires at least one mutable policy flag") + } + if deps.readConfigFile == nil || deps.editConfigTables == nil || deps.restoreConfigFile == nil { + return errors.New("people provider config editing is unavailable") + } + before, err := deps.readConfigFile() + if err != nil { + return err + } + configured, err := personProviderConfigFromSnapshot(deps, before) + if err != nil { + return err + } + provider, exists := configured.Providers[name] + if !exists { + return fmt.Errorf("people provider profile %q is not configured", name) + } + oldConfig, err := selectPersonProviderConfig(configured, name) + if err != nil { + return err + } + oldConfig.Enabled = true + oldProfile, err := oldConfig.Profile() + if err != nil { + return err + } + + replacement := provider + applyPersonProviderSetOptions(command, &replacement, options) + proposed := personProviderProposedConfig(configured, name, replacement) + proposedProfile, err := proposed.Profile() + if err != nil { + return err + } + if err := config.ValidateConfigTableEdits(before, []config.TableEdit{ + personProviderProfileUpdateEdit(name, replacement), + }); err != nil { + return err + } + directStore, daemonRunning, err := personProviderMutationScope(command.Context(), deps) + if err != nil { + return err + } + credential, err := readExistingPersonProviderCredential(deps.setup, name, proposedProfile) + if err != nil { + return err + } + if replacement.Protocol != peoplesweep.ProtocolCodexAppServer { + if deps.setup.negotiate == nil { + return errors.New("people provider capability negotiation is unavailable") + } + capabilities, negotiateErr := deps.setup.negotiate(command.Context(), replacement, credential) + if negotiateErr != nil { + return negotiateErr + } + replacement.OutputMode = capabilities.OutputMode + replacement.TokenLimitParameter = capabilities.TokenLimitParameter + replacement.ReasoningEffort = capabilities.ReasoningEffort + replacement.ReasoningMode = capabilities.ReasoningMode + replacement.DriverVersion = capabilities.DriverVersion + } + proposed = personProviderProposedConfig(configured, name, replacement) + proposedProfile, err = proposed.Profile() + if err != nil { + return err + } + if err := config.ValidateConfigTableEdits(before, []config.TableEdit{ + personProviderProfileUpdateEdit(name, replacement), + }); err != nil { + return err + } + + if err := revokePersonProviderSetConsent(command, deps, name, oldProfile.Fingerprint, directStore, true); err != nil { + return err + } + + after, err := deps.editConfigTables(before.ETag, []config.TableEdit{ + personProviderProfileUpdateEdit(name, replacement), + }) + if err != nil { + return errors.Join(err, rollbackPersonProviderSetConfig(deps, after, before), + errors.New("exact people provider consent remains revoked")) + } + if err := revokePersonProviderSetConsent(command, deps, name, oldProfile.Fingerprint, directStore, false); err != nil { + return errors.Join(err, rollbackPersonProviderSetConfig(deps, after, before), + errors.New("exact people provider consent remains revoked")) + } + checkedConfig, err := personProviderConfigFromSnapshot(deps, after) + if err == nil { + checkedDeps := deps + checkedDeps.config = func() peoplesweep.Config { return checkedConfig } + checkOutput := command.OutOrStdout() + if options.jsonOutput { + checkOutput = io.Discard + } + err = executeSavedPersonProviderCheck(command, checkedDeps, name, proposedProfile.Fingerprint, checkOutput) + } + if err != nil { + return errors.Join(err, rollbackPersonProviderSetConfig(deps, after, before), + errors.New("exact people provider consent remains revoked")) + } + if options.jsonOutput { + checkedProvider, err := selectPersonProviderConfig(checkedConfig, name) + if err != nil { + return err + } + checkedProvider.Enabled = true + checkedProfile, err := checkedProvider.Profile() + if err != nil { + return err + } + return json.NewEncoder(command.OutOrStdout()).Encode(personProviderSetOutput{ + Name: name, Fingerprint: checkedProfile.Fingerprint, Checked: true, + DaemonRestartRequired: daemonRunning, + }) + } + _, _ = fmt.Fprintf(command.OutOrStdout(), + "Updated and checked people provider profile %q; run `msgvault person provider consent %q --yes` to grant consent for the new policy.\n", + name, name) + if daemonRunning { + writePersonProviderDaemonRestartNotice(command.OutOrStdout()) + } + return nil +} + +func personProviderSetHasMutableChanges(command *cobra.Command) bool { + for _, name := range personProviderSetMutableFlags { + if command.Flags().Changed(name) { + return true + } + } + return false +} + +func revokePersonProviderSetConsent( + command *cobra.Command, + deps personProviderCommandDeps, + name, fingerprint string, + directStore bool, + guard bool, +) error { + if directStore { + if deps.openStore == nil { + return errors.New("people provider consent store is unavailable") + } + st, cleanup, err := deps.openStore() + if err != nil { + return err + } + _, revokeErr := st.RevokePersonInferenceConsent( + command.Context(), fingerprint, personProviderConsentActor, + ) + cleanup() + return revokeErr + } + if guard { + return proxySavedPersonProviderOperation(command, deps, "revoke", name, fingerprint, io.Discard) + } + return proxySavedPersonProviderRevokeFingerprint(command, deps, name, fingerprint) +} + +func rollbackPersonProviderSetConfig( + deps personProviderCommandDeps, + published, before config.ConfigFile, +) error { + if !published.Exists || published.ETag == "" { + return errors.New("cannot roll back people provider config without a verified published snapshot") + } + if _, err := deps.restoreConfigFile(published, before); err != nil { + return fmt.Errorf("restore people provider config: %w", err) + } + return nil +} + func runPersonProviderUse( command *cobra.Command, deps personProviderCommandDeps, @@ -782,11 +982,20 @@ func newPersonProviderRevokeCommand(deps personProviderCommandDeps) *cobra.Comma var jsonOutput bool var semanticEmbeddings bool var ifFingerprint string + var fingerprint string command := &cobra.Command{ Use: "revoke [name]", Short: "Revoke consent for the exact people inference policy", Args: optionalPersonProviderNameArgs, RunE: func(command *cobra.Command, args []string) error { + if fingerprint != "" { + if err := validatePersonProviderFingerprint(fingerprint); err != nil { + return err + } + if len(args) != 1 || all || semanticEmbeddings { + return errors.New("--fingerprint requires one named people provider revoke") + } + } if ifFingerprint != "" { if err := validatePersonProviderFingerprint(ifFingerprint); err != nil { return err @@ -819,6 +1028,12 @@ func newPersonProviderRevokeCommand(deps personProviderCommandDeps) *cobra.Comma return errors.New("people provider profile changed since removal began") } } + if fingerprint != "" { + return runPersonProviderRevokeFingerprint(command, runDeps, fingerprint, jsonOutput) + } + } + if fingerprint != "" { + return errors.New("--fingerprint requires one named people provider revoke") } return runPersonProviderRevoke(command, runDeps, all, jsonOutput, semanticEmbeddings) }, @@ -830,6 +1045,8 @@ func newPersonProviderRevokeCommand(deps personProviderCommandDeps) *cobra.Comma command.Flags().StringVar(&ifFingerprint, personProviderIfFingerprintFlag, "", "Require an exact provider fingerprint") _ = command.Flags().MarkHidden(personProviderIfFingerprintFlag) + command.Flags().StringVar(&fingerprint, "fingerprint", "", "Revoke a specific provider fingerprint") + _ = command.Flags().MarkHidden("fingerprint") return command } @@ -900,6 +1117,7 @@ func newPersonProviderHistoryCommand(deps personProviderCommandDeps) *cobra.Comm func newPersonProviderCheckCommand(deps personProviderCommandDeps) *cobra.Command { var jsonOutput bool + var ifFingerprint string command := &cobra.Command{ Use: "check [name]", Short: "Run a fixed synthetic request through the people inference provider", @@ -916,7 +1134,7 @@ func newPersonProviderCheckCommand(deps personProviderCommandDeps) *cobra.Comman if len(args) == 1 { name = args[0] } - return runPersonProviderCheck(command, deps, name, jsonOutput) + return runPersonProviderCheck(command, deps, name, ifFingerprint, jsonOutput) } } return deps.proxy(command, args, nil) @@ -925,10 +1143,13 @@ func newPersonProviderCheckCommand(deps personProviderCommandDeps) *cobra.Comman if len(args) == 1 { name = args[0] } - return runPersonProviderCheck(command, deps, name, jsonOutput) + return runPersonProviderCheck(command, deps, name, ifFingerprint, jsonOutput) }, } command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output structured JSON") + command.Flags().StringVar(&ifFingerprint, personProviderIfFingerprintFlag, "", + "Require an exact provider fingerprint") + _ = command.Flags().MarkHidden(personProviderIfFingerprintFlag) return command } @@ -1126,6 +1347,32 @@ func runPersonProviderRevoke( return nil } +func runPersonProviderRevokeFingerprint( + command *cobra.Command, + deps personProviderCommandDeps, + fingerprint string, + jsonOutput bool, +) error { + st, cleanup, err := deps.openStore() + if err != nil { + return err + } + defer cleanup() + revoked, err := st.RevokePersonInferenceConsent( + command.Context(), fingerprint, personProviderConsentActor, + ) + if err != nil { + return err + } + if jsonOutput { + return json.NewEncoder(command.OutOrStdout()).Encode(personProviderRevokeFingerprintOutput{ + Fingerprint: fingerprint, Revoked: revoked, + }) + } + _, _ = fmt.Fprintf(command.OutOrStdout(), "Consent revoked for %s\n", fingerprint) + return nil +} + func runPersonSemanticProviderStatus( command *cobra.Command, deps personProviderCommandDeps, @@ -1265,8 +1512,12 @@ func runPersonProviderCheck( command *cobra.Command, deps personProviderCommandDeps, name string, + ifFingerprint string, jsonOutput bool, ) error { + if err := verifyPersonProviderFingerprint(deps, name, ifFingerprint); err != nil { + return err + } output, err := checkPersonProvider(command, deps, name) if err != nil { return err diff --git a/cmd/msgvault/cmd/person_provider_setup.go b/cmd/msgvault/cmd/person_provider_setup.go index e1284bf5f..b6e72a578 100644 --- a/cmd/msgvault/cmd/person_provider_setup.go +++ b/cmd/msgvault/cmd/person_provider_setup.go @@ -60,6 +60,21 @@ type personProviderAddOptions struct { jsonOutput bool } +type personProviderSetOptions struct { + model string + retentionPosture string + trainingPosture string + allowedSources []string + sourceSince string + sourceUntil string + allowSensitive bool + reasoningEffort string + reasoningMode string + requestTimeout time.Duration + confirmed bool + jsonOutput bool +} + type personProviderAddOutput struct { Name string `json:"name"` Fingerprint string `json:"fingerprint"` @@ -155,6 +170,37 @@ func newPersonProviderAddCommand(deps personProviderCommandDeps) *cobra.Command return command } +func newPersonProviderSetCommand(deps personProviderCommandDeps) *cobra.Command { + var options personProviderSetOptions + command := &cobra.Command{ + Use: "set ", + Short: "Update and check a named people inference provider profile", + Args: exactPersonProviderNameArgs, + RunE: func(command *cobra.Command, args []string) error { + return runPersonProviderSet(command, deps, args[0], options) + }, + } + flags := command.Flags() + flags.StringVar(&options.model, "model", "", "Provider model identifier") + flags.StringVar(&options.retentionPosture, "retention-posture", "", "Provider retention assertion") + flags.StringVar(&options.trainingPosture, "training-posture", "", "Provider training assertion") + flags.StringSliceVar(&options.allowedSources, "source", nil, "Allowed source class (repeatable)") + flags.StringVar(&options.sourceSince, "source-since", "", "Earliest disclosed source date") + flags.StringVar(&options.sourceUntil, "source-until", "", "Latest disclosed source date") + flags.BoolVar(&options.allowSensitive, "allow-sensitive", false, "Allow sensitive text in provider packets") + flags.StringVar(&options.reasoningEffort, "reasoning-effort", "", "Explicit reasoning effort") + flags.StringVar(&options.reasoningMode, "reasoning-mode", "", "Explicit reasoning mode") + flags.DurationVar(&options.requestTimeout, "request-timeout", time.Minute, "Provider request timeout") + flags.BoolVar(&options.confirmed, "yes", false, "Confirm the final provider and privacy values") + flags.BoolVar(&options.jsonOutput, flagJSON, false, "Output structured JSON") + return command +} + +var personProviderSetMutableFlags = []string{ + "model", "retention-posture", "training-posture", "source", "source-since", "source-until", + "allow-sensitive", "reasoning-effort", "reasoning-mode", "request-timeout", +} + func runPersonProviderAdd( command *cobra.Command, deps personProviderCommandDeps, @@ -306,7 +352,7 @@ func runPersonProviderAdd( if err == nil { checkedDeps := deps checkedDeps.config = func() peoplesweep.Config { return checkedConfig } - err = executeSavedPersonProviderCheck(command, checkedDeps, name, checkOutput) + err = executeSavedPersonProviderCheck(command, checkedDeps, name, "", checkOutput) } if err != nil { rollbackErr := rollbackPersonProviderAdd(deps, before, after, credentialStore, name, credentialCleanup) @@ -537,6 +583,67 @@ func personProviderConfigFromSnapshot( return loaded.People.Sweep, nil } +func applyPersonProviderSetOptions( + command *cobra.Command, + provider *peoplesweep.ProviderConfig, + options personProviderSetOptions, +) { + flags := command.Flags() + if flags.Changed("model") { + provider.Model = options.model + } + if flags.Changed("retention-posture") { + provider.RetentionPosture = options.retentionPosture + } + if flags.Changed("training-posture") { + provider.TrainingPosture = options.trainingPosture + } + if flags.Changed("source") { + provider.AllowedSources = make([]peoplesweep.SourceClass, len(options.allowedSources)) + for index, source := range options.allowedSources { + provider.AllowedSources[index] = peoplesweep.SourceClass(source) + } + } + if flags.Changed("source-since") { + provider.SourceSince = options.sourceSince + } + if flags.Changed("source-until") { + provider.SourceUntil = options.sourceUntil + } + if flags.Changed("allow-sensitive") { + provider.AllowSensitive = options.allowSensitive + } + if flags.Changed("reasoning-effort") { + provider.ReasoningEffort = options.reasoningEffort + } + if flags.Changed("reasoning-mode") { + provider.ReasoningMode = options.reasoningMode + } + if flags.Changed("request-timeout") { + provider.RequestTimeout = options.requestTimeout + } +} + +func readExistingPersonProviderCredential( + setup personProviderSetupDeps, + name string, + profile peoplesweep.ProviderProfile, +) (peoplesweep.Credential, error) { + var credentials peoplesweep.CredentialStore + if profile.Credential == peoplesweep.CredentialStored { + var err error + credentials, err = setup.resolveCredentialStore() + if err != nil { + return peoplesweep.Credential{}, err + } + } + credential, err := peoplesweep.NewCredentialResolver(credentials, setup.lookupEnv).Resolve(name, profile) + if err != nil { + return peoplesweep.Credential{}, fmt.Errorf("resolve existing people provider credential: %w", err) + } + return credential, nil +} + func readPersonProviderCredential( command *cobra.Command, setup personProviderSetupDeps, @@ -768,6 +875,13 @@ func personProviderProfileEdit(name string, provider peoplesweep.ProviderConfig) } } +func personProviderProfileUpdateEdit(name string, provider peoplesweep.ProviderConfig) config.TableEdit { + return config.TableEdit{ + Path: []string{"people", "sweep", "providers", name}, + Values: personProviderTableUpdateValues(provider), + } +} + func personProviderBudgetEdit(prices *peoplesweep.BudgetConfig) config.TableEdit { return config.TableEdit{ Path: []string{"people", "sweep", "budgets"}, @@ -822,6 +936,17 @@ func personProviderTableValues(provider peoplesweep.ProviderConfig) map[string]a return values } +func personProviderTableUpdateValues(provider peoplesweep.ProviderConfig) map[string]any { + values := personProviderTableValues(provider) + values["source_until"] = provider.SourceUntil + values["reasoning_effort"] = provider.ReasoningEffort + values["reasoning_mode"] = provider.ReasoningMode + if provider.Protocol == peoplesweep.ProtocolOpenAIChat { + values["token_limit_parameter"] = provider.TokenLimitParameter + } + return values +} + func acceptedPersonProviderCatalogPrices( current peoplesweep.BudgetConfig, suggestions []peoplesweep.ProviderSuggestion, @@ -877,6 +1002,7 @@ func executeSavedPersonProviderCheck( command *cobra.Command, deps personProviderCommandDeps, name string, + ifFingerprint string, out io.Writer, ) error { if err := peoplesweep.ValidateProviderProfileName(name); err != nil { @@ -891,13 +1017,41 @@ func executeSavedPersonProviderCheck( directStore = !owned } if directStore { + if err := verifyPersonProviderFingerprint(deps, name, ifFingerprint); err != nil { + return err + } output, err := checkPersonProvider(command, deps, name) if err != nil { return err } return writePersonProviderCheckOutput(out, output, false) } - return proxySavedPersonProviderOperation(command, deps, "check", name, "", out) + return proxySavedPersonProviderOperation(command, deps, "check", name, ifFingerprint, out) +} + +func verifyPersonProviderFingerprint( + deps personProviderCommandDeps, + name, expected string, +) error { + if expected == "" { + return nil + } + if err := validatePersonProviderFingerprint(expected); err != nil { + return err + } + current, err := selectPersonProviderConfig(deps.config(), name) + if err != nil { + return err + } + current.Enabled = true + profile, err := current.Profile() + if err != nil { + return err + } + if profile.Fingerprint != expected { + return errors.New("people provider profile changed before checking") + } + return nil } func proxySavedPersonProviderRevoke( @@ -909,6 +1063,17 @@ func proxySavedPersonProviderRevoke( return proxySavedPersonProviderOperation(command, deps, "revoke", name, fingerprint, command.OutOrStdout()) } +func proxySavedPersonProviderRevokeFingerprint( + command *cobra.Command, + deps personProviderCommandDeps, + name string, + fingerprint string, +) error { + return proxySavedPersonProviderOperationWithFlag( + command, deps, "revoke", name, "fingerprint", fingerprint, io.Discard, + ) +} + func proxySavedPersonProviderOperation( command *cobra.Command, deps personProviderCommandDeps, @@ -916,6 +1081,20 @@ func proxySavedPersonProviderOperation( name string, fingerprint string, out io.Writer, +) error { + return proxySavedPersonProviderOperationWithFlag( + command, deps, operation, name, personProviderIfFingerprintFlag, fingerprint, out, + ) +} + +func proxySavedPersonProviderOperationWithFlag( + command *cobra.Command, + deps personProviderCommandDeps, + operation string, + name string, + flag string, + fingerprint string, + out io.Writer, ) error { if err := peoplesweep.ValidateProviderProfileName(name); err != nil { return err @@ -928,14 +1107,14 @@ func proxySavedPersonProviderOperation( provider := &cobra.Command{Use: "provider"} leaf := &cobra.Command{Use: operation} if fingerprint != "" { - if operation != "revoke" { + if flag != "fingerprint" && operation != "revoke" && operation != "check" { return errors.New("people provider fingerprint guard is unavailable for this operation") } if err := validatePersonProviderFingerprint(fingerprint); err != nil { return err } - leaf.Flags().String(personProviderIfFingerprintFlag, "", "") - if err := leaf.Flags().Set(personProviderIfFingerprintFlag, fingerprint); err != nil { + leaf.Flags().String(flag, "", "") + if err := leaf.Flags().Set(flag, fingerprint); err != nil { return fmt.Errorf("set person provider fingerprint guard: %w", err) } } diff --git a/cmd/msgvault/cmd/person_provider_setup_test.go b/cmd/msgvault/cmd/person_provider_setup_test.go index 16aa0271a..2000ec256 100644 --- a/cmd/msgvault/cmd/person_provider_setup_test.go +++ b/cmd/msgvault/cmd/person_provider_setup_test.go @@ -716,6 +716,291 @@ func TestPersonProviderAddRejectsInvalidSnapshotCapsBeforeSecretOrProvider(t *te assert.Zero(writes) } +func TestPersonProviderSetUpdatesExistingProfile(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + path, loaded := providerSetupConfigFile(t) + deps := providerSetupCommandDeps(t, path, loaded, newCheckedPersonProviderChecker()) + t.Setenv("DEFAULT_KEY", providerSetupSecretCanary) + + output, err := executePersonProviderCommand(t, deps, + "set", "default", "--model", "updated-model", "--yes") + require.NoError(err) + assert.Contains(output, `Updated and checked people provider profile "default"`) + + reloaded, err := config.Load(path, "") + require.NoError(err) + provider := reloaded.People.Sweep.Providers["default"] + assert.Equal("updated-model", provider.Model) + assert.Equal("https://default.example.test/v1", provider.Endpoint) + assert.Equal(peoplesweep.AuthBearer, provider.Auth) + assert.Equal(peoplesweep.CredentialEnv, provider.Credential) + assert.Equal("DEFAULT_KEY", provider.CredentialEnv) +} + +func TestPersonProviderSetClosesStoreBeforeChecking(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + path, loaded := providerSetupConfigFile(t) + st := testutil.NewSQLiteTestStore(t) + deps := providerSetupCommandDeps(t, path, loaded, newCheckedPersonProviderChecker()) + deps.isDaemonSubprocess = func() bool { return false } + deps.providerStoreOwnedByDaemon = func(context.Context) (bool, error) { return false, nil } + deps.daemonAliveForRestartNotice = func(context.Context) (bool, error) { return false, nil } + active := false + opens := 0 + deps.openStore = func() (personProviderStore, func(), error) { + opens++ + if active { + return nil, nil, errors.New("people provider store lock is held") + } + active = true + return st, func() { active = false }, nil + } + t.Setenv("DEFAULT_KEY", providerSetupSecretCanary) + + _, err := executePersonProviderCommand(t, deps, + "set", "default", "--model", "local-model", "--yes") + require.NoError(err) + assert.Equal(3, opens) + assert.False(active) +} + +func TestPersonProviderSetDoesNotRollbackAnUnverifiedConflictSnapshot(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + path, loaded := providerSetupConfigFile(t) + deps := providerSetupCommandDeps(t, path, loaded, newCheckedPersonProviderChecker()) + t.Setenv("DEFAULT_KEY", providerSetupSecretCanary) + before, err := config.ReadConfigFile(path) + require.NoError(err) + raced := before + raced.Content = []byte("operator change") + raced.ETag = "operator-change" + reads := 0 + deps.readConfigFile = func() (config.ConfigFile, error) { + reads++ + if reads == 1 { + return before, nil + } + return raced, nil + } + deps.editConfigTables = func(string, []config.TableEdit) (config.ConfigFile, error) { + return config.ConfigFile{}, config.ErrConfigConflict + } + restored := false + deps.restoreConfigFile = func(config.ConfigFile, config.ConfigFile) (config.ConfigFile, error) { + restored = true + return config.ConfigFile{}, nil + } + + _, err = executePersonProviderCommand(t, deps, + "set", "default", "--model", "conflict-model", "--yes") + require.ErrorIs(err, config.ErrConfigConflict) + assert.Equal(1, reads) + assert.False(restored) +} + +func TestPersonProviderSetClearsOptionalPolicyFields(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + path, loaded := providerSetupConfigFile(t) + snapshot, err := config.ReadConfigFile(path) + require.NoError(err) + provider := loaded.People.Sweep.Providers["default"] + provider.SourceUntil = "2025-12-31" + provider.ReasoningEffort = "high" + provider.ReasoningMode = "enabled" + _, err = config.EditConfigTables(path, snapshot.ETag, []config.TableEdit{{ + Path: []string{"people", "sweep", "providers", "default"}, + Values: personProviderTableValues(provider), + }}) + require.NoError(err) + loaded, err = config.Load(path, "") + require.NoError(err) + deps := providerSetupCommandDeps(t, path, loaded, newCheckedPersonProviderChecker()) + t.Setenv("DEFAULT_KEY", providerSetupSecretCanary) + + _, err = executePersonProviderCommand(t, deps, "set", "default", + "--source-until", "", "--reasoning-effort", "", "--reasoning-mode", "", "--yes") + require.NoError(err) + reloaded, err := config.Load(path, "") + require.NoError(err) + updated := reloaded.People.Sweep.Providers["default"] + assert.Empty(updated.SourceUntil) + assert.Empty(updated.ReasoningEffort) + assert.Empty(updated.ReasoningMode) +} + +func TestPersonProviderSetRechecksAndRevokesOldConsent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + path, loaded := providerSetupConfigFile(t) + checker := newCheckedPersonProviderChecker() + deps := providerSetupCommandDeps(t, path, loaded, checker) + t.Setenv("DEFAULT_KEY", providerSetupSecretCanary) + + oldConfig := loaded.People.Sweep + oldConfig.Enabled = true + oldProfile, err := oldConfig.Profile() + require.NoError(err) + st, cleanup, err := deps.openStore() + require.NoError(err) + t.Cleanup(cleanup) + _, err = st.EnsurePersonInferenceProfile(t.Context(), oldProfile) + require.NoError(err) + _, _, err = st.GrantPersonInferenceConsent( + t.Context(), oldProfile.Fingerprint, personProviderConsentActor) + require.NoError(err) + + _, err = executePersonProviderCommand(t, deps, + "set", "default", "--model", "rechecked-model", "--yes") + require.NoError(err) + reloaded, err := config.Load(path, "") + require.NoError(err) + newConfig := reloaded.People.Sweep + newConfig.Enabled = true + newProfile, err := newConfig.Profile() + require.NoError(err) + assert.NotEqual(oldProfile.Fingerprint, newProfile.Fingerprint) + oldActive, err := st.HasActivePersonInferenceConsent( + t.Context(), oldProfile.Fingerprint) + require.NoError(err) + assert.False(oldActive) + newChecked, err := st.HasSuccessfulPersonInferenceCheck( + t.Context(), newProfile.Fingerprint) + require.NoError(err) + assert.True(newChecked) + newActive, err := st.HasActivePersonInferenceConsent( + t.Context(), newProfile.Fingerprint) + require.NoError(err) + assert.False(newActive) + assert.Equal(int64(1), checker.calls.Load()) +} + +func TestPersonProviderSetPreservesUnselectedProfileAndConfig(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + path, _ := providerSetupConfigFile(t) + snapshot, err := config.ReadConfigFile(path) + require.NoError(err) + sibling := peoplesweep.ProviderConfig{ + Protocol: peoplesweep.ProtocolOpenAIChat, Endpoint: "https://sibling.example.test/v1", + Model: "sibling-model", Auth: peoplesweep.AuthBearer, + Credential: peoplesweep.CredentialEnv, CredentialEnv: "SIBLING_KEY", + OutputMode: peoplesweep.OutputModeNativeJSONSchema, + TokenLimitParameter: "max_completion_tokens", RetentionPosture: "zero_retention", + TrainingPosture: "no_training", AllowedSources: []peoplesweep.SourceClass{ + peoplesweep.SourceConversationText, + }, SourceSince: "2025-01-01", + } + _, err = config.EditConfigTables(path, snapshot.ETag, []config.TableEdit{ + {Path: []string{"people", "sweep", "providers", "sibling"}, Values: personProviderTableValues(sibling)}, + }) + require.NoError(err) + loaded, err := config.Load(path, "") + require.NoError(err) + deps := providerSetupCommandDeps(t, path, loaded, newCheckedPersonProviderChecker()) + t.Setenv("DEFAULT_KEY", providerSetupSecretCanary) + t.Setenv("SIBLING_KEY", "sibling-secret") + + _, err = executePersonProviderCommand(t, deps, + "set", "default", "--model", "preserved-model", "--yes") + require.NoError(err) + content, err := os.ReadFile(path) + require.NoError(err) + text := string(content) + assert.Contains(text, "# retained operator comment") + assert.Contains(text, "[future.operator_extension]") + assert.Contains(text, "answer = 42") + assert.Contains(text, "input_cost_microusd_per_million_tokens = 111 # operator price") + assert.Contains(text, `provider = "default" # selector formatting must survive rollback`) + assert.Contains(text, "credential_env = \"SIBLING_KEY\"") + assert.Contains(text, "model = \"sibling-model\"") + + reloaded, err := config.Load(path, "") + require.NoError(err) + assert.Equal("default", reloaded.People.Sweep.Provider.Name) + assert.False(reloaded.People.Sweep.Enabled) + assert.Equal("preserved-model", reloaded.People.Sweep.Providers["default"].Model) + assert.Equal("SIBLING_KEY", reloaded.People.Sweep.Providers["sibling"].CredentialEnv) +} + +func TestPersonProviderSetRemote(t *testing.T) { + assertAnError := assert.AnError + assert := assert.New(t) + require := require.New(t) + calls := 0 + deps := personProviderCommandDeps{ + remoteConfigured: func() bool { return true }, + readConfigFile: func() (config.ConfigFile, error) { + calls++ + return config.ConfigFile{}, assertAnError + }, + editConfigTables: func(string, []config.TableEdit) (config.ConfigFile, error) { + calls++ + return config.ConfigFile{}, assertAnError + }, + restoreConfigFile: func(config.ConfigFile, config.ConfigFile) (config.ConfigFile, error) { + calls++ + return config.ConfigFile{}, assertAnError + }, + setup: personProviderSetupDeps{ + credentials: countingCredentialStore{calls: &calls}, + lookupEnv: func(string) (string, bool) { + calls++ + return providerSetupSecretCanary, true + }, + }, + } + + _, err := executePersonProviderCommand(t, deps, + "set", "default", "--model", "remote-model", "--yes") + require.Error(err) + assert.Contains(err.Error(), "remote daemon") + assert.Zero(calls) +} + +func TestPersonProviderSetDaemon(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + path, loaded := providerSetupConfigFile(t) + deps := providerSetupCommandDeps(t, path, loaded, newCheckedPersonProviderChecker()) + deps.isDaemonSubprocess = func() bool { return false } + deps.providerStoreOwnedByDaemon = func(context.Context) (bool, error) { return true, nil } + var proxied []string + var checkFingerprint string + var revokeFingerprints []string + var revokeTargets []string + deps.proxy = func(command *cobra.Command, _ []string, _ map[string]string) error { + proxied = append(proxied, command.Use) + if command.Use == "revoke" { + fingerprint, _ := command.Flags().GetString(personProviderIfFingerprintFlag) + revokeFingerprints = append(revokeFingerprints, fingerprint) + target, _ := command.Flags().GetString("fingerprint") + revokeTargets = append(revokeTargets, target) + } + if command.Use == "check" { + checkFingerprint, _ = command.Flags().GetString(personProviderIfFingerprintFlag) + } + return nil + } + t.Setenv("DEFAULT_KEY", providerSetupSecretCanary) + + output, err := executePersonProviderCommand(t, deps, + "set", "default", "--model", "daemon-model", "--yes") + require.NoError(err) + assert.Contains(output, "msgvault daemon restart") + assert.Contains(output, "Updated and checked people provider profile") + assert.Equal([]string{"revoke", "revoke", "check"}, proxied) + assert.Len(revokeFingerprints, 2) + assert.NotEmpty(revokeFingerprints[0]) + assert.Empty(revokeFingerprints[1]) + assert.Empty(revokeTargets[0]) + assert.NotEmpty(revokeTargets[1]) + assert.NotEmpty(checkFingerprint) +} + func providerSetupConfigFile(t *testing.T) (string, *config.Config) { t.Helper() dir := t.TempDir() diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 16ff5349d..f3cf276db 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1439,6 +1439,40 @@ shipped definitions and complete workflow. --- +## person provider set + +Update the mutable policy fields of an existing named people inference provider +profile in place. The protocol, endpoint, auth scheme, credential source, +executable, execution boundary, selection, and enablement stay unchanged. + +```bash +msgvault person provider set --model [flags] +``` + +The command reruns the exact synthetic provider check and revokes consent for +the previous fingerprint. Grant fresh consent with +`msgvault person provider consent --yes` after reviewing the updated +policy. A running local daemon requires `msgvault daemon restart` before its +scheduled sweeps observe the change. Configured remote daemons refuse this +mutation; run it on the daemon host or pass `--local`. + +| Flag | Default | Description | +|---|---|---| +| `--model` | unchanged | Provider model identifier | +| `--retention-posture` | unchanged | Provider retention assertion | +| `--training-posture` | unchanged | Provider training assertion | +| `--source` | unchanged | Allowed source class; repeatable, replaces the existing list | +| `--source-since` | unchanged | Earliest disclosed source date | +| `--source-until` | unchanged | Latest disclosed source date | +| `--allow-sensitive` | unchanged | Allow sensitive text in provider packets | +| `--reasoning-effort` | unchanged | Explicit reasoning effort | +| `--reasoning-mode` | unchanged | Explicit reasoning mode | +| `--request-timeout` | unchanged | Provider request timeout | +| `--yes` | `false` | Confirm the final provider and privacy values; required | +| `--json` | `false` | Output structured JSON | + +--- + ## attribute-definition Manage portable field metadata. Definitions add no runtime database columns; diff --git a/internal/api/cli_allowlist_person_provider_test.go b/internal/api/cli_allowlist_person_provider_test.go index dd09a35ca..12e694e6b 100644 --- a/internal/api/cli_allowlist_person_provider_test.go +++ b/internal/api/cli_allowlist_person_provider_test.go @@ -45,8 +45,14 @@ func TestCLIRunCommandAllowedPermitsExactPersonProviderCommands(t *testing.T) { {name: "guarded revoke", args: []string{ "person", "provider", "revoke", "alpha", "--if-fingerprint", strings.Repeat("a", 64), }, want: true}, + {name: "targeted revoke", args: []string{ + "person", "provider", "revoke", "alpha", "--fingerprint", strings.Repeat("b", 64), + }, want: true}, {name: "check", args: []string{"person", "provider", "check", "--json"}, want: true}, {name: "named check", args: []string{"person", "provider", "check", "alpha", "--json"}, want: true}, + {name: "guarded check", args: []string{ + "person", "provider", "check", "alpha", "--if-fingerprint", strings.Repeat("a", 64), + }, want: true}, {name: "history", args: []string{"person", "provider", "history", "alpha", "--limit", "20"}, want: true}, {name: "add is local", args: []string{"person", "provider", "add", "alpha"}}, {name: "use is local", args: []string{"person", "provider", "use", "alpha"}}, diff --git a/internal/api/cli_handlers.go b/internal/api/cli_handlers.go index 9f47532a4..922d8a782 100644 --- a/internal/api/cli_handlers.go +++ b/internal/api/cli_handlers.go @@ -1681,6 +1681,7 @@ func cliRunPersonProviderArgsAllowed(operation string, args []string) bool { boolFlags[name] = true } valueFlags["if-fingerprint"] = true + valueFlags["fingerprint"] = true case "history": maxPositionals = 1 boolFlags["json"] = true @@ -1689,6 +1690,7 @@ func cliRunPersonProviderArgsAllowed(operation string, args []string) bool { case "check": maxPositionals = 1 boolFlags["json"] = true + valueFlags["if-fingerprint"] = true default: return false }