diff --git a/docs/docs/03-tools/04-credential-tools.md b/docs/docs/03-tools/04-credential-tools.md index 1911dc34..46a0e69e 100644 --- a/docs/docs/03-tools/04-credential-tools.md +++ b/docs/docs/03-tools/04-credential-tools.md @@ -222,3 +222,55 @@ import os print("myCred expires at " + os.getenv("GPTSCRIPT_CREDENTIAL_EXPIRATION", "")) ``` + +## Stacked Credential Contexts (Advanced) + +When setting the `--credential-context` argument in GPTScript, you can specify multiple contexts separated by commas. +We refer to this as "stacked credential contexts", or just stacked contexts for short. This allows you to specify an order +of priority for credential contexts. This is best explained by example. + +### Example: stacked contexts when running a script that uses a credential + +Let's say you have two contexts, `one` and `two`, and you specify them like this: + +```bash +gptscript --credential-context one,two my-script.gpt +``` + +``` +Credential: my-credential-tool.gpt as myCred + + +``` + +When GPTScript runs, it will first look for a credential called `myCred` in the `one` context. +If it doesn't find it there, it will look for it in the `two` context. If it also doesn't find it there, +it will run the `my-credential-tool.gpt` tool to get the credential. It will then store the new credential into the `one` +context, since that has the highest priority. + +### Example: stacked contexts when listing credentials + +```bash +gptscript --credential-context one,two credentials +``` + +When you list credentials like this, GPTScript will print out the information for all credentials in contexts one and two, +with one exception. If there is a credential name that exists in both contexts, GPTScript will only print the information +for the credential in the context with the highest priority, which in this case is `one`. + +(To see all credentials in all contexts, you can still use the `--all-contexts` flag, and it will show all credentials, +regardless of whether the same name appears in another context.) + +### Example: stacked contexts when showing credentials + +```bash +gptscript --credential-context one,two credential show myCred +``` + +When you show a credential like this, GPTScript will first look for `myCred` in the `one` context. If it doesn't find it +there, it will look for it in the `two` context. If it doesn't find it in either context, it will print an error message. + +:::note +You cannot specify stacked contexts when doing `gptscript credential delete`. GPTScript will return an error if +more than one context is specified for this command. +::: diff --git a/docs/docs/04-command-line-reference/gptscript.md b/docs/docs/04-command-line-reference/gptscript.md index b7de5e86..8a726c64 100644 --- a/docs/docs/04-command-line-reference/gptscript.md +++ b/docs/docs/04-command-line-reference/gptscript.md @@ -18,7 +18,7 @@ gptscript [flags] PROGRAM_FILE [INPUT...] --color Use color in output (default true) ($GPTSCRIPT_COLOR) --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) --debug Enable debug logging ($GPTSCRIPT_DEBUG) --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) diff --git a/docs/docs/04-command-line-reference/gptscript_credential.md b/docs/docs/04-command-line-reference/gptscript_credential.md index 435ba6e5..eb5781f4 100644 --- a/docs/docs/04-command-line-reference/gptscript_credential.md +++ b/docs/docs/04-command-line-reference/gptscript_credential.md @@ -20,7 +20,7 @@ gptscript credential [flags] ### Options inherited from parent commands ``` - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) ``` ### SEE ALSO diff --git a/docs/docs/04-command-line-reference/gptscript_credential_delete.md b/docs/docs/04-command-line-reference/gptscript_credential_delete.md index c2f78e88..c9cffdd3 100644 --- a/docs/docs/04-command-line-reference/gptscript_credential_delete.md +++ b/docs/docs/04-command-line-reference/gptscript_credential_delete.md @@ -18,7 +18,7 @@ gptscript credential delete [flags] ### Options inherited from parent commands ``` - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) ``` ### SEE ALSO diff --git a/docs/docs/04-command-line-reference/gptscript_credential_show.md b/docs/docs/04-command-line-reference/gptscript_credential_show.md index f5fb11af..f89df87a 100644 --- a/docs/docs/04-command-line-reference/gptscript_credential_show.md +++ b/docs/docs/04-command-line-reference/gptscript_credential_show.md @@ -18,7 +18,7 @@ gptscript credential show [flags] ### Options inherited from parent commands ``` - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) ``` ### SEE ALSO diff --git a/docs/docs/04-command-line-reference/gptscript_eval.md b/docs/docs/04-command-line-reference/gptscript_eval.md index ff9e6446..257cf609 100644 --- a/docs/docs/04-command-line-reference/gptscript_eval.md +++ b/docs/docs/04-command-line-reference/gptscript_eval.md @@ -30,7 +30,7 @@ gptscript eval [flags] --color Use color in output (default true) ($GPTSCRIPT_COLOR) --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) --debug Enable debug logging ($GPTSCRIPT_DEBUG) --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) diff --git a/docs/docs/04-command-line-reference/gptscript_fmt.md b/docs/docs/04-command-line-reference/gptscript_fmt.md index 7aceb957..1175a1f1 100644 --- a/docs/docs/04-command-line-reference/gptscript_fmt.md +++ b/docs/docs/04-command-line-reference/gptscript_fmt.md @@ -24,7 +24,7 @@ gptscript fmt [flags] --color Use color in output (default true) ($GPTSCRIPT_COLOR) --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) --debug Enable debug logging ($GPTSCRIPT_DEBUG) --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) diff --git a/docs/docs/04-command-line-reference/gptscript_getenv.md b/docs/docs/04-command-line-reference/gptscript_getenv.md index 80fea614..4a688439 100644 --- a/docs/docs/04-command-line-reference/gptscript_getenv.md +++ b/docs/docs/04-command-line-reference/gptscript_getenv.md @@ -23,7 +23,7 @@ gptscript getenv [flags] KEY [DEFAULT] --color Use color in output (default true) ($GPTSCRIPT_COLOR) --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) --debug Enable debug logging ($GPTSCRIPT_DEBUG) --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) diff --git a/docs/docs/04-command-line-reference/gptscript_parse.md b/docs/docs/04-command-line-reference/gptscript_parse.md index 3d84622b..66d2791c 100644 --- a/docs/docs/04-command-line-reference/gptscript_parse.md +++ b/docs/docs/04-command-line-reference/gptscript_parse.md @@ -24,7 +24,7 @@ gptscript parse [flags] --color Use color in output (default true) ($GPTSCRIPT_COLOR) --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-context strings Context name(s) in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) --debug Enable debug logging ($GPTSCRIPT_DEBUG) --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) diff --git a/integration/cred_test.go b/integration/cred_test.go index d77f096c..1ea73d35 100644 --- a/integration/cred_test.go +++ b/integration/cred_test.go @@ -45,3 +45,57 @@ func TestCredentialExpirationEnv(t *testing.T) { } } } + +// TestStackedCredentialContexts tests creating, using, listing, showing, and deleting credentials when there are multiple contexts. +func TestStackedCredentialContexts(t *testing.T) { + // First, test credential creation. We will create a credential called testcred in two different contexts called one and two. + _, err := RunScript("scripts/cred_stacked.gpt", "--sub-tool", "testcred_one", "--credential-context", "one,two") + require.NoError(t, err) + + _, err = RunScript("scripts/cred_stacked.gpt", "--sub-tool", "testcred_two", "--credential-context", "two") + require.NoError(t, err) + + // Next, we try running the testcred_one tool. It should print the value of "testcred" in whichever context it finds the cred first. + out, err := RunScript("scripts/cred_stacked.gpt", "--sub-tool", "testcred_one", "--credential-context", "one,two") + require.NoError(t, err) + require.Contains(t, out, "one") + require.NotContains(t, out, "two") + + out, err = RunScript("scripts/cred_stacked.gpt", "--sub-tool", "testcred_one", "--credential-context", "two,one") + require.NoError(t, err) + require.Contains(t, out, "two") + require.NotContains(t, out, "one") + + // Next, list credentials and specify both contexts. We should get the credential from the first specified context. + out, err = GPTScriptExec("--credential-context", "one,two", "cred") + require.NoError(t, err) + require.Contains(t, out, "one") + require.NotContains(t, out, "two") + + out, err = GPTScriptExec("--credential-context", "two,one", "cred") + require.NoError(t, err) + require.Contains(t, out, "two") + require.NotContains(t, out, "one") + + // Next, try showing the credentials. + out, err = GPTScriptExec("--credential-context", "one,two", "cred", "show", "testcred") + require.NoError(t, err) + require.Contains(t, out, "one") + require.NotContains(t, out, "two") + + out, err = GPTScriptExec("--credential-context", "two,one", "cred", "show", "testcred") + require.NoError(t, err) + require.Contains(t, out, "two") + require.NotContains(t, out, "one") + + // Make sure we get an error if we try to delete a credential with multiple contexts specified. + _, err = GPTScriptExec("--credential-context", "one,two", "cred", "delete", "testcred") + require.Error(t, err) + + // Now actually delete the credentials. + _, err = GPTScriptExec("--credential-context", "one", "cred", "delete", "testcred") + require.NoError(t, err) + + _, err = GPTScriptExec("--credential-context", "two", "cred", "delete", "testcred") + require.NoError(t, err) +} diff --git a/integration/scripts/cred_stacked.gpt b/integration/scripts/cred_stacked.gpt new file mode 100644 index 00000000..1072ca7b --- /dev/null +++ b/integration/scripts/cred_stacked.gpt @@ -0,0 +1,36 @@ +name: testcred_one +credential: cred_one as testcred + +#!python3 + +import os + +print(os.environ.get("VALUE")) + +--- +name: testcred_two +credential: cred_two as testcred + +#!python3 + +import os + +print(os.environ.get("VALUE")) + +--- +name: cred_one + +#!python3 + +import json + +print(json.dumps({"env": {"VALUE": "one"}})) + +--- +name: cred_two + +#!python3 + +import json + +print(json.dumps({"env": {"VALUE": "two"}})) diff --git a/pkg/cli/credential.go b/pkg/cli/credential.go index 733590c4..674160b9 100644 --- a/pkg/cli/credential.go +++ b/pkg/cli/credential.go @@ -9,11 +9,10 @@ import ( "time" cmd2 "github.com/gptscript-ai/cmd" - "github.com/gptscript-ai/gptscript/pkg/cache" "github.com/gptscript-ai/gptscript/pkg/config" "github.com/gptscript-ai/gptscript/pkg/credentials" + "github.com/gptscript-ai/gptscript/pkg/gptscript" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes" - "github.com/gptscript-ai/gptscript/pkg/runner" "github.com/spf13/cobra" ) @@ -43,27 +42,26 @@ func (c *Credential) Run(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to read CLI config: %w", err) } - ctx := c.root.CredentialContext - if c.AllContexts { - ctx = credentials.AllCredentialContexts - } - opts, err := c.root.NewGPTScriptOpts() if err != nil { return err } - opts.Cache = cache.Complete(opts.Cache) - opts.Runner = runner.Complete(opts.Runner) + opts = gptscript.Complete(opts) if opts.Runner.RuntimeManager == nil { opts.Runner.RuntimeManager = runtimes.Default(opts.Cache.CacheDir) } + ctxs := opts.CredentialContexts + if c.AllContexts { + ctxs = []string{credentials.AllCredentialContexts} + } + if err = opts.Runner.RuntimeManager.SetUpCredentialHelpers(cmd.Context(), cfg); err != nil { return err } // Initialize the credential store and get all the credentials. - store, err := credentials.NewStore(cfg, opts.Runner.RuntimeManager, ctx, opts.Cache.CacheDir) + store, err := credentials.NewStore(cfg, opts.Runner.RuntimeManager, ctxs, opts.Cache.CacheDir) if err != nil { return fmt.Errorf("failed to get credentials store: %w", err) } @@ -77,7 +75,7 @@ func (c *Credential) Run(cmd *cobra.Command, _ []string) error { defer w.Flush() // Sort credentials and print column names, depending on the options. - if c.AllContexts { + if c.AllContexts || len(c.root.CredentialContext) > 1 { // Sort credentials by context sort.Slice(creds, func(i, j int) bool { if creds[i].Context == creds[j].Context { @@ -114,7 +112,7 @@ func (c *Credential) Run(cmd *cobra.Command, _ []string) error { } var fields []any - if c.AllContexts { + if c.AllContexts || len(c.root.CredentialContext) > 1 { fields = []any{cred.Context, cred.ToolName, expires} } else { fields = []any{cred.ToolName, expires} diff --git a/pkg/cli/credential_delete.go b/pkg/cli/credential_delete.go index 4e9919df..b17ae851 100644 --- a/pkg/cli/credential_delete.go +++ b/pkg/cli/credential_delete.go @@ -3,11 +3,10 @@ package cli import ( "fmt" - "github.com/gptscript-ai/gptscript/pkg/cache" "github.com/gptscript-ai/gptscript/pkg/config" "github.com/gptscript-ai/gptscript/pkg/credentials" + "github.com/gptscript-ai/gptscript/pkg/gptscript" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes" - "github.com/gptscript-ai/gptscript/pkg/runner" "github.com/spf13/cobra" ) @@ -34,8 +33,7 @@ func (c *Delete) Run(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to read CLI config: %w", err) } - opts.Cache = cache.Complete(opts.Cache) - opts.Runner = runner.Complete(opts.Runner) + opts = gptscript.Complete(opts) if opts.Runner.RuntimeManager == nil { opts.Runner.RuntimeManager = runtimes.Default(opts.Cache.CacheDir) } @@ -44,7 +42,7 @@ func (c *Delete) Run(cmd *cobra.Command, args []string) error { return err } - store, err := credentials.NewStore(cfg, opts.Runner.RuntimeManager, c.root.CredentialContext, opts.Cache.CacheDir) + store, err := credentials.NewStore(cfg, opts.Runner.RuntimeManager, opts.CredentialContexts, opts.Cache.CacheDir) if err != nil { return fmt.Errorf("failed to get credentials store: %w", err) } diff --git a/pkg/cli/credential_show.go b/pkg/cli/credential_show.go index fac1b719..d8ea980b 100644 --- a/pkg/cli/credential_show.go +++ b/pkg/cli/credential_show.go @@ -5,11 +5,10 @@ import ( "os" "text/tabwriter" - "github.com/gptscript-ai/gptscript/pkg/cache" "github.com/gptscript-ai/gptscript/pkg/config" "github.com/gptscript-ai/gptscript/pkg/credentials" + "github.com/gptscript-ai/gptscript/pkg/gptscript" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes" - "github.com/gptscript-ai/gptscript/pkg/runner" "github.com/spf13/cobra" ) @@ -36,8 +35,7 @@ func (c *Show) Run(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to read CLI config: %w", err) } - opts.Cache = cache.Complete(opts.Cache) - opts.Runner = runner.Complete(opts.Runner) + opts = gptscript.Complete(opts) if opts.Runner.RuntimeManager == nil { opts.Runner.RuntimeManager = runtimes.Default(opts.Cache.CacheDir) } @@ -46,7 +44,7 @@ func (c *Show) Run(cmd *cobra.Command, args []string) error { return err } - store, err := credentials.NewStore(cfg, opts.Runner.RuntimeManager, c.root.CredentialContext, opts.Cache.CacheDir) + store, err := credentials.NewStore(cfg, opts.Runner.RuntimeManager, opts.CredentialContexts, opts.Cache.CacheDir) if err != nil { return fmt.Errorf("failed to get credentials store: %w", err) } diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go index 2d7e90d9..66719adc 100644 --- a/pkg/cli/gptscript.go +++ b/pkg/cli/gptscript.go @@ -64,7 +64,7 @@ type GPTScript struct { Chdir string `usage:"Change current working directory" short:"C"` Daemon bool `usage:"Run tool as a daemon" local:"true" hidden:"true"` Ports string `usage:"The port range to use for ephemeral daemon ports (ex: 11000-12000)" hidden:"true"` - CredentialContext string `usage:"Context name in which to store credentials" default:"default"` + CredentialContext []string `usage:"Context name(s) in which to store credentials"` CredentialOverride []string `usage:"Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234)"` ChatState string `usage:"The chat state to continue, or null to start a new chat and return the state" local:"true"` ForceChat bool `usage:"Force an interactive chat session if even the top level tool is not a chat tool" local:"true"` @@ -142,7 +142,7 @@ func (r *GPTScript) NewGPTScriptOpts() (gptscript.Options, error) { }, Quiet: r.Quiet, Env: os.Environ(), - CredentialContext: r.CredentialContext, + CredentialContexts: r.CredentialContext, Workspace: r.Workspace, DisablePromptServer: r.UI, DefaultModelProvider: r.DefaultModelProvider, diff --git a/pkg/credentials/store.go b/pkg/credentials/store.go index c8558f3a..749aba3a 100644 --- a/pkg/credentials/store.go +++ b/pkg/credentials/store.go @@ -8,7 +8,10 @@ import ( "strings" "github.com/docker/cli/cli/config/credentials" + "github.com/docker/cli/cli/config/types" + credentials2 "github.com/docker/docker-credential-helpers/credentials" "github.com/gptscript-ai/gptscript/pkg/config" + "golang.org/x/exp/maps" ) const ( @@ -28,18 +31,18 @@ type CredentialStore interface { } type Store struct { - credCtx string + credCtxs []string credBuilder CredentialBuilder credHelperDirs CredentialHelperDirs cfg *config.CLIConfig } -func NewStore(cfg *config.CLIConfig, credentialBuilder CredentialBuilder, credCtx, cacheDir string) (CredentialStore, error) { - if err := validateCredentialCtx(credCtx); err != nil { +func NewStore(cfg *config.CLIConfig, credentialBuilder CredentialBuilder, credCtxs []string, cacheDir string) (CredentialStore, error) { + if err := validateCredentialCtx(credCtxs); err != nil { return nil, err } return Store{ - credCtx: credCtx, + credCtxs: credCtxs, credBuilder: credentialBuilder, credHelperDirs: GetCredentialHelperDirs(cacheDir), cfg: cfg, @@ -47,22 +50,45 @@ func NewStore(cfg *config.CLIConfig, credentialBuilder CredentialBuilder, credCt } func (s Store) Get(ctx context.Context, toolName string) (*Credential, bool, error) { + if first(s.credCtxs) == AllCredentialContexts { + return nil, false, fmt.Errorf("cannot get a credential with context %q", AllCredentialContexts) + } + store, err := s.getStore(ctx) if err != nil { return nil, false, err } - auth, err := store.Get(toolNameWithCtx(toolName, s.credCtx)) - if err != nil { - return nil, false, err - } else if auth.Password == "" { + + var ( + authCfg types.AuthConfig + credCtx string + ) + for _, c := range s.credCtxs { + auth, err := store.Get(toolNameWithCtx(toolName, c)) + if err != nil { + if credentials2.IsErrCredentialsNotFound(err) { + continue + } + return nil, false, err + } else if auth.Password == "" { + continue + } + + authCfg = auth + credCtx = c + break + } + + if credCtx == "" { + // Didn't find the credential return nil, false, nil } - if auth.ServerAddress == "" { - auth.ServerAddress = toolNameWithCtx(toolName, s.credCtx) // Not sure why we have to do this, but we do. + if authCfg.ServerAddress == "" { + authCfg.ServerAddress = toolNameWithCtx(toolName, credCtx) // Not sure why we have to do this, but we do. } - cred, err := credentialFromDockerAuthConfig(auth) + cred, err := credentialFromDockerAuthConfig(authCfg) if err != nil { return nil, false, err } @@ -70,7 +96,12 @@ func (s Store) Get(ctx context.Context, toolName string) (*Credential, bool, err } func (s Store) Add(ctx context.Context, cred Credential) error { - cred.Context = s.credCtx + first := first(s.credCtxs) + if first == AllCredentialContexts { + return fmt.Errorf("cannot add a credential with context %q", AllCredentialContexts) + } + cred.Context = first + store, err := s.getStore(ctx) if err != nil { return err @@ -83,11 +114,17 @@ func (s Store) Add(ctx context.Context, cred Credential) error { } func (s Store) Remove(ctx context.Context, toolName string) error { + first := first(s.credCtxs) + if len(s.credCtxs) > 1 || first == AllCredentialContexts { + return fmt.Errorf("error: credential deletion is not supported when multiple credential contexts are provided") + } + store, err := s.getStore(ctx) if err != nil { return err } - return store.Erase(toolNameWithCtx(toolName, s.credCtx)) + + return store.Erase(toolNameWithCtx(toolName, first)) } func (s Store) List(ctx context.Context) ([]Credential, error) { @@ -100,7 +137,8 @@ func (s Store) List(ctx context.Context) ([]Credential, error) { return nil, err } - var creds []Credential + credsByContext := make(map[string][]Credential) + allCreds := make([]Credential, 0) for serverAddress, authCfg := range list { if authCfg.ServerAddress == "" { authCfg.ServerAddress = serverAddress // Not sure why we have to do this, but we do. @@ -110,12 +148,29 @@ func (s Store) List(ctx context.Context) ([]Credential, error) { if err != nil { return nil, err } - if s.credCtx == AllCredentialContexts || c.Context == s.credCtx { - creds = append(creds, c) + + allCreds = append(allCreds, c) + + if credsByContext[c.Context] == nil { + credsByContext[c.Context] = []Credential{c} + } else { + credsByContext[c.Context] = append(credsByContext[c.Context], c) + } + } + + if first(s.credCtxs) == AllCredentialContexts { + return allCreds, nil + } + + // Go through the contexts in reverse order so that higher priority contexts override lower ones. + credsByName := make(map[string]Credential) + for i := len(s.credCtxs) - 1; i >= 0; i-- { + for _, c := range credsByContext[s.credCtxs[i]] { + credsByName[c.ToolName] = c } } - return creds, nil + return maps.Values(credsByName), nil } func (s *Store) getStore(ctx context.Context) (credentials.Store, error) { @@ -139,19 +194,22 @@ func (s *Store) getStoreByHelper(ctx context.Context, helper string) (credential return NewHelper(s.cfg, helper) } -func validateCredentialCtx(ctx string) error { - if ctx == "" { - return fmt.Errorf("credential context cannot be empty") +func validateCredentialCtx(ctxs []string) error { + if len(ctxs) == 0 { + return fmt.Errorf("credential contexts must be provided") } - if ctx == AllCredentialContexts { + if len(ctxs) == 1 && ctxs[0] == AllCredentialContexts { return nil } // check alphanumeric r := regexp.MustCompile("^[a-zA-Z0-9]+$") - if !r.MatchString(ctx) { - return fmt.Errorf("credential context must be alphanumeric") + for _, c := range ctxs { + if !r.MatchString(c) { + return fmt.Errorf("credential contexts must be alphanumeric") + } } + return nil } diff --git a/pkg/credentials/util.go b/pkg/credentials/util.go index 70f31e97..39200369 100644 --- a/pkg/credentials/util.go +++ b/pkg/credentials/util.go @@ -15,3 +15,10 @@ func GetCredentialHelperDirs(cacheDir string) CredentialHelperDirs { BinDir: filepath.Join(cacheDir, "repos", "gptscript-credential-helpers", "bin"), } } + +func first(s []string) string { + if len(s) == 0 { + return "" + } + return s[0] +} diff --git a/pkg/gptscript/gptscript.go b/pkg/gptscript/gptscript.go index abae80ac..7a10eda2 100644 --- a/pkg/gptscript/gptscript.go +++ b/pkg/gptscript/gptscript.go @@ -45,7 +45,7 @@ type Options struct { Monitor monitor.Options Runner runner.Options DefaultModelProvider string - CredentialContext string + CredentialContexts []string Quiet *bool Workspace string DisablePromptServer bool @@ -60,7 +60,7 @@ func Complete(opts ...Options) Options { result.Runner = runner.Complete(result.Runner, opt.Runner) result.OpenAI = openai.Complete(result.OpenAI, opt.OpenAI) - result.CredentialContext = types.FirstSet(opt.CredentialContext, result.CredentialContext) + result.CredentialContexts = append(result.CredentialContexts, opt.CredentialContexts...) result.Quiet = types.FirstSet(opt.Quiet, result.Quiet) result.Workspace = types.FirstSet(opt.Workspace, result.Workspace) result.Env = append(result.Env, opt.Env...) @@ -74,8 +74,8 @@ func Complete(opts ...Options) Options { if len(result.Env) == 0 { result.Env = os.Environ() } - if result.CredentialContext == "" { - result.CredentialContext = credentials.DefaultCredentialContext + if len(result.CredentialContexts) == 0 { + result.CredentialContexts = []string{credentials.DefaultCredentialContext} } return result @@ -103,7 +103,7 @@ func New(ctx context.Context, o ...Options) (*GPTScript, error) { return nil, err } - credStore, err := credentials.NewStore(cliCfg, opts.Runner.RuntimeManager, opts.CredentialContext, cacheClient.CacheDir()) + credStore, err := credentials.NewStore(cliCfg, opts.Runner.RuntimeManager, opts.CredentialContexts, cacheClient.CacheDir()) if err != nil { return nil, err } diff --git a/pkg/sdkserver/credentials.go b/pkg/sdkserver/credentials.go index d3f86b1f..b0246621 100644 --- a/pkg/sdkserver/credentials.go +++ b/pkg/sdkserver/credentials.go @@ -5,13 +5,14 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "github.com/gptscript-ai/gptscript/pkg/config" gcontext "github.com/gptscript-ai/gptscript/pkg/context" "github.com/gptscript-ai/gptscript/pkg/credentials" ) -func (s *server) initializeCredentialStore(ctx context.Context, credCtx string) (credentials.CredentialStore, error) { +func (s *server) initializeCredentialStore(ctx context.Context, credCtxs []string) (credentials.CredentialStore, error) { cfg, err := config.ReadCLIConfig(s.gptscriptOpts.OpenAI.ConfigFile) if err != nil { return nil, fmt.Errorf("failed to read CLI config: %w", err) @@ -24,7 +25,7 @@ func (s *server) initializeCredentialStore(ctx context.Context, credCtx string) return nil, fmt.Errorf("failed to ensure credential helpers: %w", err) } - store, err := credentials.NewStore(cfg, s.runtimeManager, credCtx, s.gptscriptOpts.Cache.CacheDir) + store, err := credentials.NewStore(cfg, s.runtimeManager, credCtxs, s.gptscriptOpts.Cache.CacheDir) if err != nil { return nil, fmt.Errorf("failed to initialize credential store: %w", err) } @@ -41,9 +42,9 @@ func (s *server) listCredentials(w http.ResponseWriter, r *http.Request) { } if req.AllContexts { - req.Context = credentials.AllCredentialContexts - } else if req.Context == "" { - req.Context = credentials.DefaultCredentialContext + req.Context = []string{credentials.AllCredentialContexts} + } else if len(req.Context) == 0 { + req.Context = []string{credentials.DefaultCredentialContext} } store, err := s.initializeCredentialStore(r.Context(), req.Context) @@ -87,7 +88,7 @@ func (s *server) createCredential(w http.ResponseWriter, r *http.Request) { cred.Context = credentials.DefaultCredentialContext } - store, err := s.initializeCredentialStore(r.Context(), cred.Context) + store, err := s.initializeCredentialStore(r.Context(), []string{cred.Context}) if err != nil { writeError(logger, w, http.StatusInternalServerError, err) return @@ -114,11 +115,11 @@ func (s *server) revealCredential(w http.ResponseWriter, r *http.Request) { return } - if req.AllContexts || req.Context == credentials.AllCredentialContexts { + if req.AllContexts || slices.Contains(req.Context, credentials.AllCredentialContexts) { writeError(logger, w, http.StatusBadRequest, fmt.Errorf("allContexts is not supported for credential retrieval; please specify the specific context that the credential is in")) return - } else if req.Context == "" { - req.Context = credentials.DefaultCredentialContext + } else if len(req.Context) == 0 { + req.Context = []string{credentials.DefaultCredentialContext} } store, err := s.initializeCredentialStore(r.Context(), req.Context) @@ -151,11 +152,11 @@ func (s *server) deleteCredential(w http.ResponseWriter, r *http.Request) { return } - if req.AllContexts || req.Context == credentials.AllCredentialContexts { + if req.AllContexts || slices.Contains(req.Context, credentials.AllCredentialContexts) { writeError(logger, w, http.StatusBadRequest, fmt.Errorf("allContexts is not supported for credential deletion; please specify the specific context that the credential is in")) return - } else if req.Context == "" { - req.Context = credentials.DefaultCredentialContext + } else if len(req.Context) == 0 { + req.Context = []string{credentials.DefaultCredentialContext} } store, err := s.initializeCredentialStore(r.Context(), req.Context) diff --git a/pkg/sdkserver/routes.go b/pkg/sdkserver/routes.go index f82fa8a7..484a6fa1 100644 --- a/pkg/sdkserver/routes.go +++ b/pkg/sdkserver/routes.go @@ -184,11 +184,11 @@ func (s *server) execHandler(w http.ResponseWriter, r *http.Request) { } opts := gptscript.Options{ - Cache: cache.Options(reqObject.cacheOptions), - OpenAI: openai.Options(reqObject.openAIOptions), - Env: reqObject.Env, - Workspace: reqObject.Workspace, - CredentialContext: reqObject.CredentialContext, + Cache: cache.Options(reqObject.cacheOptions), + OpenAI: openai.Options(reqObject.openAIOptions), + Env: reqObject.Env, + Workspace: reqObject.Workspace, + CredentialContexts: reqObject.CredentialContext, Runner: runner.Options{ // Set the monitor factory so that we can get events from the server. MonitorFactory: NewSessionFactory(s.events), diff --git a/pkg/sdkserver/types.go b/pkg/sdkserver/types.go index 7ed7da78..65a0c049 100644 --- a/pkg/sdkserver/types.go +++ b/pkg/sdkserver/types.go @@ -58,7 +58,7 @@ type toolOrFileRequest struct { ChatState string `json:"chatState"` Workspace string `json:"workspace"` Env []string `json:"env"` - CredentialContext string `json:"credentialContext"` + CredentialContext []string `json:"credentialContext"` CredentialOverrides []string `json:"credentialOverrides"` Confirm bool `json:"confirm"` Location string `json:"location,omitempty"` @@ -255,7 +255,7 @@ type prompt struct { type credentialsRequest struct { content `json:",inline"` - AllContexts bool `json:"allContexts"` - Context string `json:"context"` - Name string `json:"name"` + AllContexts bool `json:"allContexts"` + Context []string `json:"context"` + Name string `json:"name"` }