Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions cmd/msgvault/cmd/documents_vector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,49 @@ func TestDocumentVectorLedgerCommandsNeverOpenRuntime(t *testing.T) {
assert.Zero(runtimeCalls)
}

func TestSetupStatusTracksBothDocumentVectorConsentPurposes(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
fixture, _ := documentVectorCommandFixture(t)
cfg.Attachments.Documents.Enabled = true
deps := documentsCommandDeps{
openStore: func() (*store.Store, func(), error) { return fixture.Store, func() {}, nil },
}
env := setupEnvironment{
lookupEnv: func(string) (string, bool) { return "synthetic-key", true },
consent: setupConsentFromStore(t.Context(), cfg, fixture.Store),
}
lane := documentVectorsLane(cfg, env)
assert.Equal(laneStatePending, lane.State)
assert.Equal(map[string]string{"document_embedding": consentMissing, "query_embedding": consentMissing}, lane.ConsentPurposes)
assert.ElementsMatch([]string{"msgvault documents vectors consent --yes", "msgvault documents vectors consent --purpose queries --yes"}, lane.Next)
for _, purpose := range []string{"documents", "queries"} {
command := newDocumentsCmd(deps)
var output bytes.Buffer
command.SetOut(&output)
command.SetArgs([]string{"vectors", "consent", "--purpose", purpose, "--yes"})
require.NoError(command.ExecuteContext(t.Context()), output.String())
env.consent = setupConsentFromStore(t.Context(), cfg, fixture.Store)
lane = documentVectorsLane(cfg, env)
assert.Equal(consentActive, lane.ConsentPurposes["document_embedding"])
if purpose == "documents" {
assert.Equal(laneStatePending, lane.State)
assert.Equal(consentMissing, lane.ConsentPurposes["query_embedding"])
assert.Equal([]string{"msgvault documents vectors consent --purpose queries --yes"}, lane.Next)
} else {
assert.Equal(laneStateOn, lane.State)
assert.Equal(consentActive, lane.ConsentPurposes["query_embedding"])
assert.Equal(consentActive, lane.Consent)
assert.Empty(lane.Next)
}
}
cfg.Vector.Embeddings.Endpoint = "https://changed.example.test/v1"
env.consent = setupConsentFromStore(t.Context(), cfg, fixture.Store)
lane = documentVectorsLane(cfg, env)
assert.Equal(laneStatePending, lane.State)
assert.Equal(map[string]string{"document_embedding": consentMissing, "query_embedding": consentMissing}, lane.ConsentPurposes)
}

func TestDocumentVectorStatusWorksWhenEmbeddingsAreDisabled(t *testing.T) {
markDaemonCLISubprocessForTest(t)
previous := cfg
Expand Down
49 changes: 49 additions & 0 deletions cmd/msgvault/cmd/multimodal_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (

"go.kenn.io/msgvault/internal/fileutil"
"go.kenn.io/msgvault/internal/providercredentials"
"go.kenn.io/msgvault/internal/vector"
"go.kenn.io/msgvault/internal/vector/visual"
)

var (
Expand Down Expand Up @@ -145,6 +147,53 @@ func writeVisualCapabilityManifest(path string, manifest voyage.CapabilityManife
return nil
}

// visualVoyageConfig binds the manifest to the media policy used by both
// runtime uploads and setup's read-only consent check.
func visualVoyageConfig(cfg vector.Config) (visual.VoyageConfig, error) {
manifest, err := loadVisualCapabilityManifest(cfg.Multimodal.CapabilitiesFile)
if err != nil {
return visual.VoyageConfig{}, err
}
media := visual.DefaultMediaPolicy()
media.IncludeImages = cfg.Multimodal.ImagesEnabled() || cfg.Multimodal.ImageQueriesEnabled()
media.IncludeVideo = cfg.Multimodal.VideoEnabled()
media.AllowAnimatedGIF = cfg.Multimodal.AnimatedGIFsEnabled()
provider := visual.VoyageConfig{
Model: cfg.Multimodal.Model, Dimension: cfg.Multimodal.Dimension,
Manifest: manifest, Media: media,
}
policy, err := provider.Policy()
if err != nil {
return visual.VoyageConfig{}, fmt.Errorf("configure visual capability policy: %w", err)
}
// Every visual search embeds its text query through this provider. A
// manifest that only authorizes indexing cannot make the lane usable.
if _, err := policy.Authorize(manifest, voyage.CapabilityQueryText); err != nil {
return visual.VoyageConfig{}, fmt.Errorf("capability manifest does not authorize text queries; re-run `msgvault multimodal probe`: %w", err)
}
return provider, nil
}

// loadVisualCapabilityManifest reads and strictly validates the operator's
// probed Voyage capability manifest. The multimodal lane cannot run without
// one: nothing has upload authority until a probe recorded it.
func loadVisualCapabilityManifest(path string) (voyage.CapabilityManifest, error) {
if strings.TrimSpace(path) == "" {
return voyage.CapabilityManifest{}, errors.New(
"vector.multimodal.capabilities_file is not set; run `msgvault multimodal probe` and configure the manifest path")
}
file, err := os.Open(path)
if err != nil {
return voyage.CapabilityManifest{}, fmt.Errorf("open Voyage capability manifest: %w", err)
}
defer func() { _ = file.Close() }()
manifest, err := voyage.DecodeCapabilityManifest(file)
if err != nil {
return voyage.CapabilityManifest{}, fmt.Errorf("decode Voyage capability manifest %s: %w", path, err)
}
return manifest, nil
}

func init() {
multimodalProbeCmd.Flags().StringVar(&multimodalProbeSeeds, "seeds", "", "Private directory with synthetic WebP and MP4 seeds (primary and contrasting variant of each)")
multimodalProbeCmd.Flags().StringVar(&multimodalProbeFixtures, "fixtures", "", "Optional directory to keep the generated fixtures (default: temporary)")
Expand Down
95 changes: 94 additions & 1 deletion cmd/msgvault/cmd/person_provider_daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"

Expand Down Expand Up @@ -53,7 +54,12 @@ func (s *inProcessPersonProviderDaemonStore) RunCLICommand(
config,
consent,
registry,
peoplesweep.NewCredentialResolver(s.credentials, os.LookupEnv),
peoplesweep.NewCredentialResolver(s.credentials, func(name string) (string, bool) {
if value, ok := req.Env[name]; ok {
return value, true
}
return os.LookupEnv(name)
}),
)
}

Expand All @@ -77,6 +83,93 @@ func (s *inProcessPersonProviderDaemonStore) RunCLICommand(
return nil
}

func TestSavedPersonProviderCheckForwardsExactCredentialThroughDaemon(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
const keyName = "SETUP_ONLY_PROVIDER_KEY"
const secret = "synthetic-onboarding-key"
t.Setenv(keyName, "") // The daemon process does not have the caller's key.
var received atomic.Int64
provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+secret {
http.Error(w, "missing credential", http.StatusUnauthorized)
return
}
received.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"model":"test-model","choices":[{"message":{"content":"{\"ok\":true}"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
t.Cleanup(provider.Close)
peopleConfig := personProviderTestConfig()
onboarded := configuredPersonProvider(peopleConfig)
onboarded.Endpoint, onboarded.CredentialEnv = provider.URL+"/v1", keyName
peopleConfig.Providers["onboarded"] = onboarded
daemonConfig := config.NewDefaultConfig()
daemonConfig.HomeDir = t.TempDir()
daemonConfig.People.Sweep = personProviderTestConfig()
// Onboarding publishes a new profile after the daemon has started.
saved := *daemonConfig
saved.People.Sweep = peopleConfig
require.NoError(saved.Save())
st := &inProcessPersonProviderDaemonStore{
storeAPIAdapter: &storeAPIAdapter{store: testutil.NewSQLiteTestStore(t)},
config: peopleConfig, httpClient: provider.Client(),
}
daemon := api.NewServerWithOptions(api.ServerOptions{
Config: daemonConfig, Store: st, Logger: slog.New(slog.DiscardHandler),
OperationGate: api.NewSerialOperationGate(),
})
server := httptest.NewServer(daemon.Router())
t.Cleanup(server.Close)
frontend := *daemonConfig
frontend.People.Sweep = peopleConfig
frontend.Remote = config.RemoteConfig{URL: server.URL, AllowInsecure: true}
withStoreResolverConfig(t, &frontend)
deps := defaultPersonProviderCommandDeps()
callerHasKey := false
deps.setup.lookupEnv = func(name string) (string, bool) {
assert.Equal(keyName, name)
return secret, callerHasKey
}
var output bytes.Buffer
command := &cobra.Command{Use: "setup"}
command.SetContext(t.Context())
command.SetOut(&output)
command.SetErr(&output)
require.Error(executeSavedPersonProviderCheck(command, deps, "onboarded", "", &output))
assert.Zero(received.Load())
output.Reset()
callerHasKey = true
require.NoError(executeSavedPersonProviderCheck(command, deps, "onboarded", "", &output), output.String())
assert.Equal(int64(1), received.Load())
assert.NotContains(output.String(), secret)

profileConfig := peopleConfig
profileConfig.Enabled = true
profileConfig.Provider = peoplesweep.ProviderSelection{Name: "onboarded"}
profile, err := profileConfig.Profile()
require.NoError(err)
for _, test := range []struct{ name, fingerprint, key string }{
{name: "other provider key", fingerprint: profile.Fingerprint, key: "TEST_PROVIDER_KEY"},
{name: "changed profile", fingerprint: strings.Repeat("a", 64), key: keyName},
{name: "ordinary check", key: keyName},
} {
t.Run(test.name, func(t *testing.T) {
args := []string{"person", "provider", "check", "onboarded"}
if test.fingerprint != "" {
args = append(args, "--if-fingerprint", test.fingerprint)
}
body := mustJSON(t, api.CLIRunRequest{Args: args, Env: map[string]string{test.key: secret}})
request := httptest.NewRequest(http.MethodPost, "/api/v1/cli/run", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
daemon.Router().ServeHTTP(response, request)
assert.Equal(http.StatusBadRequest, response.Code, response.Body.String())
assert.Equal(int64(1), received.Load())
})
}
}

func TestPersonProviderRealDaemonSyntheticCheckAndRevoke(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
Expand Down
29 changes: 25 additions & 4 deletions cmd/msgvault/cmd/person_provider_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -1026,7 +1026,26 @@ func executeSavedPersonProviderCheck(
}
return writePersonProviderCheckOutput(out, output, false)
}
return proxySavedPersonProviderOperation(command, deps, "check", name, ifFingerprint, out)
selected, err := selectPersonProviderConfig(deps.config(), name)
if err != nil {
return err
}
selected.Enabled = true
profile, err := selected.Profile()
if err != nil {
return err
}
if ifFingerprint != "" && ifFingerprint != profile.Fingerprint {
return errors.New("people provider profile changed before checking")
}
var env map[string]string
if profile.Credential == peoplesweep.CredentialEnv && profile.Auth != peoplesweep.AuthNone && deps.setup.lookupEnv != nil {
if value, ok := deps.setup.lookupEnv(profile.CredentialRef); ok && strings.TrimSpace(value) != "" {
env = map[string]string{profile.CredentialRef: value}
}
}
return proxySavedPersonProviderOperationWithFlag(command, deps, "check", name,
personProviderIfFingerprintFlag, profile.Fingerprint, out, env)
}

func verifyPersonProviderFingerprint(
Expand Down Expand Up @@ -1070,7 +1089,7 @@ func proxySavedPersonProviderRevokeFingerprint(
fingerprint string,
) error {
return proxySavedPersonProviderOperationWithFlag(
command, deps, "revoke", name, "fingerprint", fingerprint, io.Discard,
command, deps, "revoke", name, "fingerprint", fingerprint, io.Discard, nil,
)
}

Expand All @@ -1083,7 +1102,7 @@ func proxySavedPersonProviderOperation(
out io.Writer,
) error {
return proxySavedPersonProviderOperationWithFlag(
command, deps, operation, name, personProviderIfFingerprintFlag, fingerprint, out,
command, deps, operation, name, personProviderIfFingerprintFlag, fingerprint, out, nil,
)
}

Expand All @@ -1095,6 +1114,7 @@ func proxySavedPersonProviderOperationWithFlag(
flag string,
fingerprint string,
out io.Writer,
env map[string]string,
) error {
if err := peoplesweep.ValidateProviderProfileName(name); err != nil {
return err
Expand Down Expand Up @@ -1123,7 +1143,8 @@ func proxySavedPersonProviderOperationWithFlag(
root.AddCommand(person)
leaf.SetOut(out)
leaf.SetErr(command.ErrOrStderr())
return deps.proxy(leaf, []string{name}, nil)
leaf.SetContext(command.Context())
return deps.proxy(leaf, []string{name}, env)
}

func rollbackNewPersonProviderCredential(
Expand Down
41 changes: 4 additions & 37 deletions cmd/msgvault/cmd/serve_vector.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"go.kenn.io/docbank/document/voyage"
"log/slog"
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
Expand Down Expand Up @@ -806,7 +805,7 @@ func newVisualRuntime(
if err != nil {
return nil, err
}
manifest, err := loadVisualCapabilityManifest(vecCfg.Multimodal.CapabilitiesFile)
providerConfig, err := visualVoyageConfig(vecCfg)
if err != nil {
return nil, err
}
Expand All @@ -819,28 +818,16 @@ func newVisualRuntime(
// input the search layer permits. Document eligibility (the reconciler's
// mediaPolicy) stays unchanged. Configs with images already enabled are
// identical, so no existing consent fingerprint moves.
providerMedia := mediaPolicy
if vecCfg.Multimodal.ImageQueriesEnabled() {
providerMedia.IncludeImages = true
}
provider, err := visual.NewVoyageProvider(visual.VoyageConfig{
APIKey: apiKey, Model: vecCfg.Multimodal.Model,
Dimension: vecCfg.Multimodal.Dimension, Manifest: manifest, Media: providerMedia,
HTTPClient: providerHTTPClientWithoutRedirects(httpClient),
})
providerConfig.APIKey = apiKey
providerConfig.HTTPClient = providerHTTPClientWithoutRedirects(httpClient)
provider, err := visual.NewVoyageProvider(providerConfig)
if err != nil {
return nil, err
}
// A format is only eligible when the probe authorized the exact request
// shapes this archive sends: the document capability always, and its
// interleaved twin because owning-message context accompanies media
// whenever the message has any.
// Every visual search embeds its text query through the same client;
// without probed text-query authority the lane would index (and bill)
// while rejecting every search. Fail initialization with the remedy.
if !slices.Contains(provider.AuthorizedCapabilities(), voyage.CapabilityQueryText) {
return nil, errors.New("the capability manifest does not authorize text queries; re-run `msgvault multimodal probe` and configure the new manifest")
}
mediaPolicy.AuthorizedCapabilities = eligibleVisualCapabilities(
provider.AuthorizedCapabilities(), vecCfg.Multimodal.MaxContextChars > 0)
consumerKey := "visual/" + fingerprint
Expand Down Expand Up @@ -910,26 +897,6 @@ func visualScopeCheck(s *store.Store, accounts []string, expected []int64) func(
}
}

// loadVisualCapabilityManifest reads and strictly validates the operator's
// probed Voyage capability manifest. The multimodal lane cannot run without
// one: nothing has upload authority until a probe recorded it.
func loadVisualCapabilityManifest(path string) (voyage.CapabilityManifest, error) {
if strings.TrimSpace(path) == "" {
return voyage.CapabilityManifest{}, errors.New(
"vector.multimodal.capabilities_file is not set; run `msgvault multimodal probe` and configure the manifest path")
}
file, err := os.Open(path)
if err != nil {
return voyage.CapabilityManifest{}, fmt.Errorf("open Voyage capability manifest: %w", err)
}
defer func() { _ = file.Close() }()
manifest, err := voyage.DecodeCapabilityManifest(file)
if err != nil {
return voyage.CapabilityManifest{}, fmt.Errorf("decode Voyage capability manifest %s: %w", path, err)
}
return manifest, nil
}

// eligibleVisualCapabilities filters probed document capabilities to those
// whose interleaved twin is also probed when message context is enabled, so
// no eligible owner can produce a request shape the manifest does not cover.
Expand Down
Loading