From 09039d57c85d8b739595a33a34a37beec93652fd Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 12:16:28 -0700 Subject: [PATCH 01/36] [cli] add folder project links --- cmd/openai/project_link_test.go | 475 +++++++++++++++++++++ docs/project-linking.md | 110 +++++ pkg/custom/cmdutil.go | 5 +- pkg/custom/command.go | 1 + pkg/custom/image_picker_first_run.go | 2 +- pkg/custom/project_link.go | 293 +++++++++++++ pkg/custom/project_link_lock_other.go | 17 + pkg/custom/project_link_lock_unix.go | 26 ++ pkg/custom/project_link_lock_windows.go | 24 ++ pkg/custom/project_link_store.go | 314 ++++++++++++++ pkg/custom/project_link_store_test.go | 382 +++++++++++++++++ pkg/custom/project_link_store_unix_test.go | 115 +++++ pkg/custom/project_link_test.go | 145 +++++++ scripts/demos/project-linking/README.md | 42 ++ scripts/demos/project-linking/record.sh | 81 ++++ scripts/demos/project-linking/scene.sh | 35 ++ scripts/demos/project-linking/server.py | 108 +++++ 17 files changed, 2170 insertions(+), 5 deletions(-) create mode 100644 cmd/openai/project_link_test.go create mode 100644 docs/project-linking.md create mode 100644 pkg/custom/project_link.go create mode 100644 pkg/custom/project_link_lock_other.go create mode 100644 pkg/custom/project_link_lock_unix.go create mode 100644 pkg/custom/project_link_lock_windows.go create mode 100644 pkg/custom/project_link_store.go create mode 100644 pkg/custom/project_link_store_test.go create mode 100644 pkg/custom/project_link_store_unix_test.go create mode 100644 pkg/custom/project_link_test.go create mode 100644 scripts/demos/project-linking/README.md create mode 100755 scripts/demos/project-linking/record.sh create mode 100755 scripts/demos/project-linking/scene.sh create mode 100755 scripts/demos/project-linking/server.py diff --git a/cmd/openai/project_link_test.go b/cmd/openai/project_link_test.go new file mode 100644 index 00000000..a91fd77f --- /dev/null +++ b/cmd/openai/project_link_test.go @@ -0,0 +1,475 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Each invocation receives its own command tree, real cwd, and isolated config. +// Reuse the production-entrypoint subprocess without changing process-wide cwd. +func runProjectLinkMain(t *testing.T, home, directory string, env []string, stdin string, args ...string) mainDispatchResult { + t.Helper() + binary, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + argv := append([]string{"-test.run=^TestMainDispatchProcess$", "--", "openai"}, args...) + child := exec.CommandContext(ctx, binary, argv...) + child.Dir = directory + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + name = strings.ToUpper(name) + if strings.HasPrefix(name, "OPENAI_") || slices.Contains([]string{ + "HOME", "USERPROFILE", "APPDATA", "XDG_CONFIG_HOME", "COMPLETION_STYLE", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "TERM", "NO_COLOR", "FORCE_COLOR", + }, name) { + continue + } + child.Env = append(child.Env, entry) + } + child.Env = append(child.Env, "OPENAI_CLI_MAIN_DISPATCH_PROCESS=1", "COMPLETION_STYLE=bash", + "OPENAI_BASE_URL=http://127.0.0.1:1", "NO_COLOR=1", "FORCE_COLOR=0", "TERM=dumb") + if home != "" { + child.Env = append(child.Env, "HOME="+home, "USERPROFILE="+home, "APPDATA="+home, "XDG_CONFIG_HOME="+home) + } + child.Env = append(child.Env, env...) + if stdin != "" { + child.Stdin = strings.NewReader(stdin) + } + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + err = child.Run() + require.NoError(t, ctx.Err(), "command timed out") + code := 0 + if exit, ok := err.(*exec.ExitError); ok { + code = exit.ExitCode() + } else { + require.NoError(t, err) + } + return mainDispatchResult{code, stdout.String(), stderr.String()} +} + +func projectLinkRegistryPath(home string) string { + if runtime.GOOS == "darwin" { + home = filepath.Join(home, "Library", "Application Support") + } + return filepath.Join(home, "openai", "project-links.json") +} + +func projectLinkCanonicalDirectory(t *testing.T, directory string) string { + t.Helper() + canonical, err := filepath.EvalSymlinks(directory) + require.NoError(t, err) + return canonical +} + +func projectLinkRegistry(t *testing.T, home string) map[string]string { + t.Helper() + data, err := os.ReadFile(projectLinkRegistryPath(home)) + require.NoError(t, err) + var links map[string]string + require.NoError(t, json.Unmarshal(data, &links)) + return links +} + +func projectLinkCreate(t *testing.T, home, directory, project string) { + t.Helper() + got := runProjectLinkMain(t, home, directory, nil, "", "link", "--project", project) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Contains(t, got.stdout, project) +} + +func TestMainProjectLinkOfflineLifecycle(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + for _, configuration := range []struct { + name string + env []string + }{ + {"without credentials", []string{"OPENAI_BASE_URL=" + server.URL}}, + {"invalid remote configuration", []string{"OPENAI_BASE_URL=://synthetic-private-endpoint", + "OPENAI_CUSTOM_HEADERS=synthetic-private-headers", "OPENAI_MTLS_CLIENT_CERT_FILE=/synthetic-private/missing"}}, + } { + t.Run(configuration.name, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + for _, format := range []string{"text", "json"} { + for _, args := range [][]string{ + {"link", "--project=proj_first"}, {"link"}, + {"link", "--project=proj_replacement"}, {"unlink"}, {"unlink"}, + } { + argv := append(slices.Clone(args), "--format", format) + got := runProjectLinkMain(t, home, directory, configuration.env, "", argv...) + require.Zero(t, got.code, "%v: %s", argv, got.stderr) + require.Empty(t, got.stderr) + require.NotEmpty(t, got.stdout) + require.NotContains(t, got.stdout, "synthetic-private-") + if format == "json" { + require.True(t, json.Valid([]byte(got.stdout)), "%s", got.stdout) + } + if strings.Contains(strings.Join(args, " "), "--project=") { + project := strings.TrimPrefix(args[1], "--project=") + require.Equal(t, map[string]string{projectLinkCanonicalDirectory(t, directory): project}, projectLinkRegistry(t, home)) + } + } + } + for _, args := range [][]string{ + {"link", "--help"}, {"unlink", "--help"}, {"help", "link"}, {"help", "unlink"}, + } { + got := runProjectLinkMain(t, home, directory, configuration.env, "", args...) + require.Zero(t, got.code, "%v: %s", args, got.stderr) + require.Empty(t, got.stderr) + require.Contains(t, got.stdout, "openai ") + require.NotContains(t, got.stdout, "Key setup:") + } + }) + } + require.Zero(t, requests.Load(), "local linking or help reached the API") +} + +func TestMainProjectLinkRequestPrecedence(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + projectLinkCreate(t, home, directory, "proj_folder") + const response = `{"object":"list","data":[],"has_more":false}` + for _, test := range []struct { + name string + env, args, want []string + }{ + {"saved folder", nil, []string{"files", "list"}, []string{"proj_folder"}}, + {"environment", []string{"OPENAI_PROJECT_ID=proj_environment"}, []string{"files", "list"}, []string{"proj_environment"}}, + {"empty environment", []string{"OPENAI_PROJECT_ID="}, []string{"files", "list"}, []string{""}}, + {"root flag before", []string{"OPENAI_PROJECT_ID=proj_environment"}, []string{"--project=proj_explicit", "files", "list"}, []string{"proj_explicit"}}, + {"root flag between", nil, []string{"files", "--project=proj_explicit", "list"}, []string{"proj_explicit"}}, + {"root flag after", nil, []string{"files", "list", "--project=proj_explicit"}, []string{"proj_explicit"}}, + {"empty root flag", []string{"OPENAI_PROJECT_ID=proj_environment"}, []string{"files", "list", "--project", ""}, []string{""}}, + {"explicit header", nil, []string{"files", "list", "--header=OpenAI-Project: proj_header"}, []string{"proj_header"}}, + } { + t.Run(test.name, func(t *testing.T) { + server, requests := globalFlagsServer(t, response) + args := append(slices.Clone(test.args), "--format=raw") + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server, test.env...), "", args...) + require.Equal(t, mainDispatchResult{stdout: response + "\n"}, got) + request := globalFlagsOneRequest(t, requests) + require.Equal(t, http.MethodGet, request.method) + require.Equal(t, "/files", request.path) + require.Equal(t, test.want, request.header.Values("OpenAI-Project")) + require.Equal(t, "Bearer synthetic-env-key", request.header.Get("Authorization"), "linking must preserve the credential") + }) + } + require.Equal(t, map[string]string{projectLinkCanonicalDirectory(t, directory): "proj_folder"}, projectLinkRegistry(t, home)) +} + +func TestMainProjectLinkDirectoryScope(t *testing.T) { + home, parent, unrelated := t.TempDir(), t.TempDir(), t.TempDir() + child := filepath.Join(parent, "child") + grandchild := filepath.Join(child, "grandchild") + require.NoError(t, os.MkdirAll(grandchild, 0o700)) + projectLinkCreate(t, home, parent, "proj_parent") + server, requests := globalFlagsServer(t, globalFlagsPage) + assertHeader := func(directory string, want []string) { + t.Helper() + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server), "", "models", "list", "--format=raw") + require.Equal(t, mainDispatchResult{stdout: globalFlagsPage + "\n"}, got) + require.Equal(t, want, globalFlagsOneRequest(t, requests).header.Values("OpenAI-Project")) + } + assertHeader(grandchild, []string{"proj_parent"}) + assertHeader(unrelated, nil) + projectLinkCreate(t, home, child, "proj_child") + assertHeader(grandchild, []string{"proj_child"}) + got := runProjectLinkMain(t, home, grandchild, []string{"OPENAI_PROJECT_ID=proj_environment"}, "", "link", "--format=json") + require.Zero(t, got.code, "%s", got.stderr) + var inspection struct { + Directory string `json:"directory"` + LinkedDirectory string `json:"linked_directory"` + Project string `json:"project"` + Inherited bool `json:"inherited"` + EffectiveProject string `json:"effective_project"` + Source string `json:"source"` + } + require.NoError(t, json.Unmarshal([]byte(got.stdout), &inspection)) + require.Equal(t, projectLinkCanonicalDirectory(t, grandchild), inspection.Directory) + require.Equal(t, projectLinkCanonicalDirectory(t, child), inspection.LinkedDirectory) + require.Equal(t, "proj_child", inspection.Project) + require.True(t, inspection.Inherited) + require.Equal(t, "proj_environment", inspection.EffectiveProject) + require.Equal(t, "environment", inspection.Source) + got = runProjectLinkMain(t, home, grandchild, nil, "", "unlink") + require.Zero(t, got.code, "%s", got.stderr) + assertHeader(grandchild, []string{"proj_child"}) + got = runProjectLinkMain(t, home, child, nil, "", "unlink") + require.Zero(t, got.code, "%s", got.stderr) + assertHeader(grandchild, []string{"proj_parent"}) + projectLinkCreate(t, home, unrelated, "proj_moved") + moved := filepath.Join(t.TempDir(), "moved") + require.NoError(t, os.Rename(unrelated, moved)) + assertHeader(moved, nil) + assertHeader(parent, []string{"proj_parent"}) +} + +func TestMainProjectLinkSymlinkUsesCanonicalDirectory(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + alias := filepath.Join(t.TempDir(), "linked-directory") + if err := os.Symlink(directory, alias); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("Windows symlink privilege unavailable: %v", err) + } + t.Fatal(err) + } + projectLinkCreate(t, home, alias, "proj_canonical") + require.Equal(t, map[string]string{projectLinkCanonicalDirectory(t, directory): "proj_canonical"}, projectLinkRegistry(t, home)) + server, requests := globalFlagsServer(t, globalFlagsPage) + for _, path := range []string{directory, alias} { + got := runProjectLinkMain(t, home, path, globalFlagsEnv(server), "", "models", "list", "--format=raw") + require.Equal(t, mainDispatchResult{stdout: globalFlagsPage + "\n"}, got) + require.Equal(t, []string{"proj_canonical"}, globalFlagsOneRequest(t, requests).header.Values("OpenAI-Project")) + } +} + +func TestMainProjectLinkNestedRequestFieldsAndStdin(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + projectLinkCreate(t, home, directory, "proj_folder") + const requestJSON = `{"email":"synthetic@example.invalid","role":"reader","projects":[{"id":"proj_membership","role":"member"}]}` + for _, route := range [][]string{{"admin", "organization", "invites"}, {"admin:organization:invites"}} { + for _, input := range []struct { + name, stdin, body string + args []string + }{ + {"flags", "", requestJSON, []string{"--email=synthetic@example.invalid", "--role=reader", "--project", `{"id":"proj_membership","role":"member"}`}}, + {"stdin JSON", requestJSON, requestJSON, nil}, + {"stdin null projects", `{"email":"synthetic@example.invalid","role":"reader","projects":null}`, `{"email":"synthetic@example.invalid","role":"reader","projects":null}`, nil}, + } { + t.Run(strings.Join(route, "/")+"/"+input.name, func(t *testing.T) { + server, requests := globalFlagsServer(t, `{"id":"invite_synthetic"}`) + args := append([]string{"--format=json"}, route...) + args = append(args, "create") + args = append(args, input.args...) + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server, "OPENAI_ADMIN_KEY=synthetic-admin-key"), input.stdin, args...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.JSONEq(t, `{"id":"invite_synthetic"}`, got.stdout) + request := globalFlagsOneRequest(t, requests) + require.Equal(t, "/organization/invites", request.path) + require.Equal(t, http.MethodPost, request.method) + require.Equal(t, []string{"proj_folder"}, request.header.Values("OpenAI-Project")) + require.Equal(t, "Bearer synthetic-admin-key", request.header.Get("Authorization")) + require.JSONEq(t, input.body, string(request.body)) + }) + } + } +} + +func TestMainProjectLinkMalformedRegistryFailsBeforeRequests(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + path := projectLinkRegistryPath(home) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + const malformed = `{"synthetic-private-path":` + require.NoError(t, os.WriteFile(path, []byte(malformed), 0o600)) + server, requests := globalFlagsServer(t, globalFlagsPage) + for _, format := range []string{"text", "json"} { + for _, args := range [][]string{{"models", "list"}, {"link"}, {"link", "--project=proj_safe"}, {"unlink"}} { + argv := append(slices.Clone(args), "--format-error", format) + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server), "", argv...) + require.NotZero(t, got.code, "%v", argv) + require.Empty(t, got.stdout) + require.NotEmpty(t, got.stderr) + require.NotContains(t, got.stderr, "synthetic-private-path") + require.NotContains(t, got.stderr, home) + if format == "json" { + decodeMainStructuredError(t, format, got.stderr) + } + require.Empty(t, requests, "invalid registry reached the API") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, malformed, string(data), "failed mutation replaced prior registry") + } + } + for _, override := range []struct { + env, flags, want []string + }{ + {nil, []string{"--project=proj_explicit"}, []string{"proj_explicit"}}, + {nil, []string{"--project="}, []string{""}}, + {[]string{"OPENAI_PROJECT_ID=proj_environment"}, nil, []string{"proj_environment"}}, + {[]string{"OPENAI_PROJECT_ID="}, nil, []string{""}}, + } { + args := append([]string{"models", "list", "--format=raw"}, override.flags...) + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server, override.env...), "", args...) + require.Equal(t, mainDispatchResult{stdout: globalFlagsPage + "\n"}, got) + require.Equal(t, override.want, globalFlagsOneRequest(t, requests).header.Values("OpenAI-Project")) + } + for _, args := range [][]string{{"link", "--help"}, {"unlink", "--help"}, {"help", "link"}} { + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server), "", args...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Empty(t, requests) + } +} + +func TestMainProjectLinkKeepsAPIAccessFailure(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + projectLinkCreate(t, home, directory, "proj_no_access") + requests := make(chan http.Header, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"message":"Synthetic key cannot access this project","type":"permission_error","code":"project_forbidden"}}`)) + })) + t.Cleanup(server.Close) + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server), "", "files", "list", "--format-error=json") + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + decodeMainStructuredError(t, "json", got.stderr) + require.Contains(t, got.stderr, "Synthetic key cannot access this project") + require.NotContains(t, got.stderr, "synthetic-env-key") + require.Len(t, requests, 1) + header := <-requests + require.Equal(t, "proj_no_access", header.Get("OpenAI-Project")) + require.Equal(t, "Bearer synthetic-env-key", header.Get("Authorization")) + require.Equal(t, "proj_no_access", projectLinkRegistry(t, home)[projectLinkCanonicalDirectory(t, directory)]) +} + +func TestMainProjectLinkRejectsInvalidIdentifiersWithoutMutation(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + projectLinkCreate(t, home, directory, "proj_retained") + before, err := os.ReadFile(projectLinkRegistryPath(home)) + require.NoError(t, err) + server, requests := localUtilitiesRequestTrap(t) + for _, project := range []string{"", "synthetic-key", "proj_bad\nvalue", "proj_bad\x1b[31mvalue"} { + got := runProjectLinkMain(t, home, directory, []string{"OPENAI_BASE_URL=" + server.URL}, "", "link", "--project", project) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + require.NotContains(t, got.stderr, "\x1b") + require.NotContains(t, got.stderr, "synthetic-key") + after, err := os.ReadFile(projectLinkRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + } + require.Zero(t, requests.Load()) +} + +func TestMainProjectLinkEscapesDirectoryControls(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filenames do not accept these control characters") + } + home := t.TempDir() + directory := filepath.Join(t.TempDir(), "folder-\x1b]0;synthetic-title\a") + require.NoError(t, os.Mkdir(directory, 0o700)) + for _, args := range [][]string{{"link", "--project=proj_controls"}, {"link"}, {"unlink"}} { + got := runProjectLinkMain(t, home, directory, nil, "", args...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.NotContains(t, got.stdout, "\x1b") + require.NotContains(t, got.stdout, "\a") + } +} + +func TestMainProjectLinkUnlinkedConfigurationCompatibility(t *testing.T) { + const response = `{"object":"list","data":[],"has_more":false}` + for _, name := range []string{ + "existing shared-readable configuration directory", "symlinked configuration directory", + "no configuration location", "repository-provided configuration", + } { + t.Run(name, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + switch name { + case "existing shared-readable configuration directory": + config := filepath.Dir(projectLinkRegistryPath(home)) + require.NoError(t, os.MkdirAll(config, 0o755)) + require.NoError(t, os.Chmod(config, 0o755)) + case "symlinked configuration directory": + if runtime.GOOS == "windows" { + t.Skip("this case exercises Unix configuration-directory symlinks") + } + config := filepath.Dir(projectLinkRegistryPath(home)) + require.NoError(t, os.MkdirAll(filepath.Dir(config), 0o700)) + require.NoError(t, os.Symlink(t.TempDir(), config)) + case "no configuration location": + home = "" + case "repository-provided configuration": + data, err := json.Marshal(map[string]string{projectLinkCanonicalDirectory(t, directory): "proj_untrusted_repository"}) + require.NoError(t, err) + for _, path := range []string{ + filepath.Join(directory, "project-links.json"), + filepath.Join(directory, ".openai", "project-links.json"), + } { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, data, 0o600)) + } + } + server, requests := globalFlagsServer(t, response) + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server), "", "files", "list", "--format=raw") + require.Equal(t, mainDispatchResult{stdout: response + "\n"}, got) + request := globalFlagsOneRequest(t, requests) + require.Equal(t, "/files", request.path) + require.Nil(t, request.header.Values("OpenAI-Project")) + require.Equal(t, "Bearer synthetic-env-key", request.header.Get("Authorization")) + }) + } +} + +func TestMainProjectLinkInspectionRedactsUnexpectedEnvironmentValues(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + projectLinkCreate(t, home, directory, "proj_saved") + server, requests := localUtilitiesRequestTrap(t) + for _, test := range []struct { + name, value, privateMarker string + redacted bool + }{ + {"credential-looking value", "sk-synthetic-secret", "synthetic-secret", true}, + {"terminal controls", "proj_\x1b]0;synthetic-control\a", "synthetic-control", true}, + {"URL-like value", "https://synthetic.invalid/private?token=synthetic-url-token", "synthetic-url-token", true}, + {"valid identifier", "proj_environment", "", false}, + {"explicit empty", "", "", false}, + } { + for _, format := range []string{"text", "json"} { + t.Run(test.name+"/"+format, func(t *testing.T) { + env := []string{"OPENAI_BASE_URL=" + server.URL, "OPENAI_PROJECT_ID=" + test.value} + got := runProjectLinkMain(t, home, directory, env, "", "link", "--format", format) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Contains(t, got.stdout, "proj_saved") + require.NotContains(t, got.stdout, "\x1b") + require.NotContains(t, got.stdout, "\a") + if test.redacted { + require.NotContains(t, got.stdout, test.value) + require.NotContains(t, got.stdout, test.privateMarker) + } else if test.value != "" { + require.Contains(t, got.stdout, test.value) + } + if format == "json" { + var status struct { + Project string `json:"project"` + EffectiveProject string `json:"effective_project"` + Source string `json:"source"` + Redacted bool `json:"effective_project_redacted"` + } + require.NoError(t, json.Unmarshal([]byte(got.stdout), &status)) + require.Equal(t, "proj_saved", status.Project) + require.Equal(t, "environment", status.Source) + require.Equal(t, test.redacted, status.Redacted) + want := test.value + if test.redacted { + want = "" + } + require.Equal(t, want, status.EffectiveProject) + } else if test.value == "" { + require.Contains(t, got.stdout, "OPENAI_PROJECT_ID is empty") + } + }) + } + } + require.Zero(t, requests.Load(), "inspection reached the API") + require.Equal(t, map[string]string{projectLinkCanonicalDirectory(t, directory): "proj_saved"}, projectLinkRegistry(t, home)) +} diff --git a/docs/project-linking.md b/docs/project-linking.md new file mode 100644 index 00000000..a818bb58 --- /dev/null +++ b/docs/project-linking.md @@ -0,0 +1,110 @@ +# Folder project links + +Save an OpenAI project for the current folder: + +```sh +cd work-chatbot +openai link --project proj_work +openai files list +``` + +`files list` lists remote OpenAI files in that project. It does not list local files. +Linking does not authenticate, create a remote project, or grant project access. +Your API key must already have access to the selected project. + +Inspect the saved link and its effective project: + +```sh +openai link +openai link --format json +``` + +Remove the current folder's link: + +```sh +openai unlink +``` + +All three operations run locally without credentials or network requests. +They leave repository files unchanged. +Use `link --project` again to replace a saved link. +An environment variable alone never saves a link. + +## Project selection + +API requests select a project in this order: + +1. The explicit root `--project` flag. +2. `OPENAI_PROJECT_ID`. +3. The nearest linked folder, including the current folder. +4. The existing SDK default when no project is selected. + +An explicit empty flag or empty environment variable disables folder selection. +For example, `openai --project= files list` sends an empty project header. +Root request flags work before or after commands, subject to existing endpoint flag ownership. +An endpoint's own `--project` request field does not change request-header selection. +Explicit custom `OpenAI-Project` headers retain their existing override behavior. + +`link --project` saves a future folder default. +An existing `OPENAI_PROJECT_ID` still overrides that default on later API commands. +Link output identifies this override. + +## Folders and storage + +Subdirectories inherit the nearest linked ancestor. +A link in a subdirectory overrides its parent link. +`unlink` removes only the current folder's own link. +Removing a child link can expose a parent link again. +Run `unlink` in the linked parent folder to remove that parent link. + +The CLI resolves symlinks before saving or looking up a directory. +The real directory and its symlink therefore share one link. +Links follow canonical paths, not folder names or filesystem object identities. +Moving a folder does not move its link. +Deleting a folder leaves its saved entry unchanged. +Recreating the same path reuses the saved entry. +Unlink before moving or deleting a folder when you do not want this behavior. + +The registry contains only paths and project IDs. +The CLI stores it in the operating system's user configuration directory: + +- macOS: `~/Library/Application Support/openai/project-links.json` +- Linux: `$XDG_CONFIG_HOME/openai/project-links.json`, or `~/.config/openai/project-links.json` +- Windows: `%APPDATA%\openai\project-links.json` + +The CLI never reads project defaults from repository configuration. +On Unix, the application directory and registry must have private permissions. +New directories use mode `0700`; new registry files use mode `0600`. +Windows uses the user configuration directory's inherited access controls. + +Concurrent CLI writers hold a shared registry lock through validation and replacement. +Each write syncs a temporary file before renaming it over the registry. +Unix rename keeps readers from seeing partial JSON. +An interrupted writer releases its kernel lock when the process exits. +The sibling lock file remains intentionally. +External editors must not change the registry while CLI writers run. +Native Windows replacement and locking behavior remain unverified. + +The local registry supports up to 1 MiB of JSON. +This limit does not apply to API payloads. +Malformed, duplicate, insecure, or nonregular registry files cause an error. +The CLI preserves invalid settings instead of overwriting them. +An explicit project flag or environment variable bypasses registry loading for API requests. + +## Output and recovery + +`link` and `unlink` support `--format auto`, `text`, and `json`. +They reject positional arguments, `--transform`, and `--raw-output` before changing settings. +`--quiet` suppresses readable save/remove confirmations and preserves inspection and JSON output. +JSON reports the action, current directory, linked directory, saved project, inheritance, effective project, and source. +An empty effective project means the request uses an empty override or the existing default. +The `source` field distinguishes these cases. +Inspection hides environment values that do not match the project ID format. +JSON marks these values with `effective_project_redacted: true`. +This prevents inspection from printing accidentally pasted credentials. +API requests preserve the existing environment behavior. + +If output fails after saving, inspect the link before retrying. +If the registry cannot be read, check its file type, permissions, and JSON syntax. +Move an invalid registry aside only when you intend to discard its saved links. +To remove a stale entry for a deleted directory, edit that entry while no CLI process writes the registry. diff --git a/pkg/custom/cmdutil.go b/pkg/custom/cmdutil.go index ee4ec06a..4d22bc90 100644 --- a/pkg/custom/cmdutil.go +++ b/pkg/custom/cmdutil.go @@ -63,10 +63,7 @@ func GetDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { opts = append(opts, option.WithOrganization(cmd.String("organization"))) } // Endpoint body fields named project must not select the request's project context. - root := cmd.Root() - if root.IsSet("project") { - opts = append(opts, option.WithProject(root.String("project"))) - } + opts = append(opts, projectLinkRequestOptions(cmd)...) if cmd.IsSet("webhook-secret") { opts = append(opts, option.WithWebhookSecret(cmd.String("webhook-secret"))) } diff --git a/pkg/custom/command.go b/pkg/custom/command.go index 215cb038..4a1cb23c 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -33,6 +33,7 @@ func ConfigureCommand(root *cli.Command) { configureFileCommands(root) registerTokenizerCommands(root) registerCodexCommands(root) + registerProjectLinkCommands(root) configureCommandPresentation(root) configureManpageCommands(root) configureOutputPolicy(root) diff --git a/pkg/custom/image_picker_first_run.go b/pkg/custom/image_picker_first_run.go index 5016fa4e..2dd527d1 100644 --- a/pkg/custom/image_picker_first_run.go +++ b/pkg/custom/image_picker_first_run.go @@ -101,7 +101,7 @@ func imagePickerFirstRunEligible(args []string, getenv func(string) string, inpu // these fixed tokens never parses, consumes, or rewrites request arguments. for _, argument := range args[1:] { switch argument { - case "__complete", "@completion", "@manpages", "--generate-shell-completion", "tokenizer", "codex": + case "__complete", "@completion", "@manpages", "--generate-shell-completion", "tokenizer", "codex", "link", "unlink": return false } } diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go new file mode 100644 index 00000000..87e763be --- /dev/null +++ b/pkg/custom/project_link.go @@ -0,0 +1,293 @@ +package custom + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/openai/openai-cli/internal/readable" + "github.com/openai/openai-go/v3/option" + "github.com/urfave/cli/v3" +) + +// Track argv separately from PostParse: request flags count environment values +// as sets too. Inspecting a link must never save an ambient project selection. +type projectLinkFlag struct { + *rootRequestFlag + explicit bool + context context.Context +} + +func (f *projectLinkFlag) Set(name, value string) error { + if err := f.rootRequestFlag.Set(name, value); err != nil { + return err + } + f.explicit = true + return nil +} + +func registerProjectLinkCommands(root *cli.Command) { + for i, flag := range root.Flags { + if project, ok := flag.(*rootRequestFlag); ok && project.Name == "project" { + root.Flags[i] = &projectLinkFlag{rootRequestFlag: project} + } + } + previousBefore := root.Before + root.Before = func(ctx context.Context, command *cli.Command) (context.Context, error) { + if previousBefore != nil { + var err error + ctx, err = previousBefore(ctx, command) + if err != nil { + return ctx, err + } + } + for _, flag := range root.Flags { + if project, ok := flag.(*projectLinkFlag); ok { + project.context = ctx + } + } + return ctx, nil + } + for _, name := range []string{"link", "unlink"} { + usage, example := "Inspect or save this folder's OpenAI project", "link --project proj_work" + description := "With --project, save a project ID for this folder. Without it, inspect the saved link and effective project.\n" + + "Flags override OPENAI_PROJECT_ID, which overrides the nearest linked parent folder. Empty overrides disable folder defaults.\n" + + "No API request or credentials are needed. Linking does not grant project access or change your API key." + if name == "unlink" { + usage, example = "Remove this folder's saved OpenAI project", "unlink" + description = "Remove only this folder's link. An inherited parent link remains active.\n" + + "No API request is made. This does not delete a remote project, remote files, or local files." + } + description += "\nSupports --format auto, text, or json. No positional arguments, --transform, or --raw-output." + help := `{{$bin := or (index .Root.Metadata "help-invocation") "openai"}}EXAMPLE + {{$bin}} ` + example + "\n\n" + cli.CommandHelpTemplate + localUtilityGlobalHelp + root.Commands = append(root.Commands, &cli.Command{ + Name: name, Usage: usage, Description: description, HideHelpCommand: true, + CustomHelpTemplate: help, + Metadata: map[string]any{localUtilityMetadata: true, "local-help-full": help, "help-command-section": "Local tools"}, + Action: handleProjectLink, + }) + } +} + +func explicitProjectLinkFlag(root *cli.Command) bool { + for _, flag := range root.Flags { + if project, ok := flag.(*projectLinkFlag); ok { + return project.explicit + } + } + return false +} + +func projectLinkPath() (string, error) { + directory, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(directory, "openai", "project-links.json"), nil +} + +func projectLinkDirectory() (string, error) { + directory, err := os.Getwd() + if err != nil { + return "", err + } + directory, err = filepath.EvalSymlinks(directory) + if err != nil { + return "", err + } + return filepath.Clean(directory), nil +} + +func validLinkedProject(project string) bool { + suffix, ok := strings.CutPrefix(project, "proj_") + if !ok || suffix == "" { + return false + } + for _, c := range suffix { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { + return false + } + } + return true +} + +func nearestProjectLink(links map[string]string, directory string) (string, string) { + for { + if project, ok := links[directory]; ok { + return directory, project + } + parent := filepath.Dir(directory) + if parent == directory { + return "", "" + } + directory = parent + } +} + +// No environment mutation: clients retain API keys and their actual access. +// Root ownership prevents endpoint body fields named project from changing this. +func projectLinkRequestOptions(command *cli.Command) []option.RequestOption { + root := command.Root() + if root.IsSet("project") { + return []option.RequestOption{option.WithProject(root.String("project"))} + } + if project, present := os.LookupEnv("OPENAI_PROJECT_ID"); present { + return []option.RequestOption{option.WithProject(project)} + } + path, err := projectLinkPath() + if err != nil { + // A process without a user configuration location has no folder defaults. + // Preserve the existing API behavior for minimal service environments. + return nil + } + // Production callers use the context captured by the root Before hook. + // Direct option-only callers retain a context without a cancellation signal. + ctx := context.Background() + for _, flag := range root.Flags { + if project, ok := flag.(*projectLinkFlag); ok && project.context != nil { + ctx = project.context + } + } + links, err := loadProjectLinks(ctx, path) + if err == nil && len(links) == 0 { + return nil + } + var directory string + if err == nil { + directory, err = projectLinkDirectory() + } + if err != nil { + failure := projectLinkFailure("Could not resolve folder project defaults. Inspect with openai link, or override with --project.", err) + return []option.RequestOption{option.WithMaxRetries(0), option.WithMiddleware(func(*http.Request, option.MiddlewareNext) (*http.Response, error) { + return nil, failure + })} + } + _, project := nearestProjectLink(links, directory) + if project == "" { + return nil + } + return []option.RequestOption{option.WithProject(project)} +} + +type projectLinkStatus struct { + Action string `json:"action"` + Directory string `json:"directory"` + LinkedDirectory string `json:"linked_directory"` + Project string `json:"project"` + Inherited bool `json:"inherited"` + EffectiveProject string `json:"effective_project"` + Source string `json:"source"` + ProjectRedacted bool `json:"effective_project_redacted,omitempty"` +} + +func handleProjectLink(ctx context.Context, command *cli.Command) error { + root := command.Root() + format := strings.ToLower(root.String("format")) + if command.Args().Present() || root.IsSet("transform") || root.IsSet("raw-output") { + return projectLinkFailure("Folder linking takes no positional arguments and does not support --transform or --raw-output.", nil) + } + if format != "" && format != "auto" && format != "text" && format != "json" { + return projectLinkFailure("Folder linking supports --format auto, text, or json.", nil) + } + explicit := explicitProjectLinkFlag(root) + if command.Name == "unlink" && explicit { + return projectLinkFailure("unlink does not accept --project. It removes only the current folder's saved link.", nil) + } + project := root.String("project") + if command.Name == "link" && explicit && !validLinkedProject(project) { + return projectLinkFailure("Use --project with a project ID starting with proj_ and containing only letters, digits, underscores, or hyphens.", nil) + } + directory, err := projectLinkDirectory() + if err != nil { + return projectLinkFailure("Could not find the current folder. Change to an existing accessible folder and try again.", err) + } + path, err := projectLinkPath() + if err != nil { + return projectLinkFailure("Could not find your configuration folder. Check HOME, APPDATA, or XDG_CONFIG_HOME.", err) + } + status := projectLinkStatus{Action: "inspect", Directory: directory} + var links map[string]string + switch { + case command.Name == "unlink": + status.Action = "unlink" + links, err = updateProjectLink(ctx, path, directory, "") + case explicit: + status.Action = "link" + links, err = updateProjectLink(ctx, path, directory, project) + default: + links, err = loadProjectLinks(ctx, path) + } + if err != nil { + return projectLinkFailure("Could not read or save folder links. Check the user configuration folder's permissions and project-links.json. Invalid settings were kept.", err) + } + status.LinkedDirectory, status.Project = nearestProjectLink(links, directory) + status.Inherited = status.LinkedDirectory != "" && status.LinkedDirectory != directory + status.EffectiveProject, status.Source = status.Project, "folder" + if status.Project == "" { + status.Source = "default" + } + if environment, present := os.LookupEnv("OPENAI_PROJECT_ID"); present { + status.EffectiveProject, status.Source = environment, "environment" + if environment != "" && !validLinkedProject(environment) { + status.EffectiveProject, status.ProjectRedacted = "", true + } + } + // The --project on link saves a future default; this invocation has no API + // request. Show the environment override that future commands retain. + if err := ctx.Err(); err != nil { + return err + } + writer := outputWriter{ctx: ctx, out: root.Writer} + if format == "json" { + err = json.NewEncoder(writer).Encode(status) + } else if status.Action == "inspect" || !quietOutput(ctx) { + _, err = io.WriteString(writer, projectLinkText(status)) + } + if err != nil { + return projectLinkFailure("Could not write folder-link output. The saved link may already have changed; inspect with openai link.", err) + } + return nil +} + +func projectLinkText(status projectLinkStatus) string { + var out strings.Builder + switch status.Action { + case "link": + if status.Source == "folder" { + fmt.Fprintf(&out, "This folder now uses OpenAI project %s.\n", readable.Text(status.Project)) + } else { + fmt.Fprintf(&out, "Saved OpenAI project %s for this folder.\n", readable.Text(status.Project)) + } + case "unlink": + out.WriteString("This folder has no saved project link.\n") + } + if status.Project != "" && status.Action != "link" { + fmt.Fprintf(&out, "Folder project: %s\n", readable.Text(status.Project)) + if status.Inherited { + fmt.Fprintf(&out, "Inherited from: %s\n", readable.Text(status.LinkedDirectory)) + } + } else if status.Action == "inspect" { + out.WriteString("This folder has no saved project link.\n") + } + if status.Source == "environment" { + if status.ProjectRedacted { + out.WriteString("OPENAI_PROJECT_ID overrides the folder default. Its value is not a project ID and is hidden.\n") + } else if status.EffectiveProject == "" { + out.WriteString("OPENAI_PROJECT_ID is empty and disables the folder default.\n") + } else { + fmt.Fprintf(&out, "Effective project: %s (OPENAI_PROJECT_ID overrides the folder default)\n", readable.Text(status.EffectiveProject)) + } + } + out.WriteString("Your API key and its project access stay unchanged.\n") + return out.String() +} + +func projectLinkFailure(message string, cause error) error { + return &localUtilityError{message: message, cause: cause} +} diff --git a/pkg/custom/project_link_lock_other.go b/pkg/custom/project_link_lock_other.go new file mode 100644 index 00000000..3b692597 --- /dev/null +++ b/pkg/custom/project_link_lock_other.go @@ -0,0 +1,17 @@ +//go:build !darwin && !linux && !freebsd && !openbsd && !netbsd && !dragonfly && !windows + +package custom + +import ( + "errors" + "os" + "syscall" +) + +func tryProjectLinkLock(*os.File) (bool, error) { + return false, errors.New("project linking is unavailable on this platform") +} + +func projectLinkOpenFlags() int { return syscall.O_NONBLOCK } + +func privateProjectLinkPermissions(info os.FileInfo) bool { return info.Mode().Perm()&0077 == 0 } diff --git a/pkg/custom/project_link_lock_unix.go b/pkg/custom/project_link_lock_unix.go new file mode 100644 index 00000000..787de77e --- /dev/null +++ b/pkg/custom/project_link_lock_unix.go @@ -0,0 +1,26 @@ +//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly + +package custom + +import ( + "errors" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func tryProjectLinkLock(file *os.File) (bool, error) { + err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return false, nil + } + return err == nil, err +} + +func projectLinkOpenFlags() int { return syscall.O_NONBLOCK | syscall.O_NOFOLLOW } + +func privateProjectLinkPermissions(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Geteuid()) && info.Mode().Perm()&0077 == 0 +} diff --git a/pkg/custom/project_link_lock_windows.go b/pkg/custom/project_link_lock_windows.go new file mode 100644 index 00000000..86ed550a --- /dev/null +++ b/pkg/custom/project_link_lock_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package custom + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryProjectLinkLock(file *os.File) (bool, error) { + err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return err == nil, err +} + +func projectLinkOpenFlags() int { return 0 } + +// Windows permissions inherit the user's configuration-directory ACL. +// Unix permission bits cannot validate that ACL. +func privateProjectLinkPermissions(os.FileInfo) bool { return true } diff --git a/pkg/custom/project_link_store.go b/pkg/custom/project_link_store.go new file mode 100644 index 00000000..9cfbea4b --- /dev/null +++ b/pkg/custom/project_link_store.go @@ -0,0 +1,314 @@ +package custom + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" +) + +// This limit applies only to local project links, never API request or response data. +const maxProjectLinkBytes = 1 << 20 + +var errProjectLinksChanged = errors.New("project links changed while reading; retry the command") + +func loadProjectLinks(ctx context.Context, path string) (map[string]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // An absent registry cannot select a project. Preserve existing configuration + // layouts without opening or consuming a registry installed after this check. + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return map[string]string{}, nil + } + root, name, err := openProjectLinkDirectory(path, false) + if errors.Is(err, os.ErrNotExist) { + return map[string]string{}, nil + } + if err != nil { + return nil, err + } + defer root.Close() + return readProjectLinks(ctx, root, name) +} + +func readProjectLinks(ctx context.Context, root *os.Root, name string) (map[string]string, error) { + for range 64 { + if err := ctx.Err(); err != nil { + return nil, err + } + info, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return map[string]string{}, nil + } + if err != nil { + return nil, err + } + links, err := readProjectLinkSnapshot(ctx, root, name, info) + if !errors.Is(err, errProjectLinksChanged) { + return links, err + } + } + return nil, errProjectLinksChanged +} + +func readProjectLinkSnapshot(ctx context.Context, root *os.Root, name string, expected os.FileInfo) (map[string]string, error) { + if !privateProjectLinkFile(expected) { + return nil, errors.New("project links must be a private regular file owned by the current user") + } + file, err := root.OpenFile(name, os.O_RDONLY|projectLinkOpenFlags(), 0) + if err != nil { + return nil, err + } + defer file.Close() + actual, err := file.Stat() + if err != nil { + return nil, err + } + if !privateProjectLinkFile(actual) || !sameProjectLinkSnapshot(expected, actual) { + return nil, errProjectLinksChanged + } + data, err := io.ReadAll(io.LimitReader(file, maxProjectLinkBytes+1)) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + current, err := root.Lstat(name) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errProjectLinksChanged + } + return nil, err + } + if !privateProjectLinkFile(current) || !sameProjectLinkSnapshot(actual, current) { + return nil, errProjectLinksChanged + } + if len(data) > maxProjectLinkBytes { + return nil, errors.New("project links exceed the 1 MiB local registry limit; existing links were kept") + } + return decodeProjectLinks(data) +} + +func sameProjectLinkSnapshot(a, b os.FileInfo) bool { + return os.SameFile(a, b) && a.Size() == b.Size() && a.ModTime().Equal(b.ModTime()) && a.Mode() == b.Mode() +} + +func privateProjectLinkFile(info os.FileInfo) bool { + return info.Mode().IsRegular() && privateProjectLinkPermissions(info) +} + +func decodeProjectLinks(data []byte) (map[string]string, error) { + invalid := errors.New("invalid project links; existing links were kept") + if !utf8.Valid(data) { + return nil, invalid + } + decoder := json.NewDecoder(bytes.NewReader(data)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return nil, invalid + } + links := make(map[string]string) + for decoder.More() { + token, err := decoder.Token() + directory, ok := token.(string) + if err != nil || !ok || !validProjectLinkDirectory(directory) { + return nil, invalid + } + if _, exists := links[directory]; exists { + return nil, invalid + } + var project string + if err := decoder.Decode(&project); err != nil || !validLinkedProject(project) { + return nil, invalid + } + links[directory] = project + } + token, err = decoder.Token() + if err != nil || token != json.Delim('}') || decoder.Decode(new(any)) != io.EOF { + return nil, invalid + } + return links, nil +} + +func validProjectLinkDirectory(directory string) bool { + return filepath.IsAbs(directory) && filepath.Clean(directory) == directory && utf8.ValidString(directory) && !strings.ContainsRune(directory, 0) +} + +// Keep the sibling lock permanently. Removing it can split waiting writers +// between different inodes. Closing its descriptor releases the kernel lock. +func lockProjectLinks(ctx context.Context, root *os.Root, name string) (*os.File, error) { + name = "." + name + ".lock" + file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL|projectLinkOpenFlags(), 0600) + if errors.Is(err, os.ErrExist) { + info, statErr := root.Lstat(name) + if statErr != nil { + return nil, statErr + } + if !privateProjectLinkFile(info) { + return nil, errors.New("project link lock must be a private regular file owned by the current user") + } + file, err = root.OpenFile(name, os.O_RDWR|projectLinkOpenFlags(), 0) + } + if err != nil { + return nil, err + } + ok := false + defer func() { + if !ok { + file.Close() + } + }() + identity, err := file.Stat() + if err != nil { + return nil, err + } + checkIdentity := func() error { + current, err := root.Lstat(name) + if err != nil { + return err + } + if !privateProjectLinkFile(identity) || !privateProjectLinkFile(current) || !os.SameFile(identity, current) { + return errors.New("project link lock changed while opening or waiting") + } + return nil + } + if err := checkIdentity(); err != nil { + return nil, err + } + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if err := ctx.Err(); err != nil { + return nil, err + } + locked, err := tryProjectLinkLock(file) + if err != nil { + return nil, err + } + if locked { + if err := checkIdentity(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + ok = true + return file, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} + +// Cooperating writers hold one lock across validation and atomic replacement. +// Advisory locks cannot coordinate programs that ignore the lock protocol. +func updateProjectLink(ctx context.Context, path, directory, project string) (map[string]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !validProjectLinkDirectory(directory) || (project != "" && !validLinkedProject(project)) { + return nil, errors.New("invalid folder or project identifier; existing links were kept") + } + root, name, err := openProjectLinkDirectory(path, true) + if err != nil { + return nil, err + } + defer root.Close() + lock, err := lockProjectLinks(ctx, root, name) + if err != nil { + return nil, err + } + defer lock.Close() + links, err := readProjectLinks(ctx, root, name) + if err != nil { + return nil, err + } + if project == "" { + delete(links, directory) + } else { + links[directory] = project + } + data, err := json.MarshalIndent(links, "", " ") + if err != nil { + return nil, err + } + data = append(data, '\n') + if len(data) > maxProjectLinkBytes { + return nil, errors.New("project links exceed the 1 MiB local registry limit; existing links were kept") + } + temporary := ".project-links-" + rand.Text() + file, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return nil, err + } + defer root.Remove(temporary) + _, writeErr := file.Write(data) + if writeErr == nil { + writeErr = file.Sync() + } + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr, ctx.Err()); err != nil { + return nil, err + } + // Rename replaces the directory entry, never a substituted symlink's target. + if err := root.Rename(temporary, name); err != nil { + return nil, err + } + return links, nil +} + +func openProjectLinkDirectory(path string, create bool) (*os.Root, string, error) { + directory, name := filepath.Dir(path), filepath.Base(path) + if create { + if err := os.MkdirAll(directory, 0700); err != nil { + return nil, "", err + } + } + info, err := os.Lstat(directory) + if err != nil { + return nil, "", err + } + if !info.IsDir() { + return nil, "", errors.New("project links require a private configuration directory owned by the current user") + } + root, err := os.OpenRoot(directory) + if err != nil { + return nil, "", err + } + actual, err := root.Stat(".") + if err != nil || !os.SameFile(info, actual) { + root.Close() + if err == nil { + err = errors.New("configuration directory changed while opening") + } + return nil, "", err + } + // Existing CLI configuration directories can predate project linking. + // An absent registry must not introduce new permission requirements. + if !create { + if _, err := root.Lstat(name); errors.Is(err, os.ErrNotExist) { + root.Close() + return nil, "", os.ErrNotExist + } else if err != nil { + root.Close() + return nil, "", err + } + } + if !privateProjectLinkPermissions(info) || !privateProjectLinkPermissions(actual) { + root.Close() + return nil, "", errors.New("project links require a private configuration directory owned by the current user") + } + return root, name, nil +} diff --git a/pkg/custom/project_link_store_test.go b/pkg/custom/project_link_store_test.go new file mode 100644 index 00000000..b7f4b61d --- /dev/null +++ b/pkg/custom/project_link_store_test.go @@ -0,0 +1,382 @@ +package custom + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func projectLinkTestPath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "openai", "project-links.json") +} + +func writeProjectLinkTestFile(t *testing.T, path, data string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte(data), 0600)) +} + +func TestProjectLinkStoreUpdatesAndRemovesExactEntries(t *testing.T) { + path := projectLinkTestPath(t) + directory := t.TempDir() + child := filepath.Join(directory, "child") + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + require.Empty(t, links) + _, err = os.Stat(filepath.Dir(path)) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = updateProjectLink(t.Context(), path, directory, "proj_parent") + require.NoError(t, err) + _, err = updateProjectLink(t.Context(), path, child, "proj_child") + require.NoError(t, err) + links, err = updateProjectLink(t.Context(), path, directory, "proj_replacement") + require.NoError(t, err) + require.Equal(t, map[string]string{directory: "proj_replacement", child: "proj_child"}, links) + links, err = updateProjectLink(t.Context(), path, child, "") + require.NoError(t, err) + require.Equal(t, map[string]string{directory: "proj_replacement"}, links) + links, err = loadProjectLinks(t.Context(), path) + require.NoError(t, err) + require.Equal(t, map[string]string{directory: "proj_replacement"}, links) + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + info, err = os.Stat(filepath.Dir(path)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0700), info.Mode().Perm()) + } + files, err := os.ReadDir(filepath.Dir(path)) + require.NoError(t, err) + require.Len(t, files, 2, "only registry and persistent lock remain") +} + +func TestProjectLinkStoreRejectsMalformedWithoutOverwriting(t *testing.T) { + directory, err := json.Marshal(t.TempDir()) + require.NoError(t, err) + key := string(directory) + for name, data := range map[string]string{ + "empty": "", "null": "null", "array": "[]", "trailing": "{} {}", + "duplicate": "{" + key + ":\"proj_one\"," + key + ":\"proj_two\"}", + "null-project": "{" + key + ":null}", "empty-project": "{" + key + ":\"\"}", + "wrong-project": "{" + key + ":\"sk_fake\"}", "numeric-project": "{" + key + ":12}", + "relative-folder": "{\"relative\":\"proj_one\"}", + "unclean-folder": "{\"/folder/../elsewhere\":\"proj_one\"}", + "invalid-utf8": "{\"/\xff\":\"proj_one\"}", + "oversized": strings.Repeat(" ", maxProjectLinkBytes) + "{}", + } { + t.Run(name, func(t *testing.T) { + path := projectLinkTestPath(t) + writeProjectLinkTestFile(t, path, data) + _, err := loadProjectLinks(t.Context(), path) + require.Error(t, err) + _, err = updateProjectLink(t.Context(), path, t.TempDir(), "proj_new") + require.Error(t, err) + actual, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, data, string(actual)) + }) + } +} + +func TestProjectLinkStoreLocalSizeBoundary(t *testing.T) { + path := projectLinkTestPath(t) + before := "{}" + strings.Repeat(" ", maxProjectLinkBytes-2) + writeProjectLinkTestFile(t, path, before) + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + require.Empty(t, links) + _, err = updateProjectLink(t.Context(), path, t.TempDir(), "proj_"+strings.Repeat("a", maxProjectLinkBytes)) + require.ErrorContains(t, err, "1 MiB") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, string(data)) +} + +func TestProjectLinkStoreRejectsInvalidUpdateBeforeCreation(t *testing.T) { + for _, values := range [][2]string{{"relative", "proj_one"}, {t.TempDir(), "sk_fake"}, {"/bad\x00path", "proj_one"}} { + path := projectLinkTestPath(t) + _, err := updateProjectLink(t.Context(), path, values[0], values[1]) + require.Error(t, err) + _, err = os.Stat(filepath.Dir(path)) + require.ErrorIs(t, err, os.ErrNotExist) + } +} + +func TestProjectLinkStorePreservesMissingFoldersAndEscapesControls(t *testing.T) { + path := projectLinkTestPath(t) + directory := filepath.Join(t.TempDir(), "missing\n\x1b[31m") + _, err := updateProjectLink(t.Context(), path, directory, "proj_saved") + require.NoError(t, err) + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + require.Equal(t, "proj_saved", links[directory]) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.NotContains(t, string(data), "\x1b") +} + +func TestProjectLinkStoreRejectsNonregularFiles(t *testing.T) { + for _, kind := range []string{"directory", "symlink", "config-symlink"} { + t.Run(kind, func(t *testing.T) { + path := projectLinkTestPath(t) + if kind == "config-symlink" { + target := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(target, filepath.Base(path)), []byte("{}"), 0600)) + if err := os.Symlink(target, filepath.Dir(path)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } else { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + if kind == "directory" { + require.NoError(t, os.Mkdir(path, 0700)) + } else { + target := filepath.Join(t.TempDir(), "target") + require.NoError(t, os.WriteFile(target, []byte("{}"), 0600)) + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + } + _, err := loadProjectLinks(t.Context(), path) + require.Error(t, err) + _, err = updateProjectLink(t.Context(), path, t.TempDir(), "proj_new") + require.Error(t, err) + }) + } +} + +func TestProjectLinkStoreMissingRegistryPreservesSymlinkedConfiguration(t *testing.T) { + path := projectLinkTestPath(t) + target := t.TempDir() + if err := os.Symlink(target, filepath.Dir(path)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + require.Empty(t, links) + _, err = updateProjectLink(t.Context(), path, t.TempDir(), "proj_new") + require.ErrorContains(t, err, "private configuration directory") + _, err = os.Stat(filepath.Join(target, filepath.Base(path))) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestProjectLinkStoreConcurrentWritersPreserveEntries(t *testing.T) { + path := projectLinkTestPath(t) + const writers = 12 + start := make(chan struct{}) + results := make(chan error, writers) + directory := t.TempDir() + var wg sync.WaitGroup + for i := range writers { + wg.Go(func() { + <-start + _, err := updateProjectLink(t.Context(), path, filepath.Join(directory, fmt.Sprint(i)), fmt.Sprintf("proj_%d", i)) + results <- err + }) + } + close(start) + wg.Wait() + close(results) + for err := range results { + require.NoError(t, err) + } + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + require.Len(t, links, writers) + for i := range writers { + require.Equal(t, fmt.Sprintf("proj_%d", i), links[filepath.Join(directory, fmt.Sprint(i))]) + } +} + +func TestProjectLinkStoreCancellationReleasesLock(t *testing.T) { + path := projectLinkTestPath(t) + root, name, err := openProjectLinkDirectory(path, true) + require.NoError(t, err) + defer root.Close() + lock, err := lockProjectLinks(t.Context(), root, name) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + _, err = updateProjectLink(ctx, path, t.TempDir(), "proj_cancelled") + require.ErrorIs(t, err, context.DeadlineExceeded) + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + require.NoError(t, lock.Close()) + _, err = updateProjectLink(t.Context(), path, t.TempDir(), "proj_after") + require.NoError(t, err) +} + +func TestProjectLinkStoreSnapshotRejectsReplacement(t *testing.T) { + path := projectLinkTestPath(t) + writeProjectLinkTestFile(t, path, "{}") + root, name, err := openProjectLinkDirectory(path, false) + require.NoError(t, err) + defer root.Close() + info, err := root.Lstat(name) + require.NoError(t, err) + temporary := filepath.Join(filepath.Dir(path), "replacement") + require.NoError(t, os.WriteFile(temporary, []byte("{}"), 0600)) + require.NoError(t, os.Rename(temporary, path)) + _, err = readProjectLinkSnapshot(t.Context(), root, name, info) + require.ErrorIs(t, err, errProjectLinksChanged) +} + +func TestProjectLinkStoreSnapshotRejectsInPlaceRewrite(t *testing.T) { + path := projectLinkTestPath(t) + writeProjectLinkTestFile(t, path, "{}") + root, name, err := openProjectLinkDirectory(path, false) + require.NoError(t, err) + defer root.Close() + info, err := root.Lstat(name) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte("{} "), 0600)) + _, err = readProjectLinkSnapshot(t.Context(), root, name, info) + require.ErrorIs(t, err, errProjectLinksChanged) +} + +func TestProjectLinkStoreCancelledContextDoesNotCreateState(t *testing.T) { + path := projectLinkTestPath(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := loadProjectLinks(ctx, path) + require.ErrorIs(t, err, context.Canceled) + _, err = updateProjectLink(ctx, path, t.TempDir(), "proj_cancelled") + require.ErrorIs(t, err, context.Canceled) + _, err = os.Stat(filepath.Dir(path)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestProjectLinkStoreRejectsUnsafeLockFiles(t *testing.T) { + for _, kind := range []string{"directory", "symlink", "dangling-symlink"} { + t.Run(kind, func(t *testing.T) { + path := projectLinkTestPath(t) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + lockPath := filepath.Join(filepath.Dir(path), ".project-links.json.lock") + target := filepath.Join(t.TempDir(), "target") + if kind == "directory" { + require.NoError(t, os.Mkdir(lockPath, 0700)) + } else { + if kind == "symlink" { + require.NoError(t, os.WriteFile(target, []byte("keep"), 0600)) + } + if err := os.Symlink(target, lockPath); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + _, err := updateProjectLink(t.Context(), path, t.TempDir(), "proj_new") + require.Error(t, err) + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + if kind == "symlink" { + data, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, "keep", string(data)) + } else { + _, err = os.Stat(target) + require.ErrorIs(t, err, os.ErrNotExist) + } + }) + } +} + +type observedProjectLinkWait struct { + context.Context + once sync.Once + waiting chan struct{} +} + +func (ctx *observedProjectLinkWait) Done() <-chan struct{} { + ctx.once.Do(func() { close(ctx.waiting) }) + return ctx.Context.Done() +} + +func TestProjectLinkStoreWaitingWriterValidatesCurrentState(t *testing.T) { + for _, replacement := range []string{"malformed-registry", "replaced-lock"} { + t.Run(replacement, func(t *testing.T) { + path := projectLinkTestPath(t) + writeProjectLinkTestFile(t, path, "{}") + root, name, err := openProjectLinkDirectory(path, false) + require.NoError(t, err) + defer root.Close() + first, err := lockProjectLinks(t.Context(), root, name) + require.NoError(t, err) + defer first.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + observed := &observedProjectLinkWait{Context: ctx, waiting: make(chan struct{})} + result := make(chan error, 1) + directory := t.TempDir() + go func() { + _, err := updateProjectLink(observed, path, directory, "proj_new") + result <- err + }() + select { + case <-observed.waiting: + case err := <-result: + t.Fatalf("writer did not wait: %v", err) + case <-ctx.Done(): + t.Fatal("writer did not reach lock wait") + } + wantData, wantError := "{}", "lock changed" + if replacement == "malformed-registry" { + wantData, wantError = "{\"future-schema\":1}", "invalid project links" + temporary := filepath.Join(filepath.Dir(path), "replacement") + require.NoError(t, os.WriteFile(temporary, []byte(wantData), 0600)) + require.NoError(t, os.Rename(temporary, path)) + } else { + lockPath := filepath.Join(filepath.Dir(path), "."+name+".lock") + require.NoError(t, os.Rename(lockPath, lockPath+".old")) + require.NoError(t, os.WriteFile(lockPath, nil, 0600)) + } + require.NoError(t, first.Close()) + select { + case err := <-result: + require.ErrorContains(t, err, wantError) + case <-ctx.Done(): + t.Fatal("waiting writer did not finish") + } + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, wantData, string(data)) + }) + } +} + +func TestProjectLinkStoreProcessExitReleasesLock(t *testing.T) { + const helperPath = "OPENAI_CLI_TEST_PROJECT_LINK_LOCK" + if path := os.Getenv(helperPath); path != "" { + root, name, err := openProjectLinkDirectory(path, true) + require.NoError(t, err) + _, err = lockProjectLinks(t.Context(), root, name) + require.NoError(t, err) + // Intentionally exit without Close to exercise kernel lock cleanup. + os.Exit(0) + } + path := projectLinkTestPath(t) + child := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestProjectLinkStoreProcessExitReleasesLock$") + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if !strings.HasPrefix(strings.ToUpper(name), "OPENAI_") { + child.Env = append(child.Env, entry) + } + } + child.Env = append(child.Env, helperPath+"="+path) + output, err := child.CombinedOutput() + require.NoError(t, err, "%s", output) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + _, err = updateProjectLink(ctx, path, t.TempDir(), "proj_after_exit") + require.NoError(t, err) +} diff --git a/pkg/custom/project_link_store_unix_test.go b/pkg/custom/project_link_store_unix_test.go new file mode 100644 index 00000000..430c3acc --- /dev/null +++ b/pkg/custom/project_link_store_unix_test.go @@ -0,0 +1,115 @@ +//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly + +package custom + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestProjectLinkStoreMissingRegistryPreservesExistingConfiguration(t *testing.T) { + path := projectLinkTestPath(t) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.Chmod(filepath.Dir(path), 0755)) + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + require.Empty(t, links) + _, err = updateProjectLink(t.Context(), path, t.TempDir(), "proj_new") + require.ErrorContains(t, err, "private configuration directory") + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestProjectLinkStoreRejectsInsecurePermissions(t *testing.T) { + for _, target := range []string{"registry", "directory", "lock"} { + t.Run(target, func(t *testing.T) { + path := projectLinkTestPath(t) + writeProjectLinkTestFile(t, path, "{}") + switch target { + case "registry": + require.NoError(t, os.Chmod(path, 0644)) + case "directory": + require.NoError(t, os.Chmod(filepath.Dir(path), 0755)) + case "lock": + lockPath := filepath.Join(filepath.Dir(path), ".project-links.json.lock") + require.NoError(t, os.WriteFile(lockPath, nil, 0600)) + require.NoError(t, os.Chmod(lockPath, 0666)) + } + if target != "lock" { + _, err := loadProjectLinks(t.Context(), path) + require.ErrorContains(t, err, "private") + } + _, err := updateProjectLink(t.Context(), path, t.TempDir(), "proj_new") + require.ErrorContains(t, err, "private") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "{}", string(data)) + }) + } +} + +func TestProjectLinkStoreRejectsFIFOReplacementWithoutBlocking(t *testing.T) { + path := projectLinkTestPath(t) + writeProjectLinkTestFile(t, path, "{}") + root, name, err := openProjectLinkDirectory(path, false) + require.NoError(t, err) + defer root.Close() + info, err := root.Lstat(name) + require.NoError(t, err) + require.NoError(t, os.Remove(path)) + require.NoError(t, unix.Mkfifo(path, 0600)) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := readProjectLinkSnapshot(ctx, root, name, info) + result <- err + }() + select { + case err := <-result: + require.ErrorIs(t, err, errProjectLinksChanged) + case <-ctx.Done(): + t.Fatal("opening a substituted FIFO blocked") + } +} + +func TestProjectLinkStoreRejectsFIFOLockWithoutBlocking(t *testing.T) { + path := projectLinkTestPath(t) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, unix.Mkfifo(filepath.Join(filepath.Dir(path), ".project-links.json.lock"), 0600)) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + result := make(chan error, 1) + directory := t.TempDir() + go func() { + _, err := updateProjectLink(ctx, path, directory, "proj_new") + result <- err + }() + select { + case err := <-result: + require.ErrorContains(t, err, "private regular file") + case <-ctx.Done(): + t.Fatal("opening a FIFO lock blocked") + } +} + +func TestProjectLinkStoreUnwritableDirectoryKeepsRegistry(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses Unix write permissions") + } + path := projectLinkTestPath(t) + writeProjectLinkTestFile(t, path, "{}") + require.NoError(t, os.Chmod(filepath.Dir(path), 0500)) + t.Cleanup(func() { require.NoError(t, os.Chmod(filepath.Dir(path), 0700)) }) + _, err := updateProjectLink(t.Context(), path, t.TempDir(), "proj_new") + require.ErrorIs(t, err, os.ErrPermission) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "{}", string(data)) +} diff --git a/pkg/custom/project_link_test.go b/pkg/custom/project_link_test.go new file mode 100644 index 00000000..b85e7a23 --- /dev/null +++ b/pkg/custom/project_link_test.go @@ -0,0 +1,145 @@ +package custom + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func projectLinkTestCommand(out io.Writer) *cli.Command { + root := &cli.Command{Name: "openai", Writer: out, ErrWriter: io.Discard, + Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "project", Sources: cli.EnvVars("OPENAI_PROJECT_ID")}, + &cli.StringFlag{Name: "format", Value: "auto"}, + &cli.StringFlag{Name: "transform"}, + &cli.BoolFlag{Name: "raw-output"}, + }, + ExitErrHandler: func(context.Context, *cli.Command, error) {}, + } + configureRootRequestFlags(root) + registerProjectLinkCommands(root) + return root +} + +func projectLinkTestEnvironment(t *testing.T) string { + t.Helper() + directory := t.TempDir() + t.Setenv("HOME", directory) + t.Setenv("APPDATA", directory) + t.Setenv("XDG_CONFIG_HOME", directory) + t.Setenv("OPENAI_PROJECT_ID", "") + require.NoError(t, os.Unsetenv("OPENAI_PROJECT_ID")) + t.Chdir(t.TempDir()) + path, err := projectLinkPath() + require.NoError(t, err) + return path +} + +func TestProjectLinkExplicitFlagDoesNotSaveEnvironment(t *testing.T) { + path := projectLinkTestEnvironment(t) + t.Setenv("OPENAI_PROJECT_ID", "proj_environment") + var out bytes.Buffer + require.NoError(t, projectLinkTestCommand(&out).Run(t.Context(), []string{"openai", "link"})) + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + require.Contains(t, out.String(), "OPENAI_PROJECT_ID overrides") + for _, args := range [][]string{ + {"openai", "--project", "proj_explicit", "link"}, + {"openai", "link", "--project", "proj_explicit"}, + } { + require.NoError(t, projectLinkTestCommand(io.Discard).Run(t.Context(), args)) + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + directory, err := projectLinkDirectory() + require.NoError(t, err) + require.Equal(t, "proj_explicit", links[directory]) + } +} + +type projectLinkBrokenWriter struct{ err error } + +func (w projectLinkBrokenWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestProjectLinkOutputFailurePreservesCommittedLink(t *testing.T) { + path := projectLinkTestEnvironment(t) + failure := errors.New("synthetic output failure") + err := projectLinkTestCommand(projectLinkBrokenWriter{failure}).Run(t.Context(), []string{"openai", "link", "--project", "proj_saved"}) + require.ErrorIs(t, err, failure) + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + directory, err := projectLinkDirectory() + require.NoError(t, err) + require.Equal(t, "proj_saved", links[directory]) +} + +func TestProjectLinkRejectsModesBeforeMutation(t *testing.T) { + path := projectLinkTestEnvironment(t) + for _, flags := range [][]string{{"--format", "yaml"}, {"--transform", "project"}, {"--raw-output=false"}, {"unexpected"}} { + args := append([]string{"openai", "link", "--project", "proj_work"}, flags...) + require.Error(t, projectLinkTestCommand(io.Discard).Run(t.Context(), args)) + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + } +} + +func TestProjectLinkCanonicalMissingDirectory(t *testing.T) { + projectLinkTestEnvironment(t) + directory := filepath.Join(t.TempDir(), "removed") + require.NoError(t, os.Mkdir(directory, 0700)) + t.Chdir(directory) + if err := os.Remove(directory); err != nil { + t.Skip("host does not allow removing the working directory") + } + err := projectLinkTestCommand(io.Discard).Run(t.Context(), []string{"openai", "link", "--project", "proj_work"}) + require.Error(t, err) + require.Contains(t, err.Error(), "current folder") +} + +func TestProjectLinkResolutionFailureDoesNotRetry(t *testing.T) { + path := projectLinkTestEnvironment(t) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte("malformed"), 0600)) + root := projectLinkTestCommand(io.Discard) + attempts := 0 + root.Commands = append(root.Commands, &cli.Command{Name: "probe", Action: func(ctx context.Context, command *cli.Command) error { + opts := []option.RequestOption{option.WithAPIKey("synthetic-key"), option.WithMiddleware(func(request *http.Request, next option.MiddlewareNext) (*http.Response, error) { + attempts++ + return next(request) + })} + opts = append(opts, projectLinkRequestOptions(command)...) + client := openai.NewClient(opts...) + _, err := client.Models.List(ctx) + return err + }}) + err := root.Run(t.Context(), []string{"openai", "probe"}) + require.ErrorContains(t, err, "Could not resolve folder project defaults") + require.Equal(t, 1, attempts) +} + +func TestProjectLinkResolutionPreservesCommandCancellation(t *testing.T) { + projectLinkTestEnvironment(t) + root := projectLinkTestCommand(io.Discard) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + root.Commands = append(root.Commands, &cli.Command{Name: "probe", Action: func(ctx context.Context, command *cli.Command) error { + cancel() + opts := projectLinkRequestOptions(command) + require.Len(t, opts, 2, "canceled lookup must produce the deterministic failure options") + client := openai.NewClient(append(opts, option.WithAPIKey("synthetic-key"))...) + _, err := client.Models.List(ctx) + return err + }}) + err := root.Run(ctx, []string{"openai", "probe"}) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/scripts/demos/project-linking/README.md b/scripts/demos/project-linking/README.md new file mode 100644 index 00000000..763afdb8 --- /dev/null +++ b/scripts/demos/project-linking/README.md @@ -0,0 +1,42 @@ +# Folder project linking recording + +This recorder compares two real CLI binaries using one synthetic loopback Files API. +It uses the shared `scripts/demos/capture_and_render.sh` lifecycle. +It creates temporary `openai` PATH entries and separate configuration homes. +It does not change the installed CLI. + +The baseline attempts `openai link --project proj_work` and must return status 3. +The candidate links `work-chatbot`, inspects the link, lists remote files, and removes the link. +The synthetic API returns `file_remote_proj_work` only when it receives the expected project header. +These results represent remote OpenAI files, not files in `work-chatbot`. +Project selection does not change the API key or grant project access. + +Wait for the coordinator's PTY reservation before recording. +Provide independently built binaries and their full source commit IDs. +Put `python3`, `asciinema`, `agg`, `ffmpeg`, and `ffprobe` on PATH. +Use an empty output directory outside the repository. + +```sh +bash scripts/demos/project-linking/record.sh \ + /absolute/evidence/baseline-openai \ + /absolute/evidence/candidate-openai \ + BASE_COMMIT CANDIDATE_COMMIT \ + /absolute/evidence/project-linking-media +``` + +Set `DEMO_SOURCE_MANIFEST` when the candidate requires an additional source manifest. +The recorder preserves both commit IDs, binary hashes, script hashes, request evidence, and real command statuses. +The fixture stores only fixed synthetic request paths, project IDs, and file IDs. +It never logs credentials or other request headers. +SIGTERM starts fixture shutdown on a separate thread. +The shared lifecycle waits for shutdown and treats fixture failure as recording failure. + +The capture uses Bash at 100 columns and 26 rows. +The replay uses Menlo at 18 pixels with the asciinema theme. +Each scene identifies its platform and synthetic remote Files API. +macOS recordings are terminal replays, not native graphical terminal validation. +The fixture does not establish live API access or backend authorization behavior. + +Inspect `before.png`, `after.png`, and `comparison.gif` before sharing. +Check complete command output, file IDs, and terminal clipping. +Keep GIFs, screenshots, casts, and binaries outside Git. diff --git a/scripts/demos/project-linking/record.sh b/scripts/demos/project-linking/record.sh new file mode 100755 index 00000000..9c2b2517 --- /dev/null +++ b/scripts/demos/project-linking/record.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -euo pipefail +if [ "$#" -ne 5 ]; then + echo 'usage: record.sh BEFORE_BINARY AFTER_BINARY BEFORE_SHA AFTER_SHA OUTPUT_DIR' >&2 + exit 2 +fi +demo_source="$(cd "$(dirname "$0")" && pwd)" +demo_root="$(cd "$demo_source/../../.." && pwd)" +demo_python="$(command -v python3)" +demo_platform="$(uname -s)" +if [ "$demo_platform" = Darwin ]; then demo_platform=macOS; fi +source "$demo_source/../capture_and_render.sh" +demo_prepare_capture "$demo_root" "$1" "$2" "$3" "$4" "$5" "$demo_source/server.py" +demo_start_api "$demo_output/requests.jsonl" +cp "$demo_source/scene.sh" "$demo_runtime/scene.sh" +{ + echo 'feature: folder project linking' + echo "comparison base / before commit: $demo_before_sha" + echo "candidate / after commit: $demo_after_sha" + echo 'capture: terminal replay; synthetic loopback API; isolated Bash PTYs and temporary homes' + echo 'scope: no native graphical terminal, live API, or project-access validation' + echo 'render: 100 columns x 26 rows; Menlo 18px; asciinema theme' + echo 'before: link is unavailable and returns status 3' + echo 'after: link, inspect, remote files list, and unlink return status 0' + echo 'fixture: file_remote_proj_work proves the received synthetic project header' + echo 'privacy: request evidence excludes credentials and other headers' + demo_capture_metadata + "$demo_python" --version + shasum -a 256 "$demo_source/server.py" "$demo_source/scene.sh" "$demo_source/record.sh" +} > "$demo_output/metadata.txt" +if [ -n "${DEMO_SOURCE_MANIFEST:-}" ]; then + cp "$DEMO_SOURCE_MANIFEST" "$demo_output/source-manifest.txt" +fi +demo_window_size=100x26 +demo_render_options=(--renderer resvg --font-family Menlo --font-size 18 --line-height 1.2 \ + --theme asciinema --fps-cap 20 --last-frame-duration 3) +for demo_scene in before after; do + demo_home="$demo_runtime/$demo_scene-home" + demo_work="$demo_runtime/$demo_scene-work" + mkdir -p "$demo_home" "$demo_work/work-chatbot" + demo_label='Before: no saved folder project' + if [ "$demo_scene" = after ]; then demo_label='After: this folder uses proj_work'; fi + demo_capture_scene "$demo_scene" 0 "$demo_runtime/$demo_scene" \ + "$demo_api_url/$demo_scene/v1" "$demo_label" \ + "HOME=$demo_home" "USERPROFILE=$demo_home" "APPDATA=$demo_home" "XDG_CONFIG_HOME=$demo_home" \ + NO_COLOR=1 FORCE_COLOR=0 GOMAXPROCS=2 PAGER=cat \ + "DEMO_SCENE=$demo_scene" "DEMO_PLATFORM=$demo_platform" "DEMO_WORK_ROOT=$demo_work" \ + "DEMO_STATUS_LOG=$demo_output/statuses.tsv" +done +demo_stop_api +"$demo_python" -I -B - "$demo_output" <<'PY' | tee "$demo_output/validation.txt" +import json +import pathlib +import sys + +output = pathlib.Path(sys.argv[1]) +statuses = (output / "statuses.tsv").read_text().splitlines() +expected = ["before\tlink\t3", "after\tlink\t0", "after\tinspect\t0", + "after\tfiles\t0", "after\tunlink\t0"] +assert statuses == expected, statuses +requests = [json.loads(line) for line in (output / "requests.jsonl").read_text().splitlines()] +assert requests == [{"method": "GET", "path": "/after/v1/files", "project": "proj_work", + "file_id": "file_remote_proj_work", "status": 200}], requests +for scene in ("before", "after"): + text = (output / (scene + ".txt")).read_text() + assert "Terminal replay" in text and "synthetic remote Files API" in text + assert "$ cd work-chatbot" in text + assert "$ openai link --project proj_work" in text + assert "synthetic-demo-key" not in text + if scene == "after": + assert "file_remote_proj_work" in text + assert "remote-project-notes.txt" in text + assert "$ openai unlink" in text + assert "This folder has no saved project link." in text + assert len(text.splitlines()) <= 26, "candidate scene exceeds the terminal height" +print("PASS: actual statuses match; exactly one remote Files request used proj_work.") +print("PASS: baseline linking failed; candidate linked, inspected, listed remote files, and unlinked.") +print("PASS: transcript contains the synthetic file ID and fits the 26-row terminal.") +PY +demo_assemble_capture 300 before after +printf 'Recorded folder-linking terminal replays in %s\n' "$demo_output" diff --git a/scripts/demos/project-linking/scene.sh b/scripts/demos/project-linking/scene.sh new file mode 100755 index 00000000..0c722987 --- /dev/null +++ b/scripts/demos/project-linking/scene.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail +test -t 0 && test -t 1 && test -t 2 || exit 99 +cd "$DEMO_WORK_ROOT" +printf '\033[2J\033[H' +printf '%s\n' "$DEMO_SCENE_LABEL" +printf '%s\n' "Terminal replay | $DEMO_PLATFORM | synthetic remote Files API" +printf '%s\n' '$ cd work-chatbot' +cd work-chatbot + +demo_command() { + local demo_name="$1" demo_expected="$2" demo_status + shift 2 + printf '$ openai' + printf ' %s' "$@" + printf '\n' + sleep 0.4 + if openai "$@"; then demo_status=0; else demo_status=$?; fi + printf '%s\t%s\t%s\n' "$DEMO_SCENE" "$demo_name" "$demo_status" >> "$DEMO_STATUS_LOG" + if [ "$demo_status" -ne "$demo_expected" ]; then + return 97 + fi + sleep 0.8 +} + +if [ "$DEMO_SCENE" = before ]; then + demo_command link 3 link --project proj_work +else + demo_command link 0 link --project proj_work + demo_command inspect 0 link + demo_command files 0 files list + demo_command unlink 0 unlink +fi +printf '$ ' +sleep 3 diff --git a/scripts/demos/project-linking/server.py b/scripts/demos/project-linking/server.py new file mode 100755 index 00000000..0ace3217 --- /dev/null +++ b/scripts/demos/project-linking/server.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Serve synthetic remote files for the folder-linking terminal replay.""" + +import http.server +import json +import pathlib +import signal +import sys +import threading +import urllib.parse + + +def main(): + if len(sys.argv) != 3: + raise SystemExit("usage: server.py ADDRESS_FILE REQUEST_LOG") + failures = [] + lock = threading.Lock() + with open(sys.argv[2], "x", encoding="utf-8") as log: + class Fixture(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reject(self): + with lock: + failures.append("unexpected synthetic request") + body = b'{"error":{"message":"Unexpected synthetic request"}}' + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + try: + path = urllib.parse.urlsplit(self.path).path + project = self.headers.get("OpenAI-Project", "") + if ( + path != "/after/v1/files" + or project != "proj_work" + or self.headers.get("Authorization") != "Bearer synthetic-demo-key" + ): + self.reject() + return + file_id = "file_remote_" + project + body = json.dumps({ + "object": "list", + "data": [{ + "id": file_id, + "object": "file", + "filename": "remote-project-notes.txt", + "purpose": "user_data", + "bytes": 240, + "created_at": 1700000000, + "status": "processed", + }], + "has_more": False, + }, separators=(",", ":")).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + self.wfile.flush() + with lock: + # Record only these fixed synthetic values. Never log headers. + log.write(json.dumps({ + "method": "GET", "path": path, "project": project, + "file_id": file_id, "status": 200, + }) + "\n") + log.flush() + except Exception as error: + with lock: + failures.append(type(error).__name__) + + do_POST = reject + do_PUT = reject + do_PATCH = reject + do_DELETE = reject + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fixture) + server.daemon_threads = False + shutdown_threads = [] + + def stop(*_): + if shutdown_threads: + return + # shutdown() must run outside the serve_forever() thread. + worker = threading.Thread(target=server.shutdown) + shutdown_threads.append(worker) + worker.start() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + try: + pathlib.Path(sys.argv[1]).write_text( + f"http://127.0.0.1:{server.server_port}\n", encoding="utf-8" + ) + server.serve_forever(poll_interval=0.05) + finally: + server.server_close() + for worker in shutdown_threads: + worker.join() + if failures: + raise RuntimeError("synthetic fixture failed: " + ", ".join(failures)) + + +if __name__ == "__main__": + main() From 43f51044cb914721b8eb99ba546be8ccbe9c739f Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 12:22:54 -0700 Subject: [PATCH 02/36] [cli] reject relative project link configuration roots --- cmd/openai/project_link_test.go | 22 ++++++++++++++++++++++ docs/project-linking.md | 1 + pkg/custom/project_link.go | 4 ++++ 3 files changed, 27 insertions(+) diff --git a/cmd/openai/project_link_test.go b/cmd/openai/project_link_test.go index a91fd77f..54ac42a6 100644 --- a/cmd/openai/project_link_test.go +++ b/cmd/openai/project_link_test.go @@ -93,6 +93,28 @@ func projectLinkCreate(t *testing.T, home, directory, project string) { require.Contains(t, got.stdout, project) } +func TestMainProjectLinkRejectsRelativeConfiguration(t *testing.T) { + directory := t.TempDir() + registry := filepath.Join(directory, projectLinkRegistryPath(".")) + require.NoError(t, os.MkdirAll(filepath.Dir(registry), 0700)) + data, err := json.Marshal(map[string]string{projectLinkCanonicalDirectory(t, directory): "proj_repository"}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(registry, data, 0600)) + server, requests := globalFlagsServer(t, globalFlagsPage) + got := runProjectLinkMain(t, ".", directory, globalFlagsEnv(server), "", "files", "list", "--format=raw") + require.Equal(t, mainDispatchResult{stdout: globalFlagsPage + "\n"}, got) + require.Empty(t, globalFlagsOneRequest(t, requests).header.Values("OpenAI-Project")) + for _, args := range [][]string{{"link"}, {"link", "--project=proj_new"}, {"unlink"}} { + got := runProjectLinkMain(t, ".", directory, nil, "", args...) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + require.Contains(t, got.stderr, "configuration folder") + } + after, err := os.ReadFile(registry) + require.NoError(t, err) + require.Equal(t, data, after) +} + func TestMainProjectLinkOfflineLifecycle(t *testing.T) { server, requests := localUtilitiesRequestTrap(t) for _, configuration := range []struct { diff --git a/docs/project-linking.md b/docs/project-linking.md index a818bb58..9b3bc076 100644 --- a/docs/project-linking.md +++ b/docs/project-linking.md @@ -73,6 +73,7 @@ The CLI stores it in the operating system's user configuration directory: - Windows: `%APPDATA%\openai\project-links.json` The CLI never reads project defaults from repository configuration. +The user configuration directory must be an absolute path. On Unix, the application directory and registry must have private permissions. New directories use mode `0700`; new registry files use mode `0600`. Windows uses the user configuration directory's inherited access controls. diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index 87e763be..2c6be198 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -3,6 +3,7 @@ package custom import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -89,6 +90,9 @@ func projectLinkPath() (string, error) { if err != nil { return "", err } + if !filepath.IsAbs(directory) { + return "", errors.New("folder links require an absolute user configuration directory") + } return filepath.Join(directory, "openai", "project-links.json"), nil } From d0ccc2ea100de8f7c8af8957d9647a08079f33f3 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 12:26:36 -0700 Subject: [PATCH 03/36] [cli] preserve explicit project header overrides --- cmd/openai/project_link_test.go | 2 ++ docs/project-linking.md | 1 + pkg/custom/project_link.go | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/cmd/openai/project_link_test.go b/cmd/openai/project_link_test.go index 54ac42a6..4a1812e9 100644 --- a/cmd/openai/project_link_test.go +++ b/cmd/openai/project_link_test.go @@ -322,6 +322,8 @@ func TestMainProjectLinkMalformedRegistryFailsBeforeRequests(t *testing.T) { }{ {nil, []string{"--project=proj_explicit"}, []string{"proj_explicit"}}, {nil, []string{"--project="}, []string{""}}, + {nil, []string{"--header=OpenAI-Project: proj_header"}, []string{"proj_header"}}, + {nil, []string{"--header=OpenAI-Project:"}, []string{""}}, {[]string{"OPENAI_PROJECT_ID=proj_environment"}, nil, []string{"proj_environment"}}, {[]string{"OPENAI_PROJECT_ID="}, nil, []string{""}}, } { diff --git a/docs/project-linking.md b/docs/project-linking.md index 9b3bc076..660b883d 100644 --- a/docs/project-linking.md +++ b/docs/project-linking.md @@ -91,6 +91,7 @@ This limit does not apply to API payloads. Malformed, duplicate, insecure, or nonregular registry files cause an error. The CLI preserves invalid settings instead of overwriting them. An explicit project flag or environment variable bypasses registry loading for API requests. +An explicit `OpenAI-Project` custom header also bypasses registry loading. ## Output and recovery diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index 2c6be198..7918deb0 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -144,6 +144,13 @@ func projectLinkRequestOptions(command *cli.Command) []option.RequestOption { if project, present := os.LookupEnv("OPENAI_PROJECT_ID"); present { return []option.RequestOption{option.WithProject(project)} } + // Explicit literal headers are applied by FlagOptions after client defaults. + // They also make implicit registry lookup unnecessary, including empty values. + if root.IsSet("header") { + if headers, err := requestHeaders(root); err == nil && len(headers.Values("OpenAI-Project")) != 0 { + return nil + } + } path, err := projectLinkPath() if err != nil { // A process without a user configuration location has no folder defaults. From b07f9a55d59631c2b3bfdf473fc00014ad68c000 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 12:27:46 -0700 Subject: [PATCH 04/36] [cli] isolate project link tests from ambient endpoints --- pkg/custom/project_link_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/custom/project_link_test.go b/pkg/custom/project_link_test.go index b85e7a23..d32c8791 100644 --- a/pkg/custom/project_link_test.go +++ b/pkg/custom/project_link_test.go @@ -39,6 +39,7 @@ func projectLinkTestEnvironment(t *testing.T) string { t.Setenv("APPDATA", directory) t.Setenv("XDG_CONFIG_HOME", directory) t.Setenv("OPENAI_PROJECT_ID", "") + t.Setenv("OPENAI_BASE_URL", "http://127.0.0.1:1") require.NoError(t, os.Unsetenv("OPENAI_PROJECT_ID")) t.Chdir(t.TempDir()) path, err := projectLinkPath() From 7d6cbe7ad494dd23e8ec6eeaabde7a404688bea6 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 12:28:27 -0700 Subject: [PATCH 05/36] [cli] verify local linking skips shell setup --- pkg/custom/project_link_test.go | 10 ++++++++++ scripts/demos/project-linking/record.sh | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/custom/project_link_test.go b/pkg/custom/project_link_test.go index d32c8791..1576cb05 100644 --- a/pkg/custom/project_link_test.go +++ b/pkg/custom/project_link_test.go @@ -68,6 +68,16 @@ func TestProjectLinkExplicitFlagDoesNotSaveEnvironment(t *testing.T) { } } +func TestProjectLinkSkipsFirstRunShellSetup(t *testing.T) { + for _, args := range [][]string{ + {"openai", "link"}, {"openai", "link", "--project", "proj_work"}, + {"openai", "--project=proj_work", "link"}, {"openai", "unlink"}, + {"openai", "help", "link"}, {"openai", "unlink", "--help"}, + } { + require.False(t, imagePickerFirstRunEligible(args, func(string) string { return "" }, true, true, true, "bash")) + } +} + type projectLinkBrokenWriter struct{ err error } func (w projectLinkBrokenWriter) Write([]byte) (int, error) { return 0, w.err } diff --git a/scripts/demos/project-linking/record.sh b/scripts/demos/project-linking/record.sh index 9c2b2517..5e294fd5 100755 --- a/scripts/demos/project-linking/record.sh +++ b/scripts/demos/project-linking/record.sh @@ -43,7 +43,7 @@ for demo_scene in before after; do demo_capture_scene "$demo_scene" 0 "$demo_runtime/$demo_scene" \ "$demo_api_url/$demo_scene/v1" "$demo_label" \ "HOME=$demo_home" "USERPROFILE=$demo_home" "APPDATA=$demo_home" "XDG_CONFIG_HOME=$demo_home" \ - NO_COLOR=1 FORCE_COLOR=0 GOMAXPROCS=2 PAGER=cat \ + NO_COLOR=1 FORCE_COLOR=0 CI=1 GOMAXPROCS=2 PAGER=cat \ "DEMO_SCENE=$demo_scene" "DEMO_PLATFORM=$demo_platform" "DEMO_WORK_ROOT=$demo_work" \ "DEMO_STATUS_LOG=$demo_output/statuses.tsv" done From 2b571f028f45a204ccf3af8f483c70b01dde15a7 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 12:39:03 -0700 Subject: [PATCH 06/36] [cli] declare supported folder link completion formats --- pkg/custom/project_link.go | 7 +++++-- pkg/custom/project_link_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index 7918deb0..f07cb305 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -70,8 +70,11 @@ func registerProjectLinkCommands(root *cli.Command) { root.Commands = append(root.Commands, &cli.Command{ Name: name, Usage: usage, Description: description, HideHelpCommand: true, CustomHelpTemplate: help, - Metadata: map[string]any{localUtilityMetadata: true, "local-help-full": help, "help-command-section": "Local tools"}, - Action: handleProjectLink, + Metadata: map[string]any{ + localUtilityMetadata: true, "local-help-full": help, "help-command-section": "Local tools", + "completion-root-flag-values": map[string][]string{"format": {"auto", "text", "json"}}, + }, + Action: handleProjectLink, }) } } diff --git a/pkg/custom/project_link_test.go b/pkg/custom/project_link_test.go index 1576cb05..4e5ffa06 100644 --- a/pkg/custom/project_link_test.go +++ b/pkg/custom/project_link_test.go @@ -78,6 +78,16 @@ func TestProjectLinkSkipsFirstRunShellSetup(t *testing.T) { } } +func TestProjectLinkCompletionUsesSupportedFormats(t *testing.T) { + root := projectLinkTestCommand(io.Discard) + for _, name := range []string{"link", "unlink"} { + command := root.Command(name) + require.NotNil(t, command) + require.Equal(t, map[string][]string{"format": {"auto", "text", "json"}}, command.Metadata["completion-root-flag-values"]) + require.Equal(t, true, command.Metadata[localUtilityMetadata]) + } +} + type projectLinkBrokenWriter struct{ err error } func (w projectLinkBrokenWriter) Write([]byte) (int, error) { return 0, w.err } From 8476466cd72e9f6ce71acaaabda67c8825483d90 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 12:45:41 -0700 Subject: [PATCH 07/36] [cli] cover quiet folder link output --- cmd/openai/project_link_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cmd/openai/project_link_test.go b/cmd/openai/project_link_test.go index 4a1812e9..18dd8f9e 100644 --- a/cmd/openai/project_link_test.go +++ b/cmd/openai/project_link_test.go @@ -161,6 +161,25 @@ func TestMainProjectLinkOfflineLifecycle(t *testing.T) { require.Zero(t, requests.Load(), "local linking or help reached the API") } +func TestMainProjectLinkQuietPreservesInspectionAndJSON(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + for _, format := range []string{"auto", "text", "json"} { + for _, args := range [][]string{{"link", "--project=proj_quiet"}, {"link"}, {"unlink"}} { + argv := append(slices.Clone(args), "--quiet", "--format", format) + got := runProjectLinkMain(t, home, directory, nil, "", argv...) + require.Zero(t, got.code, "%v: %s", argv, got.stderr) + require.Empty(t, got.stderr) + if format == "json" { + require.True(t, json.Valid([]byte(got.stdout)), "%s", got.stdout) + } else if len(args) == 1 && args[0] == "link" { + require.Contains(t, got.stdout, "proj_quiet") + } else { + require.Empty(t, got.stdout, "quiet mutation confirmation") + } + } + } +} + func TestMainProjectLinkRequestPrecedence(t *testing.T) { home, directory := t.TempDir(), t.TempDir() projectLinkCreate(t, home, directory, "proj_folder") From 87944068a5fd6b648cdc38d5d01f457c2d7f9911 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 10 Oct 2026 00:28:10 -0700 Subject: [PATCH 08/36] fix: align folder project inspection and recovery --- cmd/openai/project_link_plan_exp_test.go | 259 ++++++++++++++++++ docs/project-linking.md | 25 +- pkg/custom/project_link.go | 59 +++- pkg/custom/project_link_errors.go | 55 ++++ pkg/custom/project_link_errors_test.go | 70 +++++ pkg/custom/project_link_store.go | 32 ++- scripts/demos/project-linking/README.md | 20 ++ .../project-linking/context-review-scene.sh | 31 +++ .../project-linking/record-context-review.sh | 77 ++++++ 9 files changed, 602 insertions(+), 26 deletions(-) create mode 100644 cmd/openai/project_link_plan_exp_test.go create mode 100644 pkg/custom/project_link_errors.go create mode 100644 pkg/custom/project_link_errors_test.go create mode 100755 scripts/demos/project-linking/context-review-scene.sh create mode 100755 scripts/demos/project-linking/record-context-review.sh diff --git a/cmd/openai/project_link_plan_exp_test.go b/cmd/openai/project_link_plan_exp_test.go new file mode 100644 index 00000000..4887c086 --- /dev/null +++ b/cmd/openai/project_link_plan_exp_test.go @@ -0,0 +1,259 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// These cases compare local inspection with the existing request-option order. +// They use synthetic headers and do not establish live project access. +func TestMainProjectLinkPlanExpInspectionMatchesRequestHeaders(t *testing.T) { + const response = `{"object":"list","data":[],"has_more":false}` + for _, test := range []struct { + name, saved, source, effective string + env, flags, headers []string + }{ + {name: "default", source: "default"}, + {name: "custom header fallback", source: "custom_headers", effective: "proj_custom", + env: []string{"OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom"}, headers: []string{"proj_custom"}}, + {name: "custom header duplicate casing", source: "custom_headers", effective: "proj_last", + env: []string{"OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_first\nopenai-project: proj_last"}, headers: []string{"proj_last"}}, + {name: "custom header whitespace", source: "custom_headers", effective: "proj_trimmed", + env: []string{"OPENAI_CUSTOM_HEADERS= \tOpEnAi-PrOjEcT\t : \tproj_trimmed\t "}, headers: []string{"proj_trimmed"}}, + {name: "custom header empty", source: "custom_headers", + env: []string{"OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_first\nopenai-project:"}, headers: []string{""}}, + {name: "unrelated malformed environment lines", source: "custom_headers", effective: "proj_custom", + env: []string{"OPENAI_CUSTOM_HEADERS=synthetic-ignored-line\nOpenAI-Project: proj_custom\n\nsecond-ignored-line"}, headers: []string{"proj_custom"}}, + {name: "folder overrides custom header", saved: "proj_folder", source: "folder", effective: "proj_folder", + env: []string{"OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom"}, headers: []string{"proj_folder"}}, + {name: "environment overrides folder and custom header", saved: "proj_folder", source: "environment", effective: "proj_environment", + env: []string{"OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom", "OPENAI_PROJECT_ID=proj_environment"}, headers: []string{"proj_environment"}}, + {name: "empty environment overrides custom header", saved: "proj_folder", source: "environment", + env: []string{"OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom", "OPENAI_PROJECT_ID="}, headers: []string{""}}, + {name: "explicit header overrides folder", saved: "proj_folder", source: "header", effective: "proj_explicit", + flags: []string{"--header=OpenAI-Project: proj_explicit"}, headers: []string{"proj_explicit"}}, + {name: "explicit header overrides environment", saved: "proj_folder", source: "header", effective: "proj_explicit", + env: []string{"OPENAI_PROJECT_ID=proj_environment", "OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom"}, + flags: []string{"--header=OpenAI-Project: proj_explicit"}, headers: []string{"proj_explicit"}}, + {name: "explicit duplicate casing", saved: "proj_folder", source: "header", effective: "proj_last", + flags: []string{"--header=OpenAI-Project: proj_first", "-H=openai-project: proj_last"}, headers: []string{"proj_last"}}, + {name: "explicit empty header", saved: "proj_folder", source: "header", + env: []string{"OPENAI_PROJECT_ID=proj_environment"}, flags: []string{"--header=OpenAI-Project:"}, headers: []string{""}}, + {name: "unrelated explicit header preserves custom fallback", source: "custom_headers", effective: "proj_custom", + env: []string{"OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom"}, flags: []string{"--header=X-Synthetic: unrelated"}, headers: []string{"proj_custom"}}, + } { + t.Run(test.name, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + if test.saved != "" { + projectLinkCreate(t, home, directory, test.saved) + } + server, requests := globalFlagsServer(t, response) + // Local inspection must tolerate invalid remote configuration without credentials. + localEnv := append(slices.Clone(test.env), "OPENAI_BASE_URL=://synthetic-unusable-endpoint", + "OPENAI_MTLS_CLIENT_CERT_FILE=/synthetic-unusable/certificate") + args := append([]string{"link", "--format=json"}, test.flags...) + got := runProjectLinkMain(t, home, directory, localEnv, "", args...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + var status struct { + Project string `json:"project"` + EffectiveProject string `json:"effective_project"` + Source string `json:"source"` + Redacted bool `json:"effective_project_redacted"` + } + require.NoError(t, json.Unmarshal([]byte(got.stdout), &status)) + require.Equal(t, test.saved, status.Project) + require.Equal(t, test.source, status.Source) + require.Equal(t, test.effective, status.EffectiveProject) + require.False(t, status.Redacted) + require.Empty(t, requests) + args = append([]string{"files", "list", "--format=raw"}, test.flags...) + got = runProjectLinkMain(t, home, directory, globalFlagsEnv(server, test.env...), "", args...) + require.Equal(t, mainDispatchResult{stdout: response + "\n"}, got) + request := globalFlagsOneRequest(t, requests) + require.Equal(t, http.MethodGet, request.method) + require.Equal(t, "/files", request.path) + require.Equal(t, test.headers, request.header.Values("OpenAI-Project")) + require.Equal(t, status.EffectiveProject, request.header.Get("OpenAI-Project")) + require.Equal(t, "Bearer synthetic-env-key", request.header.Get("Authorization")) + if test.saved != "" { + require.Equal(t, map[string]string{projectLinkCanonicalDirectory(t, directory): test.saved}, projectLinkRegistry(t, home)) + } + }) + } +} + +func TestMainProjectLinkPlanExpCustomProjectRedaction(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + for _, source := range []string{"custom_headers", "header"} { + for _, value := range []string{"sk-synthetic-project-secret", "https://synthetic.invalid/private?token=synthetic-project-secret"} { + for _, format := range []string{"text", "json"} { + t.Run(source+"/"+strings.SplitN(value, ":", 2)[0]+"/"+format, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + env := []string{"OPENAI_BASE_URL=" + server.URL} + args := []string{"link", "--format", format} + if source == "header" { + projectLinkCreate(t, home, directory, "proj_saved") + args = append(args, "--header=OpenAI-Project: "+value) + } else { + env = append(env, "OPENAI_CUSTOM_HEADERS=OpenAI-Project: "+value) + } + got := runProjectLinkMain(t, home, directory, env, "", args...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.NotContains(t, got.stdout, value) + require.NotContains(t, got.stdout, "synthetic-project-secret") + if format == "json" { + var status map[string]any + require.NoError(t, json.Unmarshal([]byte(got.stdout), &status)) + require.Equal(t, source, status["source"]) + require.Equal(t, true, status["effective_project_redacted"]) + require.Equal(t, "", status["effective_project"]) + if source == "header" { + require.Equal(t, "proj_saved", status["project"]) + } + } else { + require.Contains(t, strings.ToLower(got.stdout), "hidden") + } + }) + } + } + } + require.Zero(t, requests.Load(), "local inspection contacted the API") +} + +func TestMainProjectLinkPlanExpRemoteFilenameRecipe(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + projectLinkCreate(t, home, directory, "proj_work") + require.NoError(t, os.WriteFile(filepath.Join(directory, "local-only.txt"), []byte("synthetic local file"), 0o600)) + const response = `{"object":"list","data":[{"id":"file_remote_notes","object":"file","filename":"remote-notes.txt"},{"id":"file_remote_data","object":"file","filename":"remote-data.jsonl"}],"has_more":false}` + server, requests := globalFlagsServer(t, response) + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server), "", "files", "list", "--transform", "filename", "--raw-output") + require.Equal(t, mainDispatchResult{stdout: "remote-notes.txt\nremote-data.jsonl\n"}, got) + request := globalFlagsOneRequest(t, requests) + require.Equal(t, "/files", request.path) + require.Equal(t, "proj_work", request.header.Get("OpenAI-Project")) +} + +func TestMainProjectLinkPlanExpStoreFailuresExplainKnownCause(t *testing.T) { + for _, kind := range []string{"size", "directory privacy", "file privacy", "permission denial"} { + t.Run(kind, func(t *testing.T) { + if kind != "size" && runtime.GOOS == "windows" { + t.Skip("Unix permission semantics require native Unix execution") + } + home, directory := t.TempDir(), t.TempDir() + path := projectLinkRegistryPath(home) + config := filepath.Dir(path) + require.NoError(t, os.MkdirAll(config, 0o700)) + project := "proj_saved" + if kind == "size" { + project = "proj_" + strings.Repeat("x", 1<<20) + } + before, err := json.Marshal(map[string]string{projectLinkCanonicalDirectory(t, directory): project}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, before, 0o600)) + var required []string + switch kind { + case "size": + required = []string{"1 MiB"} + case "directory privacy": + require.NoError(t, os.Chmod(config, 0o755)) + required = []string{"private", "directory", "0700"} + case "file privacy": + require.NoError(t, os.Chmod(path, 0o644)) + required = []string{"private", "regular", "project-links.json", "0600"} + case "permission denial": + require.NoError(t, os.Chmod(path, 0o000)) + t.Cleanup(func() { require.NoError(t, os.Chmod(path, 0o600)) }) + probe, err := os.Open(path) + if err == nil { + require.NoError(t, probe.Close()) + t.Skip("current privileges bypass this file's read permissions") + } + require.True(t, errors.Is(err, os.ErrPermission), "permission probe: %v", err) + required = []string{"permission"} + } + server, requests := globalFlagsServer(t, globalFlagsPage) + for _, format := range []string{"text", "json"} { + for _, command := range [][]string{{"link"}, {"link", "--project=proj_replacement"}, {"files", "list"}} { + args := append(slices.Clone(command), "--format-error", format) + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server), "", args...) + require.NotZero(t, got.code, "%v", args) + require.Empty(t, got.stdout) + message := got.stderr + if format == "json" { + message, _ = decodeMainStructuredError(t, format, got.stderr)["message"].(string) + } + for _, expected := range required { + require.Contains(t, strings.ToLower(message), strings.ToLower(expected)) + } + require.Contains(t, message, "This command did not change saved links.") + require.NotContains(t, strings.ToLower(message), "invalid settings") + require.NotContains(t, strings.ToLower(message), "invalid registry") + require.NotContains(t, message, home) + require.Empty(t, requests, "registry failure reached the API") + } + } + require.NoError(t, os.Chmod(path, 0o600)) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } +} + +func TestMainProjectLinkPlanExpCopiedRecoveryPreservesInvocation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("this copied-command case executes a native Unix Bash shell") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("Bash is unavailable") + } + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + work := filepath.Join(t.TempDir(), "CLI with spaces & apostrophe's") + require.NoError(t, os.Mkdir(work, 0o700)) + binary, err := os.Executable() + require.NoError(t, err) + executable := filepath.Join(work, "openai") + // This launcher exercises main(), preserving the invoked openai path in argv. + launcher := "#!/bin/sh\nOPENAI_CLI_MAIN_DISPATCH_PROCESS=1 exec " + quote(binary) + + " -test.run='^TestMainDispatchProcess$' -- \"$0\" \"$@\"\n" + require.NoError(t, os.WriteFile(executable, []byte(launcher), 0o700)) + decoy := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(decoy, "openai"), []byte("#!/bin/sh\nprintf 'wrong executable\\n'\nexit 99\n"), 0o700)) + t.Setenv("PATH", decoy+string(os.PathListSeparator)+os.Getenv("PATH")) + home, directory := t.TempDir(), t.TempDir() + registry := projectLinkRegistryPath(home) + require.NoError(t, os.MkdirAll(filepath.Dir(registry), 0o700)) + require.NoError(t, os.WriteFile(registry, []byte("{"), 0o600)) + server, requests := globalFlagsServer(t, globalFlagsPage) + shell := nativeShell{name: "bash", executable: bash, args: []string{"--noprofile", "--norc", "-c"}} + got := runNativeShell(t, shell, directory, home, server.URL, + "export OPENAI_API_KEY=synthetic-recovery-key; "+quote(executable)+" files list --format=raw") + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + recovery := regexp.MustCompile(`Inspect with (.+? link)\.`).FindStringSubmatch(got.stderr) + require.Len(t, recovery, 2, "%s", got.stderr) + require.Equal(t, quote(executable)+" link", recovery[1]) + require.Empty(t, requests) + // Repair the controlled fixture before following the printed inspection command. + require.NoError(t, os.WriteFile(registry, []byte("{}\n"), 0o600)) + copied := runNativeShell(t, shell, directory, home, server.URL, recovery[1]) + require.Zero(t, copied.code, "%s", copied.stderr) + require.Empty(t, copied.stderr) + require.Contains(t, copied.stdout, "This folder has no saved project link.") + require.NotContains(t, copied.stdout, "wrong executable") + require.Empty(t, requests, "recovery inspection reached the API") +} diff --git a/docs/project-linking.md b/docs/project-linking.md index 660b883d..bbc0a470 100644 --- a/docs/project-linking.md +++ b/docs/project-linking.md @@ -11,6 +11,10 @@ openai files list `files list` lists remote OpenAI files in that project. It does not list local files. Linking does not authenticate, create a remote project, or grant project access. Your API key must already have access to the selected project. +Saving a link does not verify that the project exists, remains active, or permits access with your key. +Find the project ID in [dashboard project settings](https://platform.openai.com/settings/organization/projects). +The [API authentication reference](https://developers.openai.com/api/reference/overview) explains the `OpenAI-Project` header. +The [access guide](https://developers.openai.com/api/docs/guides/rbac) explains project permissions and API-key restrictions. Inspect the saved link and its effective project: @@ -19,6 +23,14 @@ openai link openai link --format json ``` +Existing extraction options still select remote API data: + +```sh +openai files list --transform filename --raw-output +``` + +This prints remote filenames. It does not scan your local folder. + Remove the current folder's link: ```sh @@ -44,10 +56,15 @@ For example, `openai --project= files list` sends an empty project header. Root request flags work before or after commands, subject to existing endpoint flag ownership. An endpoint's own `--project` request field does not change request-header selection. Explicit custom `OpenAI-Project` headers retain their existing override behavior. +An explicit `--header 'OpenAI-Project: ...'` takes priority over these project defaults, including an empty header value. +Without a flag, environment project, or folder default, the SDK can use `OpenAI_CUSTOM_HEADERS` as a fallback. +For repeated project headers in that environment variable, the last matching header wins. `link --project` saves a future folder default. An existing `OPENAI_PROJECT_ID` still overrides that default on later API commands. Link output identifies this override. +Inspection also identifies a supplied custom project header or the SDK's custom-header fallback. +Custom headers are never saved in the folder registry. ## Folders and storage @@ -100,13 +117,17 @@ They reject positional arguments, `--transform`, and `--raw-output` before chang `--quiet` suppresses readable save/remove confirmations and preserves inspection and JSON output. JSON reports the action, current directory, linked directory, saved project, inheritance, effective project, and source. An empty effective project means the request uses an empty override or the existing default. -The `source` field distinguishes these cases. -Inspection hides environment values that do not match the project ID format. +The `source` field distinguishes these cases: `folder`, `environment`, `header`, `custom_headers`, or `default`. +`environment` means `OPENAI_PROJECT_ID`; `header` means an explicit `--header`; `custom_headers` means the SDK environment fallback. +Inspection hides external project values that do not match the project ID format. JSON marks these values with `effective_project_redacted: true`. This prevents inspection from printing accidentally pasted credentials. API requests preserve the existing environment behavior. +Inspection reports project selection, not full authentication status or validation of every request setting. +Malformed unrelated remote settings do not prevent local linking. If output fails after saving, inspect the link before retrying. If the registry cannot be read, check its file type, permissions, and JSON syntax. +The error identifies known size, ownership, permission, or concurrent-change problems without exposing registry contents or private paths. Move an invalid registry aside only when you intend to discard its saved links. To remove a stale entry for a deleted directory, edit that entry while no CLI process writes the registry. diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index f07cb305..c6020caa 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -177,7 +177,7 @@ func projectLinkRequestOptions(command *cli.Command) []option.RequestOption { directory, err = projectLinkDirectory() } if err != nil { - failure := projectLinkFailure("Could not resolve folder project defaults. Inspect with openai link, or override with --project.", err) + failure := projectLinkStoreFailure(root, "resolve", err) return []option.RequestOption{option.WithMaxRetries(0), option.WithMiddleware(func(*http.Request, option.MiddlewareNext) (*http.Response, error) { return nil, failure })} @@ -238,22 +238,16 @@ func handleProjectLink(ctx context.Context, command *cli.Command) error { links, err = loadProjectLinks(ctx, path) } if err != nil { - return projectLinkFailure("Could not read or save folder links. Check the user configuration folder's permissions and project-links.json. Invalid settings were kept.", err) + return projectLinkStoreFailure(root, status.Action, err) } status.LinkedDirectory, status.Project = nearestProjectLink(links, directory) status.Inherited = status.LinkedDirectory != "" && status.LinkedDirectory != directory - status.EffectiveProject, status.Source = status.Project, "folder" - if status.Project == "" { - status.Source = "default" - } - if environment, present := os.LookupEnv("OPENAI_PROJECT_ID"); present { - status.EffectiveProject, status.Source = environment, "environment" - if environment != "" && !validLinkedProject(environment) { - status.EffectiveProject, status.ProjectRedacted = "", true - } - } // The --project on link saves a future default; this invocation has no API // request. Show the environment override that future commands retain. + status.EffectiveProject, status.Source = projectLinkEffectiveProject(root, status.Project) + if status.EffectiveProject != "" && !validLinkedProject(status.EffectiveProject) { + status.EffectiveProject, status.ProjectRedacted = "", true + } if err := ctx.Err(); err != nil { return err } @@ -264,11 +258,38 @@ func handleProjectLink(ctx context.Context, command *cli.Command) error { _, err = io.WriteString(writer, projectLinkText(status)) } if err != nil { - return projectLinkFailure("Could not write folder-link output. The saved link may already have changed; inspect with openai link.", err) + return projectLinkOutputFailure(root, status.Action, err) } return nil } +// Inspect the same project sources as request construction without creating a +// client or validating unrelated remote configuration. Keep SDK header parsing +// here limited to the project header; never retain or display other headers. +func projectLinkEffectiveProject(root *cli.Command, folder string) (project, source string) { + source = "default" + for _, line := range strings.Split(os.Getenv("OPENAI_CUSTOM_HEADERS"), "\n") { + name, value, ok := strings.Cut(line, ":") + if ok && strings.EqualFold(strings.TrimSpace(name), "OpenAI-Project") { + project, source = strings.TrimSpace(value), "custom_headers" + } + } + if folder != "" { + project, source = folder, "folder" + } + if environment, present := os.LookupEnv("OPENAI_PROJECT_ID"); present { + project, source = environment, "environment" + } + if root.IsSet("header") { + if headers, err := requestHeaders(root); err == nil { + if values := headers.Values("OpenAI-Project"); len(values) != 0 { + project, source = values[0], "header" + } + } + } + return project, source +} + func projectLinkText(status projectLinkStatus) string { var out strings.Builder switch status.Action { @@ -297,6 +318,18 @@ func projectLinkText(status projectLinkStatus) string { } else { fmt.Fprintf(&out, "Effective project: %s (OPENAI_PROJECT_ID overrides the folder default)\n", readable.Text(status.EffectiveProject)) } + } else if status.Source == "header" || status.Source == "custom_headers" { + setting, relationship := "--header", "overrides the folder default" + if status.Source == "custom_headers" { + setting, relationship = "OPENAI_CUSTOM_HEADERS", "fallback" + } + if status.ProjectRedacted { + fmt.Fprintf(&out, "The project value from %s is hidden because it does not match the project ID format.\n", setting) + } else if status.EffectiveProject == "" { + fmt.Fprintf(&out, "%s sets an empty project header.\n", setting) + } else { + fmt.Fprintf(&out, "Effective project: %s (%s %s)\n", readable.Text(status.EffectiveProject), setting, relationship) + } } out.WriteString("Your API key and its project access stay unchanged.\n") return out.String() diff --git a/pkg/custom/project_link_errors.go b/pkg/custom/project_link_errors.go new file mode 100644 index 00000000..26214e81 --- /dev/null +++ b/pkg/custom/project_link_errors.go @@ -0,0 +1,55 @@ +package custom + +import ( + "errors" + "os" + + "github.com/urfave/cli/v3" +) + +func projectLinkStoreFailure(root *cli.Command, action string, cause error) error { + operation := "Could not inspect folder links. " + switch action { + case "link": + operation = "Could not save the folder link. " + case "unlink": + operation = "Could not remove the folder link. " + case "resolve": + operation = "Could not resolve folder project defaults. " + } + recovery := "Check project-links.json and its lock in your user configuration folder. " + switch { + case errors.Is(cause, errProjectLinksTooLarge): + recovery = "Folder links must fit within 1 MiB. Remove unused entries or excess whitespace from project-links.json before retrying. " + case errors.Is(cause, errProjectLinksInvalid): + recovery = "Folder links contain invalid JSON or entries. Repair project-links.json in your user configuration folder. " + case errors.Is(cause, errProjectLinkDirectoryUnsafe): + recovery = "Use a private configuration directory owned by your user, without an application-directory symlink. On Unix, check ownership and mode 0700. " + case errors.Is(cause, errProjectLinkFileUnsafe): + recovery = "Use a private regular project-links.json owned by your user, without a symlink. On Unix, check ownership and mode 0600. " + case errors.Is(cause, errProjectLinkLockUnsafe): + recovery = "The .project-links.json.lock file must be private, regular, and owned by your user. On Unix, check ownership and mode 0600. " + case errors.Is(cause, os.ErrPermission): + recovery = "Your user cannot access the folder-link configuration. Check permissions on its directory, project-links.json, and .project-links.json.lock. " + case errors.Is(cause, errProjectLinksChanged), errors.Is(cause, errProjectLinkLockChanged), errors.Is(cause, errProjectLinkDirectoryChanged): + recovery = "Folder-link state changed during this command. Stop other registry writers before retrying. " + case errors.Is(cause, errProjectLinkInputInvalid): + recovery = "Use an existing folder and a project ID starting with proj_. " + } + message := operation + recovery + "This command did not change saved links." + if action == "resolve" { + message += " Inspect with " + errorHelpInvocation(root) + " link." + } + return projectLinkFailure(message, cause) +} + +func projectLinkOutputFailure(root *cli.Command, action string, cause error) error { + message := "Could not write folder-link inspection. This command did not change saved links." + switch action { + case "link": + message = "The folder link was saved, but its confirmation could not be written." + case "unlink": + message = "This folder has no saved link of its own. Its confirmation could not be written." + } + return projectLinkFailure(message+" Inspect with "+errorHelpInvocation(root)+" link.", cause) +} diff --git a/pkg/custom/project_link_errors_test.go b/pkg/custom/project_link_errors_test.go new file mode 100644 index 00000000..3c8806fe --- /dev/null +++ b/pkg/custom/project_link_errors_test.go @@ -0,0 +1,70 @@ +package custom + +import ( + "errors" + "io" + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestProjectLinkStoreFailurePreservesCauses(t *testing.T) { + root := &cli.Command{Metadata: map[string]any{"help-invocation": "'/synthetic/CLI with spaces/openai'"}} + for _, test := range []struct { + name, guidance string + cause error + }{ + {"size", "1 MiB", errProjectLinksTooLarge}, + {"invalid", "invalid JSON or entries", errProjectLinksInvalid}, + {"directory", "0700", errProjectLinkDirectoryUnsafe}, + {"file", "0600", errProjectLinkFileUnsafe}, + {"lock", ".project-links.json.lock", errProjectLinkLockUnsafe}, + {"permission", "cannot access", &os.PathError{Op: "open", Path: "/synthetic-private/folder", Err: os.ErrPermission}}, + {"snapshot", "Stop other registry writers", errProjectLinksChanged}, + {"lock changed", "Stop other registry writers", errProjectLinkLockChanged}, + {"directory changed", "Stop other registry writers", errProjectLinkDirectoryChanged}, + } { + t.Run(test.name, func(t *testing.T) { + cause := errors.Join(test.cause, errors.New("synthetic-private-cause")) + failure := projectLinkStoreFailure(root, "resolve", cause) + require.ErrorIs(t, failure, test.cause) + require.Contains(t, failure.Error(), test.guidance) + require.Contains(t, failure.Error(), "Inspect with '/synthetic/CLI with spaces/openai' link.") + require.NotContains(t, failure.Error(), "synthetic-private") + }) + } +} + +func TestProjectLinkOutputFailureReportsActualOutcome(t *testing.T) { + for _, test := range []struct { + name, outcome, wantProject string + args []string + }{ + {"inspect", "This command did not change saved links.", "proj_saved", []string{"link"}}, + {"save", "The folder link was saved", "proj_updated", []string{"link", "--project=proj_updated"}}, + {"remove", "no saved link of its own", "", []string{"unlink"}}, + } { + t.Run(test.name, func(t *testing.T) { + path := projectLinkTestEnvironment(t) + require.NoError(t, projectLinkTestCommand(io.Discard).Run(t.Context(), []string{"openai", "link", "--project=proj_saved"})) + fault := cli.Exit("synthetic-private-sink-failure", 17) + root := projectLinkTestCommand(projectLinkBrokenWriter{fault}) + root.Metadata = map[string]any{"help-invocation": "'/synthetic/CLI with spaces/openai'"} + failure := root.Run(t.Context(), append([]string{"openai"}, test.args...)) + require.ErrorIs(t, failure, fault) + var exit cli.ExitCoder + require.ErrorAs(t, failure, &exit) + require.Equal(t, 17, exit.ExitCode()) + require.Contains(t, failure.Error(), test.outcome) + require.Contains(t, failure.Error(), "Inspect with '/synthetic/CLI with spaces/openai' link.") + require.NotContains(t, failure.Error(), "synthetic-private") + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + directory, err := projectLinkDirectory() + require.NoError(t, err) + require.Equal(t, test.wantProject, links[directory]) + }) + } +} diff --git a/pkg/custom/project_link_store.go b/pkg/custom/project_link_store.go index 9cfbea4b..24ade408 100644 --- a/pkg/custom/project_link_store.go +++ b/pkg/custom/project_link_store.go @@ -17,7 +17,17 @@ import ( // This limit applies only to local project links, never API request or response data. const maxProjectLinkBytes = 1 << 20 -var errProjectLinksChanged = errors.New("project links changed while reading; retry the command") +var ( + errProjectLinksChanged = errors.New("project links changed while reading; retry the command") + errProjectLinksTooLarge = errors.New("project links exceed the 1 MiB local registry limit; existing links were kept") + errProjectLinksInvalid = errors.New("invalid project links; existing links were kept") + errProjectLinkInputInvalid = errors.New("invalid folder or project identifier; existing links were kept") + errProjectLinkFileUnsafe = errors.New("project links must be a private regular file owned by the current user") + errProjectLinkDirectoryUnsafe = errors.New("project links require a private configuration directory owned by the current user") + errProjectLinkLockUnsafe = errors.New("project link lock must be a private regular file owned by the current user") + errProjectLinkLockChanged = errors.New("project link lock changed while opening or waiting") + errProjectLinkDirectoryChanged = errors.New("configuration directory changed while opening") +) func loadProjectLinks(ctx context.Context, path string) (map[string]string, error) { if err := ctx.Err(); err != nil { @@ -61,7 +71,7 @@ func readProjectLinks(ctx context.Context, root *os.Root, name string) (map[stri func readProjectLinkSnapshot(ctx context.Context, root *os.Root, name string, expected os.FileInfo) (map[string]string, error) { if !privateProjectLinkFile(expected) { - return nil, errors.New("project links must be a private regular file owned by the current user") + return nil, errProjectLinkFileUnsafe } file, err := root.OpenFile(name, os.O_RDONLY|projectLinkOpenFlags(), 0) if err != nil { @@ -93,7 +103,7 @@ func readProjectLinkSnapshot(ctx context.Context, root *os.Root, name string, ex return nil, errProjectLinksChanged } if len(data) > maxProjectLinkBytes { - return nil, errors.New("project links exceed the 1 MiB local registry limit; existing links were kept") + return nil, errProjectLinksTooLarge } return decodeProjectLinks(data) } @@ -107,7 +117,7 @@ func privateProjectLinkFile(info os.FileInfo) bool { } func decodeProjectLinks(data []byte) (map[string]string, error) { - invalid := errors.New("invalid project links; existing links were kept") + invalid := errProjectLinksInvalid if !utf8.Valid(data) { return nil, invalid } @@ -154,7 +164,7 @@ func lockProjectLinks(ctx context.Context, root *os.Root, name string) (*os.File return nil, statErr } if !privateProjectLinkFile(info) { - return nil, errors.New("project link lock must be a private regular file owned by the current user") + return nil, errProjectLinkLockUnsafe } file, err = root.OpenFile(name, os.O_RDWR|projectLinkOpenFlags(), 0) } @@ -177,7 +187,7 @@ func lockProjectLinks(ctx context.Context, root *os.Root, name string) (*os.File return err } if !privateProjectLinkFile(identity) || !privateProjectLinkFile(current) || !os.SameFile(identity, current) { - return errors.New("project link lock changed while opening or waiting") + return errProjectLinkLockChanged } return nil } @@ -219,7 +229,7 @@ func updateProjectLink(ctx context.Context, path, directory, project string) (ma return nil, err } if !validProjectLinkDirectory(directory) || (project != "" && !validLinkedProject(project)) { - return nil, errors.New("invalid folder or project identifier; existing links were kept") + return nil, errProjectLinkInputInvalid } root, name, err := openProjectLinkDirectory(path, true) if err != nil { @@ -246,7 +256,7 @@ func updateProjectLink(ctx context.Context, path, directory, project string) (ma } data = append(data, '\n') if len(data) > maxProjectLinkBytes { - return nil, errors.New("project links exceed the 1 MiB local registry limit; existing links were kept") + return nil, errProjectLinksTooLarge } temporary := ".project-links-" + rand.Text() file, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) @@ -281,7 +291,7 @@ func openProjectLinkDirectory(path string, create bool) (*os.Root, string, error return nil, "", err } if !info.IsDir() { - return nil, "", errors.New("project links require a private configuration directory owned by the current user") + return nil, "", errProjectLinkDirectoryUnsafe } root, err := os.OpenRoot(directory) if err != nil { @@ -291,7 +301,7 @@ func openProjectLinkDirectory(path string, create bool) (*os.Root, string, error if err != nil || !os.SameFile(info, actual) { root.Close() if err == nil { - err = errors.New("configuration directory changed while opening") + err = errProjectLinkDirectoryChanged } return nil, "", err } @@ -308,7 +318,7 @@ func openProjectLinkDirectory(path string, create bool) (*os.Root, string, error } if !privateProjectLinkPermissions(info) || !privateProjectLinkPermissions(actual) { root.Close() - return nil, "", errors.New("project links require a private configuration directory owned by the current user") + return nil, "", errProjectLinkDirectoryUnsafe } return root, name, nil } diff --git a/scripts/demos/project-linking/README.md b/scripts/demos/project-linking/README.md index 763afdb8..5176ccb4 100644 --- a/scripts/demos/project-linking/README.md +++ b/scripts/demos/project-linking/README.md @@ -40,3 +40,23 @@ The fixture does not establish live API access or backend authorization behavior Inspect `before.png`, `after.png`, and `comparison.gif` before sharing. Check complete command output, file IDs, and terminal clipping. Keep GIFs, screenshots, casts, and binaries outside Git. + +## Inspection and recovery follow-up + +The follow-up recorder compares the original published draft against the inspection and recovery fixes. +Both binaries already support folder linking. +The existing loopback server acts as a trap; these scenes must make no API requests. + +```sh +bash scripts/demos/project-linking/record-context-review.sh \ + /absolute/evidence/published-draft/openai \ + /absolute/evidence/candidate/openai \ + BEFORE_COMMIT AFTER_COMMIT \ + /absolute/evidence/context-normal normal +``` + +Run the same command with a fresh output directory and `narrow` for a 40-column recovery capture. +The normal capture shows custom-header inspection and a controlled malformed-registry failure. +The narrow capture isolates recovery output at 40x24. +Both use isolated homes and `NO_COLOR`, with actual command statuses and no live credentials. +The invalid registry is a synthetic fixture. The recorder deletes its temporary configuration after capture. diff --git a/scripts/demos/project-linking/context-review-scene.sh b/scripts/demos/project-linking/context-review-scene.sh new file mode 100755 index 00000000..ae7a2ccd --- /dev/null +++ b/scripts/demos/project-linking/context-review-scene.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail +test -t 0 && test -t 1 && test -t 2 || exit 99 +cd "$DEMO_WORK_ROOT" +printf '\033[2J\033[H%s\n' "$DEMO_SCENE_LABEL" +printf 'Terminal replay | synthetic settings\n' + +demo_run() { + local demo_name="$1" demo_expected="$2" demo_display="$3" demo_status + shift 3 + printf '$ %s\n' "$demo_display" + if "$@"; then demo_status=0; else demo_status=$?; fi + printf '%s\t%s\t%s\n' "$DEMO_SCENE" "$demo_name" "$demo_status" >> "$DEMO_STATUS_LOG" + test "$demo_status" -eq "$demo_expected" + sleep 0.6 +} + +if [ "$DEMO_PROFILE" = normal ]; then + demo_run environment 0 "OPENAI_CUSTOM_HEADERS='OpenAI-Project: proj_work' openai link" \ + env 'OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_work' openai link +fi +demo_run save 0 'openai link --project proj_folder --quiet' openai link --project proj_folder --quiet +if [ "$DEMO_PROFILE" = normal ]; then + demo_run header 0 "openai link --header 'OpenAI-Project: proj_once'" \ + openai link --header 'OpenAI-Project: proj_once' +fi +printf '# Synthetic malformed registry\n' +printf '{' > "$DEMO_REGISTRY" +demo_run failure 1 'openai files list' openai files list +printf '$ ' +sleep 2 diff --git a/scripts/demos/project-linking/record-context-review.sh b/scripts/demos/project-linking/record-context-review.sh new file mode 100755 index 00000000..9683a322 --- /dev/null +++ b/scripts/demos/project-linking/record-context-review.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -euo pipefail +if [ "$#" -ne 6 ]; then + echo 'usage: record-context-review.sh BEFORE_BINARY AFTER_BINARY BEFORE_SHA AFTER_SHA OUTPUT_DIR normal|narrow' >&2 + exit 2 +fi +demo_profile="$6" +case "$demo_profile" in normal|narrow) ;; *) exit 2;; esac +demo_source="$(cd "$(dirname "$0")" && pwd)" +demo_root="$(cd "$demo_source/../../.." && pwd)" +demo_python="$(command -v python3)" +source "$demo_source/../capture_and_render.sh" +demo_prepare_capture "$demo_root" "$1" "$2" "$3" "$4" "$5" "$demo_source/server.py" +# The existing server is a trap: every scene must finish without an API call. +demo_start_api "$demo_output/requests.jsonl" +cp "$demo_source/context-review-scene.sh" "$demo_runtime/scene.sh" +demo_window_size=100x26 +if [ "$demo_profile" = narrow ]; then demo_window_size=40x24; fi +demo_render_options=(--renderer resvg --font-family Menlo --font-size 18 --line-height 1.2 \ + --theme asciinema --fps-cap 20 --last-frame-duration 2) +{ + echo 'feature: folder project inspection and recovery' + echo "before commit: $demo_before_sha" + echo "after commit: $demo_after_sha" + echo "profile: $demo_profile; $demo_window_size; NO_COLOR; Menlo18" + echo 'capture: terminal replay; synthetic local configuration; no API requests' + demo_capture_metadata + shasum -a 256 "$demo_source/context-review-scene.sh" "$demo_source/record-context-review.sh" +} > "$demo_output/metadata.txt" +for demo_scene in before after; do + demo_home="$demo_runtime/$demo_scene-home" + demo_work="$demo_runtime/$demo_scene-work" + mkdir -p "$demo_home" "$demo_work" + demo_registry="$demo_home/openai/project-links.json" + if [ "$(uname -s)" = Darwin ]; then + demo_registry="$demo_home/Library/Application Support/openai/project-links.json" + fi + demo_label='Before: saved draft' + if [ "$demo_scene" = after ]; then demo_label='After: context and recovery'; fi + demo_capture_scene "$demo_scene" 0 "$demo_runtime/$demo_scene" "$demo_api_url/$demo_scene/v1" "$demo_label" \ + "HOME=$demo_home" "USERPROFILE=$demo_home" "APPDATA=$demo_home" "XDG_CONFIG_HOME=$demo_home" \ + NO_COLOR=1 FORCE_COLOR=0 CI=1 GOMAXPROCS=2 PAGER=cat "DEMO_SCENE=$demo_scene" \ + "DEMO_PROFILE=$demo_profile" "DEMO_WORK_ROOT=$demo_work" "DEMO_REGISTRY=$demo_registry" \ + "DEMO_STATUS_LOG=$demo_output/statuses.tsv" +done +demo_stop_api +"$demo_python" -I -B - "$demo_output" "$demo_profile" <<'PY' | tee "$demo_output/validation.txt" +import pathlib +import sys + +output = pathlib.Path(sys.argv[1]) +profile = sys.argv[2] +assert (output / 'requests.jsonl').read_text() == '', 'local commands reached the API' +names = [('save', 0), ('failure', 1)] +if profile == 'normal': + names = [('environment', 0), ('save', 0), ('header', 0), ('failure', 1)] +expected = [f'{scene}\t{name}\t{status}' for scene in ('before', 'after') for name, status in names] +assert (output / 'statuses.tsv').read_text().splitlines() == expected +before = (output / 'before.txt').read_text() +after = (output / 'after.txt').read_text() +for text in (before, after): + assert 'Terminal replay | synthetic settings' in text + assert 'synthetic-demo-key' not in text +assert 'Invalid settings were kept.' in before +assert 'invalid JSON or entries' in after +assert 'Inspect with openai link.' in after +assert 'This command did not change saved links.' in after +if profile == 'normal': + assert 'Effective project: proj_work' not in before + assert 'Effective project: proj_once' not in before + assert 'Effective project: proj_work (OPENAI_CUSTOM_HEADERS fallback)' in after + assert 'Effective project: proj_once (--header overrides the folder default)' in after +print('PASS: recorded expected command statuses and zero API requests.') +print('PASS: inspection and registry-recovery output match the reviewed contracts.') +PY +demo_assemble_capture 200 before after +printf 'Recorded context review in %s\n' "$demo_output" From c171befbe7ffe4d91a649546ffd5612e40719923 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 10 Oct 2026 00:41:26 -0700 Subject: [PATCH 09/36] test: select readable errors for copied recovery --- cmd/openai/project_link_plan_exp_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/openai/project_link_plan_exp_test.go b/cmd/openai/project_link_plan_exp_test.go index 4887c086..453a955f 100644 --- a/cmd/openai/project_link_plan_exp_test.go +++ b/cmd/openai/project_link_plan_exp_test.go @@ -240,8 +240,10 @@ func TestMainProjectLinkPlanExpCopiedRecoveryPreservesInvocation(t *testing.T) { require.NoError(t, os.WriteFile(registry, []byte("{"), 0o600)) server, requests := globalFlagsServer(t, globalFlagsPage) shell := nativeShell{name: "bash", executable: bash, args: []string{"--noprofile", "--norc", "-c"}} + // Raw data output also selects raw errors unless explicitly overridden. + // Copy the displayed text command, not its JSON string representation. got := runNativeShell(t, shell, directory, home, server.URL, - "export OPENAI_API_KEY=synthetic-recovery-key; "+quote(executable)+" files list --format=raw") + "export OPENAI_API_KEY=synthetic-recovery-key; "+quote(executable)+" files list --format=raw --format-error=text") require.NotZero(t, got.code) require.Empty(t, got.stdout) recovery := regexp.MustCompile(`Inspect with (.+? link)\.`).FindStringSubmatch(got.stderr) From b078b9d0b91833b6895d44f41910be7ff6a91a32 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 10 Oct 2026 01:16:46 -0700 Subject: [PATCH 10/36] test: match project replay to observed baseline error --- scripts/demos/project-linking/record-context-review.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/demos/project-linking/record-context-review.sh b/scripts/demos/project-linking/record-context-review.sh index 9683a322..c765ac7e 100755 --- a/scripts/demos/project-linking/record-context-review.sh +++ b/scripts/demos/project-linking/record-context-review.sh @@ -61,7 +61,8 @@ after = (output / 'after.txt').read_text() for text in (before, after): assert 'Terminal replay | synthetic settings' in text assert 'synthetic-demo-key' not in text -assert 'Invalid settings were kept.' in before +assert 'Could not resolve folder project defaults.' in before +assert 'invalid JSON or entries' not in before assert 'invalid JSON or entries' in after assert 'Inspect with openai link.' in after assert 'This command did not change saved links.' in after From a1f98de93051c129adc0829eb227655f2088f01a Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 10 Oct 2026 01:26:38 -0700 Subject: [PATCH 11/36] test: name project link coverage by responsibility --- ..._plan_exp_test.go => project_link_inspection_recovery_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cmd/openai/{project_link_plan_exp_test.go => project_link_inspection_recovery_test.go} (100%) diff --git a/cmd/openai/project_link_plan_exp_test.go b/cmd/openai/project_link_inspection_recovery_test.go similarity index 100% rename from cmd/openai/project_link_plan_exp_test.go rename to cmd/openai/project_link_inspection_recovery_test.go From 32e5ae6899f086cd378ca7c920c6bb02a3df5137 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 01:20:53 -0400 Subject: [PATCH 12/36] Add offline authentication configuration status --- cmd/openai/auth_status_test.go | 350 ++++++++++++++++++++++ docs/auth-status.md | 48 +++ pkg/custom/auth_status.go | 273 +++++++++++++++++ pkg/custom/auth_status_test.go | 233 ++++++++++++++ pkg/custom/command.go | 1 + pkg/custom/image_picker_first_run.go | 2 +- pkg/custom/image_picker_first_run_test.go | 30 ++ pkg/custom/root_request_flags.go | 16 + pkg/custom/root_request_flags_test.go | 84 ++++++ 9 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 cmd/openai/auth_status_test.go create mode 100644 docs/auth-status.md create mode 100644 pkg/custom/auth_status.go create mode 100644 pkg/custom/auth_status_test.go diff --git a/cmd/openai/auth_status_test.go b/cmd/openai/auth_status_test.go new file mode 100644 index 00000000..1365f85f --- /dev/null +++ b/cmd/openai/auth_status_test.go @@ -0,0 +1,350 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type authStatusCredentialResult struct { + Configured bool `json:"configured"` + Type string `json:"type"` + Source string `json:"source"` +} + +type authStatusValueResult struct { + Value string `json:"value"` + Source string `json:"source"` + Redacted bool `json:"redacted"` +} + +type authStatusResult struct { + Offline bool `json:"offline"` + Verified bool `json:"verified"` + APICredential authStatusCredentialResult `json:"api_credential"` + AdminCredential authStatusCredentialResult `json:"admin_credential"` + Organization authStatusValueResult `json:"organization"` + Project authStatusValueResult `json:"project"` + BaseURL authStatusValueResult `json:"base_url"` +} + +func authStatusEnvironment(t *testing.T) []string { + t.Helper() + env := localUtilitiesEnvironment(t) + for _, entry := range env { + if home, ok := strings.CutPrefix(entry, "HOME="); ok { + return append(env, "APPDATA="+home, "LOCALAPPDATA="+home, "ZDOTDIR="+home, + "XDG_DATA_HOME="+home, "XDG_STATE_HOME="+home) + } + } + t.Fatal("local utility environment must provide an isolated HOME") + return nil +} + +func decodeAuthStatus(t *testing.T, got mainDispatchResult) authStatusResult { + t.Helper() + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + decoder := json.NewDecoder(strings.NewReader(got.stdout)) + decoder.DisallowUnknownFields() + var status authStatusResult + require.NoError(t, decoder.Decode(&status), "%s", got.stdout) + require.ErrorIs(t, decoder.Decode(new(any)), io.EOF) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(got.stdout), &fields)) + for _, name := range []string{"offline", "verified", "api_credential", "admin_credential", "organization", "project", "base_url"} { + require.Contains(t, fields, name) + } + require.True(t, status.Offline) + require.False(t, status.Verified) + return status +} + +func assertAuthStatusPrivate(t *testing.T, got mainDispatchResult, secrets ...string) { + t.Helper() + output := got.stdout + got.stderr + require.NotContains(t, output, "\x1b") + for _, secret := range secrets { + require.NotContains(t, output, secret) + } +} + +func TestMainAuthStatusOfflineOutputAndDiagnostics(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + const apiKey = "synthetic-private-api-credential" + const adminKey = "synthetic-private-admin-credential" + const webhook = "synthetic-private-webhook-credential" + for _, format := range []string{"auto", "text", "json"} { + t.Run(format, func(t *testing.T) { + env := append(authStatusEnvironment(t), + "OPENAI_BASE_URL="+server.URL, "OPENAI_API_KEY="+apiKey, + "OPENAI_ADMIN_KEY="+adminKey, "OPENAI_WEBHOOK_SECRET="+webhook, + "FORCE_COLOR=1", "CLICOLOR_FORCE=1") + args := []string{"openai", "auth", "status", "--format", format, "--format-error", "text"} + ordinary := runMainDispatchWithEnv(t, "bash", env, args...) + require.Zero(t, ordinary.code, "%s", ordinary.stderr) + require.Empty(t, ordinary.stderr) + assertAuthStatusPrivate(t, ordinary, apiKey, adminKey, webhook) + if format == "json" { + status := decodeAuthStatus(t, ordinary) + require.Equal(t, authStatusCredentialResult{true, "api_key", "environment:OPENAI_API_KEY"}, status.APICredential) + require.Equal(t, authStatusCredentialResult{true, "admin_api_key", "environment:OPENAI_ADMIN_KEY"}, status.AdminCredential) + } else { + require.Contains(t, strings.ToLower(ordinary.stdout), "offline") + require.Contains(t, strings.ToLower(ordinary.stdout), "not verified") + } + for _, policy := range [][]string{{"--debug"}, {"--quiet"}, {"--verbose"}, {"--quiet", "--verbose", "--debug"}} { + got := runMainDispatchWithEnv(t, "bash", env, append(append([]string{}, args...), policy...)...) + require.Equal(t, ordinary.code, got.code) + require.Equal(t, ordinary.stdout, got.stdout) + assertAuthStatusPrivate(t, got, apiKey, adminKey, webhook) + if len(policy) == 1 && policy[0] == "--verbose" && format != "json" { + assertLocalUtilityVerbose(t, got.stderr, "auth status", format, "completed", "") + } else { + require.Empty(t, got.stderr) + } + } + }) + } + require.Zero(t, requests.Load(), "offline status must never contact the API") +} + +func TestMainAuthStatusCredentialAndContextPrecedence(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + const secret = "synthetic-private-precedence-credential" + for _, tc := range []struct { + name string + env []string + args []string + credential authStatusCredentialResult + }{ + {"missing", nil, nil, authStatusCredentialResult{false, "none", "unset"}}, + {"environment", []string{"OPENAI_API_KEY=" + secret}, nil, + authStatusCredentialResult{true, "api_key", "environment:OPENAI_API_KEY"}}, + {"equal flag value", []string{"OPENAI_API_KEY=" + secret}, []string{"--api-key", secret}, + authStatusCredentialResult{true, "api_key", "flag:--api-key"}}, + {"empty flag", []string{"OPENAI_API_KEY=" + secret}, []string{"--api-key="}, + authStatusCredentialResult{false, "none", "flag:--api-key"}}, + {"last empty flag", nil, []string{"--api-key", secret, "--api-key="}, + authStatusCredentialResult{false, "none", "flag:--api-key"}}, + {"last nonempty flag", nil, []string{"--api-key=", "--api-key", secret}, + authStatusCredentialResult{true, "api_key", "flag:--api-key"}}, + {"environment key overrides authorization", []string{"OPENAI_API_KEY=" + secret, "OPENAI_CUSTOM_HEADERS=Authorization: Bearer " + secret}, nil, + authStatusCredentialResult{true, "api_key", "environment:OPENAI_API_KEY"}}, + {"environment authorization only", []string{"OPENAI_CUSTOM_HEADERS=Authorization: Bearer " + secret}, nil, + authStatusCredentialResult{true, "authorization_header", "environment:OPENAI_CUSTOM_HEADERS"}}, + {"flag authorization", []string{"OPENAI_API_KEY=" + secret}, []string{"--header", "Authorization: Bearer " + secret}, + authStatusCredentialResult{true, "authorization_header", "flag:--header"}}, + {"empty authorization", []string{"OPENAI_API_KEY=" + secret}, []string{"--header", "Authorization:"}, + authStatusCredentialResult{false, "none", "flag:--header"}}, + } { + t.Run(tc.name, func(t *testing.T) { + env := append(authStatusEnvironment(t), "OPENAI_BASE_URL="+server.URL) + args := append([]string{"openai", "auth", "status", "--format", "json"}, tc.args...) + got := runMainDispatchWithEnv(t, "bash", append(env, tc.env...), args...) + assertAuthStatusPrivate(t, got, secret) + status := decodeAuthStatus(t, got) + require.Equal(t, tc.credential, status.APICredential) + }) + } + for _, position := range []string{"before command", "between commands", "after command"} { + t.Run(position, func(t *testing.T) { + env := append(authStatusEnvironment(t), "OPENAI_BASE_URL="+server.URL, + "OPENAI_ORG_ID=org_environment", "OPENAI_PROJECT_ID=proj_environment") + flags := []string{"--organization", "org_first", "--organization=", "--project", "proj_first", "--project", "proj_last"} + args := []string{"openai", "--format", "json"} + switch position { + case "before command": + args = append(append(args, flags...), "auth", "status") + case "between commands": + args = append(append(append(args, "auth"), flags...), "status") + default: + args = append(append(args, "auth", "status"), flags...) + } + status := decodeAuthStatus(t, runMainDispatchWithEnv(t, "bash", env, args...)) + require.Equal(t, authStatusValueResult{Value: "", Source: "flag:--organization"}, status.Organization) + require.Equal(t, authStatusValueResult{Value: "proj_last", Source: "flag:--project"}, status.Project) + }) + } + require.Zero(t, requests.Load()) +} + +func TestMainAuthStatusPreservesStdin(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + const input = "synthetic-private-piped-input\n" + for _, args := range [][]string{ + {"auth", "status", "--format", "json"}, + {"auth", "status", "--help"}, + {"auth", "status", "--format", "yaml", "--format-error", "json"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + env := append(authStatusEnvironment(t), "OPENAI_BASE_URL="+server.URL) + argv := append([]string{"openai"}, args...) + file := shellFileInput(t, []byte("prefix:"+input)) + const offset = int64(len("prefix:")) + _, err := file.Seek(offset, io.SeekStart) + require.NoError(t, err) + got := runMainDispatchWithStdin(t, "bash", env, file, argv...) + position, err := file.Seek(0, io.SeekCurrent) + require.NoError(t, err) + require.Equal(t, offset, position, "status must preserve the shared stdin file position") + assertAuthStatusPrivate(t, got, input) + + reader, writer, err := os.Pipe() + require.NoError(t, err) + defer reader.Close() + defer writer.Close() + _, err = io.WriteString(writer, input) + require.NoError(t, err) + // Keep the writer open until the command exits. Reading to EOF would hang. + piped := runMainDispatchWithStdin(t, "bash", env, reader, argv...) + require.Equal(t, got, piped) + require.NoError(t, writer.Close()) + remaining, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, input, string(remaining)) + }) + } + require.Zero(t, requests.Load()) +} + +func TestMainAuthStatusDoesNotLoadMTLS(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + missing := filepath.Join(t.TempDir(), "synthetic-private-missing.pem") + for _, tc := range []struct { + name string + env []string + args []string + }{ + {"incomplete environment", []string{"OPENAI_MTLS_CLIENT_CERT_FILE=" + missing}, nil}, + {"missing environment files", []string{"OPENAI_MTLS_CLIENT_CERT_FILE=" + missing, "OPENAI_MTLS_CLIENT_KEY_FILE=" + missing}, nil}, + {"incomplete flag", nil, []string{"--mtls-client-cert-file", missing}}, + {"missing flag files", nil, []string{"--mtls-client-cert-file", missing, "--mtls-client-key-file", missing}}, + } { + t.Run(tc.name, func(t *testing.T) { + env := append(authStatusEnvironment(t), "OPENAI_BASE_URL="+server.URL) + args := append([]string{"openai", "auth", "status", "--format", "json"}, tc.args...) + got := runMainDispatchWithEnv(t, "bash", append(env, tc.env...), args...) + decodeAuthStatus(t, got) + assertAuthStatusPrivate(t, got, missing) + }) + } + require.Zero(t, requests.Load()) +} + +func TestMainAuthStatusSanitizesURLsAndTerminalControls(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + const user = "synthetic-private-url-user" + const password = "synthetic-private-url-password" + const query = "synthetic-private-url-query" + const fragment = "synthetic-private-url-fragment" + url := strings.Replace(server.URL, "://", "://"+user+":"+password+"@", 1) + "/v1?token=" + query + "#" + fragment + organization := "org_synthetic\x1b[31m\rspoofed" + project := "proj_synthetic\x1b]0;spoofed\a" + for _, format := range []string{"text", "json"} { + t.Run(format, func(t *testing.T) { + env := append(authStatusEnvironment(t), "OPENAI_BASE_URL="+url, + "OPENAI_ORG_ID="+organization, "OPENAI_PROJECT_ID="+project) + got := runMainDispatchWithEnv(t, "bash", env, "openai", "auth", "status", "--format", format) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + assertAuthStatusPrivate(t, got, user, password, query, fragment) + require.NotContains(t, got.stdout, "\r") + require.NotContains(t, got.stdout, "\a") + if format == "json" { + status := decodeAuthStatus(t, got) + require.True(t, status.BaseURL.Redacted) + require.True(t, strings.HasPrefix(status.BaseURL.Value, server.URL)) + require.Equal(t, "url_basic_auth", status.APICredential.Type) + require.Equal(t, "url_basic_auth", status.AdminCredential.Type) + require.Equal(t, organization, status.Organization.Value) + require.Equal(t, project, status.Project.Value) + } + }) + } + t.Run("explicit empty endpoint", func(t *testing.T) { + env := append(authStatusEnvironment(t), "OPENAI_BASE_URL=") + status := decodeAuthStatus(t, runMainDispatchWithEnv(t, "bash", env, "openai", "auth", "status", "--format", "json")) + require.Equal(t, authStatusValueResult{Value: "", Source: "environment:OPENAI_BASE_URL"}, status.BaseURL) + }) + require.Zero(t, requests.Load()) +} + +func TestMainAuthStatusErrorsRemainPrivate(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + const secret = "synthetic-private-invalid-value" + for _, tc := range []struct { + name string + env []string + args []string + }{ + {"unsupported format", nil, []string{"--format", "yaml"}}, + {"unknown format", nil, []string{"--format", secret}}, + {"transform", nil, []string{"--transform", secret}}, + {"raw output false", nil, []string{"--raw-output=false"}}, + {"extra argument", nil, []string{secret}}, + {"malformed header", nil, []string{"--header", secret}}, + {"header control", nil, []string{"--header", "Authorization: " + secret + "\r\nInjected: value"}}, + {"invalid environment URL", []string{"OPENAI_BASE_URL=://" + secret}, nil}, + {"invalid flag URL", nil, []string{"--base-url", "://" + secret}}, + } { + t.Run(tc.name, func(t *testing.T) { + env := append(authStatusEnvironment(t), "OPENAI_BASE_URL="+server.URL, + "OPENAI_API_KEY="+secret, "OPENAI_ADMIN_KEY="+secret) + args := append([]string{"openai", "auth", "status", "--format-error", "json"}, tc.args...) + got := runMainDispatchWithEnv(t, "bash", append(env, tc.env...), args...) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, secret) + verbose := runMainDispatchWithEnv(t, "bash", append(env, tc.env...), append(args, "--verbose", "--debug")...) + require.Equal(t, got, verbose, "machine-readable errors must not include optional diagnostics") + }) + } + require.Zero(t, requests.Load()) +} + +func TestBuiltCLIAuthStatusAndHelpAreOffline(t *testing.T) { + binary, err := shellTestCLI() + require.NoError(t, err) + server, requests := localUtilitiesRequestTrap(t) + const secret = "synthetic-private-built-cli-credential" + for _, args := range [][]string{ + {"auth", "status", "--format", "json"}, + {"auth", "status", "--help"}, + {"auth", "--help"}, + {"help", "auth", "status"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + child := exec.CommandContext(ctx, binary, args...) + child.Env = append(authStatusEnvironment(t), "OPENAI_BASE_URL="+server.URL, + "OPENAI_API_KEY="+secret, "OPENAI_ADMIN_KEY="+secret, + "OPENAI_MTLS_CLIENT_CERT_FILE=/synthetic-private/missing-cert", + "SYSTEMROOT="+os.Getenv("SYSTEMROOT")) + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + require.NoError(t, child.Run(), "%s", stderr.String()) + require.NoError(t, ctx.Err()) + got := mainDispatchResult{0, stdout.String(), stderr.String()} + require.Empty(t, got.stderr) + assertAuthStatusPrivate(t, got, secret) + if args[len(args)-1] == "json" { + decodeAuthStatus(t, got) + } else { + require.Contains(t, got.stdout, "status") + } + }) + } + require.Zero(t, requests.Load()) +} diff --git a/docs/auth-status.md b/docs/auth-status.md new file mode 100644 index 00000000..ad518fd0 --- /dev/null +++ b/docs/auth-status.md @@ -0,0 +1,48 @@ +# Authentication status + +Run `openai auth status` to inspect configuration without contacting OpenAI. +The command does not verify identity, credentials, permissions, membership, or billing. +It does not read stdin, refresh credentials, save settings, or configure your shell. + +```sh +openai auth status +openai auth status --format json +``` + +The report shows API credentials and Admin credentials separately. +Each credential includes its configured state, type, and source. It never includes credential values or fragments. +Organization, project, and base URL each include their own source. +`offline: true` and `verified: false` describe the JSON report. +Missing credentials still produce a successful configuration report. + +## Selection + +The report follows existing CLI request selection: + +- Literal `--header Authorization: ...` overrides API and Admin keys, including an explicitly empty header. +- A nonempty API key or Admin key overrides an Authorization header from `OPENAI_CUSTOM_HEADERS`. +- An empty key option suppresses its environment key. An environment Authorization header can still apply. +- Literal organization and project headers override their corresponding options. +- Organization and project options override environment custom headers, including explicitly empty values. +- Repeated options retain the parser's existing last-value behavior. +- A nonempty `--base-url` overrides `OPENAI_BASE_URL`. An empty flag falls back to the environment. +- An explicitly empty `OPENAI_BASE_URL` remains empty. An absent value selects the SDK production URL. + +Explicit flag assignment remains distinguishable from environment input, even when their values match. +Sources use `flag:--NAME`, `environment:VARIABLE`, `default`, or `unset`. +Header-derived values identify `--header` or `OPENAI_CUSTOM_HEADERS` as their source. +The report does not infer credentials or permissions from arbitrary gateway headers. + +URL user information can provide HTTP Basic authentication when the effective Authorization header is empty. +The report identifies this as `url_basic_auth`, without displaying the username or password. +Base URLs omit user information, queries, fragments, and custom paths. +Only the origin and known `/` or `/v1` paths remain visible. `redacted` identifies omitted components. +Malformed effective URLs produce a fixed error without repeating the URL. + +## Output + +`auto` and `text` produce plain labeled text, including when stdout is redirected. +`json` produces one JSON object. Terminal control characters remain escaped in text output. +The command rejects positional arguments, other output formats, `--transform`, and `--raw-output`. +Existing error-format, quiet, and diagnostic policies remain in effect. +Output failures return an error. The report does not test network connectivity or account access. diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go new file mode 100644 index 00000000..c6a331c3 --- /dev/null +++ b/pkg/custom/auth_status.go @@ -0,0 +1,273 @@ +package custom + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strings" + + "github.com/openai/openai-cli/internal/readable" + "github.com/urfave/cli/v3" +) + +func registerAuthCommands(root *cli.Command) { + if root.Command("auth") != nil { + return + } + help := `{{$bin := or (index .Root.Metadata "help-invocation") "openai"}}EXAMPLE + {{$bin}} auth status + +Shows configured authentication without sending requests or verifying access. + +` + cli.CommandHelpTemplate + localUtilityGlobalHelp + root.Commands = append(root.Commands, &cli.Command{ + Name: "auth", Usage: "Inspect authentication configuration locally", HideHelpCommand: true, + Metadata: map[string]any{localUtilityMetadata: true, "help-command-section": "Local tools"}, + Commands: []*cli.Command{{ + Name: "status", Usage: "Show effective authentication configuration without network access", + Description: "Reports API and Admin credentials separately, plus organization, project, and a sanitized base URL. " + + "Does not verify credentials, identity, permissions, or membership. Does not read stdin, refresh credentials, or configure your shell. " + + "Supports --format auto, text, or json. Credential values are never displayed.", + HideHelpCommand: true, CustomHelpTemplate: help, + Metadata: map[string]any{"local-help-full": help}, Action: handleAuthStatus, + }}, + }) +} + +type authStatusCredential struct { + Configured bool `json:"configured"` + Type string `json:"type"` + Source string `json:"source"` +} + +type authStatusValue struct { + Value string `json:"value"` + Source string `json:"source"` +} + +type authStatusEndpoint struct { + authStatusValue + Redacted bool `json:"redacted"` +} + +// Only sanitized configuration reaches either renderer. Credentials never enter this report. +type authStatusReport struct { + Offline bool `json:"offline"` + Verified bool `json:"verified"` + API authStatusCredential `json:"api_credential"` + Admin authStatusCredential `json:"admin_credential"` + Organization authStatusValue `json:"organization"` + Project authStatusValue `json:"project"` + BaseURL authStatusEndpoint `json:"base_url"` +} + +func handleAuthStatus(ctx context.Context, command *cli.Command) error { + if err := ctx.Err(); err != nil { + return err + } + root := command.Root() + if command.Args().Present() { + return &localUtilityError{message: "Auth status takes no positional arguments. Use openai auth status --help."} + } + if root.IsSet("transform") || root.IsSet("raw-output") { + return &localUtilityError{message: "Auth status does not support --transform or --raw-output. Use --format text or json."} + } + format := strings.ToLower(root.String("format")) + if format != "auto" && format != "text" && format != "json" { + return &localUtilityError{message: "Auth status supports --format auto, text, or json."} + } + report, err := resolveAuthStatus(root, os.LookupEnv) + if err != nil { + return err + } + out := outputWriter{ctx: ctx, out: root.Writer} + if format == "json" { + err = json.NewEncoder(out).Encode(report) + } else { + endpoint := authStatusTextValue(report.BaseURL.authStatusValue) + if report.BaseURL.Redacted { + endpoint += " (URL components omitted)" + } + _, err = fmt.Fprintf(out, "Authentication configuration (offline; not verified)\n"+ + "API credential: %s [%s]\nAdmin credential: %s [%s]\n"+ + "Organization: %s [%s]\nProject: %s [%s]\nBase URL: %s [%s]\n", + strings.ReplaceAll(report.API.Type, "_", " "), report.API.Source, + strings.ReplaceAll(report.Admin.Type, "_", " "), report.Admin.Source, + authStatusTextValue(report.Organization), report.Organization.Source, + authStatusTextValue(report.Project), report.Project.Source, + endpoint, report.BaseURL.Source) + } + if err != nil { + return &localUtilityError{message: "Could not write auth status. Check the output file or pipe before rerunning.", cause: err} + } + return nil +} + +func authStatusTextValue(value authStatusValue) string { + if value.Source == "unset" { + return "(unset)" + } + if value.Value == "" { + return "(empty)" + } + return readable.Text(value.Value) +} + +// Match the CLI and SDK option layers without constructing an SDK client or request. +// Snapshot the six relevant environment inputs once; never enumerate unrelated secrets. +func resolveAuthStatus(root *cli.Command, lookup func(string) (string, bool)) (authStatusReport, error) { + environment := make(map[string]authStatusValue, 6) + for _, name := range []string{"OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS"} { + if value, present := lookup(name); present { + environment[name] = authStatusValue{Value: value, Source: "environment:" + name} + } + } + literal, err := requestHeaders(root) + if err != nil { + return authStatusReport{}, &localUtilityError{message: "Invalid --header. Use 'Name: Value' without control characters."} + } + // The SDK ignores environment lines without a colon and trims names and values. + inherited := make(http.Header) + for _, line := range strings.Split(environment["OPENAI_CUSTOM_HEADERS"].Value, "\n") { + if name, value, found := strings.Cut(line, ":"); found { + inherited.Set(strings.TrimSpace(name), strings.TrimSpace(value)) + } + } + setting := func(flag, variable string) authStatusValue { + source := "unset" + if env, present := environment[variable]; present { + source = env.Source + } + for _, declared := range root.Flags { + if f, ok := declared.(*rootRequestFlag); ok && f.Name == flag && f.explicitlySet { + source = "flag:--" + flag + break + } + } + if root.IsSet(flag) { + return authStatusValue{Value: root.String(flag), Source: source} + } + if env, present := environment[variable]; present { + return env + } + return authStatusValue{Source: "unset"} + } + base := environment["OPENAI_BASE_URL"] + if base.Source == "" { + base = authStatusValue{Value: "https://api.openai.com/v1/", Source: "default"} + } + if value := root.String("base-url"); value != "" { + base = authStatusValue{Value: value, Source: "flag:--base-url"} + } + endpoint, basic, err := authStatusBaseURL(base) + if err != nil { + return authStatusReport{}, err + } + credential := func(flag, variable, kind string) authStatusCredential { + key := setting(flag, variable) + result := authStatusCredential{Type: "none", Source: key.Source} + switch { + case literal.Values("Authorization") != nil: + result.Source = "flag:--header" + if literal.Get("Authorization") != "" { + result.Type, result.Configured = "authorization_header", true + } + case key.Value != "": + result.Type, result.Configured = kind, true + case inherited.Values("Authorization") != nil: + result.Source = "environment:OPENAI_CUSTOM_HEADERS" + if inherited.Get("Authorization") != "" { + result.Type, result.Configured = "authorization_header", true + } + } + // net/http supplies Basic authorization from URL user information only when Authorization is empty. + if !result.Configured && basic { + return authStatusCredential{Configured: true, Type: "url_basic_auth", Source: base.Source} + } + return result + } + contextValue := func(flag, variable, header string) authStatusValue { + if literal.Values(header) != nil { + return authStatusValue{Value: literal.Get(header), Source: "flag:--header"} + } + value := setting(flag, variable) + if value.Source != "unset" { + return value + } + if inherited.Values(header) != nil { + return authStatusValue{Value: inherited.Get(header), Source: "environment:OPENAI_CUSTOM_HEADERS"} + } + return value + } + return authStatusReport{ + Offline: true, + API: credential("api-key", "OPENAI_API_KEY", "api_key"), + Admin: credential("admin-api-key", "OPENAI_ADMIN_KEY", "admin_api_key"), + Organization: contextValue("organization", "OPENAI_ORG_ID", "OpenAI-Organization"), + Project: contextValue("project", "OPENAI_PROJECT_ID", "OpenAI-Project"), BaseURL: endpoint, + }, nil +} + +func authStatusBaseURL(base authStatusValue) (authStatusEndpoint, bool, error) { + if base.Value == "" { + return authStatusEndpoint{authStatusValue: base}, false, nil + } + parsed, err := url.Parse(base.Value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return authStatusEndpoint{}, false, &localUtilityError{message: "Configured base URL is invalid. Use an http:// or https:// URL."} + } + redacted := parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" + // Custom paths can contain credentials. Display only the origin and known public API paths. + path := parsed.Path + if path == "" { + path = "/" + } + if path != "/" && path != "/v1" && path != "/v1/" { + path, redacted = "/", true + } + safe := (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host, Path: path}).String() + return authStatusEndpoint{authStatusValue: authStatusValue{Value: safe, Source: base.Source}, Redacted: redacted}, parsed.User != nil, nil +} + +// Identify the declared auth root before optional shell setup can spawn a worker. +// Consume root flag values by declaration, so a request value named "auth" does not match. +func skipAuthFirstRunSetup(root *cli.Command, args []string) bool { + if root == nil || len(args) < 2 || root.Command("auth") == nil { + return false + } + lineage := []*cli.Command{root} + flags, help := audioSetupParsesFlags(root), false + for index := 1; index < len(args); index++ { + arg := args[index] + if flags && strings.TrimSpace(arg) == "--" { + flags = false + continue + } + if flags && strings.HasPrefix(strings.TrimSpace(arg), "-") { + token := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(arg), "-"), "-") + name, _, assigned := strings.Cut(token, "=") + shape, ok := audioSetupFlag(lineage, name).(interface{ TakesValue() bool }) + if !ok { + return false + } + if shape.TakesValue() && !assigned { + index++ + } + continue + } + if arg == "help" && !help { + help = true + if declared := root.Command("help"); declared != nil { + lineage = append(lineage, declared) + flags = audioSetupParsesFlags(declared) + } + continue + } + selected := root.Command(arg) + return selected != nil && selected == root.Command("auth") + } + return false +} diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go new file mode 100644 index 00000000..9ef0055e --- /dev/null +++ b/pkg/custom/auth_status_test.go @@ -0,0 +1,233 @@ +package custom + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func authStatusTestRoot(out io.Writer) *cli.Command { + root := &cli.Command{Name: "openai", Writer: out, ErrWriter: io.Discard, Reader: tokenizerRejectReader{}, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "format", Value: "auto"}, &cli.StringFlag{Name: "base-url"}, + &cli.StringFlag{Name: "transform"}, &cli.BoolFlag{Name: "raw-output"}, + &cli.BoolFlag{Name: "debug"}, NewRequestHeaderFlag(), + }, + } + for _, pair := range [][2]string{{"api-key", "OPENAI_API_KEY"}, {"admin-api-key", "OPENAI_ADMIN_KEY"}, + {"organization", "OPENAI_ORG_ID"}, {"project", "OPENAI_PROJECT_ID"}} { + root.Flags = append(root.Flags, &requestflag.Flag[string]{Name: pair[0], Sources: cli.EnvVars(pair[1])}) + } + configureRootRequestFlags(root) + registerAuthCommands(root) + ConfigureCommandErrors(root) + return root +} + +func authStatusTestEnvironment(t *testing.T, values map[string]string) { + t.Helper() + for _, name := range []string{"OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS"} { + // Empty is sufficient for ordinary request fields; an absent base URL selects the SDK default. + t.Setenv(name, "") + require.NoError(t, os.Unsetenv(name)) + } + for name, value := range values { + t.Setenv(name, value) + } +} + +func TestAuthStatusSelection(t *testing.T) { + for _, tc := range []struct { + name string + env map[string]string + args []string + api, admin authStatusCredential + organization, project authStatusValue + }{ + {name: "unset", api: authStatusCredential{Type: "none", Source: "unset"}, admin: authStatusCredential{Type: "none", Source: "unset"}, organization: authStatusValue{Source: "unset"}, project: authStatusValue{Source: "unset"}}, + {name: "environment", env: map[string]string{"OPENAI_API_KEY": "synthetic-api-secret", "OPENAI_ADMIN_KEY": "synthetic-admin-secret", "OPENAI_ORG_ID": "org-env", "OPENAI_PROJECT_ID": "proj-env"}, + api: authStatusCredential{true, "api_key", "environment:OPENAI_API_KEY"}, admin: authStatusCredential{true, "admin_api_key", "environment:OPENAI_ADMIN_KEY"}, organization: authStatusValue{"org-env", "environment:OPENAI_ORG_ID"}, project: authStatusValue{"proj-env", "environment:OPENAI_PROJECT_ID"}}, + {name: "explicit empty suppresses environment", env: map[string]string{"OPENAI_API_KEY": "synthetic-api-secret", "OPENAI_ADMIN_KEY": "synthetic-admin-secret", "OPENAI_ORG_ID": "org-env", "OPENAI_PROJECT_ID": "proj-env"}, + args: []string{"--api-key=", "--admin-api-key=", "--organization=", "--project="}, + api: authStatusCredential{false, "none", "flag:--api-key"}, admin: authStatusCredential{false, "none", "flag:--admin-api-key"}, organization: authStatusValue{"", "flag:--organization"}, project: authStatusValue{"", "flag:--project"}}, + {name: "environment header", env: map[string]string{"OPENAI_CUSTOM_HEADERS": "Ignored line\nAuthorization: Bearer synthetic-header-secret\nOpenAI-Organization: org-header\nOpenAI-Project: proj-header"}, + api: authStatusCredential{true, "authorization_header", "environment:OPENAI_CUSTOM_HEADERS"}, admin: authStatusCredential{true, "authorization_header", "environment:OPENAI_CUSTOM_HEADERS"}, organization: authStatusValue{"org-header", "environment:OPENAI_CUSTOM_HEADERS"}, project: authStatusValue{"proj-header", "environment:OPENAI_CUSTOM_HEADERS"}}, + {name: "empty key preserves environment header", env: map[string]string{"OPENAI_CUSTOM_HEADERS": "Authorization: Bearer synthetic-header-secret"}, args: []string{"--api-key=", "--admin-api-key="}, + api: authStatusCredential{true, "authorization_header", "environment:OPENAI_CUSTOM_HEADERS"}, admin: authStatusCredential{true, "authorization_header", "environment:OPENAI_CUSTOM_HEADERS"}, organization: authStatusValue{Source: "unset"}, project: authStatusValue{Source: "unset"}}, + {name: "keys and context override environment headers", env: map[string]string{"OPENAI_API_KEY": "synthetic-api-secret", "OPENAI_ADMIN_KEY": "synthetic-admin-secret", "OPENAI_ORG_ID": "", "OPENAI_PROJECT_ID": "", "OPENAI_CUSTOM_HEADERS": "Authorization: Bearer synthetic-header-secret\nOpenAI-Organization: org-header\nOpenAI-Project: proj-header"}, + api: authStatusCredential{true, "api_key", "environment:OPENAI_API_KEY"}, admin: authStatusCredential{true, "admin_api_key", "environment:OPENAI_ADMIN_KEY"}, organization: authStatusValue{"", "environment:OPENAI_ORG_ID"}, project: authStatusValue{"", "environment:OPENAI_PROJECT_ID"}}, + {name: "literal empty authorization", env: map[string]string{"OPENAI_API_KEY": "synthetic-api-secret", "OPENAI_ADMIN_KEY": "synthetic-admin-secret"}, args: []string{"-H", "Authorization:"}, + api: authStatusCredential{false, "none", "flag:--header"}, admin: authStatusCredential{false, "none", "flag:--header"}, organization: authStatusValue{Source: "unset"}, project: authStatusValue{Source: "unset"}}, + {name: "last literal headers win", args: []string{"-H", "Authorization: Bearer synthetic-first-secret", "-H", "authorization: Bearer synthetic-last-secret", "--project=proj-flag", "-H", "OpenAI-Project: proj-first", "-H", "openai-project: proj-last"}, + api: authStatusCredential{true, "authorization_header", "flag:--header"}, admin: authStatusCredential{true, "authorization_header", "flag:--header"}, organization: authStatusValue{Source: "unset"}, project: authStatusValue{"proj-last", "flag:--header"}}, + {name: "URL Basic fallback", env: map[string]string{"OPENAI_BASE_URL": "https://synthetic-user:synthetic-password@example.invalid/v1/"}, args: []string{"-H", "Authorization:"}, + api: authStatusCredential{true, "url_basic_auth", "environment:OPENAI_BASE_URL"}, admin: authStatusCredential{true, "url_basic_auth", "environment:OPENAI_BASE_URL"}, organization: authStatusValue{Source: "unset"}, project: authStatusValue{Source: "unset"}}, + } { + t.Run(tc.name, func(t *testing.T) { + authStatusTestEnvironment(t, tc.env) + for _, position := range []int{0, 1, 2} { + var out bytes.Buffer + root := authStatusTestRoot(&out) + route := []string{"auth", "status"} + args := append([]string{"openai", "--format=json"}, route[:position]...) + args = append(args, tc.args...) + args = append(args, route[position:]...) + require.NoError(t, root.Run(t.Context(), args), "position %d", position) + var got authStatusReport + require.NoError(t, json.Unmarshal(out.Bytes(), &got)) + require.True(t, got.Offline) + require.False(t, got.Verified) + require.Equal(t, tc.api, got.API) + require.Equal(t, tc.admin, got.Admin) + require.Equal(t, tc.organization, got.Organization) + require.Equal(t, tc.project, got.Project) + require.NotContains(t, out.String(), "synthetic-") + } + }) + } +} + +func TestAuthStatusEndpointAndSingleEnvironmentSnapshot(t *testing.T) { + for _, tc := range []struct { + name string + env map[string]string + args []string + want authStatusEndpoint + invalid bool + }{ + {name: "default", want: authStatusEndpoint{authStatusValue: authStatusValue{"https://api.openai.com/v1/", "default"}}}, + {name: "empty environment", env: map[string]string{"OPENAI_BASE_URL": ""}, want: authStatusEndpoint{authStatusValue: authStatusValue{"", "environment:OPENAI_BASE_URL"}}}, + {name: "empty flag falls back", env: map[string]string{"OPENAI_BASE_URL": "https://env.invalid/v1"}, args: []string{"--base-url="}, want: authStatusEndpoint{authStatusValue: authStatusValue{"https://env.invalid/v1", "environment:OPENAI_BASE_URL"}}}, + {name: "override malformed environment", env: map[string]string{"OPENAI_BASE_URL": "https://%synthetic-secret"}, args: []string{"--base-url=https://flag.invalid/"}, want: authStatusEndpoint{authStatusValue: authStatusValue{"https://flag.invalid/", "flag:--base-url"}}}, + {name: "private URL components", env: map[string]string{"OPENAI_BASE_URL": "https://synthetic-user:synthetic-password@example.invalid/synthetic-path?token=synthetic-query#synthetic-fragment"}, want: authStatusEndpoint{authStatusValue: authStatusValue{"https://example.invalid/", "environment:OPENAI_BASE_URL"}, Redacted: true}}, + {name: "malformed URL", env: map[string]string{"OPENAI_BASE_URL": "https://%synthetic-secret"}, invalid: true}, + {name: "missing scheme", env: map[string]string{"OPENAI_BASE_URL": "synthetic-secret"}, invalid: true}, + {name: "control character", env: map[string]string{"OPENAI_BASE_URL": "https://example.invalid/synthetic-secret\x1b"}, invalid: true}, + } { + t.Run(tc.name, func(t *testing.T) { + authStatusTestEnvironment(t, tc.env) + root := authStatusTestRoot(io.Discard) + calls := map[string]int{} + root.Command("auth").Command("status").Action = func(_ context.Context, command *cli.Command) error { + result, err := resolveAuthStatus(command.Root(), func(name string) (string, bool) { calls[name]++; value, ok := tc.env[name]; return value, ok }) + if tc.invalid { + require.Error(t, err) + require.NotContains(t, err.Error(), "synthetic-secret") + } else { + require.NoError(t, err) + require.Equal(t, tc.want, result.BaseURL) + } + return nil + } + require.NoError(t, root.Run(t.Context(), append([]string{"openai", "auth", "status"}, tc.args...))) + require.Len(t, calls, 6) + for _, count := range calls { + require.Equal(t, 1, count) + } + }) + } +} + +func TestAuthStatusOutputAndLocalErrors(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_PROJECT_ID": "proj\x1b[31m\nspoof"}) + for _, format := range []string{"auto", "text", "json"} { + var out bytes.Buffer + root := authStatusTestRoot(&out) + require.NoError(t, root.Run(t.Context(), []string{"openai", "auth", "status", "--format", format})) + require.NotContains(t, out.String(), "\x1b") + if format == "json" { + var report authStatusReport + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + require.Equal(t, "proj\x1b[31m\nspoof", report.Project.Value) + } else { + require.Contains(t, out.String(), "offline; not verified") + require.NotContains(t, out.String(), "\nspoof") + } + } + for _, args := range [][]string{{"extra"}, {"--format=jsonl"}, {"--transform=project"}, {"--raw-output=false"}, {"-H", "synthetic-secret"}, {"-H", "Authorization: synthetic-secret\r"}} { + var out bytes.Buffer + err := authStatusTestRoot(&out).Run(t.Context(), append([]string{"openai", "auth", "status"}, args...)) + require.Error(t, err) + require.Empty(t, out.String()) + require.NotContains(t, err.Error(), "synthetic-secret") + } + failure := errors.New("synthetic writer failure") + root := authStatusTestRoot(authStatusFailWriter{err: failure}) + require.ErrorIs(t, root.Run(t.Context(), []string{"openai", "auth", "status"}), failure) + root = authStatusTestRoot(authStatusFailWriter{}) + require.ErrorIs(t, root.Run(t.Context(), []string{"openai", "auth", "status", "--format=json"}), io.ErrShortWrite) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, handleAuthStatus(ctx, authStatusTestRoot(io.Discard).Command("auth").Command("status")), context.Canceled) +} + +type authStatusFailWriter struct{ err error } + +func (w authStatusFailWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestAuthStatusMatchesSDKAuthorizationSelection(t *testing.T) { + for _, tc := range []struct { + name string + env map[string]string + args []string + api, admin, org, project string + }{ + {name: "separate keys", env: map[string]string{"OPENAI_API_KEY": "synthetic-api", "OPENAI_ADMIN_KEY": "synthetic-admin"}, api: "Bearer synthetic-api", admin: "Bearer synthetic-admin"}, + {name: "explicit empty key retains header", env: map[string]string{"OPENAI_API_KEY": "synthetic-api", "OPENAI_CUSTOM_HEADERS": "Authorization: Bearer synthetic-header"}, args: []string{"--api-key="}, api: "Bearer synthetic-header", admin: "Bearer synthetic-header"}, + {name: "literal empty header", env: map[string]string{"OPENAI_API_KEY": "synthetic-api", "OPENAI_ADMIN_KEY": "synthetic-admin"}, args: []string{"-H", "Authorization:"}}, + {name: "empty context overrides headers", env: map[string]string{"OPENAI_ORG_ID": "", "OPENAI_PROJECT_ID": "", "OPENAI_CUSTOM_HEADERS": "OpenAI-Organization: org-header\nOpenAI-Project: proj-header"}}, + {name: "literal overrides", env: map[string]string{"OPENAI_API_KEY": "synthetic-api", "OPENAI_ADMIN_KEY": "synthetic-admin", "OPENAI_PROJECT_ID": "proj-env"}, args: []string{"-H", "Authorization: Bearer synthetic-literal", "-H", "OpenAI-Project: proj-literal"}, api: "Bearer synthetic-literal", admin: "Bearer synthetic-literal", project: "proj-literal"}, + } { + t.Run(tc.name, func(t *testing.T) { + authStatusTestEnvironment(t, tc.env) + var received []http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = append(received, r.Header.Clone()) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[],"object":"list","has_more":false}`) + })) + defer server.Close() + for _, admin := range []bool{false, true} { + root := authStatusTestRoot(io.Discard) + root.Commands = append(root.Commands, &cli.Command{Name: "probe", Action: func(ctx context.Context, c *cli.Command) error { + options := GetDefaultRequestOptions(c) + headers, err := requestHeaders(c) + if err != nil { + return err + } + for name, values := range headers { + options = append(options, option.WithHeader(name, values[0])) + } + client := openai.NewClient(options...) + if admin { + _, err = client.Admin.Organization.Projects.List(ctx, openai.AdminOrganizationProjectListParams{}) + } else { + _, err = client.Models.List(ctx) + } + return err + }}) + args := append([]string{"openai", "--base-url", server.URL, "probe"}, tc.args...) + require.NoError(t, root.Run(t.Context(), args)) + } + require.Len(t, received, 2) + require.Equal(t, tc.api, received[0].Get("Authorization")) + require.Equal(t, tc.admin, received[1].Get("Authorization")) + for _, header := range received { + require.Equal(t, tc.org, header.Get("OpenAI-Organization")) + require.Equal(t, tc.project, header.Get("OpenAI-Project")) + } + }) + } +} diff --git a/pkg/custom/command.go b/pkg/custom/command.go index fea96773..7edd8033 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -40,6 +40,7 @@ func ConfigureCommand(root *cli.Command) { configureFileCommands(root) registerTokenizerCommands(root) registerCodexCommands(root) + registerAuthCommands(root) configureCommandPresentation(root) configureManpageCommands(root) configureOutputPolicy(root) diff --git a/pkg/custom/image_picker_first_run.go b/pkg/custom/image_picker_first_run.go index f565a4b9..338e1ebf 100644 --- a/pkg/custom/image_picker_first_run.go +++ b/pkg/custom/image_picker_first_run.go @@ -28,7 +28,7 @@ func SetupImagePickerShellOnFirstRun(ctx context.Context, args []string, roots . } // Read command declarations before the optional worker starts. CLI parsing // may mutate them after the foreground timeout, so the worker borrows none. - if len(roots) > 0 && skipAudioFirstRunSetup(roots[0], args) { + if len(roots) > 0 && (skipAudioFirstRunSetup(roots[0], args) || skipAuthFirstRunSetup(roots[0], args)) { return } args = append([]string(nil), args...) diff --git a/pkg/custom/image_picker_first_run_test.go b/pkg/custom/image_picker_first_run_test.go index 7c88c849..4240fdec 100644 --- a/pkg/custom/image_picker_first_run_test.go +++ b/pkg/custom/image_picker_first_run_test.go @@ -13,6 +13,7 @@ import ( "github.com/openai/openai-cli/internal/autocomplete" "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestImagePickerFirstRunBlockedWorkDoesNotBlockCommand(t *testing.T) { @@ -309,3 +310,32 @@ func TestImagePickerFirstRunConcurrentSetupAndCanceledRun(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, strings.Count(string(profile), "# >>> openai image picker")) } + +func TestAuthStatusSkipsFirstRunSetupByDeclaredCommand(t *testing.T) { + root := authStatusTestRoot(nil) + root.Commands = append(root.Commands, &cli.Command{Name: "models", Commands: []*cli.Command{{Name: "list"}}}) + for _, tc := range []struct { + args []string + want bool + }{ + {[]string{"openai", "auth", "status"}, true}, + {[]string{"openai", "--api-key", "synthetic", "auth", "status"}, true}, + {[]string{"openai", "--api-key=synthetic", "auth", "--format=json", "status"}, true}, + {[]string{"openai", "help", "auth", "status"}, true}, + {[]string{"openai", "--", "auth", "status"}, true}, + {[]string{"openai", "auth", "--help"}, true}, + {[]string{"openai", "auth", "status", "--unknown"}, true}, + {[]string{"openai", "--debug=false", "auth", "status"}, true}, + {[]string{"openai", "--api-key", "auth", "models", "list"}, false}, + {[]string{"openai", "--header", "X-Test: auth", "models", "list"}, false}, + {[]string{"openai", "--base-url=auth", "models", "list"}, false}, + {[]string{"openai", "models", "list", "auth"}, false}, + {[]string{"openai", "--unknown", "auth", "status"}, false}, + {[]string{"openai", "--api-key", "auth"}, false}, + {[]string{"openai"}, false}, + } { + require.Equal(t, tc.want, skipAuthFirstRunSetup(root, tc.args), "%q", tc.args) + } + require.False(t, skipAuthFirstRunSetup(nil, []string{"openai", "auth", "status"})) + require.False(t, skipAuthFirstRunSetup(&cli.Command{}, []string{"openai", "auth", "status"})) +} diff --git a/pkg/custom/root_request_flags.go b/pkg/custom/root_request_flags.go index 25ee2f89..1a5b51d4 100644 --- a/pkg/custom/root_request_flags.go +++ b/pkg/custom/root_request_flags.go @@ -26,9 +26,25 @@ func configureRootRequestFlags(root *cli.Command) { type rootRequestFlag struct { *requestflag.Flag[string] + explicitlySet bool } func (*rootRequestFlag) IsLocal() bool { return false } +func (f *rootRequestFlag) PreParse() error { + f.explicitlySet = false + return f.Flag.PreParse() +} + +// Environment fallback calls the embedded Flag.Set directly. Only parser or +// Command.Set assignments pass through this wrapper, including explicit empties. +func (f *rootRequestFlag) Set(name, value string) error { + if err := f.Flag.Set(name, value); err != nil { + return err + } + f.explicitlySet = true + return nil +} + // RequestFlag exposes the existing metadata for help decoration and redaction. func (f *rootRequestFlag) RequestFlag() *requestflag.Flag[string] { return f.Flag } diff --git a/pkg/custom/root_request_flags_test.go b/pkg/custom/root_request_flags_test.go index c4d943a9..fc811f1f 100644 --- a/pkg/custom/root_request_flags_test.go +++ b/pkg/custom/root_request_flags_test.go @@ -41,6 +41,90 @@ func TestRootRequestFlagsPreserveOwnershipAndMetadata(t *testing.T) { require.Same(t, wrapped, root.Flags[0]) } +func TestRootRequestFlagsExplicitAssignmentProvenance(t *testing.T) { + for _, name := range []string{"api-key", "admin-api-key", "organization", "project", "webhook-secret"} { + t.Run(name, func(t *testing.T) { + flag := "--" + name + for _, tc := range []struct { + name string + environment string + args []string + want string + explicit bool + }{ + {name: "environment", environment: "synthetic-value", args: []string{"models", "list"}, want: "synthetic-value"}, + {name: "empty environment", args: []string{"models", "list"}}, + {name: "before command", environment: "synthetic-env", args: []string{flag, "synthetic-flag", "models", "list"}, want: "synthetic-flag", explicit: true}, + {name: "between commands", environment: "synthetic-env", args: []string{"models", flag, "synthetic-flag", "list"}, want: "synthetic-flag", explicit: true}, + {name: "after command", environment: "synthetic-env", args: []string{"models", "list", flag, "synthetic-flag"}, want: "synthetic-flag", explicit: true}, + {name: "alias", environment: "synthetic-env", args: []string{"models", "list", "-p", "synthetic-flag"}, want: "synthetic-flag", explicit: true}, + {name: "equal values", environment: "synthetic-value", args: []string{"models", "list", flag + "=synthetic-value"}, want: "synthetic-value", explicit: true}, + {name: "explicit empty", environment: "synthetic-env", args: []string{"models", flag + "=", "list"}, explicit: true}, + {name: "repeated", environment: "synthetic-env", args: []string{flag + "=first", "models", flag + "=second", "list", flag + "=last"}, want: "last", explicit: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("OPENAI_TEST_ROOT_REQUEST_PROVENANCE", tc.environment) + called := false + root := &cli.Command{ + Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{&requestflag.Flag[string]{ + Name: name, Aliases: []string{"p"}, Sources: cli.EnvVars("OPENAI_TEST_ROOT_REQUEST_PROVENANCE"), + }}, + Commands: []*cli.Command{{Name: "models", Commands: []*cli.Command{{ + Name: "list", + Action: func(_ context.Context, command *cli.Command) error { + called = true + wrapped := command.Root().Flags[0].(*rootRequestFlag) + require.Equal(t, tc.explicit, wrapped.explicitlySet) + require.True(t, command.Root().IsSet(name)) + require.Equal(t, tc.want, command.Root().String(name)) + require.Equal(t, tc.want, command.String(name)) + return nil + }, + }}}}, + } + configureRootRequestFlags(root) + require.NoError(t, root.Run(t.Context(), append([]string{"openai"}, tc.args...))) + require.True(t, called) + }) + } + }) + } +} + +func TestRootRequestFlagPreParseResetsOnlyProvenance(t *testing.T) { + wrapped := &rootRequestFlag{Flag: &requestflag.Flag[string]{Name: "project", Default: "default-project"}} + require.NoError(t, wrapped.PreParse()) + require.False(t, wrapped.explicitlySet) + require.False(t, wrapped.IsSet()) + require.NoError(t, wrapped.Set("project", "explicit-project")) + require.True(t, wrapped.explicitlySet) + require.Equal(t, 1, wrapped.Count()) + + require.NoError(t, wrapped.PreParse()) + require.False(t, wrapped.explicitlySet) + // Preserve the embedded flag's existing state across repeated parsing. + require.True(t, wrapped.IsSet()) + require.Equal(t, 1, wrapped.Count()) + require.Equal(t, "default-project", wrapped.Get()) +} + +func TestRootRequestFlagsResetProvenanceAcrossRuns(t *testing.T) { + var explicit []bool + root := &cli.Command{ + Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{&requestflag.Flag[string]{Name: "project"}}, + Action: func(_ context.Context, command *cli.Command) error { + explicit = append(explicit, command.Flags[0].(*rootRequestFlag).explicitlySet) + return nil + }, + } + configureRootRequestFlags(root) + require.NoError(t, root.Run(t.Context(), []string{"openai", "--project=synthetic-project"})) + require.NoError(t, root.Run(t.Context(), []string{"openai"})) + require.Equal(t, []bool{true, false}, explicit) +} + func TestRootRequestFlagsPreserveMTLSPlacement(t *testing.T) { t.Setenv(mtlsClientCertFileEnv, "/missing/synthetic-env-certificate") t.Setenv(mtlsClientKeyFileEnv, "/missing/synthetic-env-key") From 6a3d20c8df0589863d7e99d75e9e3ddad655aecd Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 01:27:39 -0400 Subject: [PATCH 13/36] Escape line breaks in auth status fields --- cmd/openai/auth_status_test.go | 4 +++- pkg/custom/auth_status.go | 3 ++- pkg/custom/auth_status_test.go | 6 ++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/openai/auth_status_test.go b/cmd/openai/auth_status_test.go index 1365f85f..f51ad435 100644 --- a/cmd/openai/auth_status_test.go +++ b/cmd/openai/auth_status_test.go @@ -249,7 +249,7 @@ func TestMainAuthStatusSanitizesURLsAndTerminalControls(t *testing.T) { const fragment = "synthetic-private-url-fragment" url := strings.Replace(server.URL, "://", "://"+user+":"+password+"@", 1) + "/v1?token=" + query + "#" + fragment organization := "org_synthetic\x1b[31m\rspoofed" - project := "proj_synthetic\x1b]0;spoofed\a" + project := "proj_synthetic\x1b]0;spoofed\a\nforged-row\tcolumn" for _, format := range []string{"text", "json"} { t.Run(format, func(t *testing.T) { env := append(authStatusEnvironment(t), "OPENAI_BASE_URL="+url, @@ -260,6 +260,8 @@ func TestMainAuthStatusSanitizesURLsAndTerminalControls(t *testing.T) { assertAuthStatusPrivate(t, got, user, password, query, fragment) require.NotContains(t, got.stdout, "\r") require.NotContains(t, got.stdout, "\a") + require.NotContains(t, got.stdout, "\nforged-row") + require.NotContains(t, got.stdout, "\t") if format == "json" { status := decodeAuthStatus(t, got) require.True(t, status.BaseURL.Redacted) diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index c6a331c3..1f6269c7 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -113,7 +113,8 @@ func authStatusTextValue(value authStatusValue) string { if value.Value == "" { return "(empty)" } - return readable.Text(value.Value) + // Report fields occupy one row; preserve line breaks and tabs as visible escapes. + return strings.NewReplacer("\n", `\n`, "\t", `\t`).Replace(readable.Text(value.Value)) } // Match the CLI and SDK option layers without constructing an SDK client or request. diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go index 9ef0055e..4e2c7eda 100644 --- a/pkg/custom/auth_status_test.go +++ b/pkg/custom/auth_status_test.go @@ -141,7 +141,7 @@ func TestAuthStatusEndpointAndSingleEnvironmentSnapshot(t *testing.T) { } func TestAuthStatusOutputAndLocalErrors(t *testing.T) { - authStatusTestEnvironment(t, map[string]string{"OPENAI_PROJECT_ID": "proj\x1b[31m\nspoof"}) + authStatusTestEnvironment(t, map[string]string{"OPENAI_PROJECT_ID": "proj\x1b[31m\nspoof\tcolumn"}) for _, format := range []string{"auto", "text", "json"} { var out bytes.Buffer root := authStatusTestRoot(&out) @@ -150,10 +150,12 @@ func TestAuthStatusOutputAndLocalErrors(t *testing.T) { if format == "json" { var report authStatusReport require.NoError(t, json.Unmarshal(out.Bytes(), &report)) - require.Equal(t, "proj\x1b[31m\nspoof", report.Project.Value) + require.Equal(t, "proj\x1b[31m\nspoof\tcolumn", report.Project.Value) } else { require.Contains(t, out.String(), "offline; not verified") require.NotContains(t, out.String(), "\nspoof") + require.NotContains(t, out.String(), "\t") + require.Contains(t, out.String(), `\nspoof\tcolumn`) } } for _, args := range [][]string{{"extra"}, {"--format=jsonl"}, {"--transform=project"}, {"--raw-output=false"}, {"-H", "synthetic-secret"}, {"-H", "Authorization: synthetic-secret\r"}} { From 7f3668c48a37b30a56eb92357be425ceabdca1c3 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 01:30:44 -0400 Subject: [PATCH 14/36] Preserve verbose stderr policy in auth status checks --- cmd/openai/auth_status_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/openai/auth_status_test.go b/cmd/openai/auth_status_test.go index f51ad435..d62b1713 100644 --- a/cmd/openai/auth_status_test.go +++ b/cmd/openai/auth_status_test.go @@ -107,7 +107,7 @@ func TestMainAuthStatusOfflineOutputAndDiagnostics(t *testing.T) { require.Equal(t, ordinary.code, got.code) require.Equal(t, ordinary.stdout, got.stdout) assertAuthStatusPrivate(t, got, apiKey, adminKey, webhook) - if len(policy) == 1 && policy[0] == "--verbose" && format != "json" { + if len(policy) == 1 && policy[0] == "--verbose" { assertLocalUtilityVerbose(t, got.stderr, "auth status", format, "completed", "") } else { require.Empty(t, got.stderr) From cd5f1477fe835ad24bfaffc924c3168edd96f8df Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 03:31:14 -0400 Subject: [PATCH 15/36] test: include auth in legacy prefix completion --- cmd/openai/command_subgroups_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/openai/command_subgroups_test.go b/cmd/openai/command_subgroups_test.go index b22daf7d..87e668a3 100644 --- a/cmd/openai/command_subgroups_test.go +++ b/cmd/openai/command_subgroups_test.go @@ -107,6 +107,7 @@ func TestMainCommandSubgroupsHelpAndCompletion(t *testing.T) { func TestMainCommandSubgroupsLegacyPrefixCompletion(t *testing.T) { summaries := map[string]string{ "audio": "Transcribe audio, generate speech, and create voices.", + "auth": "Inspect authentication configuration locally", "audio:transcriptions": "Convert audio to text.", "audio:translations": "Translate supported audio to English text.", "admin:organization:audit-logs": "List organization actions and configuration changes.", @@ -126,7 +127,7 @@ func TestMainCommandSubgroupsLegacyPrefixCompletion(t *testing.T) { {[]string{"admin", ":", "organization", ":", "audit"}, "admin:organization:audit-logs\n", "audit-logs\n", 0}, {[]string{"beta:threads:r"}, "beta:threads:runs\nbeta:threads:runs:steps\n", "runs\nruns:steps\n", 0}, {[]string{"audio:unknown"}, "", "", 0}, - {[]string{"au"}, "audio\n", "audio\n", 0}, + {[]string{"au"}, "audio\nauth\n", "audio\nauth\n", 0}, {[]string{"__"}, "", "", 0}, {[]string{"@comp"}, "", "", 0}, {[]string{"--format", "audio:t"}, "", "", 11}, From c3e252392618261e4ed0702aeb8935a670d33571 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 05:28:54 -0400 Subject: [PATCH 16/36] fix: preserve folder links across equivalent directory paths --- cmd/openai/project_link_aliases_test.go | 341 ++++++++++++++++++++++++ docs/project-linking.md | 27 +- go.mod | 2 +- pkg/custom/project_link.go | 26 +- pkg/custom/project_link_aliases.go | 114 ++++++++ pkg/custom/project_link_errors.go | 24 +- pkg/custom/project_link_errors_test.go | 24 ++ pkg/custom/project_link_store.go | 20 +- 8 files changed, 551 insertions(+), 27 deletions(-) create mode 100644 cmd/openai/project_link_aliases_test.go create mode 100644 pkg/custom/project_link_aliases.go diff --git a/cmd/openai/project_link_aliases_test.go b/cmd/openai/project_link_aliases_test.go new file mode 100644 index 00000000..5cb139d2 --- /dev/null +++ b/cmd/openai/project_link_aliases_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const projectLinkAliasPage = `{"object":"list","data":[],"has_more":false}` + +type projectLinkAliasStatus struct { + Directory string `json:"directory"` + LinkedDirectory string `json:"linked_directory"` + Project string `json:"project"` + EffectiveProject string `json:"effective_project"` + Inherited bool `json:"inherited"` +} + +// Canonicalize temporary ancestors before creating or storing fixture paths. +// Capability checks use the actual volume, including per-directory behavior. +func projectLinkNativeAliases(t *testing.T, first, second string) (string, string) { + t.Helper() + parent := projectLinkCanonicalDirectory(t, t.TempDir()) + left, right := filepath.Join(parent, first), filepath.Join(parent, second) + require.NoError(t, os.Mkdir(left, 0o700)) + rightInfo, err := os.Stat(right) + if os.IsNotExist(err) { + t.Skipf("fixture volume does not resolve %q and %q as aliases", first, second) + } + require.NoError(t, err) + leftInfo, err := os.Stat(left) + require.NoError(t, err) + require.True(t, os.SameFile(leftInfo, rightInfo), "alias fixture must identify one actual directory") + return left, right +} + +func projectLinkDistinctFoldDirectories(t *testing.T) (string, string) { + t.Helper() + for _, pair := range [][2]string{{"Work", "work"}, {"\u1c90", "\u10d0"}, {"\ua7c0", "\ua7c1"}} { + if !strings.EqualFold(pair[0], pair[1]) { + continue + } + parent := projectLinkCanonicalDirectory(t, t.TempDir()) + left, right := filepath.Join(parent, pair[0]), filepath.Join(parent, pair[1]) + require.NoError(t, os.Mkdir(left, 0o700)) + err := os.Mkdir(right, 0o700) + if os.IsExist(err) { + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + require.NoError(t, leftErr) + require.NoError(t, rightErr) + require.True(t, os.SameFile(leftInfo, rightInfo)) + continue + } + require.NoError(t, err) + leftInfo, err := os.Stat(left) + require.NoError(t, err) + rightInfo, err := os.Stat(right) + require.NoError(t, err) + require.False(t, os.SameFile(leftInfo, rightInfo), "exclusive directory creation must produce distinct entries") + t.Logf("filesystem distinguishes EqualFold names %q and %q", pair[0], pair[1]) + return left, right + } + t.Skip("fixture volume exposes no distinct directories for the tested EqualFold name pairs") + return "", "" +} + +func projectLinkAliasRun(t *testing.T, home, directory string, env []string, args ...string) mainDispatchResult { + t.Helper() + env = append(slices.Clone(env), "PWD="+directory) + return runProjectLinkMain(t, home, directory, env, "", args...) +} + +func projectLinkAliasInspect(t *testing.T, home, directory string) projectLinkAliasStatus { + t.Helper() + got := projectLinkAliasRun(t, home, directory, nil, "link", "--format=json") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + var status projectLinkAliasStatus + require.NoError(t, json.Unmarshal([]byte(got.stdout), &status)) + return status +} + +func projectLinkAliasSeed(t *testing.T, home string, links map[string]string) []byte { + t.Helper() + path := projectLinkRegistryPath(home) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + data, err := json.MarshalIndent(links, "", " ") + require.NoError(t, err) + data = append(data, '\n') + require.NoError(t, os.WriteFile(path, data, 0o600)) + return data +} + +func projectLinkAliasUnchanged(t *testing.T, home string, before []byte) { + t.Helper() + after, err := os.ReadFile(projectLinkRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) +} + +func projectLinkAliasRequest(t *testing.T, home, directory string, env []string, requests <-chan globalFlagsRequest, want []string, flags ...string) { + t.Helper() + args := append([]string{"files", "list", "--format=raw"}, flags...) + got := projectLinkAliasRun(t, home, directory, env, args...) + require.Equal(t, mainDispatchResult{stdout: projectLinkAliasPage + "\n"}, got) + request := globalFlagsOneRequest(t, requests) + require.Equal(t, http.MethodGet, request.method) + require.Equal(t, "/files", request.path) + require.Equal(t, want, request.header.Values("OpenAI-Project")) + require.Equal(t, "Bearer synthetic-env-key", request.header.Get("Authorization")) +} + +func TestMainProjectLinkCaseAliases(t *testing.T) { + for _, saveAlias := range []bool{false, true} { + t.Run(map[bool]string{false: "save original", true: "save alias"}[saveAlias], func(t *testing.T) { + original, alias := projectLinkNativeAliases(t, "Work", "work") + home := t.TempDir() + saved, other := original, alias + if saveAlias { + saved, other = alias, original + } + got := projectLinkAliasRun(t, home, saved, nil, "link", "--project=proj_case") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + server, requests := globalFlagsServer(t, projectLinkAliasPage) + for _, directory := range []string{saved, other} { + status := projectLinkAliasInspect(t, home, directory) + require.Equal(t, "proj_case", status.Project) + require.Equal(t, "proj_case", status.EffectiveProject) + require.Equal(t, status.Directory, status.LinkedDirectory) + require.False(t, status.Inherited) + projectLinkAliasRequest(t, home, directory, globalFlagsEnv(server), requests, []string{"proj_case"}) + } + got = projectLinkAliasRun(t, home, other, nil, "unlink") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Empty(t, projectLinkRegistry(t, home)) + require.Empty(t, projectLinkAliasInspect(t, home, saved).Project) + projectLinkAliasRequest(t, home, saved, globalFlagsEnv(server), requests, nil) + }) + } +} + +func TestMainProjectLinkLegacyAndDuplicateAliases(t *testing.T) { + for _, duplicate := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy spelling", true: "same-project duplicates"}[duplicate], func(t *testing.T) { + original, alias := projectLinkNativeAliases(t, "Work", "work") + home := t.TempDir() + links := map[string]string{alias: "proj_legacy"} + if duplicate { + links[original] = "proj_legacy" + } + before := projectLinkAliasSeed(t, home, links) + server, requests := globalFlagsServer(t, projectLinkAliasPage) + status := projectLinkAliasInspect(t, home, original) + require.Equal(t, "proj_legacy", status.Project) + require.Equal(t, status.Directory, status.LinkedDirectory) + require.False(t, status.Inherited) + projectLinkAliasRequest(t, home, original, globalFlagsEnv(server), requests, []string{"proj_legacy"}) + projectLinkAliasUnchanged(t, home, before) + got := projectLinkAliasRun(t, home, original, nil, "link", "--project=proj_updated") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + updated := projectLinkRegistry(t, home) + require.Len(t, updated, 1) + for key, project := range updated { + require.Contains(t, links, key, "replacement must retain an existing key") + require.Equal(t, "proj_updated", project) + } + if !duplicate { + require.Equal(t, "proj_updated", updated[alias]) + } else { + require.Equal(t, "proj_updated", updated[projectLinkCanonicalDirectory(t, original)]) + } + got = projectLinkAliasRun(t, home, alias, nil, "unlink") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Empty(t, projectLinkRegistry(t, home)) + }) + } +} + +func TestMainProjectLinkAliasConflictsAndExplicitRepair(t *testing.T) { + original, alias := projectLinkNativeAliases(t, "Work", "work") + home := t.TempDir() + unrelated := projectLinkCanonicalDirectory(t, t.TempDir()) + links := map[string]string{original: "proj_original", alias: "proj_alias", unrelated: "proj_unrelated"} + before := projectLinkAliasSeed(t, home, links) + server, requests := globalFlagsServer(t, projectLinkAliasPage) + for _, directory := range []string{original, alias} { + for _, command := range [][]string{{"link"}, {"files", "list"}} { + got := projectLinkAliasRun(t, home, directory, globalFlagsEnv(server), command...) + require.Equal(t, 1, got.code) + require.Empty(t, got.stdout) + require.Contains(t, got.stderr, "different projects to the same folder") + require.Contains(t, got.stderr, "This command did not change saved links.") + require.NotContains(t, got.stderr, home) + projectLinkAliasUnchanged(t, home, before) + require.Empty(t, requests) + } + } + for _, override := range []struct { + flags, env, want []string + }{ + {[]string{"--project=proj_flag"}, nil, []string{"proj_flag"}}, + {[]string{"--project="}, nil, []string{""}}, + {nil, []string{"OPENAI_PROJECT_ID=proj_environment"}, []string{"proj_environment"}}, + {nil, []string{"OPENAI_PROJECT_ID="}, []string{""}}, + {[]string{"--header=OpenAI-Project: proj_header"}, nil, []string{"proj_header"}}, + {[]string{"--header=OpenAI-Project:"}, nil, []string{""}}, + } { + projectLinkAliasRequest(t, home, alias, globalFlagsEnv(server, override.env...), requests, override.want, override.flags...) + projectLinkAliasUnchanged(t, home, before) + } + got := projectLinkAliasRun(t, home, alias, nil, "link", "--project=proj_repaired") + require.Equal(t, 0, got.code, "%s", got.stderr) + updated := projectLinkRegistry(t, home) + require.Len(t, updated, 2) + require.Equal(t, "proj_unrelated", updated[unrelated]) + require.Equal(t, "proj_repaired", updated[projectLinkCanonicalDirectory(t, alias)]) + require.Equal(t, "proj_repaired", projectLinkAliasInspect(t, home, original).Project) + projectLinkAliasSeed(t, home, links) + got = projectLinkAliasRun(t, home, alias, nil, "unlink", "--format=json") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.True(t, json.Valid([]byte(got.stdout))) + require.Equal(t, map[string]string{unrelated: "proj_unrelated"}, projectLinkRegistry(t, home)) + require.Empty(t, requests) +} + +func TestMainProjectLinkAliasNearestParent(t *testing.T) { + parent, alias := projectLinkNativeAliases(t, "Work", "work") + child := filepath.Join(parent, "Child") + grandchild := filepath.Join(child, "Grandchild") + require.NoError(t, os.MkdirAll(grandchild, 0o700)) + aliasChild := filepath.Join(alias, "Child") + aliasGrandchild := filepath.Join(aliasChild, "Grandchild") + home := t.TempDir() + projectLinkAliasSeed(t, home, map[string]string{filepath.Dir(parent): "proj_outer", parent: "proj_parent"}) + server, requests := globalFlagsServer(t, projectLinkAliasPage) + status := projectLinkAliasInspect(t, home, aliasGrandchild) + require.Equal(t, "proj_parent", status.Project) + require.True(t, status.Inherited) + require.Equal(t, filepath.Dir(filepath.Dir(status.Directory)), status.LinkedDirectory) + projectLinkAliasRequest(t, home, aliasGrandchild, globalFlagsEnv(server), requests, []string{"proj_parent"}) + got := projectLinkAliasRun(t, home, aliasChild, nil, "link", "--project=proj_child") + require.Equal(t, 0, got.code, "%s", got.stderr) + projectLinkAliasRequest(t, home, grandchild, globalFlagsEnv(server), requests, []string{"proj_child"}) + got = projectLinkAliasRun(t, home, child, nil, "unlink") + require.Equal(t, 0, got.code, "%s", got.stderr) + projectLinkAliasRequest(t, home, aliasGrandchild, globalFlagsEnv(server), requests, []string{"proj_parent"}) +} + +func TestMainProjectLinkAliasUnlinkCommitsBeforeInheritedConflict(t *testing.T) { + parent, alias := projectLinkNativeAliases(t, "Work", "work") + child, aliasChild := filepath.Join(parent, "Child"), filepath.Join(alias, "Child") + require.NoError(t, os.Mkdir(child, 0o700)) + home := t.TempDir() + parents := map[string]string{parent: "proj_parent_first", alias: "proj_parent_second"} + projectLinkAliasSeed(t, home, map[string]string{parent: parents[parent], alias: parents[alias], child: "proj_child", aliasChild: "proj_child"}) + server, requests := globalFlagsServer(t, projectLinkAliasPage) + projectLinkAliasRequest(t, home, child, globalFlagsEnv(server), requests, []string{"proj_child"}) + got := projectLinkAliasRun(t, home, aliasChild, globalFlagsEnv(server), "unlink", "--format-error=json") + require.Equal(t, 1, got.code) + require.Empty(t, got.stdout) + message, ok := decodeMainStructuredError(t, "json", got.stderr)["message"].(string) + require.True(t, ok) + require.Contains(t, message, "This folder has no saved link of its own.") + require.Contains(t, message, "Its inherited project could not be resolved.") + require.Contains(t, message, "different projects to the same folder") + require.NotContains(t, message, "did not change saved links") + require.Equal(t, parents, projectLinkRegistry(t, home)) + require.Empty(t, requests, "committed local unlink must not contact the API") +} + +func TestMainProjectLinkNormalizationAliases(t *testing.T) { + nfc, nfd := projectLinkNativeAliases(t, "Caf\u00e9", "Cafe\u0301") + home := t.TempDir() + before := projectLinkAliasSeed(t, home, map[string]string{nfd: "proj_normalized"}) + status := projectLinkAliasInspect(t, home, nfc) + require.Equal(t, "proj_normalized", status.Project) + require.Equal(t, status.Directory, status.LinkedDirectory) + require.False(t, status.Inherited) + server, requests := globalFlagsServer(t, projectLinkAliasPage) + projectLinkAliasRequest(t, home, nfc, globalFlagsEnv(server), requests, []string{"proj_normalized"}) + projectLinkAliasUnchanged(t, home, before) + got := projectLinkAliasRun(t, home, nfc, nil, "unlink") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Empty(t, projectLinkRegistry(t, home)) +} + +func TestMainProjectLinkDistinctCaseFoldDirectories(t *testing.T) { + left, right := projectLinkDistinctFoldDirectories(t) + home := t.TempDir() + for directory, project := range map[string]string{left: "proj_left", right: "proj_right"} { + got := projectLinkAliasRun(t, home, directory, nil, "link", "--project", project) + require.Equal(t, 0, got.code, "%s", got.stderr) + } + server, requests := globalFlagsServer(t, projectLinkAliasPage) + projectLinkAliasRequest(t, home, left, globalFlagsEnv(server), requests, []string{"proj_left"}) + projectLinkAliasRequest(t, home, right, globalFlagsEnv(server), requests, []string{"proj_right"}) + require.Len(t, projectLinkRegistry(t, home), 2) + got := projectLinkAliasRun(t, home, left, nil, "unlink") + require.Equal(t, 0, got.code, "%s", got.stderr) + projectLinkAliasRequest(t, home, right, globalFlagsEnv(server), requests, []string{"proj_right"}) + require.Equal(t, map[string]string{right: "proj_right"}, projectLinkRegistry(t, home)) +} + +func TestMainProjectLinkRetargetedAliasDoesNotMoveSavedLink(t *testing.T) { + left, right := projectLinkDistinctFoldDirectories(t) + leftChild, rightChild := filepath.Join(left, "Child"), filepath.Join(right, "Child") + require.NoError(t, os.Mkdir(leftChild, 0o700)) + require.NoError(t, os.Mkdir(rightChild, 0o700)) + home := t.TempDir() + before := projectLinkAliasSeed(t, home, map[string]string{leftChild: "proj_stale"}) + require.NoError(t, os.Rename(left, filepath.Join(filepath.Dir(left), "Moved"))) + if err := os.Symlink(right, left); err != nil { + t.Skipf("fixture cannot create the native directory symlink: %v", err) + } + leftInfo, err := os.Stat(leftChild) + require.NoError(t, err) + rightInfo, err := os.Stat(rightChild) + require.NoError(t, err) + require.True(t, os.SameFile(leftInfo, rightInfo), "redirected leaves must identify the same object") + require.Empty(t, projectLinkAliasInspect(t, home, rightChild).Project) + server, requests := globalFlagsServer(t, projectLinkAliasPage) + projectLinkAliasRequest(t, home, rightChild, globalFlagsEnv(server), requests, nil) + projectLinkAliasUnchanged(t, home, before) + got := projectLinkAliasRun(t, home, rightChild, nil, "link", "--project=proj_target") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Equal(t, map[string]string{leftChild: "proj_stale", rightChild: "proj_target"}, projectLinkRegistry(t, home)) + got = projectLinkAliasRun(t, home, rightChild, nil, "unlink") + require.Equal(t, 0, got.code, "%s", got.stderr) + require.Equal(t, map[string]string{leftChild: "proj_stale"}, projectLinkRegistry(t, home)) +} diff --git a/docs/project-linking.md b/docs/project-linking.md index bbc0a470..7babc995 100644 --- a/docs/project-linking.md +++ b/docs/project-linking.md @@ -76,10 +76,22 @@ Run `unlink` in the linked parent folder to remove that parent link. The CLI resolves symlinks before saving or looking up a directory. The real directory and its symlink therefore share one link. -Links follow canonical paths, not folder names or filesystem object identities. -Moving a folder does not move its link. +Inheritance follows the resolved target's parent directories. +The CLI also recognizes case or Unicode-normalization variants when the filesystem confirms they identify the same existing directory. +These equivalent paths are aliases. Names that identify distinct directories keep separate links. + +Inspection and API lookup never rewrite registry entries. +Existing alias entries with the same project ID remain readable. +Conflicting project IDs for one folder prevent implicit selection, even when the current path exactly matches a saved entry. +Explicit `link --project` combines the current folder's alias entries into one entry with the selected project ID. +If aliases already exist, the CLI consistently retains one existing path key. +`unlink` removes all alias entries for the current folder while preserving parent and unrelated links. + +Saved links use directory paths. +Moving a folder to a different location does not move its link. Deleting a folder leaves its saved entry unchanged. Recreating the same path reuses the saved entry. +Entries for missing folders remain stored and do not prevent unrelated links. Unlink before moving or deleting a folder when you do not want this behavior. The registry contains only paths and project IDs. @@ -105,7 +117,7 @@ Native Windows replacement and locking behavior remain unverified. The local registry supports up to 1 MiB of JSON. This limit does not apply to API payloads. -Malformed, duplicate, insecure, or nonregular registry files cause an error. +Malformed JSON, duplicate JSON keys, insecure permissions, or nonregular registry files cause an error. The CLI preserves invalid settings instead of overwriting them. An explicit project flag or environment variable bypasses registry loading for API requests. An explicit `OpenAI-Project` custom header also bypasses registry loading. @@ -131,3 +143,12 @@ If the registry cannot be read, check its file type, permissions, and JSON synta The error identifies known size, ownership, permission, or concurrent-change problems without exposing registry contents or private paths. Move an invalid registry aside only when you intend to discard its saved links. To remove a stale entry for a deleted directory, edit that entry while no CLI process writes the registry. + +To resolve conflicting aliases, run `openai link --project proj_work` in the affected folder to choose its saved project. +Run `openai unlink` there to remove its saved aliases. +For inherited conflicts, change to the linked parent folder first. +Alternatively, repair the conflicting entries in `project-links.json` while no CLI process writes that registry. + +Removing a child link can expose conflicting parent entries. +The error then reports completed removal and unresolved inherited project selection. +The CLI keeps the removal and does not restore the child's old link. diff --git a/go.mod b/go.mod index 6fc2b7a7..da6119d5 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/urfave/cli/v3 v3.13.0 golang.org/x/image v0.46.0 golang.org/x/sys v0.48.0 + golang.org/x/text v0.42.0 ) require ( @@ -44,5 +45,4 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/sync v0.23.0 // indirect - golang.org/x/text v0.42.0 // indirect ) diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index c6020caa..4a3255f3 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -124,19 +124,6 @@ func validLinkedProject(project string) bool { return true } -func nearestProjectLink(links map[string]string, directory string) (string, string) { - for { - if project, ok := links[directory]; ok { - return directory, project - } - parent := filepath.Dir(directory) - if parent == directory { - return "", "" - } - directory = parent - } -} - // No environment mutation: clients retain API keys and their actual access. // Root ownership prevents endpoint body fields named project from changing this. func projectLinkRequestOptions(command *cli.Command) []option.RequestOption { @@ -176,13 +163,16 @@ func projectLinkRequestOptions(command *cli.Command) []option.RequestOption { if err == nil { directory, err = projectLinkDirectory() } + var project string + if err == nil { + _, project, err = nearestProjectLink(ctx, links, directory) + } if err != nil { failure := projectLinkStoreFailure(root, "resolve", err) return []option.RequestOption{option.WithMaxRetries(0), option.WithMiddleware(func(*http.Request, option.MiddlewareNext) (*http.Response, error) { return nil, failure })} } - _, project := nearestProjectLink(links, directory) if project == "" { return nil } @@ -240,7 +230,13 @@ func handleProjectLink(ctx context.Context, command *cli.Command) error { if err != nil { return projectLinkStoreFailure(root, status.Action, err) } - status.LinkedDirectory, status.Project = nearestProjectLink(links, directory) + status.LinkedDirectory, status.Project, err = nearestProjectLink(ctx, links, directory) + if err != nil { + if status.Action == "inspect" { + return projectLinkStoreFailure(root, status.Action, err) + } + return projectLinkCommittedSelectionFailure(status.Action, err) + } status.Inherited = status.LinkedDirectory != "" && status.LinkedDirectory != directory // The --project on link saves a future default; this invocation has no API // request. Show the environment override that future commands retain. diff --git a/pkg/custom/project_link_aliases.go b/pkg/custom/project_link_aliases.go new file mode 100644 index 00000000..fefd56d8 --- /dev/null +++ b/pkg/custom/project_link_aliases.go @@ -0,0 +1,114 @@ +package custom + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/text/unicode/norm" +) + +var ( + errProjectLinkAliasConflict = errors.New("equivalent folder paths select different projects") + errProjectLinkAliasAccess = errors.New("could not verify equivalent folder paths") +) + +// Normalization only selects candidates. The filesystem decides whether their +// directory entries are equivalent; distinct case-sensitive paths stay distinct. +func projectLinkAliasKeys(ctx context.Context, links map[string]string, directory string) ([]string, error) { + var keys []string + normalized := norm.NFC.String(directory) + for key := range links { + if err := ctx.Err(); err != nil { + return nil, err + } + if key == directory { + keys = append(keys, key) + continue + } + if !strings.EqualFold(norm.NFC.String(key), normalized) { + continue + } + same, err := equivalentProjectLinkDirectories(ctx, key, directory) + if err != nil { + return nil, errors.Join(errProjectLinkAliasAccess, err) + } + if same { + keys = append(keys, key) + } + } + slices.Sort(keys) + return keys, ctx.Err() +} + +func equivalentProjectLinkDirectories(ctx context.Context, stored, current string) (bool, error) { + var paths [][2]string + for { + paths = append(paths, [2]string{stored, current}) + parentStored, parentCurrent := filepath.Dir(stored), filepath.Dir(current) + if parentStored == stored || parentCurrent == current { + if parentStored != stored || parentCurrent != current { + return false, nil + } + break + } + stored, current = parentStored, parentCurrent + } + // Check from the root so a distinct sibling is rejected before descending + // into it. Lstat plus IsDir also excludes replaced symlinks and junctions: + // a moved folder must not inherit a saved path through a new redirect. + for i := len(paths) - 1; i >= 0; i-- { + if err := ctx.Err(); err != nil { + return false, err + } + left, err := os.Lstat(paths[i][0]) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !left.IsDir() { + return false, nil + } + right, err := os.Lstat(paths[i][1]) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !right.IsDir() || !os.SameFile(left, right) { + return false, nil + } + } + return true, ctx.Err() +} + +func nearestProjectLink(ctx context.Context, links map[string]string, directory string) (string, string, error) { + for { + keys, err := projectLinkAliasKeys(ctx, links, directory) + if err != nil { + return "", "", err + } + if len(keys) != 0 { + project := links[keys[0]] + for _, key := range keys[1:] { + if links[key] != project { + return "", "", errProjectLinkAliasConflict + } + } + // Report the matched ancestor in the caller's spelling. An alias of + // the current folder is its own link, not an inherited parent link. + return directory, project, nil + } + parent := filepath.Dir(directory) + if parent == directory { + return "", "", nil + } + directory = parent + } +} diff --git a/pkg/custom/project_link_errors.go b/pkg/custom/project_link_errors.go index 26214e81..489d3ac9 100644 --- a/pkg/custom/project_link_errors.go +++ b/pkg/custom/project_link_errors.go @@ -17,8 +17,20 @@ func projectLinkStoreFailure(root *cli.Command, action string, cause error) erro case "resolve": operation = "Could not resolve folder project defaults. " } + message := operation + projectLinkRecovery(cause) + "This command did not change saved links." + if action == "resolve" { + message += " Inspect with " + errorHelpInvocation(root) + " link." + } + return projectLinkFailure(message, cause) +} + +func projectLinkRecovery(cause error) string { recovery := "Check project-links.json and its lock in your user configuration folder. " switch { + case errors.Is(cause, errProjectLinkAliasConflict): + recovery = "Folder links assign different projects to the same folder. Stop CLI writers, then repair the matching paths in project-links.json. " + case errors.Is(cause, errProjectLinkAliasAccess): + recovery = "Could not verify equivalent folder paths. Check access to the linked folders, then retry. " case errors.Is(cause, errProjectLinksTooLarge): recovery = "Folder links must fit within 1 MiB. Remove unused entries or excess whitespace from project-links.json before retrying. " case errors.Is(cause, errProjectLinksInvalid): @@ -36,11 +48,15 @@ func projectLinkStoreFailure(root *cli.Command, action string, cause error) erro case errors.Is(cause, errProjectLinkInputInvalid): recovery = "Use an existing folder and a project ID starting with proj_. " } - message := operation + recovery + "This command did not change saved links." - if action == "resolve" { - message += " Inspect with " + errorHelpInvocation(root) + " link." + return recovery +} + +func projectLinkCommittedSelectionFailure(action string, cause error) error { + outcome := "The folder link was saved. Its effective project could not be resolved. " + if action == "unlink" { + outcome = "This folder has no saved link of its own. Its inherited project could not be resolved. " } - return projectLinkFailure(message, cause) + return projectLinkFailure(outcome+projectLinkRecovery(cause), cause) } func projectLinkOutputFailure(root *cli.Command, action string, cause error) error { diff --git a/pkg/custom/project_link_errors_test.go b/pkg/custom/project_link_errors_test.go index 3c8806fe..13ed76f3 100644 --- a/pkg/custom/project_link_errors_test.go +++ b/pkg/custom/project_link_errors_test.go @@ -18,6 +18,9 @@ func TestProjectLinkStoreFailurePreservesCauses(t *testing.T) { }{ {"size", "1 MiB", errProjectLinksTooLarge}, {"invalid", "invalid JSON or entries", errProjectLinksInvalid}, + {"alias conflict", "different projects", errProjectLinkAliasConflict}, + {"alias access", "Check access to the linked folders", errors.Join(errProjectLinkAliasAccess, + &os.PathError{Op: "lstat", Path: "/synthetic-private/alias", Err: os.ErrPermission})}, {"directory", "0700", errProjectLinkDirectoryUnsafe}, {"file", "0600", errProjectLinkFileUnsafe}, {"lock", ".project-links.json.lock", errProjectLinkLockUnsafe}, @@ -37,6 +40,27 @@ func TestProjectLinkStoreFailurePreservesCauses(t *testing.T) { } } +func TestProjectLinkCommittedSelectionFailurePreservesOutcomeAndCause(t *testing.T) { + private := &os.PathError{Op: "lstat", Path: "/synthetic-private/alias", Err: os.ErrPermission} + for _, test := range []struct { + action, outcome string + }{ + {"link", "The folder link was saved."}, + {"unlink", "This folder has no saved link of its own."}, + } { + t.Run(test.action, func(t *testing.T) { + cause := errors.Join(errProjectLinkAliasAccess, private) + failure := projectLinkCommittedSelectionFailure(test.action, cause) + require.ErrorIs(t, failure, private) + require.ErrorIs(t, failure, errProjectLinkAliasAccess) + message := localErrorMessage(nil, failure) + require.Contains(t, message, test.outcome) + require.NotContains(t, message, "did not change") + require.NotContains(t, message, "synthetic-private") + }) + } +} + func TestProjectLinkOutputFailureReportsActualOutcome(t *testing.T) { for _, test := range []struct { name, outcome, wantProject string diff --git a/pkg/custom/project_link_store.go b/pkg/custom/project_link_store.go index 24ade408..5ccfd261 100644 --- a/pkg/custom/project_link_store.go +++ b/pkg/custom/project_link_store.go @@ -245,10 +245,22 @@ func updateProjectLink(ctx context.Context, path, directory, project string) (ma if err != nil { return nil, err } - if project == "" { - delete(links, directory) - } else { - links[directory] = project + aliases, err := projectLinkAliasKeys(ctx, links, directory) + if err != nil { + return nil, err + } + key := directory + if len(aliases) != 0 { + key = aliases[0] + if _, exact := links[directory]; exact { + key = directory + } + } + for _, alias := range aliases { + delete(links, alias) + } + if project != "" { + links[key] = project } data, err := json.MarshalIndent(links, "", " ") if err != nil { From f7603c7444c9862a642ae43de0f1d12864790ebd Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 05:38:21 -0400 Subject: [PATCH 17/36] feat: add local profiles and project switching --- cmd/openai/profile_commands_test.go | 502 ++++++++++++++++++++ docs/auth-status.md | 12 +- docs/profiles.md | 184 ++++++++ docs/project-linking.md | 15 +- pkg/custom/auth_status.go | 71 +-- pkg/custom/cmdutil.go | 11 +- pkg/custom/command.go | 2 + pkg/custom/image_picker_first_run.go | 2 +- pkg/custom/image_picker_first_run_test.go | 12 +- pkg/custom/profile_commands.go | 368 +++++++++++++++ pkg/custom/profile_commands_test.go | 314 +++++++++++++ pkg/custom/profile_context.go | 255 ++++++++++ pkg/custom/profile_context_test.go | 111 +++++ pkg/custom/profile_store.go | 344 ++++++++++++++ pkg/custom/profile_store_test.go | 545 ++++++++++++++++++++++ pkg/custom/project_link.go | 89 ++-- pkg/custom/root_request_flags.go | 3 + scripts/demos/profiles/README.md | 52 +++ scripts/demos/profiles/scene.sh | 35 ++ 19 files changed, 2808 insertions(+), 119 deletions(-) create mode 100644 cmd/openai/profile_commands_test.go create mode 100644 docs/profiles.md create mode 100644 pkg/custom/profile_commands.go create mode 100644 pkg/custom/profile_commands_test.go create mode 100644 pkg/custom/profile_context.go create mode 100644 pkg/custom/profile_context_test.go create mode 100644 pkg/custom/profile_store.go create mode 100644 pkg/custom/profile_store_test.go create mode 100644 scripts/demos/profiles/README.md create mode 100644 scripts/demos/profiles/scene.sh diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go new file mode 100644 index 00000000..6f501926 --- /dev/null +++ b/cmd/openai/profile_commands_test.go @@ -0,0 +1,502 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func profileRegistryPath(home string) string { + return filepath.Join(filepath.Dir(projectLinkRegistryPath(home)), "profiles.json") +} + +func profileRecord(name string, active bool, values map[string]string) map[string]any { + if values == nil { + values = map[string]string{} + } + return map[string]any{"name": name, "active": active, "values": values} +} + +func profileReceipt(action, name string, active bool, values map[string]string) map[string]any { + return map[string]any{"action": action, "profile": profileRecord(name, active, values)} +} + +func assertProfileJSON(t *testing.T, got mainDispatchResult, want any) { + t.Helper() + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), got.stdout) + require.True(t, strings.HasSuffix(got.stdout, "\n"), "complete results end with a newline") +} + +func profileCommand(t *testing.T, home, directory string, args ...string) mainDispatchResult { + t.Helper() + return runProjectLinkMain(t, home, directory, nil, "", append(args, "--format=json")...) +} + +func createProfileFixture(t *testing.T, home, directory, name string, edits ...string) { + t.Helper() + assertProfileJSON(t, profileCommand(t, home, directory, "profile", "create", name), profileReceipt("create", name, false, nil)) + if len(edits) != 0 { + got := profileCommand(t, home, directory, append([]string{"profile", "set", name}, edits...)...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + } +} + +func TestMainProfileLifecycleAndExactSchemas(t *testing.T) { + for _, format := range []string{"json", "jsonl"} { + t.Run(format, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + run := func(args ...string) mainDispatchResult { + return runProjectLinkMain(t, home, directory, nil, "", append(args, "--format="+format)...) + } + assertList := func(records ...map[string]any) { + t.Helper() + got := run("profile", "list") + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + if format == "json" { + if records == nil { + records = []map[string]any{} + } + assertProfileJSON(t, got, records) + return + } + if len(records) == 0 { + require.Empty(t, got.stdout) + return + } + lines := strings.Split(strings.TrimSuffix(got.stdout, "\n"), "\n") + require.Len(t, lines, len(records)) + for i, record := range records { + assertProfileJSON(t, mainDispatchResult{stdout: lines[i] + "\n"}, record) + } + } + assertResult := func(got mainDispatchResult, want any) { + t.Helper() + assertProfileJSON(t, got, want) + if format == "jsonl" { + require.Equal(t, 1, strings.Count(got.stdout, "\n")) + } + } + assertList() + for _, name := range []string{"work", "équipe", "Zeta"} { + assertResult(run("profile", "create", name), profileReceipt("create", name, false, nil)) + } + assertList(profileRecord("Zeta", false, nil), profileRecord("work", false, nil), profileRecord("équipe", false, nil)) + values := map[string]string{"organization": "", "project": "", "base_url": ""} + assertResult(run("profile", "set", "work", "--organization=", "--project=", "--base-url="), profileReceipt("set", "work", false, values)) + assertResult(run("profile", "get", "work"), profileRecord("work", false, values)) + values = map[string]string{"project": ""} + assertResult(run("profile", "set", "work", "--unset", "organization", "--unset", "base_url"), profileReceipt("set", "work", false, values)) + assertResult(run("profile", "activate", "work"), profileReceipt("activate", "work", true, values)) + assertResult(run("profile", "get", "work"), profileRecord("work", true, values)) + assertResult(run("--profile", "Zeta", "auth", "switch", "--to-project", "proj_switched"), profileReceipt("switch", "Zeta", false, map[string]string{"project": "proj_switched"})) + assertResult(run("auth", "switch", "--to-project", "proj_default"), profileReceipt("switch", "work", true, map[string]string{"project": "proj_default"})) + before, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + got := run("profile", "delete", "work") + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + assertResult(run("profile", "delete", "work", "--deactivate"), map[string]any{"action": "delete", "name": "work", "deactivated": true}) + assertResult(run("profile", "delete", "Zeta"), map[string]any{"action": "delete", "name": "Zeta", "deactivated": false}) + assertList(profileRecord("équipe", false, nil)) + }) + } +} + +func TestMainProfileEditsNeverSaveAmbientValues(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + env := []string{"OPENAI_PROFILE=missing", "OPENAI_PROJECT_ID=proj_environment", "OPENAI_ORG_ID=org_environment", + "OPENAI_BASE_URL=https://environment.invalid/v1", "OPENAI_API_KEY=synthetic-private-profile-key"} + run := func(args ...string) mainDispatchResult { + return runProjectLinkMain(t, home, directory, env, "", append(args, "--format=json")...) + } + assertProfileJSON(t, run("--project=proj_root", "--organization=org_root", "--base-url=https://root.invalid/v1", "profile", "create", "work"), profileReceipt("create", "work", false, nil)) + values := map[string]string{"organization": "org_saved"} + assertProfileJSON(t, run("--project=proj_root", "--base-url=https://root.invalid/v1", "profile", "set", "work", "--organization=org_saved"), profileReceipt("set", "work", false, values)) + assertProfileJSON(t, run("profile", "get", "work"), profileRecord("work", false, values)) + assertProfileJSON(t, run("profile", "list"), []any{profileRecord("work", false, values)}) + assertProfileJSON(t, run("profile", "activate", "work"), profileReceipt("activate", "work", true, values)) + assertProfileJSON(t, run("--profile=work", "auth", "switch", "--to-project=proj_saved"), profileReceipt("switch", "work", true, map[string]string{"organization": "org_saved", "project": "proj_saved"})) + data, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.JSONEq(t, `{"version":1,"default_profile":"work","profiles":{"work":{"organization":"org_saved","project":"proj_saved"}}}`, string(data)) + assertProfileJSON(t, run("profile", "delete", "work", "--deactivate"), map[string]any{"action": "delete", "name": "work", "deactivated": true}) + assertProfileJSON(t, run("profile", "list"), []any{}) +} + +func TestMainProfileValidationLeavesStateUnchanged(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--project=proj_original") + before, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + for _, args := range [][]string{ + {"profile", "create", "work"}, {"profile", "create", ""}, {"profile", "create", " edged"}, + {"profile", "create", "line\nbreak"}, {"profile", "set", "work"}, + {"--project=proj_root", "profile", "set", "work"}, + {"profile", "set", "work", "--project=", "--unset=project"}, + {"profile", "set", "work", "--unset=api_key"}, + {"profile", "set", "work", "--base-url=https://user:synthetic-private-password@example.invalid/v1"}, + {"profile", "set", "work", "--base-url=https://example.invalid/v1?token=synthetic-private-query"}, + {"profile", "set", "work", "--base-url=https://example.invalid/v1#synthetic-private-fragment"}, + {"--profile=work", "auth", "switch"}, {"--profile=work", "auth", "switch", "--to-project="}, + {"--profile=", "auth", "switch", "--to-project=proj_new"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + got := runProjectLinkMain(t, home, directory, nil, "", append(slices.Clone(args), "--format-error=json")...) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + require.True(t, json.Valid([]byte(got.stderr)), "%s", got.stderr) + assertAuthStatusPrivate(t, got, "synthetic-private-password", "synthetic-private-query", "synthetic-private-fragment") + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } + for _, command := range [][]string{ + {"profile", "create", "new"}, {"profile", "set", "work", "--project=proj_changed"}, + {"profile", "activate", "work"}, {"profile", "delete", "work"}, + {"--profile=work", "auth", "switch", "--to-project=proj_changed"}, + } { + for _, mode := range [][]string{{"--format=yaml"}, {"--format=raw"}, {"--transform=values"}, {"--raw-output"}} { + args := append(slices.Clone(command), mode...) + t.Run(strings.Join(args, " "), func(t *testing.T) { + got := runProjectLinkMain(t, home, directory, nil, "", append(args, "--format-error=json")...) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + require.True(t, json.Valid([]byte(got.stderr)), "%s", got.stderr) + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } + } +} + +func TestMainProfileStatusMatchesRequestContext(t *testing.T) { + home, parent := t.TempDir(), t.TempDir() + child := filepath.Join(parent, "child") + directory := filepath.Join(child, "nested") + require.NoError(t, os.MkdirAll(directory, 0700)) + projectLinkCreate(t, home, parent, "proj_parent") + projectLinkCreate(t, home, child, "proj_folder") + createProfileFixture(t, home, directory, "work", "--project=proj_profile", "--organization=org_profile") + createProfileFixture(t, home, directory, "empty", "--project=", "--organization=") + createProfileFixture(t, home, directory, "absent") + assertProfileJSON(t, profileCommand(t, home, directory, "profile", "activate", "work"), profileReceipt("activate", "work", true, map[string]string{"project": "proj_profile", "organization": "org_profile"})) + custom := "OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom_first\nOpenAI-Project: proj_custom\nOpenAI-Organization: org_custom_first\nOpenAI-Organization: org_custom" + for _, tc := range []struct { + name string + env, flags []string + project, organization authStatusValueResult + unlinked bool + }{ + {"saved default", nil, nil, authStatusValueResult{Value: "proj_profile", Source: "profile:work"}, authStatusValueResult{Value: "org_profile", Source: "profile:work"}, false}, + {"explicit root over custom headers", nil, []string{"--project=proj_flag", "--organization=org_flag"}, authStatusValueResult{Value: "proj_flag", Source: "flag:--project"}, authStatusValueResult{Value: "org_flag", Source: "flag:--organization"}, false}, + {"root over environment", []string{"OPENAI_PROJECT_ID=proj_env", "OPENAI_ORG_ID=org_env"}, []string{"--project=proj_flag", "--organization=org_flag"}, authStatusValueResult{Value: "proj_flag", Source: "flag:--project"}, authStatusValueResult{Value: "org_flag", Source: "flag:--organization"}, false}, + {"empty root", []string{"OPENAI_PROJECT_ID=proj_env", "OPENAI_ORG_ID=org_env"}, []string{"--project=", "--organization="}, authStatusValueResult{Source: "flag:--project"}, authStatusValueResult{Source: "flag:--organization"}, false}, + {"environment over profile", []string{"OPENAI_PROJECT_ID=proj_env", "OPENAI_ORG_ID=org_env"}, nil, authStatusValueResult{Value: "proj_env", Source: "environment:OPENAI_PROJECT_ID"}, authStatusValueResult{Value: "org_env", Source: "environment:OPENAI_ORG_ID"}, false}, + {"empty environment", []string{"OPENAI_PROJECT_ID=", "OPENAI_ORG_ID="}, nil, authStatusValueResult{Source: "environment:OPENAI_PROJECT_ID"}, authStatusValueResult{Source: "environment:OPENAI_ORG_ID"}, false}, + {"empty profile fields", nil, []string{"--profile=empty"}, authStatusValueResult{Source: "profile:empty"}, authStatusValueResult{Source: "profile:empty"}, false}, + {"absent profile fields", nil, []string{"--profile=absent"}, authStatusValueResult{Value: "proj_folder", Source: "folder"}, authStatusValueResult{Value: "org_custom", Source: "environment:OPENAI_CUSTOM_HEADERS"}, false}, + {"empty flag selector", []string{"OPENAI_PROFILE=work"}, []string{"--profile="}, authStatusValueResult{Value: "proj_folder", Source: "folder"}, authStatusValueResult{Value: "org_custom", Source: "environment:OPENAI_CUSTOM_HEADERS"}, false}, + {"empty environment selector", []string{"OPENAI_PROFILE="}, nil, authStatusValueResult{Value: "proj_folder", Source: "folder"}, authStatusValueResult{Value: "org_custom", Source: "environment:OPENAI_CUSTOM_HEADERS"}, false}, + {"flag selector over environment", []string{"OPENAI_PROFILE=empty"}, []string{"--profile=work"}, authStatusValueResult{Value: "proj_profile", Source: "profile:work"}, authStatusValueResult{Value: "org_profile", Source: "profile:work"}, false}, + {"custom header fallback", nil, []string{"--profile="}, authStatusValueResult{Value: "proj_custom", Source: "environment:OPENAI_CUSTOM_HEADERS"}, authStatusValueResult{Value: "org_custom", Source: "environment:OPENAI_CUSTOM_HEADERS"}, true}, + {"literal headers", []string{"OPENAI_PROJECT_ID=proj_env", "OPENAI_ORG_ID=org_env"}, []string{"--project=proj_flag", "--organization=org_flag", "--header=OpenAI-Project: proj_header", "--header=OpenAI-Organization: org_header"}, authStatusValueResult{Value: "proj_header", Source: "flag:--header"}, authStatusValueResult{Value: "org_header", Source: "flag:--header"}, false}, + {"empty literal headers", nil, []string{"--project=proj_flag", "--organization=org_flag", "--header=OpenAI-Project:", "--header=OpenAI-Organization:"}, authStatusValueResult{Source: "flag:--header"}, authStatusValueResult{Source: "flag:--header"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + cwd := directory + if tc.unlinked { + cwd = t.TempDir() + } + server, requests := globalFlagsServer(t, globalFlagsPage) + env := globalFlagsEnv(server, append([]string{custom}, tc.env...)...) + statusArgs := append([]string{"auth", "status", "--format=json"}, tc.flags...) + status := decodeAuthStatus(t, runProjectLinkMain(t, home, cwd, env, "", statusArgs...)) + require.Equal(t, tc.project, status.Project) + require.Equal(t, tc.organization, status.Organization) + require.Empty(t, requests, "status must remain offline") + args := append([]string{"models", "list", "--format=raw"}, tc.flags...) + got := runProjectLinkMain(t, home, cwd, env, "", args...) + require.Equal(t, mainDispatchResult{stdout: globalFlagsPage + "\n"}, got) + request := globalFlagsOneRequest(t, requests) + require.Equal(t, []string{status.Project.Value}, request.header.Values("OpenAI-Project")) + require.Equal(t, []string{status.Organization.Value}, request.header.Values("OpenAI-Organization")) + require.Equal(t, "Bearer synthetic-env-key", request.header.Get("Authorization")) + }) + } +} + +func TestMainProfileUnknownSelectorsFailWithoutFallback(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + projectLinkCreate(t, home, directory, "proj_folder") + createProfileFixture(t, home, directory, "work", "--project=proj_profile") + assertProfileJSON(t, profileCommand(t, home, directory, "profile", "activate", "work"), profileReceipt("activate", "work", true, map[string]string{"project": "proj_profile"})) + server, requests := localUtilitiesRequestTrap(t) + for _, selector := range []struct{ env, flags []string }{ + {[]string{"OPENAI_PROFILE=missing"}, nil}, {nil, []string{"--profile=missing"}}, + } { + for _, command := range [][]string{{"auth", "status"}, {"auth", "switch", "--to-project=proj_new"}, {"models", "list"}} { + args := append(slices.Clone(selector.flags), command...) + args = append(args, "--format-error=json") + got := runProjectLinkMain(t, home, directory, append([]string{"OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY=synthetic-key"}, selector.env...), "", args...) + require.NotZero(t, got.code, "%q", args) + require.Empty(t, got.stdout) + require.True(t, json.Valid([]byte(got.stderr)), "%s", got.stderr) + } + } + require.Zero(t, requests.Load()) + assertProfileJSON(t, profileCommand(t, home, directory, "profile", "get", "work"), profileRecord("work", true, map[string]string{"project": "proj_profile"})) + const dangling = `{"version":1,"default_profile":"missing","profiles":{"work":{"project":"proj_profile"}}}` + require.NoError(t, os.WriteFile(profileRegistryPath(home), []byte(dangling), 0600)) + for _, command := range [][]string{{"auth", "status"}, {"models", "list"}} { + got := runProjectLinkMain(t, home, directory, []string{"OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY=synthetic-key"}, "", command...) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + } + require.Zero(t, requests.Load(), "dangling defaults must not fall back to folder links") + data, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, dangling, string(data)) +} + +func TestMainProfileProjectBypassesMalformedFolderRegistry(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--project=proj_profile") + createProfileFixture(t, home, directory, "empty", "--project=") + const malformed = "{synthetic-invalid-folder-registry" + require.NoError(t, os.WriteFile(projectLinkRegistryPath(home), []byte(malformed), 0600)) + for _, tc := range []struct{ name, project string }{{"work", "proj_profile"}, {"empty", ""}} { + t.Run(tc.name, func(t *testing.T) { + server, requests := globalFlagsServer(t, globalFlagsPage) + env := globalFlagsEnv(server) + status := decodeAuthStatus(t, runProjectLinkMain(t, home, directory, env, "", "--profile="+tc.name, "auth", "status", "--format=json")) + require.Equal(t, authStatusValueResult{Value: tc.project, Source: "profile:" + tc.name}, status.Project) + got := runProjectLinkMain(t, home, directory, env, "", "--profile="+tc.name, "models", "list", "--format=raw") + require.Equal(t, mainDispatchResult{stdout: globalFlagsPage + "\n"}, got) + require.Equal(t, []string{tc.project}, globalFlagsOneRequest(t, requests).header.Values("OpenAI-Project")) + }) + } + data, err := os.ReadFile(projectLinkRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, malformed, string(data)) +} + +func TestMainProfileEndpointProjectRemainsBodyField(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--project=proj_profile") + for _, rootFlags := range [][]string{nil, {"--project=proj_root"}} { + server, requests := globalFlagsServer(t, `{"id":"invite_synthetic"}`) + args := append([]string{"--profile=work"}, rootFlags...) + args = append(args, "admin", "organization", "invites", "create", "--email=synthetic@example.invalid", "--role=reader", + "--project", `{"id":"membership_project","role":"member"}`, "--format=json") + got := runProjectLinkMain(t, home, directory, globalFlagsEnv(server, "OPENAI_ADMIN_KEY=synthetic-admin-key"), "", args...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + request := globalFlagsOneRequest(t, requests) + project := "proj_profile" + if len(rootFlags) != 0 { + project = "proj_root" + } + require.Equal(t, []string{project}, request.header.Values("OpenAI-Project")) + require.Equal(t, http.MethodPost, request.method) + require.Equal(t, "/organization/invites", request.path) + require.JSONEq(t, `{"email":"synthetic@example.invalid","role":"reader","projects":[{"id":"membership_project","role":"member"}]}`, string(request.body)) + } +} + +func TestMainProfileCommandsPreserveStdinAndStayOffline(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work") + server, requests := localUtilitiesRequestTrap(t) + env := []string{"HOME=" + home, "USERPROFILE=" + home, "APPDATA=" + home, "XDG_CONFIG_HOME=" + home, + "OPENAI_BASE_URL=" + server.URL, "OPENAI_PROFILE=work", "OPENAI_MTLS_CLIENT_CERT_FILE=/synthetic-private/missing", + "OPENAI_CUSTOM_HEADERS=synthetic-private-invalid-header", "NO_COLOR=1", "FORCE_COLOR=0", "TERM=dumb"} + for _, args := range [][]string{ + {"profile", "create", "new"}, {"profile", "set", "work", "--project=proj_saved"}, + {"profile", "get", "work"}, {"profile", "list"}, {"profile", "activate", "work"}, + {"auth", "switch", "--to-project=proj_switched"}, {"profile", "delete", "new"}, + {"profile", "set", "work", "--project=proj_rejected", "--format=yaml"}, + {"profile", "--help"}, {"auth", "switch", "--help"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + const input = "synthetic-private-unread-input\n" + reader, writer, err := os.Pipe() + require.NoError(t, err) + defer reader.Close() + defer writer.Close() + _, err = io.WriteString(writer, input) + require.NoError(t, err) + got := runMainDispatchWithStdin(t, "bash", env, reader, append([]string{"openai"}, args...)...) + if slices.Contains(args, "--format=yaml") { + require.NotZero(t, got.code) + } else { + require.Zero(t, got.code, "%s", got.stderr) + } + assertAuthStatusPrivate(t, got, input, "synthetic-private-missing", "synthetic-private-invalid-header") + require.NoError(t, writer.Close()) + remaining, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, input, string(remaining)) + }) + } + require.Zero(t, requests.Load()) +} + +func TestMainProfileQuietPreservesResults(t *testing.T) { + for _, format := range []string{"text", "json", "jsonl"} { + t.Run(format, func(t *testing.T) { + home, quietHome, directory := t.TempDir(), t.TempDir(), t.TempDir() + ordinary := runProjectLinkMain(t, home, directory, nil, "", "profile", "create", "work", "--format="+format) + quiet := runProjectLinkMain(t, quietHome, directory, nil, "", "profile", "create", "work", "--format="+format, "--quiet") + require.Zero(t, ordinary.code, "%s", ordinary.stderr) + require.NotEmpty(t, ordinary.stdout) + require.Equal(t, ordinary, quiet) + for _, command := range [][]string{ + {"profile", "set", "work", "--project=proj_saved"}, {"profile", "get", "work"}, + {"profile", "list"}, {"profile", "--help"}, {"auth", "switch", "--help"}, + } { + args := append(slices.Clone(command), "--format="+format) + ordinary := runProjectLinkMain(t, home, directory, nil, "", args...) + quiet := runProjectLinkMain(t, quietHome, directory, nil, "", append(args, "--quiet")...) + require.Zero(t, ordinary.code, "%s", ordinary.stderr) + require.NotEmpty(t, ordinary.stdout) + require.Equal(t, ordinary, quiet) + } + }) + } +} + +func TestMainProfileCompletionFlagOwnership(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--project=proj_saved") + before, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + startup := filepath.Join(home, "startup with spaces") + const original = "# personal startup\n" + require.NoError(t, os.WriteFile(startup, []byte(original), 0600)) + for _, action := range []string{"--install-picker", "--uninstall-picker"} { + got := runProjectLinkMain(t, home, directory, []string{"OPENAI_PROFILE=missing", "ZDOTDIR=" + home}, "", + "--profile", "work", "@completion", "zsh", action, "--profile", startup) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stdout) + contents, err := os.ReadFile(startup) + require.NoError(t, err) + if action == "--install-picker" { + require.True(t, strings.HasPrefix(string(contents), original)) + require.Equal(t, 1, strings.Count(string(contents), "# >>> openai image picker")) + } else { + require.Equal(t, original, string(contents)) + } + } + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after, "completion's file option must not edit the named profile") +} + +// The transport records the SDK's final URL without opening a socket, including production-reset cases. +type profileURLCaptureTransport struct{} + +func (profileURLCaptureTransport) RoundTrip(request *http.Request) (*http.Response, error) { + fmt.Fprintln(os.Stderr, "captured-url="+request.URL.String()) + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(globalFlagsPage)), Request: request}, nil +} + +func TestMainProfileURLProcess(t *testing.T) { + if os.Getenv("OPENAI_CLI_PROFILE_URL_PROCESS") != "1" { + return + } + separator := slices.Index(os.Args, "--") + require.GreaterOrEqual(t, separator, 0) + http.DefaultTransport = profileURLCaptureTransport{} + http.DefaultClient = &http.Client{Transport: http.DefaultTransport} + os.Args = os.Args[separator+1:] + main() + os.Exit(0) +} + +func runProfileURLMain(t *testing.T, home, directory string, env, args []string) mainDispatchResult { + t.Helper() + binary, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + child := exec.CommandContext(ctx, binary, append([]string{"-test.run=^TestMainProfileURLProcess$", "--", "openai"}, args...)...) + child.Dir = directory + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + name = strings.ToUpper(name) + if strings.HasPrefix(name, "OPENAI_") || slices.Contains([]string{"HOME", "USERPROFILE", "APPDATA", "XDG_CONFIG_HOME", "COMPLETION_STYLE", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"}, name) { + continue + } + child.Env = append(child.Env, entry) + } + child.Env = append(child.Env, "OPENAI_CLI_PROFILE_URL_PROCESS=1", "COMPLETION_STYLE=bash", "OPENAI_API_KEY=synthetic-url-key", + "HOME="+home, "USERPROFILE="+home, "APPDATA="+home, "XDG_CONFIG_HOME="+home, "NO_COLOR=1", "FORCE_COLOR=0", "TERM=dumb") + child.Env = append(child.Env, env...) + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + err = child.Run() + require.NoError(t, ctx.Err(), "command timed out") + code := 0 + if exit, ok := err.(*exec.ExitError); ok { + code = exit.ExitCode() + } else { + require.NoError(t, err) + } + return mainDispatchResult{code, stdout.String(), stderr.String()} +} + +func TestMainProfileBaseURLUsesActualEffectiveEndpoint(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--base-url=https://profile.invalid/custom") + createProfileFixture(t, home, directory, "empty", "--base-url=") + for _, tc := range []struct { + name, profile, baseURL, source, requestURL string + env, flags []string + }{ + {"profile", "work", "https://profile.invalid/custom", "profile:work", "https://profile.invalid/custom/models", nil, nil}, + {"empty profile production reset", "empty", "https://api.openai.com/v1/", "profile:empty", "https://api.openai.com/v1/models", nil, nil}, + {"empty flag keeps profile", "work", "https://profile.invalid/custom", "profile:work", "https://profile.invalid/custom/models", nil, []string{"--base-url="}}, + {"empty flag keeps production reset", "empty", "https://api.openai.com/v1/", "profile:empty", "https://api.openai.com/v1/models", nil, []string{"--base-url="}}, + {"environment", "work", "https://environment.invalid/v1", "environment:OPENAI_BASE_URL", "https://environment.invalid/v1/models", []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, nil}, + {"empty flag keeps environment", "work", "https://environment.invalid/v1", "environment:OPENAI_BASE_URL", "https://environment.invalid/v1/models", []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, []string{"--base-url="}}, + {"root overrides environment", "work", "https://flag.invalid/v1", "flag:--base-url", "https://flag.invalid/v1/models", []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, []string{"--base-url=https://flag.invalid/v1"}}, + {"empty environment remains empty", "work", "", "environment:OPENAI_BASE_URL", "models", []string{"OPENAI_BASE_URL="}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + flags := append([]string{"--profile=" + tc.profile}, tc.flags...) + statusArgs := append(slices.Clone(flags), "auth", "status", "--format=json") + status := decodeAuthStatus(t, runProfileURLMain(t, home, directory, tc.env, statusArgs)) + require.Equal(t, authStatusValueResult{Value: tc.baseURL, Source: tc.source}, status.BaseURL) + requestArgs := append(slices.Clone(flags), "models", "list", "--format=raw") + got := runProfileURLMain(t, home, directory, tc.env, requestArgs) + require.Zero(t, got.code, "%s", got.stderr) + require.Equal(t, globalFlagsPage+"\n", got.stdout) + require.Equal(t, "captured-url="+tc.requestURL+"\n", got.stderr) + }) + } + assertProfileJSON(t, profileCommand(t, home, directory, "profile", "get", "empty"), profileRecord("empty", false, map[string]string{"base_url": ""})) +} diff --git a/docs/auth-status.md b/docs/auth-status.md index ad518fd0..e6f52168 100644 --- a/docs/auth-status.md +++ b/docs/auth-status.md @@ -23,13 +23,19 @@ The report follows existing CLI request selection: - A nonempty API key or Admin key overrides an Authorization header from `OPENAI_CUSTOM_HEADERS`. - An empty key option suppresses its environment key. An environment Authorization header can still apply. - Literal organization and project headers override their corresponding options. -- Organization and project options override environment custom headers, including explicitly empty values. +- Root organization and project flags override present environment values, then selected-profile fields. +- A present profile project overrides folder links. Absent profile fields permit fallback; empty fields suppress it. +- Folder links precede custom-header fallback. Explicit headers retain their higher priority. - Repeated options retain the parser's existing last-value behavior. - A nonempty `--base-url` overrides `OPENAI_BASE_URL`. An empty flag falls back to the environment. -- An explicitly empty `OPENAI_BASE_URL` remains empty. An absent value selects the SDK production URL. +- An explicitly empty `OPENAI_BASE_URL` remains empty. An absent environment value permits the selected profile endpoint. +- An effective empty stored endpoint selects the SDK production URL. Status reports that URL with profile provenance. Explicit flag assignment remains distinguishable from environment input, even when their values match. -Sources use `flag:--NAME`, `environment:VARIABLE`, `default`, or `unset`. +Sources use `flag:--NAME`, `environment:VARIABLE`, `profile:NAME`, `folder`, `default`, or `unset`. +Root `--profile`, present `OPENAI_PROFILE`, then the saved default select a profile. +An empty selector disables the saved default; folder fallback remains eligible. +Unknown selected profiles fail without fallback. See [profiles](profiles.md) for local management. Header-derived values identify `--header` or `OPENAI_CUSTOM_HEADERS` as their source. The report does not infer credentials or permissions from arbitrary gateway headers. diff --git a/docs/profiles.md b/docs/profiles.md new file mode 100644 index 00000000..bbf1da9e --- /dev/null +++ b/docs/profiles.md @@ -0,0 +1,184 @@ +# Local profiles + +Profiles save organization, project, and base URL settings on your computer. +Profile commands make no API requests. +They do not change credentials, project access, or server settings. +Use environment variables or existing request flags for credentials. + +## Create and edit + +```sh +openai profile create work +openai profile set work --organization org_demo --project proj_demo +openai profile get work +openai profile list +``` + +`create` makes an empty profile without activating it. +Names are case-sensitive UTF-8 strings without controls or surrounding whitespace. +Names can contain internal spaces when quoted. +The CLI does not normalize names or use them as filenames. + +`set` requires at least one explicit edit. +Only its command-local flags persist values. +Root flags and environment values never become saved edits. + +```sh +# Save a project with the flag owned by profile set. +openai profile set work --project proj_saved + +# The root project flag does not become a saved edit. +# This command saves only the organization. +openai --project proj_request profile set work --organization org_saved +``` + +An absent field permits lower-priority defaults. +An empty organization or project field suppresses those defaults. +An empty stored base URL explicitly selects the production endpoint when effective. +`get` and `list` retain the stored empty string. + +```sh +openai profile set work --project= +openai profile set work --base-url= +openai profile set work --unset project --unset base_url +``` + +`--unset` accepts `organization`, `project`, or `base_url`. +Repeat the flag to remove multiple fields. +Setting and unsetting the same field fails before the CLI writes anything. +Saved base URLs must use HTTP or HTTPS without user information, query parameters, or fragments. + +## Select and switch + +```sh +openai profile activate work +openai --profile work auth status +openai --profile work auth switch --to-project proj_other +``` + +`activate` changes the saved default only. +`auth switch` changes only the selected profile's saved project. +It requires a nonempty `--to-project` value. +It does not activate that profile or remove request overrides. + +The CLI selects a profile in this order: + +1. Root `--profile NAME`. +2. Present `OPENAI_PROFILE`. +3. The saved default. + +An empty root selector or present-empty `OPENAI_PROFILE` disables the saved default. +Folder project defaults remain eligible. +Unknown nonempty selectors and dangling saved defaults fail without fallback. + +```sh +# Ignore the saved default for this request. +openai --profile= models list + +# Switch a saved profile without activating it. +openai --profile work auth switch --to-project proj_other +``` + +All `profile` management commands ignore unrelated selectors, including `profile list`. +This permits inspection and repair when `OPENAI_PROFILE` names a missing profile. +`auth status` and `auth switch` resolve selectors. +Completion's command-local `@completion --profile PATH` still identifies a shell profile path. +It does not select an API configuration profile. + +## Effective request settings + +Project settings use this order: + +1. Literal `--header OpenAI-Project:...`. +2. Explicit root `--project`. +3. Present `OPENAI_PROJECT_ID`. +4. Present project field in the selected profile. +5. The nearest folder link. +6. `OPENAI_CUSTOM_HEADERS` fallback. +7. Existing SDK fallback. + +Organization settings use the same order without folder links. +Explicit empty headers, flags, environment values, and profile fields retain their meaning. +Endpoint-owned `--project` fields remain API request fields. +They do not select the root project setting. + +The saved default profile takes priority over folder links. +Use `openai auth status` to inspect effective values and their sources. +Profile sources appear as `profile:NAME`; folder sources appear as `folder`. +Status reports configuration without verifying identity or access. + +Base URLs use this order: + +1. Nonempty root `--base-url`. +2. Present `OPENAI_BASE_URL`. +3. Present base URL field in the selected profile. +4. The SDK production default. + +An empty root `--base-url` retains fallback behavior. +A present-empty `OPENAI_BASE_URL` retains its explicit empty environment value. +An empty stored base URL selects `https://api.openai.com/v1/` when effective. +Status reports that actual production URL with profile provenance. +Status sanitizes endpoint information; profile inspection shows saved values. + +## Remove a profile + +```sh +openai profile delete work --deactivate +``` + +The active profile is the saved default, regardless of environment or request selectors. +Deleting it requires `--deactivate`. +The CLI clears the default and deletes the profile in one transaction. +Deleting another profile does not change the default. +Environment selectors remain unchanged. + +## Output and storage + +Profile commands and `auth switch` support `--format auto`, `text`, `json`, and `jsonl`. +Automatic output uses readable text, including redirected output. +`--quiet` preserves profile data and mutation receipts. +Unsupported formats, `--transform`, and `--raw-output` fail before mutation. + +`get` returns one record: + +```json +{"name":"work","active":true,"values":{"project":"proj_demo"}} +``` + +`list` sorts names by UTF-8 byte order. +JSON returns an array, including `[]` when empty. +JSONL returns one record per line, with no rows when empty. +Absent fields remain omitted; empty fields remain empty strings. + +Create, set, activate, and switch receipts contain the operation name and resulting profile: + +```json +{"action":"switch","profile":{"name":"work","active":true,"values":{"project":"proj_other"}}} +``` + +Delete receipts report whether the command cleared the saved default: + +```json +{"action":"delete","name":"work","deactivated":true} +``` + +Each JSONL receipt occupies one complete line. +The CLI emits receipts only after committing storage. +If output fails afterward, the command fails and states that saved profiles changed. +Inspect the saved state before repeating a mutation. + +Profiles use `os.UserConfigDir()/openai/profiles.json`. +The location follows the operating system's user configuration directory rules. +The local registry must fit within 1 MiB. +This limit does not affect API responses or streaming events. +The file uses a versioned schema: + +```json +{"version":1,"default_profile":null,"profiles":{"work":{"project":"proj_demo"}}} +``` + +Mutations lock the registry, validate its contents, and replace it atomically. +The CLI rejects malformed schemas, duplicate keys, unknown fields, unsupported versions, and dangling defaults. +Failures preserve existing registry bytes. +Store no credentials or credential references in this file. +The profile registry remains separate from `project-links.json`. diff --git a/docs/project-linking.md b/docs/project-linking.md index 7babc995..3e689c43 100644 --- a/docs/project-linking.md +++ b/docs/project-linking.md @@ -48,21 +48,24 @@ API requests select a project in this order: 1. The explicit root `--project` flag. 2. `OPENAI_PROJECT_ID`. -3. The nearest linked folder, including the current folder. -4. The existing SDK default when no project is selected. +3. A present project field in the selected profile, including the saved default profile. +4. The nearest linked folder, including the current folder. +5. The existing SDK custom-header fallback when no project is selected. -An explicit empty flag or empty environment variable disables folder selection. +An explicit empty flag, environment variable, or profile project field disables folder selection. +An absent profile project permits folder fallback. +An empty `--profile` or `OPENAI_PROFILE` disables profile selection, while preserving folder fallback. For example, `openai --project= files list` sends an empty project header. Root request flags work before or after commands, subject to existing endpoint flag ownership. An endpoint's own `--project` request field does not change request-header selection. Explicit custom `OpenAI-Project` headers retain their existing override behavior. An explicit `--header 'OpenAI-Project: ...'` takes priority over these project defaults, including an empty header value. -Without a flag, environment project, or folder default, the SDK can use `OpenAI_CUSTOM_HEADERS` as a fallback. +Without a flag, environment project, profile project, or folder default, the SDK can use `OpenAI_CUSTOM_HEADERS` as a fallback. For repeated project headers in that environment variable, the last matching header wins. `link --project` saves a future folder default. -An existing `OPENAI_PROJECT_ID` still overrides that default on later API commands. -Link output identifies this override. +An existing `OPENAI_PROJECT_ID` or present selected-profile project still overrides that default on later API commands. +Link output identifies these overrides. Profile selection grants no project access. Inspection also identifies a supplied custom project header or the SDK's custom-header fallback. Custom headers are never saved in the folder registry. diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index 1f6269c7..c43e4f85 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net/http" "net/url" "os" "strings" @@ -118,51 +117,22 @@ func authStatusTextValue(value authStatusValue) string { } // Match the CLI and SDK option layers without constructing an SDK client or request. -// Snapshot the six relevant environment inputs once; never enumerate unrelated secrets. +// Snapshot relevant environment inputs once; never enumerate unrelated secrets. func resolveAuthStatus(root *cli.Command, lookup func(string) (string, bool)) (authStatusReport, error) { - environment := make(map[string]authStatusValue, 6) - for _, name := range []string{"OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS"} { - if value, present := lookup(name); present { - environment[name] = authStatusValue{Value: value, Source: "environment:" + name} - } - } + environment := snapshotContextEnvironment(root, lookup) literal, err := requestHeaders(root) if err != nil { return authStatusReport{}, &localUtilityError{message: "Invalid --header. Use 'Name: Value' without control characters."} } - // The SDK ignores environment lines without a colon and trims names and values. - inherited := make(http.Header) - for _, line := range strings.Split(environment["OPENAI_CUSTOM_HEADERS"].Value, "\n") { - if name, value, found := strings.Cut(line, ":"); found { - inherited.Set(strings.TrimSpace(name), strings.TrimSpace(value)) - } - } + inherited := inheritedContextHeaders(environment) setting := func(flag, variable string) authStatusValue { - source := "unset" - if env, present := environment[variable]; present { - source = env.Source - } - for _, declared := range root.Flags { - if f, ok := declared.(*rootRequestFlag); ok && f.Name == flag && f.explicitlySet { - source = "flag:--" + flag - break - } - } - if root.IsSet(flag) { - return authStatusValue{Value: root.String(flag), Source: source} - } - if env, present := environment[variable]; present { - return env - } - return authStatusValue{Source: "unset"} - } - base := environment["OPENAI_BASE_URL"] - if base.Source == "" { - base = authStatusValue{Value: "https://api.openai.com/v1/", Source: "default"} + return requestContextSetting(root, environment, flag, variable) } - if value := root.String("base-url"); value != "" { - base = authStatusValue{Value: value, Source: "flag:--base-url"} + resolved, err := resolveRequestContext(requestResolutionContext(root), root, environment, literal) + if err != nil { + return authStatusReport{}, err } + base := resolved.BaseURL endpoint, basic, err := authStatusBaseURL(base) if err != nil { return authStatusReport{}, err @@ -190,25 +160,12 @@ func resolveAuthStatus(root *cli.Command, lookup func(string) (string, bool)) (a } return result } - contextValue := func(flag, variable, header string) authStatusValue { - if literal.Values(header) != nil { - return authStatusValue{Value: literal.Get(header), Source: "flag:--header"} - } - value := setting(flag, variable) - if value.Source != "unset" { - return value - } - if inherited.Values(header) != nil { - return authStatusValue{Value: inherited.Get(header), Source: "environment:OPENAI_CUSTOM_HEADERS"} - } - return value - } return authStatusReport{ Offline: true, API: credential("api-key", "OPENAI_API_KEY", "api_key"), Admin: credential("admin-api-key", "OPENAI_ADMIN_KEY", "admin_api_key"), - Organization: contextValue("organization", "OPENAI_ORG_ID", "OpenAI-Organization"), - Project: contextValue("project", "OPENAI_PROJECT_ID", "OpenAI-Project"), BaseURL: endpoint, + Organization: resolved.Organization, + Project: resolved.Project, BaseURL: endpoint, }, nil } @@ -233,10 +190,10 @@ func authStatusBaseURL(base authStatusValue) (authStatusEndpoint, bool, error) { return authStatusEndpoint{authStatusValue: authStatusValue{Value: safe, Source: base.Source}, Redacted: redacted}, parsed.User != nil, nil } -// Identify the declared auth root before optional shell setup can spawn a worker. +// Identify local context commands before optional shell setup can spawn a worker. // Consume root flag values by declaration, so a request value named "auth" does not match. -func skipAuthFirstRunSetup(root *cli.Command, args []string) bool { - if root == nil || len(args) < 2 || root.Command("auth") == nil { +func skipContextFirstRunSetup(root *cli.Command, args []string) bool { + if root == nil || len(args) < 2 || root.Command("auth") == nil && root.Command("profile") == nil { return false } lineage := []*cli.Command{root} @@ -268,7 +225,7 @@ func skipAuthFirstRunSetup(root *cli.Command, args []string) bool { continue } selected := root.Command(arg) - return selected != nil && selected == root.Command("auth") + return selected != nil && (selected == root.Command("auth") || selected == root.Command("profile")) } return false } diff --git a/pkg/custom/cmdutil.go b/pkg/custom/cmdutil.go index be46d539..f4e7bc65 100644 --- a/pkg/custom/cmdutil.go +++ b/pkg/custom/cmdutil.go @@ -61,19 +61,12 @@ func GetDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { if cmd.IsSet("admin-api-key") { opts = append(opts, option.WithAdminAPIKey(cmd.String("admin-api-key"))) } - if cmd.IsSet("organization") { - opts = append(opts, option.WithOrganization(cmd.String("organization"))) - } - // Endpoint body fields named project must not select the request's project context. - opts = append(opts, projectLinkRequestOptions(cmd)...) + // Resolve nonsecret context once, preserving root flag and endpoint ownership. + opts = append(opts, resolvedRequestOptions(cmd)...) if cmd.IsSet("webhook-secret") { opts = append(opts, option.WithWebhookSecret(cmd.String("webhook-secret"))) } - // Override base URL if the --base-url flag is provided - if baseURL := cmd.String("base-url"); baseURL != "" { - opts = append(opts, option.WithBaseURL(baseURL)) - } if httpClient, ok := cmd.Root().Metadata[mtlsHTTPClientMetadata].(*http.Client); ok { opts = append(opts, option.WithHTTPClient(httpClient)) } diff --git a/pkg/custom/command.go b/pkg/custom/command.go index 55ee2cd7..9adfc7c6 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -42,6 +42,8 @@ func ConfigureCommand(root *cli.Command) { registerCodexCommands(root) registerProjectLinkCommands(root) registerAuthCommands(root) + configureProfileContext(root) + registerProfileCommands(root) configureCommandPresentation(root) configureManpageCommands(root) configureOutputPolicy(root) diff --git a/pkg/custom/image_picker_first_run.go b/pkg/custom/image_picker_first_run.go index b9f28db9..b62a5d04 100644 --- a/pkg/custom/image_picker_first_run.go +++ b/pkg/custom/image_picker_first_run.go @@ -28,7 +28,7 @@ func SetupImagePickerShellOnFirstRun(ctx context.Context, args []string, roots . } // Read command declarations before the optional worker starts. CLI parsing // may mutate them after the foreground timeout, so the worker borrows none. - if len(roots) > 0 && (skipAudioFirstRunSetup(roots[0], args) || skipAuthFirstRunSetup(roots[0], args)) { + if len(roots) > 0 && (skipAudioFirstRunSetup(roots[0], args) || skipContextFirstRunSetup(roots[0], args)) { return } args = append([]string(nil), args...) diff --git a/pkg/custom/image_picker_first_run_test.go b/pkg/custom/image_picker_first_run_test.go index 4240fdec..aea037fc 100644 --- a/pkg/custom/image_picker_first_run_test.go +++ b/pkg/custom/image_picker_first_run_test.go @@ -313,12 +313,18 @@ func TestImagePickerFirstRunConcurrentSetupAndCanceledRun(t *testing.T) { func TestAuthStatusSkipsFirstRunSetupByDeclaredCommand(t *testing.T) { root := authStatusTestRoot(nil) + configureProfileContext(root) + registerProfileCommands(root) root.Commands = append(root.Commands, &cli.Command{Name: "models", Commands: []*cli.Command{{Name: "list"}}}) for _, tc := range []struct { args []string want bool }{ {[]string{"openai", "auth", "status"}, true}, + {[]string{"openai", "profile", "list"}, true}, + {[]string{"openai", "--profile", "work", "auth", "switch", "--to-project", "proj_next"}, true}, + {[]string{"openai", "--profile", "profile", "models", "list"}, false}, + {[]string{"openai", "help", "profile", "list"}, true}, {[]string{"openai", "--api-key", "synthetic", "auth", "status"}, true}, {[]string{"openai", "--api-key=synthetic", "auth", "--format=json", "status"}, true}, {[]string{"openai", "help", "auth", "status"}, true}, @@ -334,8 +340,8 @@ func TestAuthStatusSkipsFirstRunSetupByDeclaredCommand(t *testing.T) { {[]string{"openai", "--api-key", "auth"}, false}, {[]string{"openai"}, false}, } { - require.Equal(t, tc.want, skipAuthFirstRunSetup(root, tc.args), "%q", tc.args) + require.Equal(t, tc.want, skipContextFirstRunSetup(root, tc.args), "%q", tc.args) } - require.False(t, skipAuthFirstRunSetup(nil, []string{"openai", "auth", "status"})) - require.False(t, skipAuthFirstRunSetup(&cli.Command{}, []string{"openai", "auth", "status"})) + require.False(t, skipContextFirstRunSetup(nil, []string{"openai", "auth", "status"})) + require.False(t, skipContextFirstRunSetup(&cli.Command{}, []string{"openai", "auth", "status"})) } diff --git a/pkg/custom/profile_commands.go b/pkg/custom/profile_commands.go new file mode 100644 index 00000000..d8cc03d5 --- /dev/null +++ b/pkg/custom/profile_commands.go @@ -0,0 +1,368 @@ +package custom + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/openai/openai-cli/internal/readable" + "github.com/urfave/cli/v3" +) + +func registerProfileCommands(root *cli.Command) { + if root.Command("profile") != nil { + return + } + profile := profileCommand("profile", "Manage saved organization, project, and endpoint settings locally", "profile create work") + profile.Description = "Profiles store nonsecret settings only. No command contacts the API or changes credentials or permissions. " + + "Management commands ignore --profile and OPENAI_PROFILE. Active means the saved default." + profile.CustomHelpTemplate = strings.Replace(profile.CustomHelpTemplate, cli.CommandHelpTemplate, cli.SubcommandHelpTemplate, 1) + profile.Metadata["local-help-full"] = profile.CustomHelpTemplate + profile.Commands = []*cli.Command{ + profileCommand("create", "Create an empty profile without activating it", "profile create work"), + profileCommand("set", "Edit only explicit saved profile fields", "profile set work --project proj_demo"), + profileCommand("get", "Show one saved profile", "profile get work"), + profileCommand("list", "List saved profiles in name order", "profile list"), + profileCommand("activate", "Select a saved default profile", "profile activate work"), + profileCommand("delete", "Delete a saved profile", "profile delete work --deactivate"), + } + for _, command := range profile.Commands { + command.ArgsUsage = "NAME" + command.Action = handleProfileCommand + } + profile.Command("list").ArgsUsage = " " + set := profile.Command("set") + set.Description = "Only flags declared by profile set edit saved fields. Root request flags and environment values never persist. " + + "An empty value remains present; --unset removes presence. Supports --format auto, text, json, or jsonl." + set.DisableSliceFlagSeparator = true + set.Flags = []cli.Flag{ + &cli.StringFlag{Name: "organization", Usage: "Save organization `VALUE`; an empty value suppresses lower defaults", OnlyOnce: true}, + &cli.StringFlag{Name: "project", Usage: "Save project `VALUE`; an empty value suppresses lower defaults", OnlyOnce: true}, + &cli.StringFlag{Name: "base-url", Usage: "Save endpoint `URL` without user information, query, or fragment; empty selects production", OnlyOnce: true}, + &cli.StringSliceFlag{Name: "unset", Usage: "Remove saved `FIELD`: organization, project, or base_url; repeat for multiple fields"}, + } + profile.Command("delete").Flags = []cli.Flag{ + &cli.BoolFlag{Name: "deactivate", Usage: "Clear the saved default when deleting its active profile", OnlyOnce: true}, + } + root.Commands = append(root.Commands, profile) + if auth := root.Command("auth"); auth != nil && auth.Command("switch") == nil { + command := profileCommand("switch", "Change the selected profile's saved project locally", "--profile work auth switch --to-project proj_demo") + command.Description = "Select a profile with root --profile, present OPENAI_PROFILE, or the saved default. " + + "Save only --to-project; root --project and environment overrides remain unchanged. " + + "This does not activate the profile, contact the API, or change credentials or permissions. " + + "Supports --format auto, text, json, or jsonl." + command.Flags = []cli.Flag{ + &cli.StringFlag{Name: "to-project", Usage: "Save nonempty project `VALUE` in the selected profile", OnlyOnce: true}, + } + command.Action = handleProfileCommand + auth.Commands = append(auth.Commands, command) + } +} + +func profileCommand(name, usage, example string) *cli.Command { + help := `{{$bin := or (index .Root.Metadata "help-invocation") "openai"}}EXAMPLE + {{$bin}} ` + example + "\n\n" + cli.CommandHelpTemplate + localUtilityGlobalHelp + return &cli.Command{ + Name: name, Usage: usage, HideHelpCommand: true, CustomHelpTemplate: help, + Description: "Works locally without API requests. Supports --format auto, text, json, or jsonl.", + Metadata: map[string]any{ + localUtilityMetadata: true, "local-help-full": help, "help-command-section": "Local tools", + "completion-root-flag-values": map[string][]string{"format": {"auto", "text", "json", "jsonl"}}, + }, + } +} + +type profileRecord struct { + Name string `json:"name"` + Active bool `json:"active"` + Values profileValues `json:"values"` +} + +type profileReceipt struct { + Action string `json:"action"` + Profile profileRecord `json:"profile"` +} + +type profileDeleteReceipt struct { + Action string `json:"action"` + Name string `json:"name"` + Deactivated bool `json:"deactivated"` +} + +func handleProfileCommand(ctx context.Context, command *cli.Command) error { + if err := ctx.Err(); err != nil { + return err + } + root := command.Root() + format := strings.ToLower(root.String("format")) + if root.IsSet("transform") || root.IsSet("raw-output") { + return profileFailure("Profile commands do not support --transform or --raw-output. Use --format text, json, or jsonl.", nil) + } + if format != "auto" && format != "text" && format != "json" && format != "jsonl" { + return profileFailure("Profile commands support --format auto, text, json, or jsonl.", nil) + } + action, name := command.Name, command.Args().First() + if action == "list" || action == "switch" { + if command.Args().Present() { + return profileFailure("This profile command takes no positional arguments. Use its --help for an example.", nil) + } + } else if command.Args().Len() != 1 || !validProfileName(name) { + return profileFailure("Provide one nonempty profile name without controls or surrounding whitespace.", nil) + } + var edits map[string]*string + if action == "set" { + var err error + edits, err = explicitProfileEdits(command) + if err != nil { + return err + } + } + if action == "switch" && (!command.IsSet("to-project") || command.String("to-project") == "" || !validProfileHeader(command.String("to-project"))) { + return profileFailure("Use --to-project with a nonempty UTF-8 project value without controls. Select a profile with root --profile or activate one.", nil) + } + path, err := profileStorePath() + if err != nil { + return profileFailure("Could not find your configuration folder. Check HOME, APPDATA, or XDG_CONFIG_HOME.", err) + } + var result any + committed := action != "get" && action != "list" + if !committed { + registry, err := loadProfiles(ctx, path) + if err != nil { + return profileCommandStoreFailure(err) + } + if action == "list" { + names := make([]string, 0, len(registry.Profiles)) + for name := range registry.Profiles { + names = append(names, name) + } + slices.Sort(names) + records := make([]profileRecord, 0, len(names)) + for _, name := range names { + records = append(records, savedProfileRecord(registry, name)) + } + result = records + } else { + if _, exists := registry.Profiles[name]; !exists { + return profileFailure("That profile does not exist. Use openai profile list to inspect saved names.", nil) + } + result = savedProfileRecord(registry, name) + } + } else { + deactivated := false + registry, err := updateProfiles(ctx, path, func(registry *profileRegistry) error { + if action == "switch" { + var err error + // Resolve the saved default while holding the same lock as the edit. + name, err = selectedProfileName(root, os.LookupEnv, *registry) + if err != nil { + return err + } + if name == "" { + return profileFailure("No profile is selected. Use root --profile or activate a saved profile.", nil) + } + } + values, exists := registry.Profiles[name] + if action == "create" { + if exists { + return profileFailure("That profile already exists. Use openai profile set to edit its saved fields.", nil) + } + registry.Profiles[name] = profileValues{} + return nil + } + if !exists { + return profileFailure("That profile does not exist. Use openai profile list to inspect saved names.", nil) + } + switch action { + case "set": + for field, value := range edits { + switch field { + case "organization": + values.Organization = value + case "project": + values.Project = value + case "base_url": + values.BaseURL = value + } + } + registry.Profiles[name] = values + case "activate": + registry.DefaultProfile = &name + case "delete": + deactivated = registry.DefaultProfile != nil && *registry.DefaultProfile == name + if deactivated && !command.Bool("deactivate") { + return profileFailure("That profile is the saved default. Repeat with --deactivate to clear the default and delete it.", nil) + } + if deactivated { + registry.DefaultProfile = nil + } + delete(registry.Profiles, name) + case "switch": + project := command.String("to-project") + values.Project = &project + registry.Profiles[name] = values + } + return nil + }) + if err != nil { + return profileCommandStoreFailure(err) + } + if action == "delete" { + result = profileDeleteReceipt{Action: action, Name: name, Deactivated: deactivated} + } else { + result = profileReceipt{Action: action, Profile: savedProfileRecord(registry, name)} + } + } + if err := writeProfileResult(ctx, root.Writer, format, result); err != nil { + message := "Could not write saved profile data. This command did not change saved profiles." + if committed { + message = "Saved profiles were updated, but the result could not be written." + } + return profileFailure(message+" Inspect with "+errorHelpInvocation(root)+" profile list.", err) + } + return nil +} + +// Read declared local flags directly. An inherited root flag is never a saved edit. +func explicitProfileEdits(command *cli.Command) (map[string]*string, error) { + edits := make(map[string]*string) + for _, declared := range command.Flags { + flag, ok := declared.(*cli.StringFlag) + if !ok || !flag.IsSet() { + continue + } + field := flag.Name + switch field { + case "organization", "project": + case "base-url": + field = "base_url" + default: + continue + } + value := flag.Get().(string) + edits[field] = &value + } + for _, field := range command.StringSlice("unset") { + if field != "organization" && field != "project" && field != "base_url" { + return nil, profileFailure("Unknown --unset field. Use organization, project, or base_url.", nil) + } + if value, present := edits[field]; present && value != nil { + return nil, profileFailure("Do not set and unset the same profile field in one command.", nil) + } + edits[field] = nil + } + if len(edits) == 0 { + return nil, profileFailure("No saved edits were specified. Use profile set flags after the command name, or use --unset FIELD.", nil) + } + for field, value := range edits { + if value == nil { + continue + } + if field == "base_url" { + if !validProfileBaseURL(*value) { + return nil, profileFailure("Use an HTTP or HTTPS base URL without user information, query, or fragment. Empty selects production.", nil) + } + } else if !validProfileHeader(*value) { + return nil, profileFailure("Profile organization and project values must contain valid UTF-8 without controls.", nil) + } + } + return edits, nil +} + +func savedProfileRecord(registry profileRegistry, name string) profileRecord { + return profileRecord{Name: name, Active: registry.DefaultProfile != nil && *registry.DefaultProfile == name, Values: registry.Profiles[name]} +} + +func writeProfileResult(ctx context.Context, writer io.Writer, format string, result any) error { + out := outputWriter{ctx: ctx, out: writer} + if format == "json" || format == "jsonl" { + encoder := json.NewEncoder(out) + if records, ok := result.([]profileRecord); ok && format == "jsonl" { + for _, record := range records { + if err := encoder.Encode(record); err != nil { + return err + } + } + return ctx.Err() + } + return encoder.Encode(result) + } + var text strings.Builder + switch value := result.(type) { + case profileRecord: + writeProfileText(&text, value) + case []profileRecord: + for index, record := range value { + if index != 0 { + text.WriteByte('\n') + } + writeProfileText(&text, record) + } + if len(value) == 0 { + text.WriteString("No saved profiles.\n") + } + case profileReceipt: + fmt.Fprintf(&text, "Saved profile (%s).\n", value.Action) + writeProfileText(&text, value.Profile) + case profileDeleteReceipt: + fmt.Fprintf(&text, "Deleted profile: %s\n", profileTextValue(value.Name)) + if value.Deactivated { + text.WriteString("Saved default: cleared\n") + } + } + _, err := io.WriteString(out, text.String()) + return err +} + +func writeProfileText(out *strings.Builder, record profileRecord) { + active := "" + if record.Active { + active = " (saved default)" + } + fmt.Fprintf(out, "Profile: %s%s\n", profileTextValue(record.Name), active) + for _, field := range []struct { + label string + value *string + }{{"Organization", record.Values.Organization}, {"Project", record.Values.Project}, {"Base URL", record.Values.BaseURL}} { + value := "(unset)" + if field.value != nil { + value = profileTextValue(*field.value) + if *field.value == "" { + value = "(empty)" + } + } + fmt.Fprintf(out, "%s: %s\n", field.label, value) + } +} + +func profileTextValue(value string) string { + return strings.NewReplacer("\n", `\n`, "\t", `\t`).Replace(readable.Text(value)) +} + +func profileCommandStoreFailure(cause error) error { + var local *localUtilityError + if errors.As(cause, &local) { + return cause + } + recovery := "Check profiles.json and its lock in your user configuration folder." + switch { + case errors.Is(cause, errProfilesInvalid): + recovery = "Repair invalid JSON, fields, or default selection in profiles.json before retrying." + case errors.Is(cause, errProfilesTooLarge): + recovery = "Reduce profiles.json to at most 1 MiB before retrying." + case errors.Is(cause, errProfilesChanged): + recovery = "Stop other profile writers before retrying." + case errors.Is(cause, errProfileFileUnsafe): + recovery = "Use a private regular profiles.json owned by your user, without a symlink." + } + return profileFailure("Could not read or update saved profiles. "+recovery+" Saved profiles were not changed.", cause) +} + +func profileFailure(message string, cause error) error { + return &localUtilityError{message: message, cause: cause} +} diff --git a/pkg/custom/profile_commands_test.go b/pkg/custom/profile_commands_test.go new file mode 100644 index 00000000..73f6beed --- /dev/null +++ b/pkg/custom/profile_commands_test.go @@ -0,0 +1,314 @@ +package custom + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func profileCommandTestRoot(out io.Writer) *cli.Command { + root := authStatusTestRoot(out) + configureProfileContext(root) + registerProfileCommands(root) + configureOutputPolicy(root) + return root +} + +func profileCommandTestEnvironment(t *testing.T) string { + t.Helper() + home := t.TempDir() + for _, name := range []string{"HOME", "USERPROFILE", "APPDATA", "XDG_CONFIG_HOME"} { + t.Setenv(name, home) + } + authStatusTestEnvironment(t, nil) + t.Setenv("OPENAI_PROFILE", "") + require.NoError(t, os.Unsetenv("OPENAI_PROFILE")) + path, err := profileStorePath() + require.NoError(t, err) + return path +} + +func runProfileCommand(t *testing.T, args ...string) string { + t.Helper() + var out bytes.Buffer + require.NoError(t, profileCommandTestRoot(&out).Run(t.Context(), append([]string{"openai"}, args...))) + return out.String() +} + +func TestProfileCommandLifecycle(t *testing.T) { + path := profileCommandTestEnvironment(t) + output := runProfileCommand(t, "--format=json", "profile", "create", "work") + require.JSONEq(t, `{"action":"create","profile":{"name":"work","active":false,"values":{}}}`, output) + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Nil(t, registry.DefaultProfile) + require.Equal(t, profileValues{}, registry.Profiles["work"]) + before, err := os.ReadFile(path) + require.NoError(t, err) + require.ErrorContains(t, profileCommandTestRoot(io.Discard).Run(t.Context(), []string{"openai", "profile", "create", "work"}), "already exists") + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + + runProfileCommand(t, "profile", "set", "work", "--organization=", "--project=proj_demo", "--base-url=") + output = runProfileCommand(t, "--format=jsonl", "profile", "get", "work") + require.JSONEq(t, `{"name":"work","active":false,"values":{"organization":"","project":"proj_demo","base_url":""}}`, output) + require.Equal(t, 1, strings.Count(output, "\n")) + output = runProfileCommand(t, "--format=json", "profile", "activate", "work") + require.JSONEq(t, `{"action":"activate","profile":{"name":"work","active":true,"values":{"organization":"","project":"proj_demo","base_url":""}}}`, output) + before, err = os.ReadFile(path) + require.NoError(t, err) + require.ErrorContains(t, profileCommandTestRoot(io.Discard).Run(t.Context(), []string{"openai", "profile", "delete", "work"}), "--deactivate") + after, err = os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + output = runProfileCommand(t, "--format=json", "profile", "delete", "work", "--deactivate") + require.JSONEq(t, `{"action":"delete","name":"work","deactivated":true}`, output) + registry, err = loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Nil(t, registry.DefaultProfile) + require.Empty(t, registry.Profiles) +} + +func TestProfileSetOnlyPersistsLocalExplicitEdits(t *testing.T) { + path := profileCommandTestEnvironment(t) + t.Setenv("OPENAI_ORG_ID", "org_environment") + t.Setenv("OPENAI_PROJECT_ID", "proj_environment") + t.Setenv("OPENAI_BASE_URL", "https://environment.invalid/v1") + t.Setenv("OPENAI_PROFILE", "unknown") + runProfileCommand(t, "--project=proj_root", "--organization=org_root", "profile", "create", "work") + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, profileValues{}, registry.Profiles["work"]) + for _, flags := range [][]string{nil, {"--project=proj_root"}, {"--organization=org_root"}, {"--base-url=https://root.invalid/v1"}} { + args := append([]string{"openai"}, flags...) + args = append(args, "profile", "set", "work") + require.ErrorContains(t, profileCommandTestRoot(io.Discard).Run(t.Context(), args), "No saved edits") + } + runProfileCommand(t, "--project=proj_root", "--base-url=https://root.invalid/v1", "profile", "set", "work", "--organization=org_saved") + registry, err = loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, "org_saved", *registry.Profiles["work"].Organization) + require.Nil(t, registry.Profiles["work"].Project) + require.Nil(t, registry.Profiles["work"].BaseURL) + runProfileCommand(t, "profile", "set", "work", "--project=", "--base-url=", "--unset", "organization") + registry, err = loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Nil(t, registry.Profiles["work"].Organization) + require.Equal(t, "", *registry.Profiles["work"].Project) + require.Equal(t, "", *registry.Profiles["work"].BaseURL) + runProfileCommand(t, "profile", "set", "work", "--unset", "project", "--unset", "base_url", "--unset", "project") + registry, err = loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, profileValues{}, registry.Profiles["work"]) +} + +func TestProfileCommandsRejectBeforeMutation(t *testing.T) { + path := profileCommandTestEnvironment(t) + for _, args := range [][]string{ + {"profile", "create", "work", "--format=yaml"}, + {"profile", "create", "work", "--transform=name"}, + {"profile", "create", "work", "--raw-output=false"}, + {"profile", "create"}, {"profile", "create", ""}, + {"profile", "create", " work"}, {"profile", "create", "work\nspoof"}, + {"profile", "create", "work", "extra"}, {"profile", "list", "extra"}, + {"auth", "switch"}, {"auth", "switch", "--to-project="}, + {"auth", "switch", "--to-project=proj_demo", "extra"}, + } { + var out bytes.Buffer + require.Error(t, profileCommandTestRoot(&out).Run(t.Context(), append([]string{"openai"}, args...)), "%q", args) + require.Empty(t, out.String(), "%q", args) + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + } + runProfileCommand(t, "profile", "create", "work") + before, err := os.ReadFile(path) + require.NoError(t, err) + for _, flags := range [][]string{ + {"--project=proj_demo", "--unset=project"}, + {"--base-url=", "--unset=base_url"}, + {"--unset=api_key"}, {"--unset=project,organization"}, + {"--base-url=https://user:synthetic-secret@example.invalid/v1"}, + {"--base-url=https://example.invalid/v1?token=synthetic-secret"}, + {"--base-url=https://example.invalid/v1#synthetic-secret"}, + } { + args := append([]string{"openai", "profile", "set", "work"}, flags...) + err := profileCommandTestRoot(io.Discard).Run(t.Context(), args) + require.Error(t, err, "%q", flags) + require.NotContains(t, err.Error(), "synthetic-secret") + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + } +} + +func TestProfileListOrderingFormatsAndSelectorRecovery(t *testing.T) { + profileCommandTestEnvironment(t) + t.Setenv("OPENAI_PROFILE", "missing") + require.JSONEq(t, `[]`, runProfileCommand(t, "--format=json", "profile", "list")) + require.Empty(t, runProfileCommand(t, "--format=jsonl", "profile", "list")) + for _, name := range []string{"é", "work", "Work", "work/qa"} { + runProfileCommand(t, "--profile=also-missing", "profile", "create", name) + } + runProfileCommand(t, "profile", "activate", "work") + var records []profileRecord + require.NoError(t, json.Unmarshal([]byte(runProfileCommand(t, "--format=json", "profile", "list")), &records)) + var names []string + for _, record := range records { + names = append(names, record.Name) + require.Equal(t, record.Name == "work", record.Active) + } + require.Equal(t, []string{"Work", "work", "work/qa", "é"}, names) + lines := strings.Split(strings.TrimSuffix(runProfileCommand(t, "--format=jsonl", "profile", "list"), "\n"), "\n") + require.Len(t, lines, len(records)) + for index, line := range lines { + var record profileRecord + require.NoError(t, json.Unmarshal([]byte(line), &record)) + require.Equal(t, records[index], record) + } + runProfileCommand(t, "profile", "set", "work", "--project=proj_demo") + runProfileCommand(t, "profile", "get", "work") + require.JSONEq(t, `{"action":"delete","name":"Work","deactivated":false}`, runProfileCommand(t, "--format=json", "profile", "delete", "Work")) +} + +func TestAuthSwitchMutatesOnlySelectedProject(t *testing.T) { + path := profileCommandTestEnvironment(t) + for _, name := range []string{"default", "environment", "explicit"} { + runProfileCommand(t, "profile", "create", name) + runProfileCommand(t, "profile", "set", name, "--organization=org_demo", "--project=proj_old", "--base-url=https://example.invalid/v1") + } + runProfileCommand(t, "profile", "activate", "default") + t.Setenv("OPENAI_PROJECT_ID", "proj_environment_override") + for _, tc := range []struct { + selector []string + env *string + name string + }{ + {name: "default"}, + {name: "environment", env: profileCommandString("environment")}, + {name: "explicit", env: profileCommandString("environment"), selector: []string{"--profile=explicit"}}, + } { + if tc.env != nil { + t.Setenv("OPENAI_PROFILE", *tc.env) + } + args := append([]string{"--format=jsonl", "--project=proj_root_override"}, tc.selector...) + args = append(args, "auth", "switch", "--to-project=proj_new") + out := runProfileCommand(t, args...) + var receipt profileReceipt + require.NoError(t, json.Unmarshal([]byte(out), &receipt)) + require.Equal(t, "switch", receipt.Action) + require.Equal(t, tc.name, receipt.Profile.Name) + require.Equal(t, tc.name == "default", receipt.Profile.Active) + require.Equal(t, "proj_new", *receipt.Profile.Values.Project) + require.Equal(t, "org_demo", *receipt.Profile.Values.Organization) + require.Equal(t, "https://example.invalid/v1", *receipt.Profile.Values.BaseURL) + require.Equal(t, 1, strings.Count(out, "\n")) + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, "default", *registry.DefaultProfile) + require.Equal(t, "proj_environment_override", os.Getenv("OPENAI_PROJECT_ID")) + } + before, err := os.ReadFile(path) + require.NoError(t, err) + for _, selector := range []string{"--profile=", "--profile=missing"} { + require.Error(t, profileCommandTestRoot(io.Discard).Run(t.Context(), []string{"openai", selector, "auth", "switch", "--to-project=proj_bad"})) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + } +} + +func profileCommandString(value string) *string { return &value } + +func TestProfileCommandsPreserveReceiptsUnderQuiet(t *testing.T) { + profileCommandTestEnvironment(t) + for _, format := range []string{"auto", "text", "json", "jsonl"} { + out := runProfileCommand(t, "--quiet", "--format="+format, "profile", "create", format) + require.NotEmpty(t, out) + if format == "json" || format == "jsonl" { + var receipt profileReceipt + require.NoError(t, json.Unmarshal([]byte(out), &receipt)) + require.Equal(t, "create", receipt.Action) + } else { + require.Contains(t, out, "Saved profile (create).") + } + } +} + +func TestProfileCommandsOutputFailureAndCancellation(t *testing.T) { + path := profileCommandTestEnvironment(t) + failure := errors.New("synthetic output failure") + err := profileCommandTestRoot(authStatusFailWriter{err: failure}).Run(t.Context(), []string{"openai", "profile", "create", "work"}) + require.ErrorIs(t, err, failure) + require.ErrorContains(t, err, "Saved profiles were updated") + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Contains(t, registry.Profiles, "work") + err = profileCommandTestRoot(authStatusFailWriter{}).Run(t.Context(), []string{"openai", "--format=json", "profile", "get", "work"}) + require.ErrorIs(t, err, io.ErrShortWrite) + require.ErrorContains(t, err, "did not change saved profiles") + before, err := os.ReadFile(path) + require.NoError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err = profileCommandTestRoot(io.Discard).Run(ctx, []string{"openai", "profile", "delete", "work"}) + require.ErrorIs(t, err, context.Canceled) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) +} + +func TestProfileCommandsKeepStoreErrorsPrivate(t *testing.T) { + path := profileCommandTestEnvironment(t) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + data := []byte(`{"version":1,"default_profile":null,"profiles":{},"api_key":"synthetic-secret"}`) + require.NoError(t, os.WriteFile(path, data, 0600)) + err := profileCommandTestRoot(io.Discard).Run(t.Context(), []string{"openai", "profile", "create", "work"}) + require.Error(t, err) + require.NotContains(t, err.Error(), "synthetic-secret") + require.NotContains(t, err.Error(), filepath.Dir(path)) + require.Contains(t, err.Error(), "profiles.json") + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, data, after) +} + +func TestProfileTextEscapesEveryExternalField(t *testing.T) { + value := "synthetic\x1b[31m\nspoof\tcolumn\r" + var out bytes.Buffer + err := writeProfileResult(t.Context(), &out, "text", profileRecord{ + Name: value, Values: profileValues{Organization: &value, Project: &value, BaseURL: &value}, + }) + require.NoError(t, err) + require.NotContains(t, out.String(), "\x1b") + require.NotContains(t, out.String(), "\nspoof") + require.NotContains(t, out.String(), "\t") + require.NotContains(t, out.String(), "\r") + require.Equal(t, 4, strings.Count(out.String(), `\nspoof\tcolumn`)) +} + +func TestProfileHelpDescribesLocalFlagOwnership(t *testing.T) { + path := profileCommandTestEnvironment(t) + var out bytes.Buffer + root := profileCommandTestRoot(&out) + require.NoError(t, root.Run(t.Context(), []string{"openai", "profile", "set", "--help"})) + require.Contains(t, out.String(), "openai profile set work --project proj_demo") + require.Equal(t, 1, strings.Count(out.String(), "EXAMPLE")) + require.Contains(t, out.String(), "Root request flags and environment values never persist") + require.Contains(t, out.String(), "base_url") + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + for _, name := range []string{"create", "set", "get", "list", "activate", "delete"} { + command := root.Command("profile").Command(name) + require.Equal(t, map[string][]string{"format": {"auto", "text", "json", "jsonl"}}, command.Metadata["completion-root-flag-values"]) + } +} diff --git a/pkg/custom/profile_context.go b/pkg/custom/profile_context.go new file mode 100644 index 00000000..5bd1521c --- /dev/null +++ b/pkg/custom/profile_context.go @@ -0,0 +1,255 @@ +package custom + +import ( + "context" + "net/http" + "net/url" + "os" + "slices" + "strings" + + "github.com/openai/openai-go/v3/option" + "github.com/urfave/cli/v3" +) + +const productionProfileURL = "https://api.openai.com/v1/" + +// Keep the snapshot on the declared root flag, never in process-global state. +// PreParse clears it when a command tree is reused for another invocation. +type profileSelectorFlag struct { + *cli.StringFlag + ctx context.Context + resolved *resolvedRequestContext + err error +} + +func (f *profileSelectorFlag) PreParse() error { + f.ctx, f.resolved, f.err = nil, nil, nil + return f.StringFlag.PreParse() +} + +func configureProfileContext(root *cli.Command) { + flag := &profileSelectorFlag{StringFlag: &cli.StringFlag{ + Name: "profile", Usage: "Use a saved nonsecret profile; empty disables the saved default (OPENAI_PROFILE)", + }} + root.Flags = append(root.Flags, flag) + previous := root.Before + root.Before = func(ctx context.Context, command *cli.Command) (context.Context, error) { + if previous != nil { + var err error + ctx, err = previous(ctx, command) + if err != nil { + return ctx, err + } + } + flag.ctx = ctx + return ctx, nil + } +} + +func rootProfileSelector(root *cli.Command) *profileSelectorFlag { + for _, flag := range root.Flags { + if selected, ok := flag.(*profileSelectorFlag); ok { + return selected + } + } + return nil +} + +func selectedProfileName(root *cli.Command, lookup func(string) (string, bool), registry profileRegistry) (string, error) { + var name string + if root.IsSet("profile") { + name = root.String("profile") + } else if value, present := lookup("OPENAI_PROFILE"); present { + name = value + } else if registry.DefaultProfile != nil { + name = *registry.DefaultProfile + } + if name != "" { + if _, exists := registry.Profiles[name]; !exists { + return "", &localUtilityError{message: "Selected profile does not exist. Use openai profile list or create the profile first."} + } + } + return name, nil +} + +func resolveSelectedProfile(ctx context.Context, root *cli.Command, lookup func(string) (string, bool)) (string, profileValues, error) { + if rootProfileSelector(root) == nil { + return "", profileValues{}, nil + } + selector, selected := lookup("OPENAI_PROFILE") + if root.IsSet("profile") { + selector, selected = root.String("profile"), true + } + if selected && selector == "" { + return "", profileValues{}, nil + } + path, err := profileStorePath() + if err != nil && !selected { + // Services without a user configuration directory retain SDK defaults. + return "", profileValues{}, nil + } + if err != nil { + return "", profileValues{}, &localUtilityError{message: "Could not locate saved profiles. Check your user configuration directory.", cause: err} + } + registry, err := loadProfiles(ctx, path) + if err != nil { + return "", profileValues{}, &localUtilityError{message: "Could not read saved profiles. Existing profiles were kept.", cause: err} + } + name, err := selectedProfileName(root, lookup, registry) + return name, registry.Profiles[name], err +} + +// The same snapshot and presence rules drive requests and offline status. +func snapshotContextEnvironment(root *cli.Command, lookup func(string) (string, bool)) map[string]authStatusValue { + names := []string{"OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS"} + if rootProfileSelector(root) != nil { + names = append(names, "OPENAI_PROFILE") + } + environment := make(map[string]authStatusValue, len(names)) + for _, name := range names { + if value, present := lookup(name); present { + environment[name] = authStatusValue{Value: value, Source: "environment:" + name} + } + } + return environment +} + +func requestContextSetting(root *cli.Command, environment map[string]authStatusValue, flag, variable string) authStatusValue { + value := authStatusValue{Source: "unset"} + if env, present := environment[variable]; present { + value = env + } + for _, declared := range root.Flags { + if explicit, ok := declared.(interface{ ExplicitRequestFlag() bool }); ok && + slices.Contains(declared.Names(), flag) && explicit.ExplicitRequestFlag() { + return authStatusValue{Value: root.String(flag), Source: "flag:--" + flag} + } + } + if root.IsSet(flag) { + value.Value = root.String(flag) + if value.Source == "unset" { + value.Source = "flag:--" + flag + } + } + return value +} + +func inheritedContextHeaders(environment map[string]authStatusValue) http.Header { + headers := make(http.Header) + for _, line := range strings.Split(environment["OPENAI_CUSTOM_HEADERS"].Value, "\n") { + if name, value, found := strings.Cut(line, ":"); found { + headers.Set(strings.TrimSpace(name), strings.TrimSpace(value)) + } + } + return headers +} + +type resolvedRequestContext struct { + Organization authStatusValue + Project authStatusValue + BaseURL authStatusValue +} + +func resolveRequestContext(ctx context.Context, root *cli.Command, environment map[string]authStatusValue, literal http.Header) (resolvedRequestContext, error) { + lookup := func(name string) (string, bool) { value, ok := environment[name]; return value.Value, ok } + name, profile, err := resolveSelectedProfile(ctx, root, lookup) + if err != nil { + return resolvedRequestContext{}, err + } + inherited := inheritedContextHeaders(environment) + field := func(flag, variable, header string, saved *string) authStatusValue { + if literal.Values(header) != nil { + return authStatusValue{Value: literal.Get(header), Source: "flag:--header"} + } + value := requestContextSetting(root, environment, flag, variable) + if value.Source != "unset" { + return value + } + if saved != nil { + return authStatusValue{Value: *saved, Source: "profile:" + name} + } + return value + } + result := resolvedRequestContext{ + Organization: field("organization", "OPENAI_ORG_ID", "OpenAI-Organization", profile.Organization), + Project: field("project", "OPENAI_PROJECT_ID", "OpenAI-Project", profile.Project), + BaseURL: authStatusValue{Value: productionProfileURL, Source: "default"}, + } + if result.Project.Source == "unset" { + project, err := resolveFolderProject(ctx, root) + if err != nil { + return resolvedRequestContext{}, err + } + if project != "" { + result.Project = authStatusValue{Value: project, Source: "folder"} + } + } + for header, value := range map[string]*authStatusValue{"OpenAI-Organization": &result.Organization, "OpenAI-Project": &result.Project} { + if value.Source == "unset" && inherited.Values(header) != nil { + *value = authStatusValue{Value: inherited.Get(header), Source: "environment:OPENAI_CUSTOM_HEADERS"} + } + } + if profile.BaseURL != nil { + value := *profile.BaseURL + if value == "" { + value = productionProfileURL + } else if parsed, err := url.Parse(value); err == nil && parsed.Path != "" && !strings.HasSuffix(parsed.Path, "/") { + parsed.Path += "/" + value = parsed.String() + } + result.BaseURL = authStatusValue{Value: value, Source: "profile:" + name} + } + if env, present := environment["OPENAI_BASE_URL"]; present { + result.BaseURL = env + } + if value := root.String("base-url"); value != "" { + result.BaseURL = authStatusValue{Value: value, Source: "flag:--base-url"} + } + return result, nil +} + +func requestResolutionContext(root *cli.Command) context.Context { + if selected := rootProfileSelector(root); selected != nil && selected.ctx != nil { + return selected.ctx + } + for _, flag := range root.Flags { + if project, ok := flag.(*projectLinkFlag); ok && project.context != nil { + return project.context + } + } + return context.Background() +} + +func resolvedRequestOptions(command *cli.Command) []option.RequestOption { + root := command.Root() + selected := rootProfileSelector(root) + var result resolvedRequestContext + var err error + if selected != nil && selected.resolved != nil { + result, err = *selected.resolved, selected.err + } else { + var literal http.Header + literal, err = requestHeaders(root) + if err == nil { + result, err = resolveRequestContext(requestResolutionContext(root), root, snapshotContextEnvironment(root, os.LookupEnv), literal) + } + if selected != nil { + selected.resolved, selected.err = &result, err + } + } + if err != nil { + return []option.RequestOption{option.WithMaxRetries(0), option.WithMiddleware(func(*http.Request, option.MiddlewareNext) (*http.Response, error) { return nil, err })} + } + var options []option.RequestOption + if result.Organization.Source != "unset" { + options = append(options, option.WithOrganization(result.Organization.Value)) + } + if result.Project.Source != "unset" { + options = append(options, option.WithProject(result.Project.Value)) + } + if strings.HasPrefix(result.BaseURL.Source, "profile:") || result.BaseURL.Source == "flag:--base-url" { + options = append(options, option.WithBaseURL(result.BaseURL.Value)) + } + return options +} diff --git a/pkg/custom/profile_context_test.go b/pkg/custom/profile_context_test.go new file mode 100644 index 00000000..08fccd8d --- /dev/null +++ b/pkg/custom/profile_context_test.go @@ -0,0 +1,111 @@ +package custom + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func profileContextTestRoot(t *testing.T) *cli.Command { + t.Helper() + root := authStatusTestRoot(io.Discard) + registerProjectLinkCommands(root) + configureProfileContext(root) + return root +} + +func TestProfileContextWrappedFlagMatchesRequest(t *testing.T) { + projectLinkTestEnvironment(t) + authStatusTestEnvironment(t, map[string]string{"OPENAI_CUSTOM_HEADERS": "OpenAI-Project: proj_header"}) + root := profileContextTestRoot(t) + root.Commands = append(root.Commands, &cli.Command{Name: "probe", Action: func(ctx context.Context, command *cli.Command) error { + report, err := resolveAuthStatus(root, os.LookupEnv) + require.NoError(t, err) + require.Equal(t, authStatusValue{Value: "proj_flag", Source: "flag:--project"}, report.Project) + var got string + options := append(GetDefaultRequestOptions(command), option.WithHTTPClient(profileContextTransport(func(request *http.Request) { + got = request.Header.Get("OpenAI-Project") + }))) + client := openai.NewClient(options...) + _, err = client.Models.List(ctx) + require.NoError(t, err) + require.Equal(t, report.Project.Value, got) + return nil + }}) + require.NoError(t, root.Run(t.Context(), []string{"openai", "--project", "proj_flag", "probe"})) +} + +type profileContextTransport func(*http.Request) + +func (capture profileContextTransport) Do(request *http.Request) (*http.Response, error) { + capture(request) + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[],"object":"list"}`)), Request: request}, nil +} + +func TestProfileContextSnapshotAndCommandReuse(t *testing.T) { + projectLinkTestEnvironment(t) + authStatusTestEnvironment(t, nil) + t.Setenv("OPENAI_PROFILE", "work") + path, err := profileStorePath() + require.NoError(t, err) + first, second := "proj_first", "proj_second" + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["work"] = profileValues{Project: &first} + return nil + }) + require.NoError(t, err) + root := profileContextTestRoot(t) + var got []string + root.Commands = append(root.Commands, &cli.Command{Name: "probe", Action: func(ctx context.Context, command *cli.Command) error { + for range 2 { + options := append(GetDefaultRequestOptions(command), option.WithHTTPClient(profileContextTransport(func(request *http.Request) { + got = append(got, request.Header.Get("OpenAI-Project")) + }))) + client := openai.NewClient(options...) + _, err := client.Models.List(ctx) + if err != nil { + return err + } + _, err = updateProfiles(ctx, path, func(registry *profileRegistry) error { + registry.Profiles["work"] = profileValues{Project: &second} + return nil + }) + if err != nil { + return err + } + } + return nil + }}) + require.NoError(t, root.Run(t.Context(), []string{"openai", "probe"})) + require.Equal(t, []string{first, first}, got) + require.NoError(t, root.Run(t.Context(), []string{"openai", "--project=", "probe"})) + require.Equal(t, []string{first, first, "", ""}, got) +} + +func TestProfileContextEnvironmentSnapshot(t *testing.T) { + projectLinkTestEnvironment(t) + authStatusTestEnvironment(t, nil) + root := profileContextTestRoot(t) + calls := make(map[string]int) + root.Command("auth").Command("status").Action = func(ctx context.Context, command *cli.Command) error { + _, err := resolveAuthStatus(root, func(name string) (string, bool) { + calls[name]++ + return "", false + }) + return err + } + require.NoError(t, root.Run(t.Context(), []string{"openai", "auth", "status"})) + require.Len(t, calls, 7) + for name, count := range calls { + require.Equal(t, 1, count, name) + } +} diff --git a/pkg/custom/profile_store.go b/pkg/custom/profile_store.go new file mode 100644 index 00000000..16d1a0cd --- /dev/null +++ b/pkg/custom/profile_store.go @@ -0,0 +1,344 @@ +package custom + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +// This bound applies only to profiles.json, never API bodies or streaming events. +const maxProfileBytes = 1 << 20 + +var ( + errProfilesInvalid = errors.New("invalid profiles; existing profiles were kept") + errProfilesChanged = errors.New("profiles changed during the operation; retry the command") + errProfilesTooLarge = errors.New("profiles exceed the 1 MiB local registry limit; existing profiles were kept") + errProfileFileUnsafe = errors.New("profiles must be a private regular file owned by the current user") +) + +type profileValues struct { + Organization *string `json:"organization,omitempty"` + Project *string `json:"project,omitempty"` + BaseURL *string `json:"base_url,omitempty"` +} + +type profileRegistry struct { + Version int `json:"version"` + DefaultProfile *string `json:"default_profile"` + Profiles map[string]profileValues `json:"profiles"` +} + +func profileStorePath() (string, error) { + directory, err := os.UserConfigDir() + if err != nil { + return "", err + } + if !filepath.IsAbs(directory) { + return "", errors.New("profiles require an absolute user configuration directory") + } + return filepath.Join(directory, "openai", "profiles.json"), nil +} + +func emptyProfileRegistry() profileRegistry { + return profileRegistry{Version: 1, Profiles: make(map[string]profileValues)} +} + +func validProfileName(name string) bool { + return name != "" && strings.TrimSpace(name) == name && validProfileHeader(name) +} + +func validProfileHeader(value string) bool { + return utf8.ValidString(value) && !strings.ContainsFunc(value, unicode.IsControl) +} + +func validProfileBaseURL(value string) bool { + if value == "" { + return true + } + endpoint, err := url.Parse(value) + return err == nil && validProfileHeader(value) && endpoint.Hostname() != "" && + (endpoint.Scheme == "http" || endpoint.Scheme == "https") && endpoint.Opaque == "" && + endpoint.User == nil && endpoint.RawQuery == "" && !endpoint.ForceQuery && !strings.Contains(value, "#") +} + +func validateProfileRegistry(registry profileRegistry) error { + if registry.Version != 1 || registry.Profiles == nil { + return errProfilesInvalid + } + if registry.DefaultProfile != nil { + if _, ok := registry.Profiles[*registry.DefaultProfile]; !ok { + return errProfilesInvalid + } + } + for name, values := range registry.Profiles { + if !validProfileName(name) || + (values.Organization != nil && !validProfileHeader(*values.Organization)) || + (values.Project != nil && !validProfileHeader(*values.Project)) || + (values.BaseURL != nil && !validProfileBaseURL(*values.BaseURL)) { + return errProfilesInvalid + } + } + return nil +} + +// Token decoding rejects duplicate keys and null field values before Go's JSON +// decoder can replace them with indistinguishable zero values. +func decodeProfiles(data []byte) (profileRegistry, error) { + var registry profileRegistry + if !utf8.Valid(data) { + return registry, errProfilesInvalid + } + decoder := json.NewDecoder(bytes.NewReader(data)) + fields := 0 + err := decodeProfileObject(decoder, func(key string) error { + fields++ + switch key { + case "version": + return decoder.Decode(®istry.Version) + case "default_profile": + value, err := decoder.Token() + if value == nil || err != nil { + return err + } + name, ok := value.(string) + if !ok { + return errProfilesInvalid + } + registry.DefaultProfile = &name + return nil + case "profiles": + registry.Profiles = make(map[string]profileValues) + return decodeProfileObject(decoder, func(name string) error { + var values profileValues + err := decodeProfileObject(decoder, func(field string) error { + value, err := decoder.Token() + text, ok := value.(string) + if err != nil || !ok { + return errProfilesInvalid + } + switch field { + case "organization": + values.Organization = &text + case "project": + values.Project = &text + case "base_url": + values.BaseURL = &text + default: + return errProfilesInvalid + } + return nil + }) + registry.Profiles[name] = values + return err + }) + default: + return errProfilesInvalid + } + }) + if err != nil || fields != 3 || decoder.Decode(new(any)) != io.EOF || validateProfileRegistry(registry) != nil { + return profileRegistry{}, errProfilesInvalid + } + return registry, nil +} + +func decodeProfileObject(decoder *json.Decoder, field func(string) error) error { + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return errProfilesInvalid + } + seen := make(map[string]bool) + for decoder.More() { + token, err := decoder.Token() + name, ok := token.(string) + if err != nil || !ok || seen[name] { + return errProfilesInvalid + } + seen[name] = true + if err := field(name); err != nil { + return err + } + } + token, err = decoder.Token() + if err != nil || token != json.Delim('}') { + return errProfilesInvalid + } + return nil +} + +func loadProfiles(ctx context.Context, path string) (profileRegistry, error) { + if err := ctx.Err(); err != nil { + return profileRegistry{}, err + } + // A missing registry must not create state or tighten existing config permissions. + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return emptyProfileRegistry(), nil + } else if err != nil { + return profileRegistry{}, err + } + root, name, err := openProjectLinkDirectory(path, false) + if errors.Is(err, os.ErrNotExist) { + return emptyProfileRegistry(), nil + } + if err != nil { + return profileRegistry{}, profileStoreError(err) + } + defer root.Close() + registry, _, err := readProfiles(ctx, root, name) + return registry, err +} + +func readProfiles(ctx context.Context, root *os.Root, name string) (profileRegistry, os.FileInfo, error) { + for range 64 { + if err := ctx.Err(); err != nil { + return profileRegistry{}, nil, err + } + info, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return emptyProfileRegistry(), nil, nil + } + if err != nil { + return profileRegistry{}, nil, err + } + registry, err := readProfileSnapshot(ctx, root, name, info) + if !errors.Is(err, errProfilesChanged) { + return registry, info, err + } + } + return profileRegistry{}, nil, errProfilesChanged +} + +func readProfileSnapshot(ctx context.Context, root *os.Root, name string, expected os.FileInfo) (profileRegistry, error) { + if !privateProjectLinkFile(expected) { + return profileRegistry{}, errProfileFileUnsafe + } + file, err := root.OpenFile(name, os.O_RDONLY|projectLinkOpenFlags(), 0) + if err != nil { + return profileRegistry{}, err + } + defer file.Close() + actual, err := file.Stat() + if err != nil { + return profileRegistry{}, err + } + if !privateProjectLinkFile(actual) || !sameProjectLinkSnapshot(expected, actual) { + return profileRegistry{}, errProfilesChanged + } + data, err := io.ReadAll(io.LimitReader(file, maxProfileBytes+1)) + if err != nil { + return profileRegistry{}, err + } + if err := ctx.Err(); err != nil { + return profileRegistry{}, err + } + if !profileSnapshotMatches(root, name, actual) { + return profileRegistry{}, errProfilesChanged + } + if len(data) > maxProfileBytes { + return profileRegistry{}, errProfilesTooLarge + } + return decodeProfiles(data) +} + +func profileSnapshotMatches(root *os.Root, name string, expected os.FileInfo) bool { + current, err := root.Lstat(name) + if expected == nil { + return errors.Is(err, os.ErrNotExist) + } + return err == nil && privateProjectLinkFile(current) && sameProjectLinkSnapshot(expected, current) +} + +// The callback sees the latest state while the sibling lock is held. It must +// resolve saved-default edits here, not from an earlier unlocked snapshot. +func updateProfiles(ctx context.Context, path string, edit func(*profileRegistry) error) (profileRegistry, error) { + if err := ctx.Err(); err != nil { + return profileRegistry{}, err + } + if edit == nil { + return profileRegistry{}, errProfilesInvalid + } + root, name, err := openProjectLinkDirectory(path, true) + if err != nil { + return profileRegistry{}, profileStoreError(err) + } + defer root.Close() + directoryInfo, err := root.Stat(".") + if err != nil { + return profileRegistry{}, err + } + lock, err := lockProjectLinks(ctx, root, name) + if err != nil { + return profileRegistry{}, profileStoreError(err) + } + defer lock.Close() + lockInfo, err := lock.Stat() + if err != nil { + return profileRegistry{}, err + } + registry, snapshot, err := readProfiles(ctx, root, name) + if err != nil { + return profileRegistry{}, err + } + if err := edit(®istry); err != nil { + return profileRegistry{}, err + } + if err := validateProfileRegistry(registry); err != nil { + return profileRegistry{}, err + } + data, err := json.MarshalIndent(registry, "", " ") + if err != nil { + return profileRegistry{}, err + } + data = append(data, '\n') + if len(data) > maxProfileBytes { + return profileRegistry{}, errProfilesTooLarge + } + temporary := ".profiles-" + rand.Text() + file, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return profileRegistry{}, err + } + defer root.Remove(temporary) + _, writeErr := file.Write(data) + if writeErr == nil { + writeErr = file.Sync() + } + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr, ctx.Err()); err != nil { + return profileRegistry{}, err + } + currentDirectory, err := os.Lstat(filepath.Dir(path)) + if err != nil || !os.SameFile(directoryInfo, currentDirectory) || !privateProjectLinkPermissions(currentDirectory) || + !profileSnapshotMatches(root, "."+name+".lock", lockInfo) || !profileSnapshotMatches(root, name, snapshot) { + return profileRegistry{}, errProfilesChanged + } + if err := ctx.Err(); err != nil { + return profileRegistry{}, err + } + // Rename replaces the entry itself, never a substituted symlink's target. + if err := root.Rename(temporary, name); err != nil { + return profileRegistry{}, err + } + return registry, nil +} + +func profileStoreError(err error) error { + switch { + case errors.Is(err, errProjectLinkDirectoryUnsafe): + return errors.New("profiles require a private configuration directory owned by the current user") + case errors.Is(err, errProjectLinkLockUnsafe): + return errors.New("profile lock must be a private regular file owned by the current user") + case errors.Is(err, errProjectLinkLockChanged), errors.Is(err, errProjectLinkDirectoryChanged): + return errProfilesChanged + default: + return err + } +} diff --git a/pkg/custom/profile_store_test.go b/pkg/custom/profile_store_test.go new file mode 100644 index 00000000..ea3d3577 --- /dev/null +++ b/pkg/custom/profile_store_test.go @@ -0,0 +1,545 @@ +package custom + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const emptyProfileJSON = `{"version":1,"default_profile":null,"profiles":{}}` + +func profileRegistryTestPath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "openai", "profiles.json") +} + +func writeProfileRegistryTestFile(t *testing.T, path, data string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte(data), 0600)) +} + +func profileStoreString(value string) *string { return &value } + +func TestProfileStorePathAndMissingRegistry(t *testing.T) { + directory := t.TempDir() + t.Setenv("HOME", directory) + t.Setenv("XDG_CONFIG_HOME", directory) + t.Setenv("AppData", directory) + config, err := os.UserConfigDir() + require.NoError(t, err) + path, err := profileStorePath() + require.NoError(t, err) + require.Equal(t, filepath.Join(config, "openai", "profiles.json"), path) + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, emptyProfileRegistry(), registry) + _, err = os.Stat(filepath.Dir(path)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestProfileStorePreservesPresenceAndExplicitEdits(t *testing.T) { + path := profileRegistryTestPath(t) + t.Setenv("OPENAI_ORG_ID", "org_ambient") + t.Setenv("OPENAI_PROJECT_ID", "proj_ambient") + t.Setenv("OPENAI_BASE_URL", "https://ambient.invalid") + t.Setenv("OPENAI_PROFILE", "ambient") + t.Setenv("OPENAI_API_KEY", "sk_fake_ambient") + registry, err := updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["Work"] = profileValues{} + registry.Profiles["work"] = profileValues{Project: profileStoreString("custom/project")} + registry.Profiles["研发 team"] = profileValues{ + Organization: profileStoreString(""), Project: profileStoreString(""), BaseURL: profileStoreString(""), + } + registry.DefaultProfile = profileStoreString("work") + return nil + }) + require.NoError(t, err) + loaded, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, registry, loaded) + data, err := os.ReadFile(path) + require.NoError(t, err) + var encoded map[string]any + require.NoError(t, json.Unmarshal(data, &encoded)) + profiles := encoded["profiles"].(map[string]any) + require.Empty(t, profiles["Work"]) + require.Equal(t, map[string]any{"project": "custom/project"}, profiles["work"]) + require.Equal(t, map[string]any{"organization": "", "project": "", "base_url": ""}, profiles["研发 team"]) + require.NotContains(t, string(data), "ambient") + registry, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + values := registry.Profiles["研发 team"] + values.Project = nil + registry.Profiles["研发 team"] = values + return nil + }) + require.NoError(t, err) + require.Nil(t, registry.Profiles["研发 team"].Project) + require.Equal(t, "custom/project", *registry.Profiles["work"].Project) + files, err := os.ReadDir(filepath.Dir(path)) + require.NoError(t, err) + require.Len(t, files, 2, "only profiles and the persistent lock remain") + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + info, err = os.Stat(filepath.Dir(path)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0700), info.Mode().Perm()) + } +} + +func TestProfileStoreRejectsMalformedWithoutOverwriting(t *testing.T) { + for name, data := range map[string]string{ + "empty": "", "null": "null", "array": "[]", "empty-object": "{}", "trailing": emptyProfileJSON + " {}", + "missing-version": `{"default_profile":null,"profiles":{}}`, + "missing-default": `{"version":1,"profiles":{}}`, + "missing-profiles": `{"version":1,"default_profile":null}`, + "future-version": `{"version":2,"default_profile":null,"profiles":{}}`, + "null-version": `{"version":null,"default_profile":null,"profiles":{}}`, + "string-version": `{"version":"1","default_profile":null,"profiles":{}}`, + "fraction-version": `{"version":1.0,"default_profile":null,"profiles":{}}`, + "null-profiles": `{"version":1,"default_profile":null,"profiles":null}`, + "array-profiles": `{"version":1,"default_profile":null,"profiles":[]}`, + "array-value": `{"version":1,"default_profile":null,"profiles":{"work":[]}}`, + "null-value": `{"version":1,"default_profile":null,"profiles":{"work":null}}`, + "null-project": `{"version":1,"default_profile":null,"profiles":{"work":{"project":null}}}`, + "null-organization": `{"version":1,"default_profile":null,"profiles":{"work":{"organization":null}}}`, + "null-url": `{"version":1,"default_profile":null,"profiles":{"work":{"base_url":null}}}`, + "number-project": `{"version":1,"default_profile":null,"profiles":{"work":{"project":1}}}`, + "array-organization": `{"version":1,"default_profile":null,"profiles":{"work":{"organization":[]}}}`, + "boolean-url": `{"version":1,"default_profile":null,"profiles":{"work":{"base_url":true}}}`, + "unknown-top-field": `{"version":1,"default_profile":null,"profiles":{},"extra":true}`, + "credential-value": `{"version":1,"default_profile":null,"profiles":{"work":{"api_key":"sk_fake"}}}`, + "credential-reference": `{"version":1,"default_profile":null,"profiles":{"work":{"credential_ref":"fake"}}}`, + "duplicate-version": `{"version":1,"version":1,"default_profile":null,"profiles":{}}`, + "duplicate-default": `{"version":1,"default_profile":null,"default_profile":null,"profiles":{}}`, + "duplicate-profiles": `{"version":1,"default_profile":null,"profiles":{},"profiles":{}}`, + "duplicate-profile": `{"version":1,"default_profile":null,"profiles":{"work":{},"wo\u0072k":{}}}`, + "duplicate-value": `{"version":1,"default_profile":null,"profiles":{"work":{"project":"one","project":"two"}}}`, + "dangling-default": `{"version":1,"default_profile":"missing","profiles":{"work":{}}}`, + "empty-default": `{"version":1,"default_profile":"","profiles":{}}`, + "numeric-default": `{"version":1,"default_profile":1,"profiles":{}}`, + "empty-name": `{"version":1,"default_profile":null,"profiles":{"":{}}}`, + "whitespace-name": `{"version":1,"default_profile":null,"profiles":{"work ":{}}}`, + "control-name": `{"version":1,"default_profile":null,"profiles":{"wo\u001brk":{}}}`, + "control-value": `{"version":1,"default_profile":null,"profiles":{"work":{"project":"a\r\nb"}}}`, + "url-userinfo": `{"version":1,"default_profile":null,"profiles":{"work":{"base_url":"https://fake:fake@example.invalid"}}}`, + "url-query": `{"version":1,"default_profile":null,"profiles":{"work":{"base_url":"https://example.invalid?"}}}`, + "url-fragment": `{"version":1,"default_profile":null,"profiles":{"work":{"base_url":"https://example.invalid#"}}}`, + "invalid-utf8": "{\"version\":1,\"default_profile\":null,\"profiles\":{\"\xff\":{}}}", + } { + t.Run(name, func(t *testing.T) { + path := profileRegistryTestPath(t) + writeProfileRegistryTestFile(t, path, data) + _, err := loadProfiles(t.Context(), path) + require.ErrorIs(t, err, errProfilesInvalid) + called := false + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + called = true + registry.Profiles["new"] = profileValues{} + return nil + }) + require.ErrorIs(t, err, errProfilesInvalid) + require.False(t, called, "malformed bytes must not reach the editor") + actual, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, data, string(actual)) + }) + } +} + +func TestProfileStoreValidatesNamesAndEndpoints(t *testing.T) { + for _, name := range []string{"work", "Work", "研发 team", "../work", "a/b", "with interior space", "�"} { + require.True(t, validProfileName(name), "%q", name) + } + for _, name := range []string{"", " work", "work\u00a0", "a\x00b", "a\x1bb", "a\u0085b", "\xff"} { + require.False(t, validProfileName(name), "%q", name) + } + for _, endpoint := range []string{"", "https://example.invalid", "http://127.0.0.1:4000/v1/", "https://[::1]/v1", "https://example.invalid/a%3Fb"} { + require.True(t, validProfileBaseURL(endpoint), "%q", endpoint) + } + for _, endpoint := range []string{"relative", "/v1", "https:", "https:///v1", "ftp://example.invalid", "https://u@example.invalid", "https://example.invalid?key=x", "https://example.invalid?", "https://example.invalid#x", "https://example.invalid#", "https://example.invalid/\n", "https://example.invalid/\xff"} { + require.False(t, validProfileBaseURL(endpoint), "%q", endpoint) + } +} + +func TestProfileStoreInvalidEditsPreserveBytes(t *testing.T) { + for name, edit := range map[string]func(*profileRegistry){ + "version": func(registry *profileRegistry) { registry.Version = 2 }, + "nil-profiles": func(registry *profileRegistry) { registry.Profiles = nil }, + "dangling-default": func(registry *profileRegistry) { registry.DefaultProfile = profileStoreString("missing") }, + "empty-name": func(registry *profileRegistry) { registry.Profiles[""] = profileValues{} }, + "invalid-name": func(registry *profileRegistry) { registry.Profiles["\xff"] = profileValues{} }, + "organization-control": func(registry *profileRegistry) { + registry.Profiles["work"] = profileValues{Organization: profileStoreString("a\tb")} + }, + "project-control": func(registry *profileRegistry) { + registry.Profiles["work"] = profileValues{Project: profileStoreString("a\x7fb")} + }, + "url": func(registry *profileRegistry) { + registry.Profiles["work"] = profileValues{BaseURL: profileStoreString("https://u@example.invalid")} + }, + } { + t.Run(name, func(t *testing.T) { + path := profileRegistryTestPath(t) + before := emptyProfileJSON + " \n" + writeProfileRegistryTestFile(t, path, before) + _, err := updateProfiles(t.Context(), path, func(registry *profileRegistry) error { edit(registry); return nil }) + require.ErrorIs(t, err, errProfilesInvalid) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, string(data)) + }) + } + path := profileRegistryTestPath(t) + writeProfileRegistryTestFile(t, path, emptyProfileJSON) + editError := errors.New("edit rejected") + _, err := updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["discard"] = profileValues{} + return editError + }) + require.ErrorIs(t, err, editError) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, emptyProfileJSON, string(data)) +} + +func TestProfileStoreLocalSizeBoundary(t *testing.T) { + path := profileRegistryTestPath(t) + before := emptyProfileJSON + strings.Repeat(" ", maxProfileBytes-len(emptyProfileJSON)) + writeProfileRegistryTestFile(t, path, before) + _, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["large"] = profileValues{Project: profileStoreString(strings.Repeat("p", maxProfileBytes))} + return nil + }) + require.ErrorIs(t, err, errProfilesTooLarge) + require.ErrorContains(t, err, "1 MiB local registry limit") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, string(data)) + writeProfileRegistryTestFile(t, path, before+" ") + _, err = loadProfiles(t.Context(), path) + require.ErrorIs(t, err, errProfilesTooLarge) +} + +func TestProfileStoreConcurrentWritersPreserveProfiles(t *testing.T) { + path := profileRegistryTestPath(t) + const writers = 12 + start := make(chan struct{}) + results := make(chan error, writers) + var wg sync.WaitGroup + for i := range writers { + wg.Go(func() { + <-start + _, err := updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles[fmt.Sprintf("work-%d", i)] = profileValues{Project: profileStoreString(fmt.Sprint(i))} + return nil + }) + results <- err + }) + } + close(start) + wg.Wait() + close(results) + for err := range results { + require.NoError(t, err) + } + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Len(t, registry.Profiles, writers) + for i := range writers { + require.Equal(t, fmt.Sprint(i), *registry.Profiles[fmt.Sprintf("work-%d", i)].Project) + } +} + +func TestProfileStoreWaitingWriterResolvesCurrentDefault(t *testing.T) { + path := profileRegistryTestPath(t) + writeProfileRegistryTestFile(t, path, `{"version":1,"default_profile":"one","profiles":{"one":{},"two":{}}}`) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + inside := make(chan struct{}) + release := make(chan struct{}) + first := make(chan error, 1) + go func() { + _, err := updateProfiles(ctx, path, func(registry *profileRegistry) error { + close(inside) + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + registry.DefaultProfile = profileStoreString("two") + registry.Profiles["one"] = profileValues{Organization: profileStoreString("keep")} + return nil + }) + first <- err + }() + select { + case <-inside: + case <-ctx.Done(): + t.Fatal("first writer did not enter its transaction") + } + observed := &observedProjectLinkWait{Context: ctx, waiting: make(chan struct{})} + second := make(chan error, 1) + go func() { + _, err := updateProfiles(observed, path, func(registry *profileRegistry) error { + values := registry.Profiles[*registry.DefaultProfile] + values.Project = profileStoreString("switched") + registry.Profiles[*registry.DefaultProfile] = values + return nil + }) + second <- err + }() + select { + case <-observed.waiting: + case err := <-second: + t.Fatalf("second writer did not wait: %v", err) + case <-ctx.Done(): + t.Fatal("second writer did not reach lock wait") + } + close(release) + require.NoError(t, <-first) + require.NoError(t, <-second) + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, "two", *registry.DefaultProfile) + require.Equal(t, "switched", *registry.Profiles["two"].Project) + require.Nil(t, registry.Profiles["one"].Project) + require.Equal(t, "keep", *registry.Profiles["one"].Organization) +} + +func TestProfileStoreCancellationPreservesStateAndReleasesLock(t *testing.T) { + path := profileRegistryTestPath(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := loadProfiles(ctx, path) + require.ErrorIs(t, err, context.Canceled) + _, err = updateProfiles(ctx, path, func(*profileRegistry) error { t.Fatal("editor called"); return nil }) + require.ErrorIs(t, err, context.Canceled) + _, err = os.Stat(filepath.Dir(path)) + require.ErrorIs(t, err, os.ErrNotExist) + writeProfileRegistryTestFile(t, path, emptyProfileJSON) + ctx, cancel = context.WithCancel(t.Context()) + defer cancel() + _, err = updateProfiles(ctx, path, func(registry *profileRegistry) error { + registry.Profiles["discard"] = profileValues{} + cancel() + return nil + }) + require.ErrorIs(t, err, context.Canceled) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, emptyProfileJSON, string(data)) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["after"] = profileValues{} + return nil + }) + require.NoError(t, err) + files, err := os.ReadDir(filepath.Dir(path)) + require.NoError(t, err) + require.Len(t, files, 2) +} + +func TestProfileStoreCancellationWhileWaiting(t *testing.T) { + path := profileRegistryTestPath(t) + root, name, err := openProjectLinkDirectory(path, true) + require.NoError(t, err) + defer root.Close() + lock, err := lockProjectLinks(t.Context(), root, name) + require.NoError(t, err) + defer lock.Close() + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + _, err = updateProfiles(ctx, path, func(*profileRegistry) error { t.Fatal("editor called"); return nil }) + require.ErrorIs(t, err, context.DeadlineExceeded) + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + require.NoError(t, lock.Close()) + _, err = updateProfiles(t.Context(), path, func(*profileRegistry) error { return nil }) + require.NoError(t, err) +} + +func TestProfileStoreSnapshotRejectsReplacementAndRewrite(t *testing.T) { + for _, replacement := range []bool{false, true} { + t.Run(fmt.Sprint(replacement), func(t *testing.T) { + path := profileRegistryTestPath(t) + writeProfileRegistryTestFile(t, path, emptyProfileJSON) + root, name, err := openProjectLinkDirectory(path, false) + require.NoError(t, err) + defer root.Close() + info, err := root.Lstat(name) + require.NoError(t, err) + if replacement { + temporary := filepath.Join(filepath.Dir(path), "replacement") + writeProfileRegistryTestFile(t, temporary, emptyProfileJSON) + require.NoError(t, os.Rename(temporary, path)) + } else { + writeProfileRegistryTestFile(t, path, emptyProfileJSON+" ") + } + _, err = readProfileSnapshot(t.Context(), root, name, info) + require.ErrorIs(t, err, errProfilesChanged) + }) + } +} + +func TestProfileStoreRejectsReplacementBeforeCommit(t *testing.T) { + for _, kind := range []string{"registry", "registry-symlink", "rewrite", "lock", "directory", "new-registry"} { + t.Run(kind, func(t *testing.T) { + path := profileRegistryTestPath(t) + if kind != "new-registry" { + writeProfileRegistryTestFile(t, path, emptyProfileJSON) + } + before := emptyProfileJSON + " \n" + if kind == "registry-symlink" { + target := filepath.Join(t.TempDir(), "target") + writeProfileRegistryTestFile(t, target, before) + if err := os.Symlink(target, path+"-replacement"); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + _, err := updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["discard"] = profileValues{} + switch kind { + case "registry-symlink": + require.NoError(t, os.Rename(path+"-replacement", path)) + case "directory": + require.NoError(t, os.Rename(filepath.Dir(path), filepath.Dir(path)+"-old")) + writeProfileRegistryTestFile(t, path, before) + case "lock": + lockPath := filepath.Join(filepath.Dir(path), ".profiles.json.lock") + require.NoError(t, os.Rename(lockPath, lockPath+"-old")) + require.NoError(t, os.WriteFile(lockPath, nil, 0600)) + before = emptyProfileJSON + case "rewrite", "new-registry": + writeProfileRegistryTestFile(t, path, before) + default: + temporary := filepath.Join(filepath.Dir(path), "replacement") + writeProfileRegistryTestFile(t, temporary, before) + require.NoError(t, os.Rename(temporary, path)) + } + return nil + }) + require.ErrorIs(t, err, errProfilesChanged) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, string(data)) + if kind == "registry-symlink" { + info, err := os.Lstat(path) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) + } + }) + } +} + +func TestProfileStoreMissingRegistryPreservesConfiguration(t *testing.T) { + for _, kind := range []string{"symlink", "permissions"} { + t.Run(kind, func(t *testing.T) { + path := profileRegistryTestPath(t) + if kind == "symlink" { + if err := os.Symlink(t.TempDir(), filepath.Dir(path)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } else { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits do not verify Windows ACLs") + } + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.Chmod(filepath.Dir(path), 0755)) + } + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, emptyProfileRegistry(), registry) + _, err = updateProfiles(t.Context(), path, func(*profileRegistry) error { return nil }) + require.ErrorContains(t, err, "private configuration directory") + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + }) + } +} + +func TestProfileStoreRejectsUnsafeFiles(t *testing.T) { + for _, kind := range []string{"registry-directory", "registry-symlink", "config-symlink", "lock-directory", "lock-symlink", "dangling-lock-symlink"} { + t.Run(kind, func(t *testing.T) { + path := profileRegistryTestPath(t) + target := filepath.Join(t.TempDir(), "target") + writeProfileRegistryTestFile(t, target, emptyProfileJSON) + if kind == "config-symlink" { + writeProfileRegistryTestFile(t, filepath.Join(filepath.Dir(target), "profiles.json"), emptyProfileJSON) + if err := os.Symlink(filepath.Dir(target), filepath.Dir(path)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } else { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + unsafePath := path + if strings.Contains(kind, "lock") { + unsafePath = filepath.Join(filepath.Dir(path), ".profiles.json.lock") + } + if strings.HasSuffix(kind, "directory") { + require.NoError(t, os.Mkdir(unsafePath, 0700)) + } else { + linkTarget := target + if kind == "dangling-lock-symlink" { + linkTarget += "-missing" + } + if err := os.Symlink(linkTarget, unsafePath); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + } + if !strings.Contains(kind, "lock") { + _, err := loadProfiles(t.Context(), path) + require.Error(t, err) + } + _, err := updateProfiles(t.Context(), path, func(*profileRegistry) error { t.Fatal("editor called"); return nil }) + require.Error(t, err) + data, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, emptyProfileJSON, string(data)) + }) + } +} + +func TestProfileStoreRejectsInsecurePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows inherits the configuration directory ACL; Unix permission bits do not verify it") + } + for _, target := range []string{"registry", "directory", "lock"} { + t.Run(target, func(t *testing.T) { + path := profileRegistryTestPath(t) + writeProfileRegistryTestFile(t, path, emptyProfileJSON) + switch target { + case "registry": + require.NoError(t, os.Chmod(path, 0644)) + case "directory": + require.NoError(t, os.Chmod(filepath.Dir(path), 0755)) + case "lock": + lockPath := filepath.Join(filepath.Dir(path), ".profiles.json.lock") + require.NoError(t, os.WriteFile(lockPath, nil, 0600)) + require.NoError(t, os.Chmod(lockPath, 0666)) + } + if target != "lock" { + _, err := loadProfiles(t.Context(), path) + require.ErrorContains(t, err, "private") + } + _, err := updateProfiles(t.Context(), path, func(*profileRegistry) error { t.Fatal("editor called"); return nil }) + require.ErrorContains(t, err, "private") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, emptyProfileJSON, string(data)) + }) + } +} diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index 4a3255f3..0d33a551 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net/http" "os" "path/filepath" "strings" @@ -24,6 +23,12 @@ type projectLinkFlag struct { context context.Context } +func (f *projectLinkFlag) PreParse() error { + f.explicit = false + f.context = nil + return f.rootRequestFlag.PreParse() +} + func (f *projectLinkFlag) Set(name, value string) error { if err := f.rootRequestFlag.Set(name, value); err != nil { return err @@ -57,7 +62,7 @@ func registerProjectLinkCommands(root *cli.Command) { for _, name := range []string{"link", "unlink"} { usage, example := "Inspect or save this folder's OpenAI project", "link --project proj_work" description := "With --project, save a project ID for this folder. Without it, inspect the saved link and effective project.\n" + - "Flags override OPENAI_PROJECT_ID, which overrides the nearest linked parent folder. Empty overrides disable folder defaults.\n" + + "Flags override OPENAI_PROJECT_ID, then the selected profile, then the nearest linked parent folder. Empty overrides disable folder defaults.\n" + "No API request or credentials are needed. Linking does not grant project access or change your API key." if name == "unlink" { usage, example = "Remove this folder's saved OpenAI project", "unlink" @@ -127,56 +132,39 @@ func validLinkedProject(project string) bool { // No environment mutation: clients retain API keys and their actual access. // Root ownership prevents endpoint body fields named project from changing this. func projectLinkRequestOptions(command *cli.Command) []option.RequestOption { - root := command.Root() - if root.IsSet("project") { - return []option.RequestOption{option.WithProject(root.String("project"))} - } - if project, present := os.LookupEnv("OPENAI_PROJECT_ID"); present { - return []option.RequestOption{option.WithProject(project)} - } - // Explicit literal headers are applied by FlagOptions after client defaults. - // They also make implicit registry lookup unnecessary, including empty values. - if root.IsSet("header") { - if headers, err := requestHeaders(root); err == nil && len(headers.Values("OpenAI-Project")) != 0 { - return nil + return resolvedRequestOptions(command) +} + +func resolveFolderProject(ctx context.Context, root *cli.Command) (string, error) { + enabled := false + for _, flag := range root.Flags { + if _, ok := flag.(*projectLinkFlag); ok { + enabled = true + break } } + if !enabled { + return "", nil + } path, err := projectLinkPath() if err != nil { - // A process without a user configuration location has no folder defaults. - // Preserve the existing API behavior for minimal service environments. - return nil - } - // Production callers use the context captured by the root Before hook. - // Direct option-only callers retain a context without a cancellation signal. - ctx := context.Background() - for _, flag := range root.Flags { - if project, ok := flag.(*projectLinkFlag); ok && project.context != nil { - ctx = project.context - } + return "", nil } links, err := loadProjectLinks(ctx, path) if err == nil && len(links) == 0 { - return nil + return "", nil } - var directory string + var directory, project string if err == nil { directory, err = projectLinkDirectory() } - var project string if err == nil { _, project, err = nearestProjectLink(ctx, links, directory) } if err != nil { - failure := projectLinkStoreFailure(root, "resolve", err) - return []option.RequestOption{option.WithMaxRetries(0), option.WithMiddleware(func(*http.Request, option.MiddlewareNext) (*http.Response, error) { - return nil, failure - })} + return "", projectLinkStoreFailure(root, "resolve", err) } - if project == "" { - return nil - } - return []option.RequestOption{option.WithProject(project)} + return project, nil } type projectLinkStatus struct { @@ -240,7 +228,13 @@ func handleProjectLink(ctx context.Context, command *cli.Command) error { status.Inherited = status.LinkedDirectory != "" && status.LinkedDirectory != directory // The --project on link saves a future default; this invocation has no API // request. Show the environment override that future commands retain. - status.EffectiveProject, status.Source = projectLinkEffectiveProject(root, status.Project) + status.EffectiveProject, status.Source, err = projectLinkEffectiveProject(ctx, root, status.Project) + if err != nil { + if status.Action != "inspect" { + return projectLinkCommittedSelectionFailure(status.Action, err) + } + return err + } if status.EffectiveProject != "" && !validLinkedProject(status.EffectiveProject) { status.EffectiveProject, status.ProjectRedacted = "", true } @@ -262,7 +256,11 @@ func handleProjectLink(ctx context.Context, command *cli.Command) error { // Inspect the same project sources as request construction without creating a // client or validating unrelated remote configuration. Keep SDK header parsing // here limited to the project header; never retain or display other headers. -func projectLinkEffectiveProject(root *cli.Command, folder string) (project, source string) { +func projectLinkEffectiveProject(ctx context.Context, root *cli.Command, folder string) (project, source string, err error) { + _, selected, err := resolveSelectedProfile(ctx, root, os.LookupEnv) + if err != nil { + return "", "", err + } source = "default" for _, line := range strings.Split(os.Getenv("OPENAI_CUSTOM_HEADERS"), "\n") { name, value, ok := strings.Cut(line, ":") @@ -273,6 +271,9 @@ func projectLinkEffectiveProject(root *cli.Command, folder string) (project, sou if folder != "" { project, source = folder, "folder" } + if selected.Project != nil { + project, source = *selected.Project, "profile" + } if environment, present := os.LookupEnv("OPENAI_PROJECT_ID"); present { project, source = environment, "environment" } @@ -283,7 +284,7 @@ func projectLinkEffectiveProject(root *cli.Command, folder string) (project, sou } } } - return project, source + return project, source, nil } func projectLinkText(status projectLinkStatus) string { @@ -306,7 +307,15 @@ func projectLinkText(status projectLinkStatus) string { } else if status.Action == "inspect" { out.WriteString("This folder has no saved project link.\n") } - if status.Source == "environment" { + if status.Source == "profile" { + if status.ProjectRedacted { + fmt.Fprintln(&out, "A selected profile overrides the folder default (value omitted).") + } else if status.EffectiveProject == "" { + fmt.Fprintln(&out, "The selected profile disables the folder default.") + } else { + fmt.Fprintf(&out, "Effective project: %s (selected profile overrides the folder default)\n", readable.Text(status.EffectiveProject)) + } + } else if status.Source == "environment" { if status.ProjectRedacted { out.WriteString("OPENAI_PROJECT_ID overrides the folder default. Its value is not a project ID and is hidden.\n") } else if status.EffectiveProject == "" { diff --git a/pkg/custom/root_request_flags.go b/pkg/custom/root_request_flags.go index 1a5b51d4..197a0989 100644 --- a/pkg/custom/root_request_flags.go +++ b/pkg/custom/root_request_flags.go @@ -48,3 +48,6 @@ func (f *rootRequestFlag) Set(name, value string) error { // RequestFlag exposes the existing metadata for help decoration and redaction. func (f *rootRequestFlag) RequestFlag() *requestflag.Flag[string] { return f.Flag } + +// ExplicitRequestFlag preserves presence through wrappers that embed this flag. +func (f *rootRequestFlag) ExplicitRequestFlag() bool { return f.explicitlySet } diff --git a/scripts/demos/profiles/README.md b/scripts/demos/profiles/README.md new file mode 100644 index 00000000..2e87c724 --- /dev/null +++ b/scripts/demos/profiles/README.md @@ -0,0 +1,52 @@ +# Local profile recording + +This scene compares an accepted A1 binary against the A2 candidate. +The baseline lacks profile commands and returns status 3 for `profile list`. +The candidate creates, edits, activates, switches, and inspects a synthetic profile. +`auth status` shows the resulting effective project and profile source. +All scene commands run offline. + +Use the existing [`capture_and_render.sh`](../capture_and_render.sh) lifecycle. +Use the loopback fixture from [`project-linking/server.py`](../project-linking/server.py) as an unexpected-request trap. +Reuse the recorder structure from [`project-linking/record-context-review.sh`](../project-linking/record-context-review.sh). +Do not add another recorder lifecycle or commit binary media. + +Prepare an external recorder with these settings: + +- Supply independently built baseline and candidate binaries, with full commit IDs. +- Copy this `scene.sh` to the shared lifecycle's temporary runtime directory. +- Use isolated homes for `HOME`, `USERPROFILE`, `APPDATA`, and `XDG_CONFIG_HOME`. +- Supply `DEMO_SCENE`, `DEMO_SCENE_LABEL`, `DEMO_PLATFORM`, `DEMO_WORK_ROOT`, and `DEMO_STATUS_LOG` for each scene. +- Use `env -i` through `demo_capture_scene` to exclude personal settings and credentials. +- Set `NO_COLOR=1`, `FORCE_COLOR=0`, `CI=1`, `PAGER=cat`, and `GOMAXPROCS=2`. +- Capture Bash at 120 columns and 40 rows. +- Render Menlo at 18 pixels with the asciinema theme. +- Keep all captures, screenshots, binaries, and manifests outside the repository. + +Use the current bounded PTY resource claim before recording. +Native foreground access requires its separate user window. +This recipe does not establish native graphical-terminal behavior. + +The external recorder must assert these exact command statuses: + +```text +before list 3 +after create 0 +after set 0 +after activate 0 +after switch 0 +after status 0 +after inspect 0 +``` + +The scene writes tab-separated status rows to `DEMO_STATUS_LOG`. +Assert that the trap's request log contains no requests. +Check the candidate transcript for `proj_other`, `profile:work`, and the saved-default label. +Check that create returns `active:false` and activate returns `active:true`. +Check that no credential value appears in either transcript. +Record binary hashes, source identities, script hashes, actual statuses, and environment information. +Preserve a source manifest when the candidate includes uncommitted changes. + +Inspect `before.png`, `after.png`, and `comparison.gif` before sharing. +Check complete output and clipping at the recorded dimensions. +Keep rendering evidence separate from native interaction and platform validation. diff --git a/scripts/demos/profiles/scene.sh b/scripts/demos/profiles/scene.sh new file mode 100644 index 00000000..1db35699 --- /dev/null +++ b/scripts/demos/profiles/scene.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail +test -t 0 && test -t 1 && test -t 2 || exit 99 +cd "$DEMO_WORK_ROOT" +printf '\033[2J\033[H' +printf '%s\n' "$DEMO_SCENE_LABEL" +printf '%s\n' "Terminal replay | $DEMO_PLATFORM | offline synthetic profiles" + +demo_command() { + local demo_name="$1" demo_expected="$2" demo_status + shift 2 + printf '$ openai' + printf ' %s' "$@" + printf '\n' + sleep 0.4 + if openai "$@"; then demo_status=0; else demo_status=$?; fi + printf '%s\t%s\t%s\n' "$DEMO_SCENE" "$demo_name" "$demo_status" >> "$DEMO_STATUS_LOG" + if [ "$demo_status" -ne "$demo_expected" ]; then + return 97 + fi + sleep 0.8 +} + +if [ "$DEMO_SCENE" = before ]; then + demo_command list 3 profile list +else + demo_command create 0 --format=json profile create work + demo_command set 0 --format=json profile set work --project proj_demo + demo_command activate 0 --format=json profile activate work + demo_command switch 0 --format=json auth switch --to-project proj_other + demo_command status 0 auth status + demo_command inspect 0 profile get work +fi +printf '$ ' +sleep 3 From b317f9f6f9c41b3802b9a232755af42a18f3c776 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 05:46:36 -0400 Subject: [PATCH 18/36] fix: reset profile flags between command invocations --- pkg/custom/profile_commands.go | 37 +++++++++++--- pkg/custom/profile_commands_test.go | 75 +++++++++++++++++++++++++++++ pkg/custom/profile_context.go | 3 ++ pkg/custom/profile_context_test.go | 42 ++++++++++++++++ 4 files changed, 150 insertions(+), 7 deletions(-) diff --git a/pkg/custom/profile_commands.go b/pkg/custom/profile_commands.go index d8cc03d5..31efd7f5 100644 --- a/pkg/custom/profile_commands.go +++ b/pkg/custom/profile_commands.go @@ -14,6 +14,29 @@ import ( "github.com/urfave/cli/v3" ) +// These command-local flags have no sources, destinations, or nonzero defaults. +// Fresh declarations clear urfave's retained presence and duplicate counters. +type profileStringFlag struct{ cli.StringFlag } + +func (f *profileStringFlag) PreParse() error { + f.StringFlag = cli.StringFlag{Name: f.Name, Usage: f.Usage, OnlyOnce: true} + return f.StringFlag.PreParse() +} + +type profileUnsetFlag struct{ cli.StringSliceFlag } + +func (f *profileUnsetFlag) PreParse() error { + f.StringSliceFlag = cli.StringSliceFlag{Name: f.Name, Usage: f.Usage} + return f.StringSliceFlag.PreParse() +} + +type profileDeactivateFlag struct{ cli.BoolFlag } + +func (f *profileDeactivateFlag) PreParse() error { + f.BoolFlag = cli.BoolFlag{Name: f.Name, Usage: f.Usage, OnlyOnce: true} + return f.BoolFlag.PreParse() +} + func registerProfileCommands(root *cli.Command) { if root.Command("profile") != nil { return @@ -41,13 +64,13 @@ func registerProfileCommands(root *cli.Command) { "An empty value remains present; --unset removes presence. Supports --format auto, text, json, or jsonl." set.DisableSliceFlagSeparator = true set.Flags = []cli.Flag{ - &cli.StringFlag{Name: "organization", Usage: "Save organization `VALUE`; an empty value suppresses lower defaults", OnlyOnce: true}, - &cli.StringFlag{Name: "project", Usage: "Save project `VALUE`; an empty value suppresses lower defaults", OnlyOnce: true}, - &cli.StringFlag{Name: "base-url", Usage: "Save endpoint `URL` without user information, query, or fragment; empty selects production", OnlyOnce: true}, - &cli.StringSliceFlag{Name: "unset", Usage: "Remove saved `FIELD`: organization, project, or base_url; repeat for multiple fields"}, + &profileStringFlag{cli.StringFlag{Name: "organization", Usage: "Save organization `VALUE`; an empty value suppresses lower defaults", OnlyOnce: true}}, + &profileStringFlag{cli.StringFlag{Name: "project", Usage: "Save project `VALUE`; an empty value suppresses lower defaults", OnlyOnce: true}}, + &profileStringFlag{cli.StringFlag{Name: "base-url", Usage: "Save endpoint `URL` without user information, query, or fragment; empty selects production", OnlyOnce: true}}, + &profileUnsetFlag{cli.StringSliceFlag{Name: "unset", Usage: "Remove saved `FIELD`: organization, project, or base_url; repeat for multiple fields"}}, } profile.Command("delete").Flags = []cli.Flag{ - &cli.BoolFlag{Name: "deactivate", Usage: "Clear the saved default when deleting its active profile", OnlyOnce: true}, + &profileDeactivateFlag{cli.BoolFlag{Name: "deactivate", Usage: "Clear the saved default when deleting its active profile", OnlyOnce: true}}, } root.Commands = append(root.Commands, profile) if auth := root.Command("auth"); auth != nil && auth.Command("switch") == nil { @@ -57,7 +80,7 @@ func registerProfileCommands(root *cli.Command) { "This does not activate the profile, contact the API, or change credentials or permissions. " + "Supports --format auto, text, json, or jsonl." command.Flags = []cli.Flag{ - &cli.StringFlag{Name: "to-project", Usage: "Save nonempty project `VALUE` in the selected profile", OnlyOnce: true}, + &profileStringFlag{cli.StringFlag{Name: "to-project", Usage: "Save nonempty project `VALUE` in the selected profile", OnlyOnce: true}}, } command.Action = handleProfileCommand auth.Commands = append(auth.Commands, command) @@ -232,7 +255,7 @@ func handleProfileCommand(ctx context.Context, command *cli.Command) error { func explicitProfileEdits(command *cli.Command) (map[string]*string, error) { edits := make(map[string]*string) for _, declared := range command.Flags { - flag, ok := declared.(*cli.StringFlag) + flag, ok := declared.(*profileStringFlag) if !ok || !flag.IsSet() { continue } diff --git a/pkg/custom/profile_commands_test.go b/pkg/custom/profile_commands_test.go index 73f6beed..d2a80da8 100644 --- a/pkg/custom/profile_commands_test.go +++ b/pkg/custom/profile_commands_test.go @@ -112,6 +112,81 @@ func TestProfileSetOnlyPersistsLocalExplicitEdits(t *testing.T) { require.Equal(t, profileValues{}, registry.Profiles["work"]) } +func TestProfileCommandReusePreservesExplicitEdits(t *testing.T) { + path := profileCommandTestEnvironment(t) + root := profileCommandTestRoot(io.Discard) + run := func(args ...string) { + t.Helper() + require.NoError(t, root.Run(t.Context(), append([]string{"openai"}, args...))) + } + check := func(organization, project, baseURL *string) { + t.Helper() + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, profileValues{Organization: organization, Project: project, BaseURL: baseURL}, registry.Profiles["work"]) + } + run("profile", "create", "work") + run("profile", "set", "work", "--project=proj_first") + run("profile", "set", "work", "--organization=org_demo") + check(profileCommandString("org_demo"), profileCommandString("proj_first"), nil) + run("profile", "set", "work", "--project=proj_second", "--base-url=https://example.invalid/v1") + check(profileCommandString("org_demo"), profileCommandString("proj_second"), profileCommandString("https://example.invalid/v1")) + run("profile", "set", "work", "--unset=project", "--unset=organization") + check(nil, nil, profileCommandString("https://example.invalid/v1")) + run("profile", "set", "work", "--project=", "--organization=org_again") + check(profileCommandString("org_again"), profileCommandString(""), profileCommandString("https://example.invalid/v1")) + run("profile", "set", "work", "--unset=base_url") + check(profileCommandString("org_again"), profileCommandString(""), nil) + run("profile", "set", "work", "--base-url=") + check(profileCommandString("org_again"), profileCommandString(""), profileCommandString("")) + require.ErrorContains(t, root.Run(t.Context(), []string{"openai", "profile", "set", "work"}), "No saved edits") + check(profileCommandString("org_again"), profileCommandString(""), profileCommandString("")) + for _, duplicate := range [][]string{ + {"--project=proj_one", "--project=proj_two"}, + {"--organization=org_one", "--organization=org_two"}, + {"--base-url=", "--base-url=https://example.invalid/v1"}, + } { + args := append([]string{"openai", "profile", "set", "work"}, duplicate...) + require.Error(t, root.Run(t.Context(), args)) + check(profileCommandString("org_again"), profileCommandString(""), profileCommandString("")) + } + run("profile", "set", "work", "--project=proj_after_failure") + check(profileCommandString("org_again"), profileCommandString("proj_after_failure"), profileCommandString("")) +} + +func TestProfileCommandReuseResetsSwitchAndDeactivation(t *testing.T) { + path := profileCommandTestEnvironment(t) + root := profileCommandTestRoot(io.Discard) + run := func(args ...string) { + t.Helper() + require.NoError(t, root.Run(t.Context(), append([]string{"openai"}, args...))) + } + run("profile", "create", "work") + run("profile", "activate", "work") + run("auth", "switch", "--to-project=proj_first") + run("auth", "switch", "--to-project=proj_second") + require.Error(t, root.Run(t.Context(), []string{"openai", "auth", "switch"})) + require.Error(t, root.Run(t.Context(), []string{"openai", "auth", "switch", "--to-project=proj_three", "--to-project=proj_four"})) + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, "proj_second", *registry.Profiles["work"].Project) + run("auth", "switch", "--to-project=proj_after_failure") + run("profile", "delete", "work", "--deactivate") + for range 2 { + run("profile", "create", "work") + run("profile", "activate", "work") + for _, flags := range [][]string{nil, {"--deactivate=false"}, {"--deactivate", "--deactivate"}} { + args := append([]string{"openai", "profile", "delete", "work"}, flags...) + require.Error(t, root.Run(t.Context(), args)) + registry, err = loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Equal(t, "work", *registry.DefaultProfile) + require.Contains(t, registry.Profiles, "work") + } + run("profile", "delete", "work", "--deactivate") + } +} + func TestProfileCommandsRejectBeforeMutation(t *testing.T) { path := profileCommandTestEnvironment(t) for _, args := range [][]string{ diff --git a/pkg/custom/profile_context.go b/pkg/custom/profile_context.go index 5bd1521c..06e6c563 100644 --- a/pkg/custom/profile_context.go +++ b/pkg/custom/profile_context.go @@ -25,6 +25,9 @@ type profileSelectorFlag struct { func (f *profileSelectorFlag) PreParse() error { f.ctx, f.resolved, f.err = nil, nil, nil + // urfave retains presence and duplicate counters across PreParse calls. + // This selector has no environment sources or caller-owned destination. + f.StringFlag = &cli.StringFlag{Name: f.Name, Usage: f.Usage} return f.StringFlag.PreParse() } diff --git a/pkg/custom/profile_context_test.go b/pkg/custom/profile_context_test.go index 08fccd8d..b7576311 100644 --- a/pkg/custom/profile_context_test.go +++ b/pkg/custom/profile_context_test.go @@ -109,3 +109,45 @@ func TestProfileContextEnvironmentSnapshot(t *testing.T) { require.Equal(t, 1, count, name) } } + +func TestProfileContextSelectorCommandReuse(t *testing.T) { + for _, useEnvironment := range []bool{false, true} { + name := "saved default" + if useEnvironment { + name = "environment" + } + t.Run(name, func(t *testing.T) { + projectLinkTestEnvironment(t) + authStatusTestEnvironment(t, nil) + if useEnvironment { + t.Setenv("OPENAI_PROFILE", "default") + } + path, err := profileStorePath() + require.NoError(t, err) + work, fallback, selected := "proj_work", "proj_default", "default" + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["work"] = profileValues{Project: &work} + registry.Profiles["default"] = profileValues{Project: &fallback} + registry.DefaultProfile = &selected + return nil + }) + require.NoError(t, err) + root := profileContextTestRoot(t) + var got []string + root.Command("auth").Command("status").Action = func(_ context.Context, _ *cli.Command) error { + report, err := resolveAuthStatus(root, os.LookupEnv) + got = append(got, report.Project.Value) + return err + } + for _, args := range [][]string{ + {"openai", "--profile=work", "auth", "status"}, + {"openai", "auth", "status"}, + {"openai", "--profile=", "auth", "status"}, + {"openai", "auth", "status"}, + } { + require.NoError(t, root.Run(t.Context(), args)) + } + require.Equal(t, []string{work, fallback, "", fallback}, got) + }) + } +} From 55dd940044cc9e49bfaa00ff8031bd7cad12057c Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 05:58:04 -0400 Subject: [PATCH 19/36] fix: preserve profile source and recovery diagnostics --- cmd/openai/profile_commands_test.go | 169 ++++++++++++++++++++++++++-- docs/project-linking.md | 3 +- pkg/custom/auth_status.go | 10 +- pkg/custom/profile_context_test.go | 33 ++++++ pkg/custom/project_link_errors.go | 6 + 5 files changed, 206 insertions(+), 15 deletions(-) diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index 6f501926..e30f28b8 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -303,6 +304,39 @@ func TestMainProfileProjectBypassesMalformedFolderRegistry(t *testing.T) { require.Equal(t, malformed, string(data)) } +func TestMainProfileMissingSelectionPreservesCommittedFolderLink(t *testing.T) { + server, requests := localUtilitiesRequestTrap(t) + for _, format := range []string{"text", "json"} { + t.Run(format, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--project=proj_profile") + assertProfileJSON(t, profileCommand(t, home, directory, "profile", "activate", "work"), profileReceipt("activate", "work", true, map[string]string{"project": "proj_profile"})) + before, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + projectLinkCreate(t, home, directory, "proj_original") + + got := runProjectLinkMain(t, home, directory, []string{"OPENAI_PROFILE=missing", "OPENAI_BASE_URL=" + server.URL}, "", + "link", "--project=proj_saved", "--format-error="+format) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + if format == "json" { + decodeMainStructuredError(t, format, got.stderr) + } + require.Contains(t, got.stderr, "The folder link was saved.") + require.Contains(t, got.stderr, "Its effective project could not be resolved.") + require.Contains(t, got.stderr, "Selected profile does not exist.") + require.Contains(t, got.stderr, "profile list") + require.NotContains(t, got.stderr, "project-links.json") + require.NotContains(t, got.stderr, "did not change saved links") + require.Equal(t, map[string]string{projectLinkCanonicalDirectory(t, directory): "proj_saved"}, projectLinkRegistry(t, home)) + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } + require.Zero(t, requests.Load(), "folder links and profile selection must remain offline") +} + func TestMainProfileEndpointProjectRemainsBodyField(t *testing.T) { home, directory := t.TempDir(), t.TempDir() createProfileFixture(t, home, directory, "work", "--project=proj_profile") @@ -326,6 +360,120 @@ func TestMainProfileEndpointProjectRemainsBodyField(t *testing.T) { } } +func TestMainProfileStreamingPreservesContextAndLifecycle(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--project=proj_profile", "--organization=org_profile") + for _, tc := range []struct { + name, project string + flags []string + earlyExit bool + }{ + {"profile failure", "proj_profile", nil, false}, + {"literal override failure", "proj_header", []string{"--project=proj_flag", "--header=OpenAI-Project: proj_header"}, false}, + {"profile early exit", "proj_profile", []string{"--max-items=1"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + release, closed := make(chan struct{}), make(chan struct{}, 1) + requests := make(chan globalFlagsRequest, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + select { + case closed <- struct{}{}: + default: + } + }() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + select { + case requests <- globalFlagsRequest{r.Method, r.URL.Path, r.Header.Clone(), body}: + default: + t.Error("unexpected repeated streaming request") + return + } + w.Header().Set("Content-Type", "text/event-stream") + writeStreamingTextEvent(w, `{"type":"response.output_text.delta","item_id":"msg_profile","output_index":0,"content_index":0,"delta":"Partial answer"}`) + w.(http.Flusher).Flush() + select { + case <-release: + writeStreamingTextEvent(w, `{"type":"response.failed","response":{"status":"failed","error":{"code":"server_error","message":"synthetic failure"}}}`) + case <-r.Context().Done(): + } + })) + t.Cleanup(server.Close) + t.Cleanup(server.CloseClientConnections) + binary, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + args := streamingTextArgs("responses", "--profile=work", "--format=text", "--format-error=json") + args = append(args, tc.flags...) + childTest := "TestMainDispatchProcess" + if tc.earlyExit { + childTest = "TestMainDispatchOutputStreamExitChild" + } + child := exec.CommandContext(ctx, binary, append([]string{"-test.run=^" + childTest + "$", "--", "openai"}, args...)...) + child.Dir = directory + child.Env = []string{"OPENAI_CLI_MAIN_DISPATCH_PROCESS=1", "OPENAI_API_KEY=synthetic-stream-key", "OPENAI_BASE_URL=" + server.URL, + "OPENAI_CUSTOM_HEADERS=OpenAI-Project: proj_custom", "HOME=" + home, "USERPROFILE=" + home, "APPDATA=" + home, + "XDG_CONFIG_HOME=" + home, "NO_COLOR=1", "FORCE_COLOR=0", "TERM=dumb", "GOMAXPROCS=2"} + barrier := t.TempDir() + if tc.earlyExit { + child.Env = append(child.Env, "OPENAI_CLI_OUTPUT_STREAM_BARRIER="+barrier) + } else if len(tc.flags) != 0 { + child.Env = append(child.Env, "OPENAI_PROJECT_ID=proj_environment") + } + stderr := new(bytes.Buffer) + child.Stderr = stderr + stdout, err := child.StdoutPipe() + require.NoError(t, err) + require.NoError(t, child.Start()) + t.Cleanup(func() { + cancel() + stdout.Close() + if child.ProcessState == nil { + child.Wait() + } + }) + readStreamingTextPrefix(t, ctx, stdout, "Partial answer") + request := globalFlagsOneRequest(t, requests) + require.Equal(t, http.MethodPost, request.method) + require.Equal(t, "/responses", request.path) + require.Equal(t, []string{tc.project}, request.header.Values("OpenAI-Project")) + require.Equal(t, []string{"org_profile"}, request.header.Values("OpenAI-Organization")) + require.JSONEq(t, `{"model":"fake-model","input":"synthetic input","stream":true}`, string(request.body)) + if tc.earlyExit { + select { + case <-closed: + case <-ctx.Done(): + t.Fatal("early output exit retained the blocked HTTP stream") + } + // The existing child cannot exit successfully before this release. + require.NoError(t, os.WriteFile(filepath.Join(barrier, "release-child"), nil, 0600)) + } else { + close(release) + } + rest, err := io.ReadAll(stdout) + require.NoError(t, err) + err = child.Wait() + require.NoError(t, ctx.Err()) + require.Empty(t, requests, "stream must not restart after partial output") + if tc.earlyExit { + require.NoError(t, err, "%s", stderr) + require.Equal(t, "\n", string(rest)) + require.Empty(t, stderr.String()) + } else { + var exit *exec.ExitError + require.ErrorAs(t, err, &exit) + require.Greater(t, exit.ExitCode(), 0) + require.Contains(t, string(rest), "synthetic failure") + require.True(t, json.Valid(stderr.Bytes()), "%s", stderr) + } + }) + } +} + func TestMainProfileCommandsPreserveStdinAndStayOffline(t *testing.T) { home, directory := t.TempDir(), t.TempDir() createProfileFixture(t, home, directory, "work") @@ -475,22 +623,25 @@ func TestMainProfileBaseURLUsesActualEffectiveEndpoint(t *testing.T) { createProfileFixture(t, home, directory, "empty", "--base-url=") for _, tc := range []struct { name, profile, baseURL, source, requestURL string + redacted bool env, flags []string }{ - {"profile", "work", "https://profile.invalid/custom", "profile:work", "https://profile.invalid/custom/models", nil, nil}, - {"empty profile production reset", "empty", "https://api.openai.com/v1/", "profile:empty", "https://api.openai.com/v1/models", nil, nil}, - {"empty flag keeps profile", "work", "https://profile.invalid/custom", "profile:work", "https://profile.invalid/custom/models", nil, []string{"--base-url="}}, - {"empty flag keeps production reset", "empty", "https://api.openai.com/v1/", "profile:empty", "https://api.openai.com/v1/models", nil, []string{"--base-url="}}, - {"environment", "work", "https://environment.invalid/v1", "environment:OPENAI_BASE_URL", "https://environment.invalid/v1/models", []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, nil}, - {"empty flag keeps environment", "work", "https://environment.invalid/v1", "environment:OPENAI_BASE_URL", "https://environment.invalid/v1/models", []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, []string{"--base-url="}}, - {"root overrides environment", "work", "https://flag.invalid/v1", "flag:--base-url", "https://flag.invalid/v1/models", []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, []string{"--base-url=https://flag.invalid/v1"}}, - {"empty environment remains empty", "work", "", "environment:OPENAI_BASE_URL", "models", []string{"OPENAI_BASE_URL="}, nil}, + // A1 hides custom endpoint paths in status; the request must retain the complete path. + {"profile", "work", "https://profile.invalid/", "profile:work", "https://profile.invalid/custom/models", true, nil, nil}, + {"empty profile production reset", "empty", "https://api.openai.com/v1/", "profile:empty", "https://api.openai.com/v1/models", false, nil, nil}, + {"empty flag keeps profile", "work", "https://profile.invalid/", "profile:work", "https://profile.invalid/custom/models", true, nil, []string{"--base-url="}}, + {"empty flag keeps production reset", "empty", "https://api.openai.com/v1/", "profile:empty", "https://api.openai.com/v1/models", false, nil, []string{"--base-url="}}, + {"environment", "work", "https://environment.invalid/v1", "environment:OPENAI_BASE_URL", "https://environment.invalid/v1/models", false, []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, nil}, + {"empty flag keeps environment", "work", "https://environment.invalid/v1", "environment:OPENAI_BASE_URL", "https://environment.invalid/v1/models", false, []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, []string{"--base-url="}}, + {"root overrides environment", "work", "https://flag.invalid/v1", "flag:--base-url", "https://flag.invalid/v1/models", false, []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, []string{"--base-url=https://flag.invalid/v1"}}, + // SDK URL resolution adds a slash but preserves the empty scheme and host. + {"empty environment remains empty", "work", "", "environment:OPENAI_BASE_URL", "/models", false, []string{"OPENAI_BASE_URL="}, nil}, } { t.Run(tc.name, func(t *testing.T) { flags := append([]string{"--profile=" + tc.profile}, tc.flags...) statusArgs := append(slices.Clone(flags), "auth", "status", "--format=json") status := decodeAuthStatus(t, runProfileURLMain(t, home, directory, tc.env, statusArgs)) - require.Equal(t, authStatusValueResult{Value: tc.baseURL, Source: tc.source}, status.BaseURL) + require.Equal(t, authStatusValueResult{Value: tc.baseURL, Source: tc.source, Redacted: tc.redacted}, status.BaseURL) requestArgs := append(slices.Clone(flags), "models", "list", "--format=raw") got := runProfileURLMain(t, home, directory, tc.env, requestArgs) require.Zero(t, got.code, "%s", got.stderr) diff --git a/docs/project-linking.md b/docs/project-linking.md index 3e689c43..ae2f155f 100644 --- a/docs/project-linking.md +++ b/docs/project-linking.md @@ -132,7 +132,8 @@ They reject positional arguments, `--transform`, and `--raw-output` before chang `--quiet` suppresses readable save/remove confirmations and preserves inspection and JSON output. JSON reports the action, current directory, linked directory, saved project, inheritance, effective project, and source. An empty effective project means the request uses an empty override or the existing default. -The `source` field distinguishes these cases: `folder`, `environment`, `header`, `custom_headers`, or `default`. +The `source` field distinguishes these cases: `profile`, `folder`, `environment`, `header`, `custom_headers`, or `default`. +`profile` means a present project field in the selected profile. `environment` means `OPENAI_PROJECT_ID`; `header` means an explicit `--header`; `custom_headers` means the SDK environment fallback. Inspection hides external project values that do not match the project ID format. JSON marks these values with `effective_project_redacted: true`. diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index c43e4f85..667e692c 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -93,11 +93,11 @@ func handleAuthStatus(ctx context.Context, command *cli.Command) error { _, err = fmt.Fprintf(out, "Authentication configuration (offline; not verified)\n"+ "API credential: %s [%s]\nAdmin credential: %s [%s]\n"+ "Organization: %s [%s]\nProject: %s [%s]\nBase URL: %s [%s]\n", - strings.ReplaceAll(report.API.Type, "_", " "), report.API.Source, - strings.ReplaceAll(report.Admin.Type, "_", " "), report.Admin.Source, - authStatusTextValue(report.Organization), report.Organization.Source, - authStatusTextValue(report.Project), report.Project.Source, - endpoint, report.BaseURL.Source) + strings.ReplaceAll(report.API.Type, "_", " "), readable.Text(report.API.Source), + strings.ReplaceAll(report.Admin.Type, "_", " "), readable.Text(report.Admin.Source), + authStatusTextValue(report.Organization), readable.Text(report.Organization.Source), + authStatusTextValue(report.Project), readable.Text(report.Project.Source), + endpoint, readable.Text(report.BaseURL.Source)) } if err != nil { return &localUtilityError{message: "Could not write auth status. Check the output file or pipe before rerunning.", cause: err} diff --git a/pkg/custom/profile_context_test.go b/pkg/custom/profile_context_test.go index b7576311..30b92038 100644 --- a/pkg/custom/profile_context_test.go +++ b/pkg/custom/profile_context_test.go @@ -1,7 +1,9 @@ package custom import ( + "bytes" "context" + "encoding/json" "io" "net/http" "os" @@ -151,3 +153,34 @@ func TestProfileContextSelectorCommandReuse(t *testing.T) { }) } } + +func TestProfileContextStatusEscapesProfileSource(t *testing.T) { + projectLinkTestEnvironment(t) + name := "work\u202e\u2028demo" + authStatusTestEnvironment(t, map[string]string{"OPENAI_PROFILE": name}) + path, err := profileStorePath() + require.NoError(t, err) + organization, project, endpoint := "org_demo", "proj_demo", "https://example.invalid/v1" + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles[name] = profileValues{Organization: &organization, Project: &project, BaseURL: &endpoint} + return nil + }) + require.NoError(t, err) + for _, format := range []string{"text", "json"} { + var output bytes.Buffer + root := profileContextTestRoot(t) + root.Writer = &output + require.NoError(t, root.Run(t.Context(), []string{"openai", "auth", "status", "--format=" + format})) + if format == "text" { + require.NotContains(t, output.String(), "\u202e") + require.NotContains(t, output.String(), "\u2028") + require.Equal(t, 3, strings.Count(output.String(), `[profile:work\u202e\u2028demo]`)) + } else { + var report authStatusReport + require.NoError(t, json.Unmarshal(output.Bytes(), &report)) + for _, source := range []string{report.Organization.Source, report.Project.Source, report.BaseURL.Source} { + require.Equal(t, "profile:"+name, source) + } + } + } +} diff --git a/pkg/custom/project_link_errors.go b/pkg/custom/project_link_errors.go index 489d3ac9..bf9f86ad 100644 --- a/pkg/custom/project_link_errors.go +++ b/pkg/custom/project_link_errors.go @@ -56,6 +56,12 @@ func projectLinkCommittedSelectionFailure(action string, cause error) error { if action == "unlink" { outcome = "This folder has no saved link of its own. Its inherited project could not be resolved. " } + var utility *localUtilityError + if errors.As(cause, &utility) { + // Profile selection can fail after the independent folder transaction. + // Keep its vetted recovery instead of blaming the folder registry. + return projectLinkFailure(outcome+utility.Error(), cause) + } return projectLinkFailure(outcome+projectLinkRecovery(cause), cause) } From 86f9c8ef295a174456ace731b6a41c11509ede76 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 06:14:04 -0400 Subject: [PATCH 20/36] fix: preserve profile context and committed cancellation outcomes --- cmd/openai/profile_commands_test.go | 99 +++++++++++++++++++++++++- pkg/custom/batch_wait.go | 3 +- pkg/custom/batch_wait_test.go | 7 ++ pkg/custom/local_errors.go | 3 + pkg/custom/local_utilities.go | 2 + pkg/custom/profile_commands.go | 5 +- pkg/custom/profile_commands_test.go | 48 +++++++++++++ pkg/custom/project_link.go | 3 + pkg/custom/project_link_errors.go | 17 ++++- pkg/custom/project_link_errors_test.go | 46 ++++++++++++ 10 files changed, 225 insertions(+), 8 deletions(-) diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index e30f28b8..036798da 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -16,6 +16,8 @@ import ( "testing" "time" + "github.com/openai/openai-cli/internal/app" + "github.com/openai/openai-cli/pkg/cmd" "github.com/stretchr/testify/require" ) @@ -573,7 +575,7 @@ func (profileURLCaptureTransport) RoundTrip(request *http.Request) (*http.Respon } func TestMainProfileURLProcess(t *testing.T) { - if os.Getenv("OPENAI_CLI_PROFILE_URL_PROCESS") != "1" { + if os.Getenv("OPENAI_CLI_PROFILE_TEST_PROCESS") != "1" { return } separator := slices.Index(os.Args, "--") @@ -586,12 +588,17 @@ func TestMainProfileURLProcess(t *testing.T) { } func runProfileURLMain(t *testing.T, home, directory string, env, args []string) mainDispatchResult { + t.Helper() + return runProfileTestMain(t, "TestMainProfileURLProcess", home, directory, env, args) +} + +func runProfileTestMain(t *testing.T, processTest, home, directory string, env, args []string) mainDispatchResult { t.Helper() binary, err := os.Executable() require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - child := exec.CommandContext(ctx, binary, append([]string{"-test.run=^TestMainProfileURLProcess$", "--", "openai"}, args...)...) + child := exec.CommandContext(ctx, binary, append([]string{"-test.run=^" + processTest + "$", "--", "openai"}, args...)...) child.Dir = directory for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") @@ -601,7 +608,7 @@ func runProfileURLMain(t *testing.T, home, directory string, env, args []string) } child.Env = append(child.Env, entry) } - child.Env = append(child.Env, "OPENAI_CLI_PROFILE_URL_PROCESS=1", "COMPLETION_STYLE=bash", "OPENAI_API_KEY=synthetic-url-key", + child.Env = append(child.Env, "OPENAI_CLI_PROFILE_TEST_PROCESS=1", "COMPLETION_STYLE=bash", "OPENAI_API_KEY=synthetic-url-key", "HOME="+home, "USERPROFILE="+home, "APPDATA="+home, "XDG_CONFIG_HOME="+home, "NO_COLOR=1", "FORCE_COLOR=0", "TERM=dumb") child.Env = append(child.Env, env...) var stdout, stderr bytes.Buffer @@ -617,6 +624,92 @@ func runProfileURLMain(t *testing.T, home, directory string, env, args []string) return mainDispatchResult{code, stdout.String(), stderr.String()} } +type profileCancelReceiptWriter struct{ cancel context.CancelFunc } + +func (writer profileCancelReceiptWriter) Write([]byte) (int, error) { + writer.cancel() + return 0, context.Canceled +} + +func TestMainProfileCanceledReceiptProcess(t *testing.T) { + if os.Getenv("OPENAI_CLI_PROFILE_TEST_PROCESS") != "1" { + return + } + separator := slices.Index(os.Args, "--") + require.GreaterOrEqual(t, separator, 0) + ctx, cancel := context.WithCancel(context.Background()) + cmd.Command.Writer = profileCancelReceiptWriter{cancel: cancel} + if os.Getenv("OPENAI_CLI_PROFILE_CANCEL_BEFORE") == "1" { + cancel() + } + os.Exit(app.Run(ctx, os.Args[separator+1:])) +} + +func TestMainProfileCancellationPreservesCommittedOutcomes(t *testing.T) { + const profileCommitted = "Saved profiles were updated, but the result could not be written. Inspect with openai profile list." + for _, format := range []string{"text", "json"} { + t.Run(format, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + run := func(message string, env []string, args ...string) { + t.Helper() + args = append(args, "--format="+format, "--format-error="+format) + got := runProfileTestMain(t, "TestMainProfileCanceledReceiptProcess", home, directory, env, args) + require.Equal(t, 1, got.code, "%s", got.stderr) + require.Empty(t, got.stdout) + if format == "json" { + want, err := json.Marshal(map[string]string{"message": message}) + require.NoError(t, err) + require.JSONEq(t, string(want), got.stderr) + } else { + require.Equal(t, message+"\n", got.stderr) + } + assertAuthStatusPrivate(t, got, "synthetic-url-key", home, directory) + } + run("Request canceled.", []string{"OPENAI_CLI_PROFILE_CANCEL_BEFORE=1"}, "profile", "create", "work") + _, err := os.Stat(profileRegistryPath(home)) + require.ErrorIs(t, err, os.ErrNotExist) + for _, step := range []struct { + args []string + want string + inspect bool + }{ + {[]string{"profile", "create", "work"}, `{"version":1,"default_profile":null,"profiles":{"work":{}}}`, false}, + {[]string{"profile", "set", "work", "--project=proj_saved"}, `{"version":1,"default_profile":null,"profiles":{"work":{"project":"proj_saved"}}}`, false}, + {[]string{"profile", "activate", "work"}, `{"version":1,"default_profile":"work","profiles":{"work":{"project":"proj_saved"}}}`, false}, + {[]string{"profile", "get", "work"}, `{"version":1,"default_profile":"work","profiles":{"work":{"project":"proj_saved"}}}`, true}, + {[]string{"profile", "delete", "work", "--deactivate"}, `{"version":1,"default_profile":null,"profiles":{}}`, false}, + } { + message := profileCommitted + var before []byte + if step.inspect { + message = "Request canceled." + before, err = os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + } + run(message, nil, step.args...) + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.JSONEq(t, step.want, string(after)) + if step.inspect { + require.Equal(t, before, after) + } + } + + run("The folder link was saved, but its confirmation could not be written. Inspect with openai link.", nil, + "link", "--project=proj_folder") + require.Equal(t, map[string]string{projectLinkCanonicalDirectory(t, directory): "proj_folder"}, projectLinkRegistry(t, home)) + before, err := os.ReadFile(projectLinkRegistryPath(home)) + require.NoError(t, err) + run("Request canceled.", nil, "link") + after, err := os.ReadFile(projectLinkRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + run("This folder has no saved link of its own. Its confirmation could not be written. Inspect with openai link.", nil, "unlink") + require.Empty(t, projectLinkRegistry(t, home)) + }) + } +} + func TestMainProfileBaseURLUsesActualEffectiveEndpoint(t *testing.T) { home, directory := t.TempDir(), t.TempDir() createProfileFixture(t, home, directory, "work", "--base-url=https://profile.invalid/custom") diff --git a/pkg/custom/batch_wait.go b/pkg/custom/batch_wait.go index b2a970ed..73623e04 100644 --- a/pkg/custom/batch_wait.go +++ b/pkg/custom/batch_wait.go @@ -330,7 +330,8 @@ func showBatchNextCommand(batch gjson.Result, opts ShowJSONOpts) error { // A new process inherits environment configuration but loses command-line // overrides. Compare effective values without ever printing private options. func batchHintNeedsRequestOptions(root *cli.Command) bool { - if root.IsSet("header") { + // Even an empty selector overrides the saved default in this process only. + if root.IsSet("header") || root.IsSet("profile") { return true } // Startup may temporarily normalize an invalid environment URL to this diff --git a/pkg/custom/batch_wait_test.go b/pkg/custom/batch_wait_test.go index 5a248ff4..80cf7b53 100644 --- a/pkg/custom/batch_wait_test.go +++ b/pkg/custom/batch_wait_test.go @@ -67,6 +67,12 @@ func TestBatchHintRetainsOnlyReproducibleRequestContext(t *testing.T) { {"explicit endpoint", "unused", "OPENAI_BASE_URL", "http://127.0.0.1:1", []string{"--base-url", "http://127.0.0.1:1"}, true}, {"different endpoint", "unused", "OPENAI_BASE_URL", "http://127.0.0.1:1", []string{"--base-url", "http://127.0.0.1:2"}, true}, {"empty endpoint fallback", "unused", "OPENAI_BASE_URL", "http://127.0.0.1:1", []string{"--base-url="}, false}, + {"environment profile", "unused", "OPENAI_PROFILE", "profile_environment", nil, false}, + {"empty environment profile", "unused", "OPENAI_PROFILE", "", nil, false}, + {"explicit profile", "unused", "OPENAI_PROFILE", "profile_environment", []string{"--profile", "profile_override"}, true}, + {"matching profile", "unused", "OPENAI_PROFILE", "profile_environment", []string{"--profile", "profile_environment"}, true}, + {"empty profile override", "unused", "OPENAI_PROFILE", "profile_environment", []string{"--profile="}, true}, + {"empty profile without environment value", "unused", "OPENAI_PROFILE", "", []string{"--profile="}, true}, } { t.Run(tc.name, func(t *testing.T) { t.Setenv(tc.environment, tc.inherited) @@ -82,6 +88,7 @@ func TestBatchHintRetainsOnlyReproducibleRequestContext(t *testing.T) { return nil }, } + configureProfileContext(root) if err := root.Run(t.Context(), append([]string{"openai"}, tc.args...)); err != nil { t.Fatal(err) } diff --git a/pkg/custom/local_errors.go b/pkg/custom/local_errors.go index 434c51e0..a21eb0fd 100644 --- a/pkg/custom/local_errors.go +++ b/pkg/custom/local_errors.go @@ -54,6 +54,9 @@ func localErrorMessage(root *cli.Command, failure error) string { if errors.As(failure, &imageFailure) && imageFailure.preserveOnCancel { return imageFailure.message } + if errors.As(failure, &utilityFailure) && utilityFailure.preserveOnCancel { + return utilityFailure.message + } return "Request canceled." case errors.As(failure, &utilityFailure): return utilityFailure.Error() diff --git a/pkg/custom/local_utilities.go b/pkg/custom/local_utilities.go index 5fb22f65..258bb489 100644 --- a/pkg/custom/local_utilities.go +++ b/pkg/custom/local_utilities.go @@ -24,6 +24,8 @@ func IsLocalUtilityCommand(command *cli.Command) bool { type localUtilityError struct { message string cause error + // Keep proven committed outcomes visible when receipt delivery is canceled. + preserveOnCancel bool } func (e *localUtilityError) Error() string { return e.message } diff --git a/pkg/custom/profile_commands.go b/pkg/custom/profile_commands.go index 31efd7f5..1a1e6312 100644 --- a/pkg/custom/profile_commands.go +++ b/pkg/custom/profile_commands.go @@ -246,7 +246,10 @@ func handleProfileCommand(ctx context.Context, command *cli.Command) error { if committed { message = "Saved profiles were updated, but the result could not be written." } - return profileFailure(message+" Inspect with "+errorHelpInvocation(root)+" profile list.", err) + return &localUtilityError{ + message: message + " Inspect with " + errorHelpInvocation(root) + " profile list.", + cause: err, preserveOnCancel: committed, + } } return nil } diff --git a/pkg/custom/profile_commands_test.go b/pkg/custom/profile_commands_test.go index d2a80da8..fd42ca03 100644 --- a/pkg/custom/profile_commands_test.go +++ b/pkg/custom/profile_commands_test.go @@ -342,6 +342,54 @@ func TestProfileCommandsOutputFailureAndCancellation(t *testing.T) { require.Equal(t, before, after) } +type profileCancelOutputWriter struct { + ctx context.Context + cancel context.CancelFunc +} + +func (writer profileCancelOutputWriter) Write([]byte) (int, error) { + writer.cancel() + return 0, writer.ctx.Err() +} + +func TestProfileCommittedCancellationPreservesOutcome(t *testing.T) { + for _, format := range []string{"text", "json"} { + for _, action := range []string{"create", "get"} { + t.Run(format+"/"+action, func(t *testing.T) { + path := profileCommandTestEnvironment(t) + runProfileCommand(t, "profile", "create", "work") + before, err := os.ReadFile(path) + require.NoError(t, err) + name, want := "work", "Request canceled." + if action == "create" { + name, want = "committed", "Saved profiles were updated, but the result could not be written." + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + root := profileCommandTestRoot(profileCancelOutputWriter{ctx, cancel}) + failure := root.Run(ctx, []string{"openai", "--format=" + format, "profile", action, name}) + require.ErrorIs(t, failure, context.Canceled) + var diagnostic bytes.Buffer + require.NoError(t, ShowCommandError(root, failure, &diagnostic)) + require.Contains(t, diagnostic.String(), want) + if format == "json" { + require.True(t, json.Valid(diagnostic.Bytes()), diagnostic.String()) + } + after, err := os.ReadFile(path) + require.NoError(t, err) + if action == "get" { + require.Equal(t, before, after) + } else { + registry, err := loadProfiles(t.Context(), path) + require.NoError(t, err) + require.Contains(t, registry.Profiles, "committed") + require.Contains(t, diagnostic.String(), "profile list") + } + }) + } + } +} + func TestProfileCommandsKeepStoreErrorsPrivate(t *testing.T) { path := profileCommandTestEnvironment(t) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index 0d33a551..a7521942 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -239,6 +239,9 @@ func handleProjectLink(ctx context.Context, command *cli.Command) error { status.EffectiveProject, status.ProjectRedacted = "", true } if err := ctx.Err(); err != nil { + if status.Action != "inspect" { + return projectLinkOutputFailure(root, status.Action, err) + } return err } writer := outputWriter{ctx: ctx, out: root.Writer} diff --git a/pkg/custom/project_link_errors.go b/pkg/custom/project_link_errors.go index bf9f86ad..dc9892f1 100644 --- a/pkg/custom/project_link_errors.go +++ b/pkg/custom/project_link_errors.go @@ -1,6 +1,7 @@ package custom import ( + "context" "errors" "os" @@ -56,13 +57,20 @@ func projectLinkCommittedSelectionFailure(action string, cause error) error { if action == "unlink" { outcome = "This folder has no saved link of its own. Its inherited project could not be resolved. " } + if errors.Is(cause, context.Canceled) { + return &localUtilityError{ + message: outcome + "The command was canceled. Inspect saved links before retrying.", + cause: cause, preserveOnCancel: true, + } + } var utility *localUtilityError + recovery := projectLinkRecovery(cause) if errors.As(cause, &utility) { // Profile selection can fail after the independent folder transaction. // Keep its vetted recovery instead of blaming the folder registry. - return projectLinkFailure(outcome+utility.Error(), cause) + recovery = utility.Error() } - return projectLinkFailure(outcome+projectLinkRecovery(cause), cause) + return &localUtilityError{message: outcome + recovery, cause: cause, preserveOnCancel: true} } func projectLinkOutputFailure(root *cli.Command, action string, cause error) error { @@ -73,5 +81,8 @@ func projectLinkOutputFailure(root *cli.Command, action string, cause error) err case "unlink": message = "This folder has no saved link of its own. Its confirmation could not be written." } - return projectLinkFailure(message+" Inspect with "+errorHelpInvocation(root)+" link.", cause) + return &localUtilityError{ + message: message + " Inspect with " + errorHelpInvocation(root) + " link.", + cause: cause, preserveOnCancel: action == "link" || action == "unlink", + } } diff --git a/pkg/custom/project_link_errors_test.go b/pkg/custom/project_link_errors_test.go index 13ed76f3..5b47b244 100644 --- a/pkg/custom/project_link_errors_test.go +++ b/pkg/custom/project_link_errors_test.go @@ -1,6 +1,9 @@ package custom import ( + "bytes" + "context" + "encoding/json" "errors" "io" "os" @@ -92,3 +95,46 @@ func TestProjectLinkOutputFailureReportsActualOutcome(t *testing.T) { }) } } + +func TestProjectLinkCommittedCancellationPreservesOutcome(t *testing.T) { + for _, format := range []string{"text", "json"} { + for _, test := range []struct { + name, outcome, wantProject string + args []string + }{ + {"inspect", "Request canceled.", "proj_saved", []string{"link"}}, + {"save", "The folder link was saved", "proj_updated", []string{"link", "--project=proj_updated"}}, + {"remove", "no saved link of its own", "", []string{"unlink"}}, + } { + t.Run(format+"/"+test.name, func(t *testing.T) { + path := projectLinkTestEnvironment(t) + require.NoError(t, projectLinkTestCommand(io.Discard).Run(t.Context(), []string{"openai", "link", "--project=proj_saved"})) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + root := projectLinkTestCommand(profileCancelOutputWriter{ctx, cancel}) + failure := root.Run(ctx, append([]string{"openai", "--format=" + format}, test.args...)) + require.ErrorIs(t, failure, context.Canceled) + var diagnostic bytes.Buffer + require.NoError(t, ShowCommandError(root, failure, &diagnostic)) + require.Contains(t, diagnostic.String(), test.outcome) + if format == "json" { + require.True(t, json.Valid(diagnostic.Bytes()), diagnostic.String()) + } + links, err := loadProjectLinks(t.Context(), path) + require.NoError(t, err) + directory, err := projectLinkDirectory() + require.NoError(t, err) + require.Equal(t, test.wantProject, links[directory]) + }) + } + } + for _, action := range []string{"link", "unlink"} { + cause := errors.Join(context.Canceled, &os.PathError{Op: "open", Path: "/synthetic-private/state", Err: os.ErrPermission}) + failure := projectLinkCommittedSelectionFailure(action, cause) + require.ErrorIs(t, failure, context.Canceled) + message := localErrorMessage(nil, failure) + require.Contains(t, message, "The command was canceled.") + require.NotContains(t, message, "synthetic-private") + require.NotContains(t, message, "project-links.json") + } +} From 8772eb068a583dca6b15b4e882a5deee0ae25ebf Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 06:58:24 -0400 Subject: [PATCH 21/36] Preserve profile context across admin and copied commands --- cmd/openai/profile_commands_test.go | 58 ++++++ pkg/custom/admin_credentials.go | 23 ++- pkg/custom/admin_credentials_test.go | 168 +++++++++++++++++ pkg/custom/admin_setup_interactive.go | 12 +- pkg/custom/admin_setup_origin_test.go | 223 +++++++++++++++++++++++ pkg/custom/file_receipt_context.go | 2 +- pkg/custom/file_receipt_context_test.go | 102 ++++++++++- pkg/custom/image_picker.go | 5 + pkg/custom/image_picker_command.go | 9 + pkg/custom/image_picker_generate.go | 6 +- pkg/custom/image_picker_generate_test.go | 60 ++++++ pkg/custom/image_picker_session.go | 2 +- pkg/custom/image_picker_session_test.go | 133 ++++++++++++++ pkg/custom/image_picker_view.go | 2 +- pkg/custom/profile_context.go | 7 +- 15 files changed, 791 insertions(+), 21 deletions(-) diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index 036798da..86cad71c 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "os/exec" "path/filepath" @@ -744,3 +745,60 @@ func TestMainProfileBaseURLUsesActualEffectiveEndpoint(t *testing.T) { } assertProfileJSON(t, profileCommand(t, home, directory, "profile", "get", "empty"), profileRecord("empty", false, map[string]string{"base_url": ""})) } + +func TestMainProfileAdminEndpointPreflightMatchesSelection(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "gateway", "--base-url=https://gateway.invalid/custom") + createProfileFixture(t, home, directory, "standard", "--base-url=https://api.openai.com/v1") + createProfileFixture(t, home, directory, "empty", "--base-url=") + activated := profileCommand(t, home, directory, "profile", "activate", "gateway") + require.Zero(t, activated.code, "%s", activated.stderr) + before, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + for _, tc := range []struct { + name, endpoint, failure string + env, flags []string + }{ + {name: "saved gateway", endpoint: "https://gateway.invalid/custom/organization/projects"}, + {name: "standard profile", flags: []string{"--profile=standard"}, failure: "Admin API key required."}, + {name: "empty profile endpoint", flags: []string{"--profile=empty"}, failure: "Admin API key required."}, + {name: "empty selector", flags: []string{"--profile="}, failure: "Admin API key required."}, + {name: "environment selector", env: []string{"OPENAI_PROFILE=standard"}, failure: "Admin API key required."}, + {name: "empty environment selector", env: []string{"OPENAI_PROFILE="}, failure: "Admin API key required."}, + {name: "flag selector overrides environment", env: []string{"OPENAI_PROFILE=standard"}, flags: []string{"--profile=gateway"}, endpoint: "https://gateway.invalid/custom/organization/projects"}, + {name: "standard flag overrides profile", flags: []string{"--base-url=https://api.openai.com/v1"}, failure: "Admin API key required."}, + {name: "standard environment overrides profile", env: []string{"OPENAI_BASE_URL=https://api.openai.com/v1"}, failure: "Admin API key required."}, + {name: "gateway flag overrides standard profile", flags: []string{"--profile=standard", "--base-url=https://flag.invalid/v1"}, endpoint: "https://flag.invalid/v1/organization/projects"}, + {name: "gateway environment overrides standard profile", env: []string{"OPENAI_BASE_URL=https://environment.invalid/v1"}, flags: []string{"--profile=standard"}, endpoint: "https://environment.invalid/v1/organization/projects"}, + {name: "empty flag retains profile", flags: []string{"--base-url="}, endpoint: "https://gateway.invalid/custom/organization/projects"}, + {name: "empty environment retains credential guard", env: []string{"OPENAI_BASE_URL="}, failure: "Admin API key required."}, + {name: "unknown profile preserves selection error", flags: []string{"--profile=missing"}, failure: "Selected profile does not exist."}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append(slices.Clone(tc.flags), "admin", "organization", "projects", "list", "--limit=1", "--format=raw", "--format-error=json") + // This subprocess replaces the transport before main. No destination can receive a real request. + got := runProfileURLMain(t, home, directory, tc.env, args) + if tc.failure != "" { + require.Equal(t, 1, got.code) + require.Empty(t, got.stdout) + require.True(t, json.Valid([]byte(got.stderr)), "%s", got.stderr) + require.Contains(t, got.stderr, tc.failure) + require.NotContains(t, got.stderr, "captured-url=") + } else { + require.Zero(t, got.code, "%s", got.stderr) + require.Equal(t, globalFlagsPage+"\n", got.stdout) + require.Equal(t, 1, strings.Count(got.stderr, "\n")) + require.True(t, strings.HasPrefix(got.stderr, "captured-url="), "%s", got.stderr) + captured, err := url.Parse(strings.TrimSuffix(strings.TrimPrefix(got.stderr, "captured-url="), "\n")) + require.NoError(t, err) + require.Equal(t, "1", captured.Query().Get("limit")) + captured.RawQuery = "" + require.Equal(t, tc.endpoint, captured.String()) + } + assertAuthStatusPrivate(t, got, "synthetic-url-key", home) + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } +} diff --git a/pkg/custom/admin_credentials.go b/pkg/custom/admin_credentials.go index 75e86d6d..57a263d6 100644 --- a/pkg/custom/admin_credentials.go +++ b/pkg/custom/admin_credentials.go @@ -55,7 +55,11 @@ func checkAdminCredentials(command *cli.Command) error { missing := &adminCredentialsError{invocation: invocation} // Custom endpoints own their authentication contracts and server errors. // Do not infer whether an arbitrary gateway requires credentials. - if !usesStandardAdminService(command) { + standard, err := usesStandardAdminService(command) + if err != nil { + return err + } + if !standard { return nil } // Validate headers before considering authentication, matching FlagOptions. @@ -96,20 +100,23 @@ func checkAdminCredentials(command *cli.Command) error { return missing } -func usesStandardAdminService(command *cli.Command) bool { - baseURL := command.String("base-url") - if baseURL == "" { - baseURL = os.Getenv("OPENAI_BASE_URL") +func usesStandardAdminService(command *cli.Command) (bool, error) { + resolved, err := resolvedCommandRequestContext(command) + if err != nil { + return false, err } + baseURL := resolved.BaseURL.Value if baseURL == "" { - return true + // Preserve the credential guard for an explicitly empty environment URL. + // This does not replace the endpoint that the SDK will receive. + return true, nil } endpoint, err := url.Parse(baseURL) if err != nil { - return false + return false, nil } // URL userinfo can provide HTTP Basic authentication through Go's client. return endpoint.User == nil && strings.EqualFold(endpoint.Scheme, "https") && (strings.EqualFold(endpoint.Hostname(), "api.openai.com") || strings.EqualFold(endpoint.Hostname(), "mtls.api.openai.com")) && - (endpoint.Port() == "" || endpoint.Port() == "443") + (endpoint.Port() == "" || endpoint.Port() == "443"), nil } diff --git a/pkg/custom/admin_credentials_test.go b/pkg/custom/admin_credentials_test.go index eb66b990..bc79274e 100644 --- a/pkg/custom/admin_credentials_test.go +++ b/pkg/custom/admin_credentials_test.go @@ -11,16 +11,19 @@ import ( "io" "net/http" "net/http/httptest" + "os" "testing" "github.com/openai/openai-cli/internal/apiquery" "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" ) func adminCredentialsTestCommand(t *testing.T, action cli.ActionFunc) *cli.Command { t.Helper() + profileCommandTestEnvironment(t) for _, name := range []string{ "OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_CUSTOM_HEADERS", "OPENAI_BASE_URL", mtlsClientCertFileEnv, mtlsClientKeyFileEnv, @@ -217,6 +220,171 @@ func TestAdminCredentialsEndpointOverrides(t *testing.T) { } } +func TestAdminCredentialsProfileEndpointsMatchRequests(t *testing.T) { + for _, tc := range []struct { + name, endpoint string + absentEndpoint bool + env map[string]string + args []string + wantURL string + wantAuth string + }{ + {name: "saved gateway", endpoint: "https://gateway.example.test/v1", wantURL: "https://gateway.example.test/v1/organization/projects"}, + {name: "standard profile", endpoint: productionProfileURL}, + {name: "standard mtls profile", endpoint: "https://MTLS.API.OpenAI.com:443/v1"}, + {name: "empty profile endpoint"}, + {name: "absent profile endpoint", absentEndpoint: true}, + {name: "empty flag retains profile", endpoint: "https://gateway.example.test/v1", args: []string{"--base-url="}, wantURL: "https://gateway.example.test/v1/organization/projects"}, + {name: "environment replaces profile", endpoint: "https://gateway.example.test/v1", env: map[string]string{"OPENAI_BASE_URL": productionProfileURL}}, + {name: "environment gateway", endpoint: productionProfileURL, env: map[string]string{"OPENAI_BASE_URL": "https://environment.example.test/v1/"}, wantURL: "https://environment.example.test/v1/organization/projects"}, + {name: "empty environment retains credential guard", endpoint: "https://gateway.example.test/v1", env: map[string]string{"OPENAI_BASE_URL": ""}}, + {name: "flag replaces environment and profile", endpoint: "https://gateway.example.test/v1", env: map[string]string{"OPENAI_BASE_URL": "https://environment.example.test/v1/"}, args: []string{"--base-url=" + productionProfileURL}}, + {name: "flag gateway", endpoint: productionProfileURL, args: []string{"--base-url=https://flag.example.test/v1/"}, wantURL: "https://flag.example.test/v1/organization/projects"}, + {name: "disabled profile", endpoint: "https://gateway.example.test/v1", args: []string{"--profile="}}, + {name: "disabled environment selector", endpoint: "https://gateway.example.test/v1", env: map[string]string{"OPENAI_PROFILE": ""}}, + {name: "explicit selector", endpoint: "https://gateway.example.test/v1", args: []string{"--profile=work"}, wantURL: "https://gateway.example.test/v1/organization/projects"}, + {name: "standard profile admin key", endpoint: productionProfileURL, env: map[string]string{"OPENAI_ADMIN_KEY": "synthetic-admin-key"}, wantURL: "https://api.openai.com/v1/organization/projects", wantAuth: "Bearer synthetic-admin-key"}, + {name: "standard profile literal header", endpoint: productionProfileURL, args: []string{"-H", "Authorization: Basic synthetic-header"}, wantURL: "https://api.openai.com/v1/organization/projects", wantAuth: "Basic synthetic-header"}, + {name: "empty literal header overrides admin key", endpoint: productionProfileURL, env: map[string]string{"OPENAI_ADMIN_KEY": "synthetic-admin-key"}, args: []string{"-H", "Authorization:"}}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + root := adminCredentialsTestCommand(t, func(ctx context.Context, command *cli.Command) error { + methodOptions, err := FlagOptions(command, apiquery.NestedQueryFormatBrackets, apiquery.ArrayQueryFormatRepeat, EmptyBody, false) + if err != nil { + return err + } + options := append(GetDefaultRequestOptions(command), option.WithHTTPClient(profileContextTransport(func(request *http.Request) { + calls++ + require.Equal(t, tc.wantURL, request.URL.String()) + require.Equal(t, tc.wantAuth, request.Header.Get("Authorization")) + }))) + client := openai.NewClient(options...) + _, err = client.Admin.Organization.Projects.List(ctx, openai.AdminOrganizationProjectListParams{}, methodOptions...) + return err + }) + require.NoError(t, os.Unsetenv("OPENAI_BASE_URL")) + for name, value := range tc.env { + t.Setenv(name, value) + } + path, err := profileStorePath() + require.NoError(t, err) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + values := profileValues{} + if !tc.absentEndpoint { + values.BaseURL = &tc.endpoint + } + registry.Profiles["work"] = values + name := "work" + registry.DefaultProfile = &name + return nil + }) + require.NoError(t, err) + ConfigureCommand(root) + args := append([]string{root.Name, "admin", "organization", "projects", "list"}, tc.args...) + err = root.Run(t.Context(), args) + if tc.wantURL != "" { + require.NoError(t, err) + require.Equal(t, 1, calls) + } else { + var missing *adminCredentialsError + require.ErrorAs(t, err, &missing) + require.Zero(t, calls) + } + }) + } +} + +func TestAdminCredentialsProfileResolutionFailures(t *testing.T) { + for _, malformed := range []bool{false, true} { + t.Run(fmt.Sprintf("malformed=%t", malformed), func(t *testing.T) { + root := adminCredentialsTestCommand(t, func(context.Context, *cli.Command) error { + t.Fatal("profile resolution failure must stop the action") + return nil + }) + t.Setenv("OPENAI_PROFILE", "missing") + if malformed { + path, err := profileStorePath() + require.NoError(t, err) + writeProfileRegistryTestFile(t, path, `{"synthetic-sensitive-field":"synthetic-sensitive-value"}`) + } + ConfigureCommand(root) + err := root.Run(t.Context(), []string{root.Name, "--base-url=https://gateway.example.test/v1/", "admin", "organization", "projects", "list"}) + var local *localUtilityError + require.ErrorAs(t, err, &local) + require.NotContains(t, err.Error(), "synthetic-sensitive") + var missing *adminCredentialsError + require.False(t, errors.As(err, &missing)) + }) + } +} + +func TestAdminCredentialsProfileEndpointSnapshot(t *testing.T) { + endpoint := "https://gateway.example.test/v1" + var path string + requests := 0 + root := adminCredentialsTestCommand(t, func(ctx context.Context, command *cli.Command) error { + _, err := updateProfiles(ctx, path, func(registry *profileRegistry) error { + standard := productionProfileURL + registry.Profiles["work"] = profileValues{BaseURL: &standard} + return nil + }) + if err != nil { + return err + } + options := append(GetDefaultRequestOptions(command), option.WithHTTPClient(profileContextTransport(func(request *http.Request) { + requests++ + require.Equal(t, endpoint+"/organization/projects", request.URL.String()) + }))) + client := openai.NewClient(options...) + _, err = client.Admin.Organization.Projects.List(ctx, openai.AdminOrganizationProjectListParams{}) + return err + }) + require.NoError(t, os.Unsetenv("OPENAI_BASE_URL")) + var err error + path, err = profileStorePath() + require.NoError(t, err) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["work"] = profileValues{BaseURL: &endpoint} + name := "work" + registry.DefaultProfile = &name + return nil + }) + require.NoError(t, err) + ConfigureCommand(root) + args := []string{root.Name, "admin", "organization", "projects", "list"} + require.NoError(t, root.Run(t.Context(), args)) + require.Equal(t, 1, requests) + var missing *adminCredentialsError + require.ErrorAs(t, root.Run(t.Context(), args), &missing) + require.Equal(t, 1, requests) +} + +func TestAdminProfileEndpointDoesNotAuthorizeMTLS(t *testing.T) { + for _, route := range [][]string{{"admin", "organization", "projects", "list"}, {"setup", "admin"}} { + t.Run(route[0], func(t *testing.T) { + root := adminCredentialsTestCommand(t, func(context.Context, *cli.Command) error { + t.Fatal("mTLS must reject a profile-only endpoint before the action") + return nil + }) + require.NoError(t, os.Unsetenv("OPENAI_BASE_URL")) + path, err := profileStorePath() + require.NoError(t, err) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + endpoint, name := "https://mtls.api.openai.com/v1", "work" + registry.Profiles[name] = profileValues{BaseURL: &endpoint} + registry.DefaultProfile = &name + return nil + }) + require.NoError(t, err) + ConfigureCommand(root) + args := []string{root.Name, "--mtls-client-cert-file=synthetic-cert.pem", "--mtls-client-key-file=synthetic-key.pem"} + err = root.Run(t.Context(), append(args, route...)) + require.ErrorContains(t, err, "mTLS requires an explicit HTTPS base URL") + }) + } +} + func TestAdminCredentialsTypedErrorPresentation(t *testing.T) { for _, format := range []string{"text", "json"} { t.Run(format, func(t *testing.T) { diff --git a/pkg/custom/admin_setup_interactive.go b/pkg/custom/admin_setup_interactive.go index a1d5976c..f6faf971 100644 --- a/pkg/custom/admin_setup_interactive.go +++ b/pkg/custom/admin_setup_interactive.go @@ -37,7 +37,7 @@ Do not ask the owner to share a key. Setup requires a terminal and text output. Ctrl+C cancels. Interactive entry is available on macOS, Linux, and Windows. Verification stops after 15 seconds, without retries or redirects. -The displayed destination follows --base-url or OPENAI_BASE_URL. +The displayed destination follows --base-url, OPENAI_BASE_URL, or the selected profile. HTTPS is required except for a local loopback server. The pasted key replaces the Authorization header for this request. Debug logging and structured output are unavailable during interactive setup. @@ -153,13 +153,11 @@ func handleAdminSetup(ctx context.Context, command *cli.Command) error { } func adminSetupRequestOptions(command *cli.Command) ([]option.RequestOption, string, error) { - baseURL := command.String("base-url") - if baseURL == "" { - baseURL = os.Getenv("OPENAI_BASE_URL") - } - if baseURL == "" { - baseURL = "https://api.openai.com/v1" + resolved, err := resolvedCommandRequestContext(command) + if err != nil { + return nil, "", err } + baseURL := resolved.BaseURL.Value endpoint, err := url.Parse(baseURL) if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { return nil, "", &adminSetupError{message: "Admin setup needs a base URL without embedded credentials, a query, or a fragment.", code: 3} diff --git a/pkg/custom/admin_setup_origin_test.go b/pkg/custom/admin_setup_origin_test.go index 3cc047a6..c6da28da 100644 --- a/pkg/custom/admin_setup_origin_test.go +++ b/pkg/custom/admin_setup_origin_test.go @@ -4,8 +4,11 @@ import ( "context" "io" "net/http" + "net/http/httptest" "net/url" + "os" "strings" + "sync/atomic" "testing" "github.com/stretchr/testify/require" @@ -51,6 +54,226 @@ func TestAdminSetupOriginDisplayPreservesDestination(t *testing.T) { } } +func TestAdminSetupProfileEndpointOptions(t *testing.T) { + for _, tc := range []struct { + name, endpoint, wantURL, wantDisplay string + absentEndpoint bool + env map[string]string + args []string + }{ + {name: "saved destination", endpoint: "https://gateway.example.test/custom/v1", wantURL: "https://gateway.example.test/custom/v1/organization/projects?limit=1", wantDisplay: "https://gateway.example.test"}, + {name: "empty saved endpoint", wantURL: "https://api.openai.com/v1/organization/projects?limit=1", wantDisplay: "https://api.openai.com"}, + {name: "absent saved endpoint", absentEndpoint: true, wantURL: "https://api.openai.com/v1/organization/projects?limit=1", wantDisplay: "https://api.openai.com"}, + {name: "empty flag retains profile", endpoint: "https://gateway.example.test/v1", args: []string{"--base-url="}, wantURL: "https://gateway.example.test/v1/organization/projects?limit=1", wantDisplay: "https://gateway.example.test"}, + {name: "environment overrides profile", endpoint: "https://gateway.example.test/v1", env: map[string]string{"OPENAI_BASE_URL": "https://environment.example.test/v1/"}, wantURL: "https://environment.example.test/v1/organization/projects?limit=1", wantDisplay: "https://environment.example.test"}, + {name: "flag overrides environment", endpoint: "https://gateway.example.test/v1", env: map[string]string{"OPENAI_BASE_URL": "https://environment.example.test/v1/"}, args: []string{"--base-url=https://flag.example.test/v1/"}, wantURL: "https://flag.example.test/v1/organization/projects?limit=1", wantDisplay: "https://flag.example.test"}, + {name: "environment selector", endpoint: "https://gateway.example.test/v1", env: map[string]string{"OPENAI_PROFILE": "work"}, wantURL: "https://gateway.example.test/v1/organization/projects?limit=1", wantDisplay: "https://gateway.example.test"}, + {name: "disabled default", endpoint: "https://gateway.example.test/v1", args: []string{"--profile="}, wantURL: "https://api.openai.com/v1/organization/projects?limit=1", wantDisplay: "https://api.openai.com"}, + {name: "escaped saved origin", endpoint: "https://prefix\u202e.example.test/v1", wantURL: "https://prefix\u202e.example.test/v1/organization/projects?limit=1", wantDisplay: `https://prefix\u202e.example.test`}, + } { + t.Run(tc.name, func(t *testing.T) { + expected, err := url.Parse(tc.wantURL) + require.NoError(t, err) + requests := 0 + root := adminCredentialsTestCommand(t, func(ctx context.Context, command *cli.Command) error { + options, display, err := adminSetupRequestOptions(command) + if err != nil { + return err + } + require.Equal(t, tc.wantDisplay, display) + return verifyAdminSetup(ctx, options, []byte("synthetic-entered-key")) + }) + require.NoError(t, os.Unsetenv("OPENAI_BASE_URL")) + t.Setenv("OPENAI_ADMIN_KEY", "synthetic-environment-key") + t.Setenv("OPENAI_CUSTOM_HEADERS", "Authorization: Bearer synthetic-environment-header\nOpenAI-Project: proj_inherited\nX-Api-Key: synthetic-gateway-key") + for name, value := range tc.env { + t.Setenv(name, value) + } + path, err := profileStorePath() + require.NoError(t, err) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + values := profileValues{} + if !tc.absentEndpoint { + values.BaseURL = &tc.endpoint + } + registry.Profiles["work"] = values + name := "work" + registry.DefaultProfile = &name + return nil + }) + require.NoError(t, err) + configureProfileContext(root) + root.Metadata = map[string]any{mtlsHTTPClientMetadata: &http.Client{ + Transport: adminSetupOriginTransport(func(request *http.Request) (*http.Response, error) { + requests++ + require.Equal(t, expected.Scheme, request.URL.Scheme) + require.Equal(t, expected.Host, request.URL.Host) + require.Equal(t, expected.RequestURI(), request.URL.RequestURI()) + require.Equal(t, "Bearer synthetic-entered-key", request.Header.Get("Authorization")) + require.Equal(t, "proj_literal", request.Header.Get("OpenAI-Project")) + require.Equal(t, "synthetic-gateway-key", request.Header.Get("X-Api-Key")) + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[],"has_more":false}`)), Request: request}, nil + }), + }} + args := []string{root.Name, "--admin-api-key=synthetic-flag-key", "-H", "Authorization: Bearer synthetic-literal-key", "-H", "OpenAI-Project: proj_literal"} + args = append(args, tc.args...) + require.NoError(t, root.Run(t.Context(), append(args, "admin:organization:projects", "list"))) + require.Equal(t, 1, requests) + }) + } +} + +func TestAdminSetupProfileEndpointRejectsUnsafeOverrides(t *testing.T) { + for _, tc := range []struct { + name, profileURL, environmentURL string + useEnvironment bool + }{ + {name: "profile HTTP", profileURL: "http://gateway.example.test/v1"}, + {name: "empty environment", profileURL: "https://gateway.example.test/v1", useEnvironment: true}, + {name: "environment HTTP", environmentURL: "http://gateway.example.test/v1", useEnvironment: true}, + {name: "embedded credential", environmentURL: "https://synthetic-user:synthetic-secret@gateway.example.test/v1", useEnvironment: true}, + {name: "query", environmentURL: "https://gateway.example.test/v1?key=synthetic-secret", useEnvironment: true}, + {name: "fragment", environmentURL: "https://gateway.example.test/v1#synthetic-secret", useEnvironment: true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := adminCredentialsTestCommand(t, func(_ context.Context, command *cli.Command) error { + options, display, err := adminSetupRequestOptions(command) + require.Nil(t, options) + require.Empty(t, display) + return err + }) + require.NoError(t, os.Unsetenv("OPENAI_BASE_URL")) + if tc.useEnvironment { + t.Setenv("OPENAI_BASE_URL", tc.environmentURL) + } + path, err := profileStorePath() + require.NoError(t, err) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["work"] = profileValues{BaseURL: &tc.profileURL} + name := "work" + registry.DefaultProfile = &name + return nil + }) + require.NoError(t, err) + configureProfileContext(root) + root.ExitErrHandler = func(context.Context, *cli.Command, error) {} + err = root.Run(t.Context(), []string{root.Name, "admin:organization:projects", "list"}) + var setup *adminSetupError + require.ErrorAs(t, err, &setup) + require.Equal(t, 3, setup.ExitCode()) + require.NotContains(t, err.Error(), "synthetic-secret") + require.NotContains(t, err.Error(), "gateway.example.test") + }) + } +} + +func TestAdminSetupProfileResolutionFailures(t *testing.T) { + for _, malformed := range []bool{false, true} { + name := "missing" + if malformed { + name = "malformed" + } + t.Run(name, func(t *testing.T) { + root := adminCredentialsTestCommand(t, func(_ context.Context, command *cli.Command) error { + options, display, err := adminSetupRequestOptions(command) + require.Nil(t, options) + require.Empty(t, display) + return err + }) + t.Setenv("OPENAI_PROFILE", "missing") + if malformed { + path, err := profileStorePath() + require.NoError(t, err) + writeProfileRegistryTestFile(t, path, `{"synthetic-sensitive-field":"synthetic-sensitive-value"}`) + } + configureProfileContext(root) + err := root.Run(t.Context(), []string{root.Name, "--base-url=https://gateway.example.test/v1/", "admin:organization:projects", "list"}) + var local *localUtilityError + require.ErrorAs(t, err, &local) + require.NotContains(t, err.Error(), "synthetic-sensitive") + }) + } +} + +func TestAdminSetupProfileEndpointLoopback(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusFound, http.StatusForbidden} { + t.Run(http.StatusText(status), func(t *testing.T) { + var requests atomic.Int32 + type requestDetails struct{ uri, authorization, organization, project string } + observed := make(chan requestDetails, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requests.Add(1) + select { + case observed <- requestDetails{request.URL.RequestURI(), request.Header.Get("Authorization"), request.Header.Get("OpenAI-Organization"), request.Header.Get("OpenAI-Project")}: + default: + } + w.Header().Set("Content-Type", "application/json") + if request.URL.Path == "/redirected" { + _, _ = io.WriteString(w, `{"object":"list","data":[],"has_more":false}`) + return + } + w.Header().Set("Location", "/redirected") + w.WriteHeader(status) + if status == http.StatusOK { + _, _ = io.WriteString(w, `{"object":"list","data":[],"has_more":false}`) + } else { + _, _ = io.WriteString(w, `{"error":{"message":"synthetic-entered-key"}}`) + } + })) + t.Cleanup(server.Close) + root := adminCredentialsTestCommand(t, func(ctx context.Context, command *cli.Command) error { + options, display, err := adminSetupRequestOptions(command) + if err != nil { + return err + } + require.Equal(t, server.URL, display) + return verifyAdminSetup(ctx, options, []byte("synthetic-entered-key")) + }) + require.NoError(t, os.Unsetenv("OPENAI_BASE_URL")) + t.Setenv("OPENAI_ADMIN_KEY", "synthetic-environment-key") + t.Setenv("OPENAI_CUSTOM_HEADERS", "Authorization: Bearer synthetic-environment-header\nOpenAI-Organization: org_inherited\nOpenAI-Project: proj_inherited") + path, err := profileStorePath() + require.NoError(t, err) + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + endpoint, organization, project, name := server.URL+"/saved/v1", "org_profile", "proj_profile", "work" + registry.Profiles[name] = profileValues{Organization: &organization, Project: &project, BaseURL: &endpoint} + registry.DefaultProfile = &name + return nil + }) + require.NoError(t, err) + configureProfileContext(root) + client := server.Client() + redirects := 0 + client.CheckRedirect = func(*http.Request, []*http.Request) error { redirects++; return nil } + root.Metadata = map[string]any{mtlsHTTPClientMetadata: client} + root.ExitErrHandler = func(context.Context, *cli.Command, error) {} + err = root.Run(t.Context(), []string{root.Name, "admin:organization:projects", "list"}) + if status == http.StatusOK { + require.NoError(t, err) + } else { + require.Error(t, err) + require.NotContains(t, err.Error(), "synthetic-entered-key") + if status == http.StatusFound { + require.ErrorContains(t, err, "stopped at a redirect") + } else { + require.ErrorContains(t, err, "denied admin access (403)") + } + } + require.EqualValues(t, 1, requests.Load()) + select { + case request := <-observed: + require.Equal(t, requestDetails{"/saved/v1/organization/projects?limit=1", "Bearer synthetic-entered-key", "org_profile", "proj_profile"}, request) + default: + t.Fatal("verification did not reach the profile endpoint") + } + require.Zero(t, redirects) + require.NoError(t, client.CheckRedirect(nil, nil)) + require.Equal(t, 1, redirects) + }) + } +} + type adminSetupOriginTransport func(*http.Request) (*http.Response, error) func (transport adminSetupOriginTransport) RoundTrip(request *http.Request) (*http.Response, error) { diff --git a/pkg/custom/file_receipt_context.go b/pkg/custom/file_receipt_context.go index 3efbc3d4..10acf91c 100644 --- a/pkg/custom/file_receipt_context.go +++ b/pkg/custom/file_receipt_context.go @@ -28,7 +28,7 @@ func fileReceiptRequestOptions(root *cli.Command) (args []string, omit bool) { return nil, true } } - for _, name := range []string{"project", "organization", "base-url"} { + for _, name := range []string{"profile", "project", "organization", "base-url"} { if !root.IsSet(name) { continue } diff --git a/pkg/custom/file_receipt_context_test.go b/pkg/custom/file_receipt_context_test.go index 576aabdc..0357bc3f 100644 --- a/pkg/custom/file_receipt_context_test.go +++ b/pkg/custom/file_receipt_context_test.go @@ -5,8 +5,11 @@ import ( "context" "io" "os" + "strings" "testing" + "unicode" + "github.com/openai/openai-cli/internal/audiohistory" "github.com/openai/openai-cli/internal/requestflag" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -21,6 +24,9 @@ func TestFileReceiptRequestContext(t *testing.T) { omit bool }{ {"environment credentials", nil, nil, false}, + {"explicit profile", []string{"--profile=selected"}, []string{"--profile=selected"}, false}, + {"explicit empty profile", []string{"--profile="}, []string{"--profile="}, false}, + {"profile and request selection", []string{"--profile=selected", "--project=project-one"}, []string{"--profile=selected", "--project=project-one"}, false}, {"request selection", []string{"--project=project-one", "--organization=org-one", "--base-url=https://example.invalid/proxy/v1"}, []string{"--project=project-one", "--organization=org-one", "--base-url=https://example.invalid/proxy/v1"}, false}, {"explicit empty project", []string{"--project="}, []string{"--project="}, false}, {"same inherited key", []string{"--api-key=sk-fake-inherited"}, nil, false}, @@ -36,6 +42,10 @@ func TestFileReceiptRequestContext(t *testing.T) { {"URL fragment", []string{"--base-url=https://example.invalid/v1#fake-secret"}, nil, true}, {"URL empty query", []string{"--base-url=https://example.invalid/v1?"}, nil, true}, {"invalid project", []string{"--project=proj\x00name"}, nil, true}, + {"invalid profile NUL", []string{"--profile=bad\x00name"}, nil, true}, + {"invalid profile UTF-8", []string{"--profile=bad\xffname"}, nil, true}, + {"profile with secret override", []string{"--profile=selected", "--api-key=sk-fake-override"}, nil, true}, + {"profile with private URL", []string{"--profile=selected", "--base-url=https://example.invalid/v1?key=fake-secret"}, nil, true}, } { t.Run(tc.name, func(t *testing.T) { for _, name := range []string{"OPENAI_ADMIN_KEY", "OPENAI_WEBHOOK_SECRET", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", mtlsClientCertFileEnv, mtlsClientKeyFileEnv} { @@ -43,7 +53,8 @@ func TestFileReceiptRequestContext(t *testing.T) { require.NoError(t, os.Unsetenv(name)) } t.Setenv("OPENAI_API_KEY", "sk-fake-inherited") - root := fileWorkflowCommandTree(func(ctx context.Context, _ *cli.Command) error { + t.Setenv("OPENAI_PROFILE", "inherited-profile") + root := fileWorkflowCommandTree(func(ctx context.Context, command *cli.Command) error { invocation := ctx.Value(fileInvocationKey{}).(fileInvocation) require.Equal(t, tc.want, invocation.requestArgs) require.Equal(t, tc.omit, invocation.omitHint) @@ -53,6 +64,17 @@ func TestFileReceiptRequestContext(t *testing.T) { for _, secret := range []string{"sk-fake", "fake-secret", "/synthetic/private/"} { require.NotContains(t, receipt.String(), secret) } + model := newAudioWorkbench(ctx, command, nil, audiohistory.Store{}) + model.input.SetValue("SYNTHETIC_PRIVATE_EDITOR_TEXT") + preview := model.commandPreview() + if tc.omit { + require.Equal(t, "Command preview unavailable with private request overrides.", preview) + } else { + require.Contains(t, preview, "OPENAI_UNTRUSTED_STDIN=true") + } + for _, secret := range []string{"sk-fake", "fake-secret", "/synthetic/private/", "inherited-profile"} { + require.NotContains(t, preview, secret) + } return nil }) root.Writer, root.ErrWriter = io.Discard, io.Discard @@ -61,6 +83,7 @@ func TestFileReceiptRequestContext(t *testing.T) { root.Flags = append(root.Flags, &requestflag.Flag[string]{Name: pair[0], Sources: cli.EnvVars(pair[1])}) } root.Flags = append(root.Flags, mtlsClientFlags()...) + configureProfileContext(root) configureRootRequestFlags(root) configureTaskCommands(root) configureFileCommands(root) @@ -69,3 +92,80 @@ func TestFileReceiptRequestContext(t *testing.T) { }) } } + +func TestFileAndAudioCopiedCommandsPreserveProfileSelector(t *testing.T) { + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + binary, directory := audioPreviewExecutable(t, "openai preview's binary") + for _, tc := range []struct { + name, environment, rootFlag, childFlag string + want []string + }{ + {"explicit", "inherited-profile", "--profile=team's \"$(printf altered)\" 世界\u202e", "", []string{"--profile=team's \"$(printf altered)\" 世界\u202e"}}, + {"explicit empty", "inherited-profile", "--profile=", "", []string{"--profile="}}, + {"inherited", "inherited-profile", "", "", nil}, + {"inherited empty", "", "", "", nil}, + {"child ownership", "inherited-profile", "", "--profile=/synthetic/private/child", nil}, + {"root and child ownership", "inherited-profile", "--profile=selected", "--profile=/synthetic/private/child", []string{"--profile=selected"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("OPENAI_PROFILE", tc.environment) + var invocation fileInvocation + var model *audioWorkbench + root := fileWorkflowCommandTree(func(ctx context.Context, command *cli.Command) error { + invocation = ctx.Value(fileInvocationKey{}).(fileInvocation) + model = newAudioWorkbench(ctx, command, nil, audiohistory.Store{}) + return nil + }) + root.Metadata = map[string]any{"help-invocation": audioShellQuote(binary)} + configureProfileContext(root) + if tc.childFlag != "" { + create := root.Command("files").Command("create") + create.Flags = append(create.Flags, &cli.StringFlag{Name: "profile"}) + } + configureTaskCommands(root) + configureFileCommands(root) + args := []string{"openai"} + if tc.rootFlag != "" { + args = append(args, tc.rootFlag) + } + args = append(args, "files", "upload", "source.txt", "--purpose=user_data") + if tc.childFlag != "" { + args = append(args, tc.childFlag) + } + require.NoError(t, root.Run(t.Context(), args)) + require.NotNil(t, model, "upload action must run") + require.Equal(t, tc.want, invocation.requestArgs) + require.False(t, invocation.omitHint) + + // Use the recorder executable while retaining parsed request arguments. + invocation.display, invocation.executable = binary, binary + invocation.goRun = false + var receipt bytes.Buffer + require.NoError(t, writeFileReceipt(&receipt, gjson.Parse(fileReceiptFixture), shell, invocation)) + _, copiedFile, found := strings.Cut(receipt.String(), "\nInspect it: ") + require.True(t, found, receipt.String()) + fileCapture := executeAudioPreview(t, shell, strings.TrimSpace(copiedFile), directory) + require.Equal(t, append(append([]string{}, tc.want...), "files", "get", "file-example"), fileCapture.args) + require.Empty(t, fileCapture.stdin) + require.Equal(t, "unset", fileCapture.protected) + + model.settings.Format = "wav" + model.input.SetValue("@synthetic 'literal' text") + preview := model.commandPreview() + for _, copied := range []string{copiedFile, preview} { + require.NotContains(t, copied, "/synthetic/private/") + require.NotContains(t, copied, "inherited-profile") + for _, character := range strings.TrimSpace(copied) { + require.False(t, unicode.IsControl(character) || unicode.Is(unicode.Cf, character), "literal control in copied command") + } + } + audioCapture := executeAudioPreview(t, shell, preview, directory) + require.Equal(t, append(append([]string{}, tc.want...), "audio", "speak", "--output", "speech.wav"), audioCapture.args) + require.Equal(t, "true", audioCapture.protected) + assertAudioPreviewJSON(t, model, audioCapture.stdin) + }) + } + }) + } +} diff --git a/pkg/custom/image_picker.go b/pkg/custom/image_picker.go index 398747ec..812dc575 100644 --- a/pkg/custom/image_picker.go +++ b/pkg/custom/image_picker.go @@ -25,6 +25,7 @@ type imagePickerOptions struct { Prompt string // Shell selects quoting for the displayed command, never execution. Shell string + profile *string initial *imagePickerSettings resuming bool initialNote string @@ -40,6 +41,7 @@ type imagePickerResult struct { ExitCode int settings imagePickerSettings shell string + profile *string changed bool draftSaved bool } @@ -160,6 +162,7 @@ func runImagePicker(parent context.Context, input, output *os.File, options imag func (m *imagePicker) snapshotResult(initial imagePickerSettings) imagePickerResult { result := m.result result.settings = m.settings + result.profile = m.profile result.changed = m.settings != initial return result } @@ -209,6 +212,7 @@ type imagePicker struct { settings imagePickerSettings page, field string shell string + profile *string focus string returnPage string returnSelected int @@ -245,6 +249,7 @@ func newImagePicker(options imagePickerOptions) (*imagePicker, error) { m.settings.prompt = options.Prompt } m.shell = options.Shell + m.profile = options.profile m.hidePromptHint = options.resuming m.note = options.initialNote return m, nil diff --git a/pkg/custom/image_picker_command.go b/pkg/custom/image_picker_command.go index 054833b1..4a5a4714 100644 --- a/pkg/custom/image_picker_command.go +++ b/pkg/custom/image_picker_command.go @@ -25,6 +25,15 @@ func (s imagePickerSettings) args() []string { const imagePickerUnsupportedShell = "Command printing needs Bash, zsh, fish or PowerShell 7." +// The explicit selector belongs only to displayed commands, never request arguments or saved drafts. +// A non-nil empty selector disables the saved default profile. +func imagePickerDisplayArgs(args []string, profile *string) []string { + if profile == nil { + return args + } + return append([]string{"--profile", *profile}, args...) +} + func imagePickerShellQuoter(shell string) func(string) string { switch shell { case "bash", "zsh": diff --git a/pkg/custom/image_picker_generate.go b/pkg/custom/image_picker_generate.go index 5ae13004..4a4e7a66 100644 --- a/pkg/custom/image_picker_generate.go +++ b/pkg/custom/image_picker_generate.go @@ -48,6 +48,10 @@ func imagePickerWorkflow(next cli.ActionFunc) cli.ActionFunc { // selection from the picker; restoring text never starts generation. func runImagePickerDrafts(ctx context.Context, command *cli.Command, next cli.ActionFunc, diagnostics io.Writer, pick func(imagePickerOptions) (imagePickerResult, error)) error { options := imagePickerOptions{} + if command.Root().IsSet("profile") { + profile := command.Root().String("profile") + options.profile = &profile + } for { if err := ctx.Err(); err != nil { return err @@ -69,7 +73,7 @@ func runImagePickerDrafts(ctx context.Context, command *cli.Command, next cli.Ac if result.PrintOnly { return nil } - options = imagePickerOptions{Prompt: result.settings.prompt, initial: &result.settings, resuming: true} + options = imagePickerOptions{Prompt: result.settings.prompt, profile: options.profile, initial: &result.settings, resuming: true} requestCtx := context.WithValue(ctx, imagePickerLoadingPromptKey{}, result.settings.prompt) if err := runImagePickerAction(requestCtx, command, result.Args, next); err != nil { if ctx.Err() != nil || !imagePickerRecoverableRequest(err) { diff --git a/pkg/custom/image_picker_generate_test.go b/pkg/custom/image_picker_generate_test.go index 659ed2fa..0bff653e 100644 --- a/pkg/custom/image_picker_generate_test.go +++ b/pkg/custom/image_picker_generate_test.go @@ -134,6 +134,66 @@ func TestImagePickerDraftRecovery(t *testing.T) { } } +func TestImagePickerDraftsKeepExplicitRootProfile(t *testing.T) { + for _, test := range []struct { + name string + args []string + explicit bool + profile string + }{ + {name: "environment only"}, + {name: "explicit", args: []string{"--profile", "studio"}, explicit: true, profile: "studio"}, + {name: "explicit empty", args: []string{"--profile="}, explicit: true}, + {name: "special name", args: []string{"--profile", "studio '雪' $HOME $(touch NOT_RUN)"}, explicit: true, profile: "studio '雪' $HOME $(touch NOT_RUN)"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("OPENAI_PROFILE", "ambient-profile") + generate := &cli.Command{Name: "generate", Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "prompt", BodyPath: "prompt"}, + }} + root := &cli.Command{Name: "openai", Commands: []*cli.Command{{Name: "images", Commands: []*cli.Command{generate}}}} + configureProfileContext(root) + picks, requests := 0, 0 + generate.Action = func(ctx context.Context, command *cli.Command) error { + require.True(t, imagePickerRequested(command), "a root selector must still allow the picker") + return runImagePickerDrafts(ctx, command, func(context.Context, *cli.Command) error { + requests++ + require.Equal(t, test.explicit, root.IsSet("profile")) + require.Equal(t, test.profile, root.String("profile")) + require.Equal(t, map[string]any{"prompt": "synthetic scene"}, requestflag.ExtractRequestContents(command).Body) + if requests == 1 { + return &openai.Error{StatusCode: 429} + } + return nil + }, io.Discard, func(options imagePickerOptions) (imagePickerResult, error) { + picks++ + require.Equal(t, picks-1, requests) + if test.explicit { + require.NotNil(t, options.profile) + require.Equal(t, test.profile, *options.profile) + } else { + require.Nil(t, options.profile, "ambient selection must not become an explicit copied flag") + } + require.Equal(t, picks > 1, options.resuming) + require.False(t, command.IsSet("prompt"), "each picker must receive restored request flags") + if picks == 3 { + return imagePickerResult{PrintOnly: true}, nil + } + return imagePickerResult{ + settings: imagePickerSettings{prompt: "synthetic scene"}, + Args: []string{"images", "generate", "--prompt", "synthetic scene"}, + }, nil + }) + } + args := append([]string{"openai"}, test.args...) + args = append(args, "images", "generate") + require.NoError(t, root.Run(t.Context(), args)) + require.Equal(t, 3, picks) + require.Equal(t, 2, requests) + }) + } +} + func TestImagePickerRecoverableRequest(t *testing.T) { apiFailure := &openai.Error{StatusCode: 400} transportFailure := &url.Error{Op: "Post", URL: "https://example.invalid", Err: io.EOF} diff --git a/pkg/custom/image_picker_session.go b/pkg/custom/image_picker_session.go index 4ed86c5d..a5abd225 100644 --- a/pkg/custom/image_picker_session.go +++ b/pkg/custom/image_picker_session.go @@ -97,7 +97,7 @@ func finishImagePickerSelection(ctx context.Context, output, diagnostics io.Writ if result.Canceled || len(result.Args) < 4 || (len(result.Args)-2)%2 != 0 { return fmt.Errorf("no image request was selected") } - command := formatImagePickerCommand(result.Args, result.shell) + command := formatImagePickerCommand(imagePickerDisplayArgs(result.Args, result.profile), result.shell) if command == "" { if result.PrintOnly { return imageSavingFailure(imagePickerUnsupportedShell, nil) diff --git a/pkg/custom/image_picker_session_test.go b/pkg/custom/image_picker_session_test.go index 5ad262a4..3ab81106 100644 --- a/pkg/custom/image_picker_session_test.go +++ b/pkg/custom/image_picker_session_test.go @@ -6,8 +6,11 @@ import ( "errors" "io" "os" + "os/exec" "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" @@ -76,6 +79,136 @@ func TestImagePickerSelectionHistoryFollowsSuccessfulEcho(t *testing.T) { require.Equal(t, want, got) } +func TestImagePickerProfileDisplayRemainsEphemeral(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + for _, test := range []struct { + name, profile string + explicit bool + }{ + {name: "absent"}, + {name: "empty", explicit: true}, + {name: "special", profile: "studio '雪' $HOME $(touch NOT_RUN)", explicit: true}, + {name: "format controls", profile: "studio\u202e\u200b", explicit: true}, + } { + t.Run(shell+"/"+test.name, func(t *testing.T) { + options := imagePickerOptions{Prompt: "synthetic scene", Shell: shell} + if test.explicit { + options.profile = &test.profile + } + model, err := newImagePicker(options) + require.NoError(t, err) + requestArgs := model.settings.args() + wantArgs := requestArgs + if test.explicit { + wantArgs = append([]string{"--profile", test.profile}, requestArgs...) + } + wantCommand := formatImagePickerCommand(wantArgs, shell) + require.Equal(t, wantCommand, strings.Join(model.commandLines(40), "")) + require.NotContains(t, wantCommand, "\u202e") + require.NotContains(t, wantCommand, "\u200b") + path := filepath.Join(t.TempDir(), "image-picker.json") + require.NoError(t, saveImagePickerState(t.Context(), path, model.settings)) + before, err := os.ReadFile(path) + require.NoError(t, err) + for _, printOnly := range []bool{false, true} { + model.finishSubmission(model.settings, printOnly) + result := model.snapshotResult(model.settings) + require.Equal(t, options.profile, result.profile) + require.Equal(t, requestArgs, result.Args, "the selector must not enter generated request arguments") + var output, diagnostics bytes.Buffer + require.NoError(t, finishImagePickerSelection(t.Context(), &output, &diagnostics, path, &result)) + require.Equal(t, wantCommand+"\n", output.String()) + require.Empty(t, diagnostics.String()) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after, "the selector must not change saved draft bytes") + } + saved, found, err := loadImagePickerState(t.Context(), path) + require.NoError(t, err) + require.True(t, found) + restored, err := newImagePicker(restoreImagePickerDraft(imagePickerOptions{Shell: shell}, saved)) + require.NoError(t, err) + require.Nil(t, restored.profile) + require.Equal(t, formatImagePickerCommand(requestArgs, shell), strings.Join(restored.commandLines(40), "")) + restored, err = newImagePicker(restoreImagePickerDraft(options, saved)) + require.NoError(t, err) + require.Equal(t, wantCommand, strings.Join(restored.commandLines(40), "")) + }) + } + } +} + +func TestImagePickerProfileCommandNativeShellRoundTrip(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + t.Run(shell, func(t *testing.T) { + binary, err := exec.LookPath(shell) + if err != nil { + if strings.Contains(","+os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS")+",", ","+shell+",") { + t.Fatalf("required shell %s unavailable", shell) + } + t.Skipf("%s is not installed", shell) + } + prefix := "openai() { printf '%s\\0' \"$@\"; }\n" + var shellArgs []string + switch shell { + case "bash": + shellArgs = []string{"--noprofile", "--norc", "-c"} + case "zsh": + shellArgs = []string{"-f", "-c"} + case "fish": + shellArgs = []string{"--no-config", "-c"} + prefix = "function openai; printf '%s\\0' $argv; end\n" + case "pwsh": + shellArgs = []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-Command"} + prefix = "function openai { foreach ($v in $args) { [Console]::Out.Write($v); [Console]::Out.Write([char]0) } }\n" + } + profiles := []string{"", "-studio", "studio '雪' $HOME $(touch NOT_RUN); & | > [x] {x}\u202e"} + var commands, expected bytes.Buffer + var requestArgs []string + for _, profile := range profiles { + model, err := newImagePicker(imagePickerOptions{Prompt: "synthetic scene", Shell: shell, profile: &profile}) + require.NoError(t, err) + model.finishSubmission(model.settings, true) + result := model.snapshotResult(model.settings) + require.NoError(t, finishImagePickerSelection(t.Context(), &commands, io.Discard, "", &result)) + requestArgs = model.settings.args() + expected.WriteString(strings.Join(append([]string{"--profile", profile}, requestArgs...), "\x00") + "\x00") + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + process := exec.CommandContext(ctx, binary, append(shellArgs, prefix+commands.String())...) + dir := t.TempDir() + process.Dir = dir + process.Env = append(os.Environ(), "HOME="+dir, "XDG_CONFIG_HOME="+dir, "OPENAI_API_KEY=synthetic-not-used", "OPENAI_BASE_URL=http://127.0.0.1:9") + process.WaitDelay = time.Second + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + require.NoError(t, process.Run(), stderr.String()) + require.Empty(t, stderr.String()) + require.Equal(t, expected.String(), stdout.String()) + parsed := strings.Split(strings.TrimSuffix(stdout.String(), "\x00"), "\x00") + for i, profile := range profiles { + calls := 0 + generate := &cli.Command{Name: "generate", SkipFlagParsing: true} + root := &cli.Command{Name: "openai", Commands: []*cli.Command{{Name: "images", Commands: []*cli.Command{generate}}}} + configureProfileContext(root) + generate.Action = func(context.Context, *cli.Command) error { + calls++ + require.True(t, root.IsSet("profile")) + require.Equal(t, profile, root.String("profile")) + require.Equal(t, requestArgs[2:], generate.Args().Slice()) + return nil + } + count := len(requestArgs) + 2 + require.NoError(t, root.Run(t.Context(), append([]string{"openai"}, parsed[i*count:(i+1)*count]...))) + require.Equal(t, 1, calls) + } + _, err = os.Stat(filepath.Join(dir, "NOT_RUN")) + require.ErrorIs(t, err, os.ErrNotExist) + }) + } +} + func TestImagePickerOptionalHistoryFailureDoesNotBlockSelection(t *testing.T) { path := filepath.Join(t.TempDir(), "image-picker.json") invalid := []byte(`{"version":99,"prompt":"private text"}`) diff --git a/pkg/custom/image_picker_view.go b/pkg/custom/image_picker_view.go index 464bf997..dca62d89 100644 --- a/pkg/custom/image_picker_view.go +++ b/pkg/custom/image_picker_view.go @@ -309,7 +309,7 @@ func (m *imagePicker) commandPreview(width, height int) ([]string, int, int) { } func (m *imagePicker) commandLines(width int) []string { - command := formatImagePickerCommand(m.settings.args(), m.shell) + command := formatImagePickerCommand(imagePickerDisplayArgs(m.settings.args(), m.profile), m.shell) if command == "" { command = imagePickerUnsupportedShell } diff --git a/pkg/custom/profile_context.go b/pkg/custom/profile_context.go index 06e6c563..973b7179 100644 --- a/pkg/custom/profile_context.go +++ b/pkg/custom/profile_context.go @@ -224,7 +224,7 @@ func requestResolutionContext(root *cli.Command) context.Context { return context.Background() } -func resolvedRequestOptions(command *cli.Command) []option.RequestOption { +func resolvedCommandRequestContext(command *cli.Command) (resolvedRequestContext, error) { root := command.Root() selected := rootProfileSelector(root) var result resolvedRequestContext @@ -241,6 +241,11 @@ func resolvedRequestOptions(command *cli.Command) []option.RequestOption { selected.resolved, selected.err = &result, err } } + return result, err +} + +func resolvedRequestOptions(command *cli.Command) []option.RequestOption { + result, err := resolvedCommandRequestContext(command) if err != nil { return []option.RequestOption{option.WithMaxRetries(0), option.WithMiddleware(func(*http.Request, option.MiddlewareNext) (*http.Response, error) { return nil, err })} } From 05ca236123514025aef70193aed0b90cb8e28b04 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 07:34:35 -0400 Subject: [PATCH 22/36] Reject folder-link writes after configuration replacement --- pkg/custom/project_link_store.go | 11 ++++ pkg/custom/project_link_store_test.go | 81 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/pkg/custom/project_link_store.go b/pkg/custom/project_link_store.go index 5ccfd261..162eda0d 100644 --- a/pkg/custom/project_link_store.go +++ b/pkg/custom/project_link_store.go @@ -236,6 +236,10 @@ func updateProjectLink(ctx context.Context, path, directory, project string) (ma return nil, err } defer root.Close() + directoryInfo, err := root.Stat(".") + if err != nil { + return nil, err + } lock, err := lockProjectLinks(ctx, root, name) if err != nil { return nil, err @@ -284,6 +288,13 @@ func updateProjectLink(ctx context.Context, path, directory, project string) (ma if err := errors.Join(writeErr, closeErr, ctx.Err()); err != nil { return nil, err } + currentDirectory, err := os.Lstat(filepath.Dir(path)) + if err != nil || !os.SameFile(directoryInfo, currentDirectory) || !privateProjectLinkPermissions(currentDirectory) { + return nil, errProjectLinkDirectoryChanged + } + if err := ctx.Err(); err != nil { + return nil, err + } // Rename replaces the directory entry, never a substituted symlink's target. if err := root.Rename(temporary, name); err != nil { return nil, err diff --git a/pkg/custom/project_link_store_test.go b/pkg/custom/project_link_store_test.go index b7f4b61d..266331b7 100644 --- a/pkg/custom/project_link_store_test.go +++ b/pkg/custom/project_link_store_test.go @@ -354,6 +354,87 @@ func TestProjectLinkStoreWaitingWriterValidatesCurrentState(t *testing.T) { } } +func TestProjectLinkStoreWaitingWriterRejectsReplacedDirectory(t *testing.T) { + for _, action := range []string{"link", "unlink"} { + t.Run(action, func(t *testing.T) { + path := projectLinkTestPath(t) + directory := t.TempDir() + before, err := json.Marshal(map[string]string{directory: "proj_before"}) + require.NoError(t, err) + before = append(before, ' ', '\n') + current, err := json.Marshal(map[string]string{directory: "proj_current"}) + require.NoError(t, err) + current = append(current, '\n') + writeProjectLinkTestFile(t, path, string(before)) + root, name, err := openProjectLinkDirectory(path, false) + require.NoError(t, err) + defer root.Close() + first, err := lockProjectLinks(t.Context(), root, name) + require.NoError(t, err) + defer first.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + observed := &observedProjectLinkWait{Context: ctx, waiting: make(chan struct{})} + result := make(chan error, 1) + finished := make(chan struct{}) + project := "proj_new" + if action == "unlink" { + project = "" + } + go func() { + defer close(finished) + _, err := updateProjectLink(observed, path, directory, project) + result <- err + }() + defer func() { + cancel() + _ = first.Close() + <-finished + }() + select { + case <-observed.waiting: + case err := <-result: + t.Fatalf("writer did not wait: %v", err) + case <-ctx.Done(): + t.Fatal("writer did not reach lock wait") + } + oldDirectory := filepath.Dir(path) + "-old" + if err := os.Rename(filepath.Dir(path), oldDirectory); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("filesystem cannot rename an open configuration directory: %v", err) + } + require.NoError(t, err) + } + writeProjectLinkTestFile(t, path, string(current)) + require.NoError(t, first.Close()) + select { + case err := <-result: + require.ErrorIs(t, err, errProjectLinkDirectoryChanged) + case <-ctx.Done(): + t.Fatal("waiting writer did not finish") + } + for _, state := range []struct { + path string + data []byte + files []string + }{ + {filepath.Join(oldDirectory, name), before, []string{name, "." + name + ".lock"}}, + {path, current, []string{name}}, + } { + data, err := os.ReadFile(state.path) + require.NoError(t, err) + require.Equal(t, state.data, data) + entries, err := os.ReadDir(filepath.Dir(state.path)) + require.NoError(t, err) + var names []string + for _, entry := range entries { + names = append(names, entry.Name()) + } + require.ElementsMatch(t, state.files, names, "only the original registry and lock may remain") + } + }) + } +} + func TestProjectLinkStoreProcessExitReleasesLock(t *testing.T) { const helperPath = "OPENAI_CLI_TEST_PROJECT_LINK_LOCK" if path := os.Getenv(helperPath); path != "" { From 6b427ebfc82fb62b28c579ad0a3eda35dfe6b94c Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 14:32:09 -0400 Subject: [PATCH 23/36] Add explicit credential identity status and lookup --- cmd/openai/auth_status_test.go | 480 ++++++++++++++++++++++++++- cmd/openai/command_subgroups_test.go | 2 +- docs/auth-status.md | 53 ++- pkg/custom/auth_status.go | 325 ++++++++++++++++-- pkg/custom/auth_status_test.go | 443 ++++++++++++++++++++++++ 5 files changed, 1274 insertions(+), 29 deletions(-) diff --git a/cmd/openai/auth_status_test.go b/cmd/openai/auth_status_test.go index d62b1713..fde3cdc8 100644 --- a/cmd/openai/auth_status_test.go +++ b/cmd/openai/auth_status_test.go @@ -4,11 +4,16 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" + "net/http" "os" "os/exec" "path/filepath" + "runtime" + "slices" "strings" + "syscall" "testing" "time" @@ -35,6 +40,18 @@ type authStatusResult struct { Organization authStatusValueResult `json:"organization"` Project authStatusValueResult `json:"project"` BaseURL authStatusValueResult `json:"base_url"` + APIIdentity authStatusIdentityResult `json:"api_identity"` + AdminIdentity authStatusIdentityResult `json:"admin_identity"` + VerifiedKind string `json:"verified_credential_kind,omitempty"` +} + +type authStatusIdentityResult struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Provider string `json:"provider,omitempty"` + ID string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` } func authStatusEnvironment(t *testing.T) []string { @@ -54,18 +71,50 @@ func decodeAuthStatus(t *testing.T, got mainDispatchResult) authStatusResult { t.Helper() require.Zero(t, got.code, "%s", got.stderr) require.Empty(t, got.stderr) - decoder := json.NewDecoder(strings.NewReader(got.stdout)) + status := decodeAuthStatusJSON(t, got.stdout) + require.True(t, status.Offline) + require.False(t, status.Verified) + require.Empty(t, status.VerifiedKind) + for _, credential := range []struct { + configured bool + identity authStatusIdentityResult + }{{status.APICredential.Configured, status.APIIdentity}, {status.AdminCredential.Configured, status.AdminIdentity}} { + require.Equal(t, "unknown", credential.identity.Status) + reason := "not_configured" + if credential.configured { + reason = "not_requested" + } + require.Equal(t, reason, credential.identity.Reason) + } + return status +} + +func decodeAuthStatusJSON(t *testing.T, output string) authStatusResult { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(output)) decoder.DisallowUnknownFields() var status authStatusResult - require.NoError(t, decoder.Decode(&status), "%s", got.stdout) + require.NoError(t, decoder.Decode(&status), "%s", output) require.ErrorIs(t, decoder.Decode(new(any)), io.EOF) var fields map[string]json.RawMessage - require.NoError(t, json.Unmarshal([]byte(got.stdout), &fields)) - for _, name := range []string{"offline", "verified", "api_credential", "admin_credential", "organization", "project", "base_url"} { + require.NoError(t, json.Unmarshal([]byte(output), &fields)) + for _, name := range []string{"offline", "verified", "api_credential", "admin_credential", "organization", "project", "base_url", "api_identity", "admin_identity"} { require.Contains(t, fields, name) } - require.True(t, status.Offline) - require.False(t, status.Verified) + for _, name := range []string{"api_identity", "admin_identity"} { + var identity map[string]json.RawMessage + require.NoError(t, json.Unmarshal(fields[name], &identity)) + if string(identity["status"]) != `"resolved"` { + for _, field := range []string{"provider", "id"} { + require.NotContains(t, identity, field, "unknown identity must not imply an account") + } + require.JSONEq(t, `null`, string(identity["name"])) + require.JSONEq(t, `null`, string(identity["email"])) + } + } + if !status.Verified { + require.NotContains(t, fields, "verified_credential_kind") + } return status } @@ -350,3 +399,422 @@ func TestBuiltCLIAuthStatusAndHelpAreOffline(t *testing.T) { } require.Zero(t, requests.Load()) } + +type authStatusIdentityFixture struct { + Status int `json:"status"` + Body string `json:"body"` + ContentType string `json:"content_type"` + Location string `json:"location"` + Error string `json:"error"` + Wait bool `json:"wait"` +} + +type authStatusIdentityRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers http.Header `json:"headers"` + BodyPresent bool `json:"body_present"` + DeadlineMilliseconds int64 `json:"deadline_milliseconds"` +} + +type authStatusIdentityTransport func(*http.Request) (*http.Response, error) + +func (transport authStatusIdentityTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +// Install the synthetic transport only in this test subprocess. The production +// command still resolves its provider, credentials, request, output, and exit. +func TestMainAuthStatusIdentityProcess(t *testing.T) { + if os.Getenv("OPENAI_CLI_AUTH_IDENTITY_PROCESS") != "1" { + return + } + data, err := os.ReadFile(os.Getenv("OPENAI_CLI_AUTH_IDENTITY_FIXTURE")) + require.NoError(t, err) + var fixture authStatusIdentityFixture + require.NoError(t, json.Unmarshal(data, &fixture)) + http.DefaultTransport = authStatusIdentityTransport(func(request *http.Request) (*http.Response, error) { + deadline, _ := request.Context().Deadline() + observed := authStatusIdentityRequest{ + Method: request.Method, URL: request.URL.String(), Headers: request.Header.Clone(), + BodyPresent: request.Body != nil && request.Body != http.NoBody, + DeadlineMilliseconds: time.Until(deadline).Milliseconds(), + } + file, err := os.OpenFile(os.Getenv("OPENAI_CLI_AUTH_IDENTITY_REQUESTS"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + err = json.NewEncoder(file).Encode(observed) + closeErr := file.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + return nil, closeErr + } + if fixture.Wait { + <-request.Context().Done() + return nil, request.Context().Err() + } + if fixture.Error != "" { + return nil, errors.New(fixture.Error) + } + headers := http.Header{"Content-Type": {fixture.ContentType}} + if fixture.Location != "" { + headers.Set("Location", fixture.Location) + } + return &http.Response{StatusCode: fixture.Status, Header: headers, + Body: io.NopCloser(strings.NewReader(fixture.Body)), Request: request}, nil + }) + separator := slices.Index(os.Args, "--") + require.GreaterOrEqual(t, separator, 0) + os.Args = os.Args[separator+1:] + main() + os.Exit(0) +} + +type authStatusIdentityProcess struct { + command *exec.Cmd + stdout bytes.Buffer + stderr bytes.Buffer + context context.Context + done chan error + requests string +} + +func startAuthStatusIdentity(t *testing.T, fixture authStatusIdentityFixture, env []string, args ...string) *authStatusIdentityProcess { + t.Helper() + binary, err := os.Executable() + require.NoError(t, err) + directory := t.TempDir() + fixturePath := filepath.Join(directory, "fixture.json") + data, err := json.Marshal(fixture) + require.NoError(t, err) + require.NoError(t, os.WriteFile(fixturePath, data, 0600)) + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + process := &authStatusIdentityProcess{context: ctx, done: make(chan error, 1), requests: filepath.Join(directory, "requests.jsonl")} + argv := append([]string{"-test.run=^TestMainAuthStatusIdentityProcess$", "--", "openai", "auth", "status"}, args...) + process.command = exec.CommandContext(ctx, binary, argv...) + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if !strings.HasPrefix(strings.ToUpper(name), "OPENAI_") && !strings.EqualFold(name, "COMPLETION_STYLE") { + process.command.Env = append(process.command.Env, entry) + } + } + process.command.Env = append(process.command.Env, authStatusEnvironment(t)...) + process.command.Env = append(process.command.Env, + "OPENAI_CLI_AUTH_IDENTITY_PROCESS=1", "OPENAI_CLI_AUTH_IDENTITY_FIXTURE="+fixturePath, + "OPENAI_CLI_AUTH_IDENTITY_REQUESTS="+process.requests, "COMPLETION_STYLE=bash", + "OPENAI_BASE_URL=https://api.openai.com/v1/") + process.command.Env = append(process.command.Env, env...) + process.command.Stdout, process.command.Stderr = &process.stdout, &process.stderr + process.command.WaitDelay = time.Second + require.NoError(t, process.command.Start()) + finished := make(chan struct{}) + go func() { + process.done <- process.command.Wait() + close(finished) + }() + t.Cleanup(func() { + cancel() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("identity subprocess did not stop after cleanup cancellation") + } + }) + return process +} + +func finishAuthStatusIdentity(t *testing.T, process *authStatusIdentityProcess) (mainDispatchResult, []authStatusIdentityRequest) { + t.Helper() + err := <-process.done + require.NoError(t, process.context.Err(), "identity process exceeded the outer deadline") + code := 0 + if err != nil { + var exit *exec.ExitError + require.ErrorAs(t, err, &exit) + code = exit.ExitCode() + } + var requests []authStatusIdentityRequest + data, err := os.ReadFile(process.requests) + if !errors.Is(err, os.ErrNotExist) { + require.NoError(t, err) + decoder := json.NewDecoder(bytes.NewReader(data)) + for { + var request authStatusIdentityRequest + err := decoder.Decode(&request) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + requests = append(requests, request) + } + } + return mainDispatchResult{code, process.stdout.String(), process.stderr.String()}, requests +} + +func runAuthStatusIdentity(t *testing.T, fixture authStatusIdentityFixture, env []string, args ...string) (mainDispatchResult, []authStatusIdentityRequest) { + t.Helper() + return finishAuthStatusIdentity(t, startAuthStatusIdentity(t, fixture, env, args...)) +} + +func authStatusIdentitySuccessFixture() authStatusIdentityFixture { + return authStatusIdentityFixture{Status: http.StatusOK, ContentType: "application/json", + Body: `{"object":"user","id":"user-synthetic","name":"Synthetic Name","email":"synthetic@example.invalid","ignored":"synthetic-private-response-field"}`} +} + +func TestMainAuthStatusIdentitySelectionAndRequestBoundary(t *testing.T) { + const api, admin = "synthetic-private-api-token", "synthetic-private-admin-token" + for _, kind := range []string{"api", "admin"} { + t.Run(kind, func(t *testing.T) { + env := []string{"OPENAI_API_KEY=" + api, "OPENAI_ADMIN_KEY=" + admin, + "OPENAI_ORG_ID=org_environment", "OPENAI_PROJECT_ID=proj_environment"} + got, requests := runAuthStatusIdentity(t, authStatusIdentitySuccessFixture(), env, + "--verify", "--credential-kind", kind, "--format=json", "--debug", + "--organization=org_flag", "--project=proj_flag", + "--header=OpenAI-Organization: org_header", "--header=OpenAI-Project: proj_header", + "--header=X-Private: synthetic-private-extra-header", "--header=Cookie: synthetic-private-cookie") + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + assertAuthStatusPrivate(t, got, api, admin, "synthetic-private-extra-header", "synthetic-private-cookie", "synthetic-private-response-field") + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Offline) + require.True(t, status.Verified) + require.Equal(t, kind, status.VerifiedKind) + selected, unselected, credential := status.APIIdentity, status.AdminIdentity, api + if kind == "admin" { + selected, unselected, credential = status.AdminIdentity, status.APIIdentity, admin + } + require.Equal(t, "resolved", selected.Status) + require.Equal(t, "openai_me_v1", selected.Provider) + require.Equal(t, "user-synthetic", selected.ID) + require.NotNil(t, selected.Name) + require.NotNil(t, selected.Email) + require.Equal(t, "Synthetic Name", *selected.Name) + require.Equal(t, "synthetic@example.invalid", *selected.Email) + require.Equal(t, "unknown", unselected.Status) + require.Equal(t, "not_requested", unselected.Reason) + require.Len(t, requests, 1, "identity lookup must not retry or use SDK fallback") + require.Equal(t, http.MethodGet, requests[0].Method) + require.Equal(t, "https://api.openai.com/v1/me", requests[0].URL) + require.Equal(t, http.Header{"Authorization": {"Bearer " + credential}, "Accept": {"application/json"}, + "Openai-Organization": {"org_header"}, "Openai-Project": {"proj_header"}}, requests[0].Headers) + require.False(t, requests[0].BodyPresent) + require.Greater(t, requests[0].DeadlineMilliseconds, int64(0)) + require.LessOrEqual(t, requests[0].DeadlineMilliseconds, int64(15000)) + }) + } +} + +func TestMainAuthStatusIdentityProviderAndCredentialRejections(t *testing.T) { + for _, endpoint := range []string{ + "http://api.openai.com/v1/", "https://example.invalid/v1/", "https://api.openai.com.example.invalid/v1/", + "https://api.openai.com:444/v1/", "https://api.openai.com/", "https://api.openai.com/v1/other", + "https://api.openai.com/%76%31/", "https://api.openai.com/v1/?", "https://api.openai.com/v1/?token=synthetic-private-query", + "https://api.openai.com/v1/#synthetic-private-fragment", "https://synthetic-private-user:synthetic-private-password@api.openai.com/v1/", + } { + t.Run(endpoint, func(t *testing.T) { + got, requests := runAuthStatusIdentity(t, authStatusIdentitySuccessFixture(), []string{ + "OPENAI_BASE_URL=" + endpoint, "OPENAI_API_KEY=synthetic-private-api-token"}, + "--verify", "--credential-kind=api", "--format=json", "--format-error=json", "--debug") + require.NotZero(t, got.code) + require.Empty(t, requests, "unsupported providers must not receive credentials") + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Verified) + require.Equal(t, "unsupported_provider", status.APIIdentity.Reason) + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, "synthetic-private-api-token", "synthetic-private-query", "synthetic-private-fragment", "synthetic-private-user", "synthetic-private-password") + }) + } + for _, tc := range []struct { + name, reason string + env, args []string + }{ + {"missing selected admin", "not_configured", []string{"OPENAI_API_KEY=synthetic-private-api-token"}, nil}, + {"empty selected admin override", "not_configured", []string{"OPENAI_ADMIN_KEY=synthetic-private-admin-token"}, []string{"--admin-api-key="}}, + {"unsupported authorization", "invalid_configuration", nil, []string{"--header=Authorization: Basic synthetic-private-basic"}}, + {"incomplete TLS", "invalid_configuration", []string{"OPENAI_ADMIN_KEY=synthetic-private-admin-token", "OPENAI_MTLS_CLIENT_CERT_FILE=/synthetic-private/missing-cert"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{"--verify", "--credential-kind=admin", "--format=json", "--format-error=json"}, tc.args...) + got, requests := runAuthStatusIdentity(t, authStatusIdentitySuccessFixture(), tc.env, args...) + require.NotZero(t, got.code) + require.Empty(t, requests) + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Verified) + require.Equal(t, tc.reason, status.AdminIdentity.Reason) + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, "synthetic-private-api-token", "synthetic-private-admin-token", "synthetic-private-basic", "/synthetic-private/missing-cert") + }) + } +} + +func TestMainAuthStatusIdentitySelectionRequiresExplicitVerification(t *testing.T) { + for _, args := range [][]string{{"--verify"}, {"--verify", "--credential-kind=other"}, {"--credential-kind=api"}, {"--verify=false", "--credential-kind=admin"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + got, requests := runAuthStatusIdentity(t, authStatusIdentitySuccessFixture(), nil, + append(args, "--format=json", "--format-error=json")...) + require.NotZero(t, got.code) + require.Empty(t, got.stdout) + require.Empty(t, requests) + decodeMainStructuredError(t, "json", got.stderr) + }) + } +} + +func TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate(t *testing.T) { + const secret = "synthetic-private-api-token" + for _, tc := range []struct { + name, reason string + fixture authStatusIdentityFixture + }{ + {"unauthorized", "unauthorized", authStatusIdentityFixture{Status: 401}}, + {"forbidden", "forbidden", authStatusIdentityFixture{Status: 403}}, + {"missing provider", "unavailable", authStatusIdentityFixture{Status: 404}}, + {"rate limited", "rate_limited", authStatusIdentityFixture{Status: 429}}, + {"server failure", "unavailable", authStatusIdentityFixture{Status: 503}}, + {"same-origin redirect", "redirect", authStatusIdentityFixture{Status: 302, Location: "https://api.openai.com/v1/other"}}, + {"cross-origin redirect", "redirect", authStatusIdentityFixture{Status: 307, Location: "https://example.invalid/synthetic-private-redirect"}}, + {"transport failure", "request_failed", authStatusIdentityFixture{Error: "synthetic-private-transport-details " + secret}}, + {"wrong content type", "invalid_response", authStatusIdentityFixture{Status: 200, ContentType: "text/html", Body: "synthetic-private-response-body"}}, + {"malformed JSON", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":`}}, + {"missing ID", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","email":"synthetic-private-response-body"}`}}, + {"wrong principal", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"organization","id":"org-synthetic"}`}}, + {"wrong name type", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":"user-synthetic","name":7}`}}, + {"duplicate ID", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":"first","id":"second"}`}}, + {"duplicate unknown field", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":"user-synthetic","ignored":1,"ignored":2}`}}, + {"trailing JSON", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":"user-synthetic"}{}`}}, + {"credential echoed in ID", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":"` + secret + `"}`}}, + {"credential echoed in name", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":"user-synthetic","name":"prefix ` + secret + ` suffix"}`}}, + {"credential echoed in email", "invalid_response", authStatusIdentityFixture{Status: 200, Body: `{"object":"user","id":"user-synthetic","email":"` + secret + `@example.invalid"}`}}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := tc.fixture + if fixture.ContentType == "" { + fixture.ContentType = "application/json" + } + if fixture.Body == "" { + fixture.Body = `{"error":{"message":"synthetic-private-response-body ` + secret + `"}}` + } + got, requests := runAuthStatusIdentity(t, fixture, []string{"OPENAI_API_KEY=" + secret}, + "--verify", "--credential-kind=api", "--format=json", "--format-error=json", "--verbose", "--debug") + require.NotZero(t, got.code) + require.Len(t, requests, 1, "failure must not trigger retries, redirects, or fallback") + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Offline) + require.False(t, status.Verified) + require.Equal(t, "unknown", status.APIIdentity.Status) + require.Equal(t, tc.reason, status.APIIdentity.Reason) + require.Equal(t, "not_configured", status.AdminIdentity.Reason) + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, secret, "synthetic-private-response-body", "synthetic-private-transport-details", "synthetic-private-redirect") + }) + } +} + +func TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints(t *testing.T) { + for _, endpoint := range []string{"https://api.openai.com/v1", "https://api.openai.com/v1/", "https://api.openai.com:443/v1", "https://api.openai.com:443/v1/"} { + t.Run(endpoint, func(t *testing.T) { + fixture := authStatusIdentitySuccessFixture() + fixture.Body = `{"object":"user","id":"user-synthetic","name":null,"email":null}` + got, requests := runAuthStatusIdentity(t, fixture, []string{"OPENAI_API_KEY=synthetic-private-api-token", "OPENAI_BASE_URL=" + endpoint}, + "--verify", "--credential-kind=api", "--format=json") + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Len(t, requests, 1) + require.True(t, strings.HasSuffix(requests[0].URL, "/v1/me")) + status := decodeAuthStatusJSON(t, got.stdout) + require.True(t, status.Verified) + require.Nil(t, status.APIIdentity.Name) + require.Nil(t, status.APIIdentity.Email) + require.NotContains(t, got.stdout, "synthetic-private-api-token") + }) + } +} + +func TestMainAuthStatusIdentityAuthorizationPrecedence(t *testing.T) { + for _, tc := range []struct { + name, kind, authorization string + env, args []string + }{ + {"selected API flag", "api", "Bearer synthetic-private-flag", []string{"OPENAI_API_KEY=synthetic-private-environment"}, []string{"--api-key=synthetic-private-flag"}}, + {"selected admin flag", "admin", "Bearer synthetic-private-flag", []string{"OPENAI_ADMIN_KEY=synthetic-private-environment"}, []string{"--admin-api-key=synthetic-private-flag"}}, + {"literal authorization", "admin", "Bearer synthetic-private-header", []string{"OPENAI_API_KEY=synthetic-private-api", "OPENAI_ADMIN_KEY=synthetic-private-admin"}, []string{"--header=Authorization: Bearer synthetic-private-header"}}, + {"inherited authorization", "api", "Bearer synthetic-private-header", []string{"OPENAI_CUSTOM_HEADERS=Authorization: Bearer synthetic-private-header"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{"--verify", "--credential-kind=" + tc.kind, "--format=json"}, tc.args...) + got, requests := runAuthStatusIdentity(t, authStatusIdentitySuccessFixture(), tc.env, args...) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Len(t, requests, 1) + require.Equal(t, []string{tc.authorization}, requests[0].Headers.Values("Authorization")) + require.True(t, decodeAuthStatusJSON(t, got.stdout).Verified) + assertAuthStatusPrivate(t, got, "synthetic-private-flag", "synthetic-private-environment", "synthetic-private-header", "synthetic-private-api", "synthetic-private-admin") + }) + } +} + +func TestMainAuthStatusIdentityTextEscapesTerminalControls(t *testing.T) { + fixture := authStatusIdentitySuccessFixture() + fixture.Body = `{"object":"user","id":"user-synthetic","name":"Name\u001b[31m\rspoofed\nforged-row\tcolumn","email":"synthetic@example.invalid"}` + got, requests := runAuthStatusIdentity(t, fixture, []string{"OPENAI_API_KEY=synthetic-private-api-token", "FORCE_COLOR=1"}, + "--verify", "--credential-kind=api", "--format=text", "--debug", "--quiet") + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Len(t, requests, 1) + require.Contains(t, got.stdout, "API identity: resolved") + require.Contains(t, got.stdout, "user-synthetic") + require.Contains(t, got.stdout, "synthetic@example.invalid") + require.Contains(t, got.stdout, "Admin identity: unknown") + assertAuthStatusPrivate(t, got, "synthetic-private-api-token") + for _, control := range []string{"\r", "\t", "\nforged-row"} { + require.NotContains(t, got.stdout, control) + } +} + +func TestMainAuthStatusIdentityDeadline(t *testing.T) { + started := time.Now() + got, requests := runAuthStatusIdentity(t, authStatusIdentityFixture{Wait: true}, []string{"OPENAI_API_KEY=synthetic-private-api-token"}, + "--verify", "--credential-kind=api", "--format=json", "--format-error=json") + require.NotZero(t, got.code) + require.Less(t, time.Since(started), 23*time.Second, "provider timeout must precede the outer process limit") + require.Len(t, requests, 1) + require.Greater(t, requests[0].DeadlineMilliseconds, int64(14000)) + require.LessOrEqual(t, requests[0].DeadlineMilliseconds, int64(15000)) + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Verified) + require.Equal(t, "timed_out", status.APIIdentity.Reason) + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, "synthetic-private-api-token") +} + +func TestMainAuthStatusIdentitySignals(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows process signals require native console control events") + } + for _, tc := range []struct { + name string + signal os.Signal + code int + }{{"SIGINT", os.Interrupt, 130}, {"SIGTERM", syscall.Signal(15), 143}} { + t.Run(tc.name, func(t *testing.T) { + process := startAuthStatusIdentity(t, authStatusIdentityFixture{Wait: true}, []string{"OPENAI_API_KEY=synthetic-private-api-token"}, + "--verify", "--credential-kind=api", "--format=json", "--format-error=json", "--debug") + require.Eventually(t, func() bool { + data, err := os.ReadFile(process.requests) + return err == nil && json.Valid(bytes.TrimSpace(data)) + }, 5*time.Second, time.Millisecond, "request must start before the signal") + started := time.Now() + require.NoError(t, process.command.Process.Signal(tc.signal)) + got, requests := finishAuthStatusIdentity(t, process) + require.Less(t, time.Since(started), 5*time.Second) + require.Equal(t, tc.code, got.code, "%s", got.stderr) + require.Empty(t, got.stdout) + require.Len(t, requests, 1) + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, "synthetic-private-api-token") + }) + } +} diff --git a/cmd/openai/command_subgroups_test.go b/cmd/openai/command_subgroups_test.go index 87e668a3..4cbab39b 100644 --- a/cmd/openai/command_subgroups_test.go +++ b/cmd/openai/command_subgroups_test.go @@ -107,7 +107,7 @@ func TestMainCommandSubgroupsHelpAndCompletion(t *testing.T) { func TestMainCommandSubgroupsLegacyPrefixCompletion(t *testing.T) { summaries := map[string]string{ "audio": "Transcribe audio, generate speech, and create voices.", - "auth": "Inspect authentication configuration locally", + "auth": "Inspect authentication configuration and identity", "audio:transcriptions": "Convert audio to text.", "audio:translations": "Translate supported audio to English text.", "admin:organization:audit-logs": "List organization actions and configuration changes.", diff --git a/docs/auth-status.md b/docs/auth-status.md index e6f52168..1d4becb1 100644 --- a/docs/auth-status.md +++ b/docs/auth-status.md @@ -1,7 +1,7 @@ # Authentication status Run `openai auth status` to inspect configuration without contacting OpenAI. -The command does not verify identity, credentials, permissions, membership, or billing. +The default command does not verify identity, credentials, permissions, membership, or billing. It does not read stdin, refresh credentials, save settings, or configure your shell. ```sh @@ -14,6 +14,54 @@ Each credential includes its configured state, type, and source. It never includ Organization, project, and base URL each include their own source. `offline: true` and `verified: false` describe the JSON report. Missing credentials still produce a successful configuration report. +`api_identity` and `admin_identity` explicitly report `status: "unknown"`. +Their reason is `not_configured` for missing credentials, or `not_requested` otherwise. + +## Identity lookup + +Select one credential explicitly to request its authenticated principal: + +```sh +openai auth status --verify --credential-kind api +openai auth status --verify --credential-kind admin --format json +``` + +The lookup sends one `GET /v1/me` request using the selected effective credential. +API and Admin credentials never substitute for each other. +The request includes the resolved organization and project headers. +It does not forward arbitrary custom headers, cookies, or gateway credentials. +The selected Authorization header must use the Bearer scheme. + +The provider accepts `https://api.openai.com/v1` and `https://api.openai.com/v1/`, including explicit port 443. +Other endpoints, paths, ports, URL credentials, queries, and fragments produce `unsupported_provider` without a request. +The command never replaces an unsupported endpoint with production. +Certificate options require an explicit supported base URL, even when a profile selects that URL. + +The provider returns an authenticated principal ID, plus nullable name and email fields. +It does not identify the active key resource, distinguish humans from service accounts, or establish membership or permissions. +The `/v1/me` route is absent from the public SDK and API reference. +Backend contract inspection establishes its response semantics; deployed availability remains unverified. +Unavailable or incompatible responses leave identity unknown. +The lookup performs no model request, but authentication can update key usage metadata. + +Successful JSON sets `offline: false`, `verified: true`, and `verified_credential_kind` to the selected credential kind. +Here, `verified` describes only that credential's authenticated principal. +It does not verify the entire configuration, organization, project, permissions, or billing. +The selected identity contains `status: "resolved"`, `provider: "openai_me_v1"`, `id`, `name`, and `email`. +The other identity remains unknown. +Unknown identities have a `reason`, null `name` and `email`, and no `id` or `provider`. + +Failures produce an unknown identity report and a nonzero exit status. +Reasons include `unauthorized`, `forbidden`, `rate_limited`, `unavailable`, `redirect`, `invalid_response`, `request_failed`, and `timed_out`. +Invalid local credentials or TLS configuration produce `invalid_configuration` without a request. +Missing selected credentials produce `not_configured` without fallback. +The command discards provider error bodies and never includes them in diagnostics, including debug output. + +The lookup has a 15-second deadline, follows no redirects, and performs no automatic retries. +Interrupt and termination signals stop the lookup without printing a status report. +Their exit statuses are 130 and 143, respectively. +The command never caches identity, saves credentials, or stores credential hashes. +Each lookup uses the same configuration snapshot that supplies the report. ## Selection @@ -51,4 +99,5 @@ Malformed effective URLs produce a fixed error without repeating the URL. `json` produces one JSON object. Terminal control characters remain escaped in text output. The command rejects positional arguments, other output formats, `--transform`, and `--raw-output`. Existing error-format, quiet, and diagnostic policies remain in effect. -Output failures return an error. The report does not test network connectivity or account access. +Output failures return an error. +The offline report does not test network connectivity or account access. diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index 667e692c..eee4756a 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -3,10 +3,15 @@ package custom import ( "context" "encoding/json" + "errors" "fmt" + "io" + "mime" + "net/http" "net/url" "os" "strings" + "time" "github.com/openai/openai-cli/internal/readable" "github.com/urfave/cli/v3" @@ -19,23 +24,37 @@ func registerAuthCommands(root *cli.Command) { help := `{{$bin := or (index .Root.Metadata "help-invocation") "openai"}}EXAMPLE {{$bin}} auth status -Shows configured authentication without sending requests or verifying access. +Shows configured authentication offline by default. +Use --verify --credential-kind api or admin to look up one authenticated principal. ` + cli.CommandHelpTemplate + localUtilityGlobalHelp root.Commands = append(root.Commands, &cli.Command{ - Name: "auth", Usage: "Inspect authentication configuration locally", HideHelpCommand: true, + Name: "auth", Usage: "Inspect authentication configuration and identity", HideHelpCommand: true, Metadata: map[string]any{localUtilityMetadata: true, "help-command-section": "Local tools"}, Commands: []*cli.Command{{ - Name: "status", Usage: "Show effective authentication configuration without network access", + Name: "status", Usage: "Show authentication configuration; optionally resolve one identity", Description: "Reports API and Admin credentials separately, plus organization, project, and a sanitized base URL. " + - "Does not verify credentials, identity, permissions, or membership. Does not read stdin, refresh credentials, or configure your shell. " + + "Identity stays unknown unless --verify selects an API or Admin credential. Identity lookup does not verify permissions or membership. " + + "Does not read stdin, refresh credentials, or configure your shell. " + "Supports --format auto, text, or json. Credential values are never displayed.", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "verify", Usage: "Resolve one authenticated principal using the OpenAI identity provider"}, + &authStatusCredentialKindFlag{StringFlag: &cli.StringFlag{Name: "credential-kind", Usage: "Credential to resolve with --verify: api or admin"}}, + }, HideHelpCommand: true, CustomHelpTemplate: help, Metadata: map[string]any{"local-help-full": help}, Action: handleAuthStatus, }}, }) } +// urfave keeps presence across command reuse; verification must be explicit each time. +type authStatusCredentialKindFlag struct{ *cli.StringFlag } + +func (f *authStatusCredentialKindFlag) PreParse() error { + f.StringFlag = &cli.StringFlag{Name: f.Name, Usage: f.Usage} + return f.StringFlag.PreParse() +} + type authStatusCredential struct { Configured bool `json:"configured"` Type string `json:"type"` @@ -52,15 +71,27 @@ type authStatusEndpoint struct { Redacted bool `json:"redacted"` } +type authStatusIdentity struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Provider string `json:"provider,omitempty"` + ID string `json:"id,omitempty"` + Name *string `json:"name"` + Email *string `json:"email"` +} + // Only sanitized configuration reaches either renderer. Credentials never enter this report. type authStatusReport struct { - Offline bool `json:"offline"` - Verified bool `json:"verified"` - API authStatusCredential `json:"api_credential"` - Admin authStatusCredential `json:"admin_credential"` - Organization authStatusValue `json:"organization"` - Project authStatusValue `json:"project"` - BaseURL authStatusEndpoint `json:"base_url"` + Offline bool `json:"offline"` + Verified bool `json:"verified"` + VerifiedCredentialKind string `json:"verified_credential_kind,omitempty"` + APIIdentity authStatusIdentity `json:"api_identity"` + AdminIdentity authStatusIdentity `json:"admin_identity"` + API authStatusCredential `json:"api_credential"` + Admin authStatusCredential `json:"admin_credential"` + Organization authStatusValue `json:"organization"` + Project authStatusValue `json:"project"` + BaseURL authStatusEndpoint `json:"base_url"` } func handleAuthStatus(ctx context.Context, command *cli.Command) error { @@ -78,10 +109,42 @@ func handleAuthStatus(ctx context.Context, command *cli.Command) error { if format != "auto" && format != "text" && format != "json" { return &localUtilityError{message: "Auth status supports --format auto, text, or json."} } - report, err := resolveAuthStatus(root, os.LookupEnv) + verify, kind := command.Bool("verify"), command.String("credential-kind") + if verify && kind != "api" && kind != "admin" || !verify && command.IsSet("credential-kind") { + return &localUtilityError{message: "Use --verify with --credential-kind api or --credential-kind admin."} + } + snapshot, err := resolveAuthStatusSnapshot(root, os.LookupEnv) if err != nil { return err } + report := snapshot.report + var lookupErr error + if verify { + var stop func() + ctx, stop = adminSetupSignalContext(ctx) + defer stop() + report.Offline = false + identity, failure := authStatusLookup(ctx, root, snapshot, kind) + lookupErr = failure + if ctx.Err() != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return &localUtilityError{message: "Identity lookup timed out. Identity remains unknown.", cause: context.DeadlineExceeded} + } + code := 130 + if errors.Is(context.Cause(ctx), errAdminSetupTerminated) { + code = 143 + } + return &authStatusLookupError{localUtilityError: &localUtilityError{message: "Identity lookup canceled.", cause: context.Canceled, preserveOnCancel: true}, code: code} + } + if kind == "api" { + report.APIIdentity = identity + } else { + report.AdminIdentity = identity + } + if failure == nil { + report.Verified, report.VerifiedCredentialKind = true, kind + } + } out := outputWriter{ctx: ctx, out: root.Writer} if format == "json" { err = json.NewEncoder(out).Encode(report) @@ -90,19 +153,40 @@ func handleAuthStatus(ctx context.Context, command *cli.Command) error { if report.BaseURL.Redacted { endpoint += " (URL components omitted)" } - _, err = fmt.Fprintf(out, "Authentication configuration (offline; not verified)\n"+ + title := "Authentication configuration (offline; not verified)" + if verify { + title = "Authentication configuration (identity lookup requested: " + kind + ")" + } + _, err = fmt.Fprintf(out, "%s\n"+ "API credential: %s [%s]\nAdmin credential: %s [%s]\n"+ + "API identity: %s\nAdmin identity: %s\n"+ "Organization: %s [%s]\nProject: %s [%s]\nBase URL: %s [%s]\n", + title, strings.ReplaceAll(report.API.Type, "_", " "), readable.Text(report.API.Source), strings.ReplaceAll(report.Admin.Type, "_", " "), readable.Text(report.Admin.Source), + authStatusIdentityText(report.APIIdentity), authStatusIdentityText(report.AdminIdentity), authStatusTextValue(report.Organization), readable.Text(report.Organization.Source), authStatusTextValue(report.Project), readable.Text(report.Project.Source), endpoint, readable.Text(report.BaseURL.Source)) } if err != nil { - return &localUtilityError{message: "Could not write auth status. Check the output file or pipe before rerunning.", cause: err} + return &localUtilityError{message: "Could not write auth status. Check the output file or pipe before rerunning.", cause: errors.Join(err, lookupErr)} } - return nil + return lookupErr +} + +func authStatusIdentityText(identity authStatusIdentity) string { + if identity.Status != "resolved" { + return "unknown (" + identity.Reason + ")" + } + value := identity.ID + if identity.Name != nil { + value += " | name: " + *identity.Name + } + if identity.Email != nil { + value += " | email: " + *identity.Email + } + return "resolved | " + authStatusTextValue(authStatusValue{Value: value}) } func authStatusTextValue(value authStatusValue) string { @@ -119,10 +203,23 @@ func authStatusTextValue(value authStatusValue) string { // Match the CLI and SDK option layers without constructing an SDK client or request. // Snapshot relevant environment inputs once; never enumerate unrelated secrets. func resolveAuthStatus(root *cli.Command, lookup func(string) (string, bool)) (authStatusReport, error) { + snapshot, err := resolveAuthStatusSnapshot(root, lookup) + return snapshot.report, err +} + +// Secrets stay in this invocation-local snapshot and never enter a renderer. +type authStatusSnapshot struct { + report authStatusReport + baseURL, explicitBaseURL string + apiAuthorization, adminAuthorization string + secrets []string +} + +func resolveAuthStatusSnapshot(root *cli.Command, lookup func(string) (string, bool)) (authStatusSnapshot, error) { environment := snapshotContextEnvironment(root, lookup) literal, err := requestHeaders(root) if err != nil { - return authStatusReport{}, &localUtilityError{message: "Invalid --header. Use 'Name: Value' without control characters."} + return authStatusSnapshot{}, &localUtilityError{message: "Invalid --header. Use 'Name: Value' without control characters."} } inherited := inheritedContextHeaders(environment) setting := func(flag, variable string) authStatusValue { @@ -130,12 +227,12 @@ func resolveAuthStatus(root *cli.Command, lookup func(string) (string, bool)) (a } resolved, err := resolveRequestContext(requestResolutionContext(root), root, environment, literal) if err != nil { - return authStatusReport{}, err + return authStatusSnapshot{}, err } base := resolved.BaseURL endpoint, basic, err := authStatusBaseURL(base) if err != nil { - return authStatusReport{}, err + return authStatusSnapshot{}, err } credential := func(flag, variable, kind string) authStatusCredential { key := setting(flag, variable) @@ -160,13 +257,201 @@ func resolveAuthStatus(root *cli.Command, lookup func(string) (string, bool)) (a } return result } - return authStatusReport{ + snapshot := authStatusSnapshot{baseURL: base.Value} + snapshot.secrets = append(snapshot.secrets, environment["OPENAI_API_KEY"].Value, environment["OPENAI_ADMIN_KEY"].Value) + if base.Source == "flag:--base-url" || base.Source == "environment:OPENAI_BASE_URL" { + snapshot.explicitBaseURL = base.Value + } + authorization := func(flag, variable string) string { + key := setting(flag, variable).Value + snapshot.secrets = append(snapshot.secrets, key) + if literal.Values("Authorization") != nil { + return literal.Get("Authorization") + } + if key != "" { + return "Bearer " + key + } + return inherited.Get("Authorization") + } + snapshot.apiAuthorization = authorization("api-key", "OPENAI_API_KEY") + snapshot.adminAuthorization = authorization("admin-api-key", "OPENAI_ADMIN_KEY") + for _, header := range []string{literal.Get("Authorization"), inherited.Get("Authorization")} { + snapshot.secrets = append(snapshot.secrets, header) + if _, token, ok := strings.Cut(header, " "); ok { + snapshot.secrets = append(snapshot.secrets, token) + } + } + snapshot.report = authStatusReport{ Offline: true, API: credential("api-key", "OPENAI_API_KEY", "api_key"), Admin: credential("admin-api-key", "OPENAI_ADMIN_KEY", "admin_api_key"), Organization: resolved.Organization, Project: resolved.Project, BaseURL: endpoint, - }, nil + } + unknown := func(credential authStatusCredential) authStatusIdentity { + reason := "not_requested" + if !credential.Configured { + reason = "not_configured" + } + return authStatusIdentity{Status: "unknown", Reason: reason} + } + snapshot.report.APIIdentity, snapshot.report.AdminIdentity = unknown(snapshot.report.API), unknown(snapshot.report.Admin) + return snapshot, nil +} + +type authStatusLookupError struct { + *localUtilityError + code int +} + +func (e *authStatusLookupError) ExitCode() int { return e.code } + +// The provider returns an authenticated principal, not a key ID or membership assertion. +// Keep its request independent of SDK API/Admin fallback and API-body error rendering. +func authStatusLookup(ctx context.Context, root *cli.Command, snapshot authStatusSnapshot, kind string) (authStatusIdentity, error) { + failure := func(reason, message string) (authStatusIdentity, error) { + return authStatusIdentity{Status: "unknown", Reason: reason}, &localUtilityError{message: message} + } + endpoint, err := url.Parse(snapshot.baseURL) + if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() != "api.openai.com" || + (endpoint.Port() != "" && endpoint.Port() != "443") || endpoint.User != nil || + endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" || endpoint.RawFragment != "" || + endpoint.RawPath != "" || (endpoint.Path != "/v1" && endpoint.Path != "/v1/") { + return failure("unsupported_provider", "Identity lookup requires the supported https://api.openai.com/v1/ endpoint. No request was sent.") + } + authorization := snapshot.apiAuthorization + if kind == "admin" { + authorization = snapshot.adminAuthorization + } + if authorization == "" { + return failure("not_configured", "The selected credential is not configured. No request was sent.") + } + scheme, token, bearer := strings.Cut(authorization, " ") + if !bearer || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(token) == "" { + return failure("invalid_configuration", "Identity lookup requires a Bearer credential. No request was sent.") + } + cert, _ := root.Value(mtlsClientCertFileFlag).(string) + key, _ := root.Value(mtlsClientKeyFileFlag).(string) + client, err := newMTLSHTTPClient(cert, key, snapshot.explicitBaseURL) + if err != nil { + return failure("invalid_configuration", "Could not configure identity lookup TLS. Check the certificate, key, and explicit base URL.") + } + if client == nil { + client = &http.Client{Transport: http.DefaultTransport} + if transport, ok := http.DefaultTransport.(*http.Transport); ok { + client.Transport = transport.Clone() + } + } + defer client.CloseIdleConnections() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + endpoint.Path = "/v1/me" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return failure("invalid_configuration", "Could not configure identity lookup. No request was sent.") + } + request.Header.Set("Authorization", authorization) + request.Header.Set("Accept", "application/json") + request.Header.Set("OpenAI-Organization", snapshot.report.Organization.Value) + request.Header.Set("OpenAI-Project", snapshot.report.Project.Value) + response, err := client.Do(request) + requestFailure := func() (authStatusIdentity, error) { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return failure("timed_out", "Identity lookup timed out. Identity remains unknown.") + } + if ctx.Err() != nil { + return failure("canceled", "Identity lookup canceled.") + } + return failure("request_failed", "Could not resolve identity. Check your connection and retry.") + } + if err != nil { + return requestFailure() + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + reason := "unavailable" + switch { + case response.StatusCode == http.StatusUnauthorized: + reason = "unauthorized" + case response.StatusCode == http.StatusForbidden: + reason = "forbidden" + case response.StatusCode == http.StatusTooManyRequests: + reason = "rate_limited" + case response.StatusCode >= 300 && response.StatusCode < 400: + reason = "redirect" + } + return failure(reason, "The identity provider did not resolve identity. Identity remains unknown.") + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + return failure("invalid_response", "The identity provider returned an invalid response. Identity remains unknown.") + } + identity, err := decodeAuthStatusIdentity(response.Body) + if ctx.Err() != nil { + return requestFailure() + } + if err != nil { + return failure("invalid_response", "The identity provider returned an invalid response. Identity remains unknown.") + } + values := []string{identity.ID} + if identity.Name != nil { + values = append(values, *identity.Name) + } + if identity.Email != nil { + values = append(values, *identity.Email) + } + for _, value := range values { + for _, secret := range snapshot.secrets { + if secret != "" && strings.Contains(value, secret) { + return failure("invalid_response", "The identity provider returned an invalid response. Identity remains unknown.") + } + } + } + return identity, nil +} + +func decodeAuthStatusIdentity(body io.Reader) (authStatusIdentity, error) { + decoder := json.NewDecoder(body) + invalid := errors.New("invalid identity response") + identity := authStatusIdentity{Status: "resolved", Provider: "openai_me_v1"} + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return authStatusIdentity{}, invalid + } + seen := map[string]bool{} + object := "" + for decoder.More() { + token, err = decoder.Token() + name, ok := token.(string) + if err != nil || !ok || seen[name] { + return authStatusIdentity{}, invalid + } + seen[name] = true + switch name { + case "object": + err = decoder.Decode(&object) + case "id": + err = decoder.Decode(&identity.ID) + case "name": + err = decoder.Decode(&identity.Name) + case "email": + err = decoder.Decode(&identity.Email) + default: + var ignored json.RawMessage + err = decoder.Decode(&ignored) + } + if err != nil { + return authStatusIdentity{}, invalid + } + } + if _, err = decoder.Token(); err != nil { + return authStatusIdentity{}, invalid + } + if _, err = decoder.Token(); err != io.EOF || object != "user" || strings.TrimSpace(identity.ID) == "" { + return authStatusIdentity{}, invalid + } + return identity, nil } func authStatusBaseURL(base authStatusValue) (authStatusEndpoint, bool, error) { diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go index 4e2c7eda..3ddb87bd 100644 --- a/pkg/custom/auth_status_test.go +++ b/pkg/custom/auth_status_test.go @@ -9,7 +9,10 @@ import ( "net/http" "net/http/httptest" "os" + "strings" + "syscall" "testing" + "time" "github.com/openai/openai-cli/internal/requestflag" "github.com/openai/openai-go/v3" @@ -233,3 +236,443 @@ func TestAuthStatusMatchesSDKAuthorizationSelection(t *testing.T) { }) } } + +type authStatusRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f authStatusRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func authStatusTestTransport(t *testing.T, roundTrip authStatusRoundTripFunc) { + t.Helper() + previous := http.DefaultTransport + http.DefaultTransport = roundTrip + t.Cleanup(func() { http.DefaultTransport = previous }) +} + +func authStatusRunJSON(t *testing.T, ctx context.Context, args ...string) (map[string]any, string, error) { + t.Helper() + var out bytes.Buffer + err := authStatusTestRoot(&out).Run(ctx, append([]string{"openai", "auth", "status", "--format=json"}, args...)) + var report map[string]any + if out.Len() > 0 { + require.NoError(t, json.Unmarshal(out.Bytes(), &report), "status output must remain valid JSON") + } + return report, out.String(), err +} + +func authStatusUnknownIdentity(t *testing.T, report map[string]any, kind, reason string) { + t.Helper() + identity, ok := report[kind+"_identity"].(map[string]any) + require.True(t, ok, "missing %s identity", kind) + require.Equal(t, "unknown", identity["status"]) + require.IsType(t, "", identity["reason"]) + require.NotEmpty(t, identity["reason"]) + if reason != "" { + require.Equal(t, reason, identity["reason"]) + } + require.Nil(t, identity["name"]) + require.Nil(t, identity["email"]) + require.Len(t, identity, 4, "unknown identity must not claim a provider or principal ID") +} + +func TestAuthStatusIdentityOfflineAndFlagErrors(t *testing.T) { + for _, configured := range []bool{false, true} { + t.Run(map[bool]string{false: "not configured", true: "configured"}[configured], func(t *testing.T) { + env := map[string]string{} + reason := "not_configured" + if configured { + env = map[string]string{"OPENAI_API_KEY": "synthetic-api", "OPENAI_ADMIN_KEY": "synthetic-admin"} + reason = "not_requested" + } + authStatusTestEnvironment(t, env) + calls := 0 + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("unexpected transport call") + }) + report, _, err := authStatusRunJSON(t, t.Context()) + require.NoError(t, err) + require.Equal(t, true, report["offline"]) + require.Equal(t, false, report["verified"]) + require.NotContains(t, report, "verified_credential_kind") + authStatusUnknownIdentity(t, report, "api", reason) + authStatusUnknownIdentity(t, report, "admin", reason) + for _, args := range [][]string{{"--verify"}, {"--credential-kind=api"}, {"--credential-kind=admin"}, {"--verify", "--credential-kind=other"}} { + _, output, err := authStatusRunJSON(t, t.Context(), args...) + require.Error(t, err) + require.Empty(t, output) + } + require.Zero(t, calls) + }) + } +} + +func TestAuthStatusIdentitySelectedCredentialAndHeaders(t *testing.T) { + for _, tc := range []struct { + name, kind, baseURL, authorization string + args []string + }{ + {name: "API default", kind: "api", authorization: "Bearer synthetic-api"}, + {name: "Admin default", kind: "admin", authorization: "Bearer synthetic-admin"}, + {name: "without trailing slash", kind: "api", baseURL: "https://api.openai.com/v1", authorization: "Bearer synthetic-api"}, + {name: "explicit default port", kind: "admin", baseURL: "https://api.openai.com:443/v1/", authorization: "Bearer synthetic-admin"}, + {name: "environment header fallback", kind: "api", authorization: "Bearer synthetic-environment-header", args: []string{"--api-key="}}, + {name: "literal header wins", kind: "admin", authorization: "Bearer synthetic-last", args: []string{"-H", "Authorization: Bearer synthetic-first", "-H", "authorization: Bearer synthetic-last"}}, + } { + t.Run(tc.name, func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{ + "OPENAI_API_KEY": "synthetic-api", "OPENAI_ADMIN_KEY": "synthetic-admin", + "OPENAI_ORG_ID": "org-environment", "OPENAI_PROJECT_ID": "proj-environment", + "OPENAI_CUSTOM_HEADERS": "Authorization: Bearer synthetic-environment-header\nCookie: synthetic-cookie\nX-Synthetic-Ignored: synthetic-header", + }) + calls := 0 + body := &authStatusCloseBody{Reader: strings.NewReader(`{"object":"user","id":"user-synthetic","name":"Synthetic User","email":"synthetic@example.invalid","orgs":{"data":[{"id":"org-unrelated"}]}}`)} + authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { + calls++ + require.Equal(t, http.MethodGet, request.Method) + require.Equal(t, "https", request.URL.Scheme) + require.Equal(t, "api.openai.com", request.URL.Hostname()) + require.Equal(t, "/v1/me", request.URL.Path) + require.Empty(t, request.URL.RawQuery) + require.Nil(t, request.Body) + require.Equal(t, http.Header{ + "Accept": {"application/json"}, "Authorization": {tc.authorization}, + "Openai-Organization": {"org-selected"}, "Openai-Project": {"proj-selected"}, + }, request.Header) + deadline, ok := request.Context().Deadline() + require.True(t, ok, "identity lookup must have a finite deadline") + require.Positive(t, time.Until(deadline)) + require.LessOrEqual(t, time.Until(deadline), 15*time.Second) + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: body}, nil + }) + args := []string{"--verify", "--credential-kind=" + tc.kind, "--organization=org-selected", "-H", "OpenAI-Project: proj-selected", "-H", "X-Do-Not-Forward: synthetic-extra"} + if tc.baseURL != "" { + args = append(args, "--base-url="+tc.baseURL) + } + args = append(args, tc.args...) + report, output, err := authStatusRunJSON(t, t.Context(), args...) + require.NoError(t, err) + require.Equal(t, 1, calls) + require.True(t, body.closed) + require.Equal(t, false, report["offline"]) + require.Equal(t, true, report["verified"]) + require.Equal(t, tc.kind, report["verified_credential_kind"]) + require.Equal(t, map[string]any{"status": "resolved", "provider": "openai_me_v1", "id": "user-synthetic", "name": "Synthetic User", "email": "synthetic@example.invalid"}, report[tc.kind+"_identity"]) + other := map[string]string{"api": "admin", "admin": "api"}[tc.kind] + authStatusUnknownIdentity(t, report, other, "not_requested") + for _, secret := range []string{"synthetic-api", "synthetic-admin", "synthetic-environment-header", "synthetic-first", "synthetic-last", "synthetic-cookie", "synthetic-extra"} { + require.NotContains(t, output, secret) + } + }) + } +} + +type authStatusCloseBody struct { + *strings.Reader + closed bool +} + +func (body *authStatusCloseBody) Close() error { body.closed = true; return nil } + +func TestAuthStatusIdentityUnsupportedProviderAndMissingCredential(t *testing.T) { + for _, baseURL := range []string{ + "http://api.openai.com/v1", "https://example.invalid/v1", "https://api.openai.com.example.invalid/v1", + "https://api.openai.com./v1", "https://api.openai.com:444/v1", "https://api.openai.com/", "https://api.openai.com/v1/extra", + "https://api.openai.com/v1?token=synthetic-query", "https://api.openai.com/v1?", "https://api.openai.com/v1#synthetic-fragment", + "https://synthetic-user:synthetic-password@api.openai.com/v1", "https://api.openai.com/v%31", + } { + t.Run(baseURL, func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + calls := 0 + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("unexpected transport call") + }) + report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api", "--base-url="+baseURL) + require.Error(t, err) + require.Zero(t, calls) + authStatusUnknownIdentity(t, report, "api", "unsupported_provider") + require.Equal(t, false, report["verified"]) + require.NotContains(t, report, "verified_credential_kind") + for _, secret := range []string{"synthetic-api", "synthetic-query", "synthetic-fragment", "synthetic-user", "synthetic-password"} { + require.NotContains(t, output+err.Error(), secret) + } + }) + } + for _, kind := range []string{"api", "admin"} { + t.Run("missing "+kind, func(t *testing.T) { + authStatusTestEnvironment(t, nil) + calls := 0 + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("unexpected transport call") + }) + report, _, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind="+kind) + require.Error(t, err) + require.Zero(t, calls) + authStatusUnknownIdentity(t, report, kind, "not_configured") + }) + } +} + +func TestAuthStatusIdentityRejectsInvalidOrReflectedResponse(t *testing.T) { + for _, body := range []string{ + `{`, `[]`, `null`, `{}`, `{"id":"user-synthetic"}`, `{"object":"organization","id":"org-synthetic"}`, + `{"object":"user","id":null}`, `{"object":"user","id":123}`, `{"object":"user","id":""}`, + `{"object":"user","id":" "}`, `{"object":"user","user":{"id":"user-nested"}}`, + `{"object":"user","id":"user-synthetic","name":42}`, `{"object":"user","id":"user-synthetic","email":[]}`, + `{"object":"user","id":"user-one","id":"user-two"}`, `{"object":"user","id":"user-synthetic","name":"one","name":"two"}`, + `{"object":"user","id":"user-synthetic","email":null,"email":"synthetic@example.invalid"}`, + `{"object":"user","object":"user","id":"user-synthetic"}`, + `{"object":"user","id":"user-synthetic","ignored":1,"ignored":2}`, + `{"object":"user","id":"user-synthetic"} {"id":"user-trailing"}`, `{"object":"user","id":"user-synthetic"} trailing`, + `{"object":"user","id":"user-synthetic-api"}`, `{"object":"user","id":"user-synthetic","name":"synthetic-admin"}`, + `{"object":"user","id":"user-synthetic","email":"synthetic-api@example.invalid"}`, + } { + t.Run(body, func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api", "OPENAI_ADMIN_KEY": "synthetic-admin"}) + calls := 0 + responseBody := &authStatusCloseBody{Reader: strings.NewReader(body)} + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: responseBody}, nil + }) + report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") + require.Error(t, err) + require.Equal(t, 1, calls) + require.True(t, responseBody.closed) + authStatusUnknownIdentity(t, report, "api", "invalid_response") + authStatusUnknownIdentity(t, report, "admin", "not_requested") + require.Equal(t, false, report["verified"]) + require.NotContains(t, report, "verified_credential_kind") + require.NotContains(t, output+err.Error(), "synthetic-api") + require.NotContains(t, output+err.Error(), "synthetic-admin") + }) + } +} + +func TestAuthStatusIdentityStatusErrorsAndRedirects(t *testing.T) { + for _, status := range []int{http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect, http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + calls := 0 + body := &authStatusCloseBody{Reader: strings.NewReader(`{"error":"synthetic-api"}`)} + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: status, Header: http.Header{"Location": {"https://example.invalid/synthetic-api"}}, Body: body}, nil + }) + report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") + require.Error(t, err) + require.Equal(t, 1, calls, "identity lookup must not redirect or retry") + require.True(t, body.closed) + authStatusUnknownIdentity(t, report, "api", "") + require.NotContains(t, output+err.Error(), "synthetic-api") + require.NotContains(t, output+err.Error(), "example.invalid") + }) + } +} + +func TestAuthStatusIdentityOptionalFieldsAndLargeIgnoredData(t *testing.T) { + for _, body := range []string{ + `{"object":"user","id":"user-synthetic"}`, + `{"object":"user","id":"user-synthetic","name":null,"email":null}`, + `{"object":"user","id":"user-synthetic","ignored":"` + strings.Repeat("x", 2*1024*1024) + `","orgs":{"data":[{"id":"org-other"}]}}`, + } { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") + require.NoError(t, err) + require.Equal(t, map[string]any{"status": "resolved", "provider": "openai_me_v1", "id": "user-synthetic", "name": nil, "email": nil}, report["api_identity"]) + require.NotContains(t, output, "org-other") + require.Less(t, len(output), 4096) + } +} + +func TestAuthStatusIdentityTransportFailureAndCancellation(t *testing.T) { + t.Run("transport error stays private", func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + calls := 0 + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("synthetic-api credential reflected by transport") + }) + report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") + require.Error(t, err) + require.Equal(t, 1, calls) + authStatusUnknownIdentity(t, report, "api", "") + require.NotContains(t, output+err.Error(), "synthetic-api") + }) + t.Run("in flight cancellation", func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + calls := 0 + authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { + calls++ + cancel() + <-request.Context().Done() + return nil, request.Context().Err() + }) + _, output, err := authStatusRunJSON(t, ctx, "--verify", "--credential-kind=api") + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, calls) + require.NotContains(t, output+err.Error(), "synthetic-api") + }) +} + +func TestAuthStatusIdentityRejectsNonBearerCredential(t *testing.T) { + for _, authorization := range []string{"Basic synthetic-basic", "Bearer ", "synthetic-api"} { + t.Run(authorization, func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + calls := 0 + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("unexpected transport call") + }) + report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api", "-H", "Authorization: "+authorization) + require.Error(t, err) + require.Zero(t, calls) + authStatusUnknownIdentity(t, report, "api", "invalid_configuration") + require.NotContains(t, output+err.Error(), "synthetic-basic") + require.NotContains(t, output+err.Error(), "synthetic-api") + }) + } +} + +func TestAuthStatusIdentityRequiresJSONContentType(t *testing.T) { + for _, contentType := range []string{"", "text/html", "text/plain", "application/json; charset=\"unterminated"} { + t.Run(contentType, func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + body := &authStatusCloseBody{Reader: strings.NewReader(`{"object":"user","id":"user-synthetic"}`)} + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {contentType}}, Body: body}, nil + }) + report, _, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") + require.Error(t, err) + require.True(t, body.closed) + authStatusUnknownIdentity(t, report, "api", "invalid_response") + }) + } +} + +func TestAuthStatusIdentityRepeatedCallsUseCurrentCredential(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-first"}) + var authorization []string + var bodies []*authStatusCloseBody + authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { + authorization = append(authorization, request.Header.Get("Authorization")) + id := "user-first" + if len(authorization) == 2 { + id = "user-second" + } + body := &authStatusCloseBody{Reader: strings.NewReader(`{"object":"user","id":"` + id + `"}`)} + bodies = append(bodies, body) + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json; charset=utf-8"}}, Body: body}, nil + }) + first, _, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") + require.NoError(t, err) + t.Setenv("OPENAI_API_KEY", "synthetic-second") + second, _, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") + require.NoError(t, err) + require.Equal(t, []string{"Bearer synthetic-first", "Bearer synthetic-second"}, authorization) + require.Equal(t, "user-first", first["api_identity"].(map[string]any)["id"]) + require.Equal(t, "user-second", second["api_identity"].(map[string]any)["id"]) + for _, body := range bodies { + require.True(t, body.closed) + } +} + +type authStatusDeadlineBody struct { + ctx context.Context + closed bool +} + +func (body *authStatusDeadlineBody) Read([]byte) (int, error) { + <-body.ctx.Done() + return 0, body.ctx.Err() +} + +func (body *authStatusDeadlineBody) Close() error { body.closed = true; return nil } + +func TestAuthStatusIdentityParentDeadline(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + calls := 0 + var body *authStatusDeadlineBody + authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { + calls++ + body = &authStatusDeadlineBody{ctx: request.Context()} + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: body}, nil + }) + _, output, err := authStatusRunJSON(t, ctx, "--verify", "--credential-kind=api") + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NotErrorIs(t, err, context.Canceled) + require.Equal(t, 1, calls) + require.True(t, body.closed) + require.NotContains(t, output+err.Error(), "synthetic-api") + require.NotContains(t, output, `"status":"resolved"`) +} + +func TestAuthStatusIdentityOutputFailurePreservesLookupFailure(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusUnauthorized} { + t.Run(http.StatusText(status), func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + body := &authStatusCloseBody{Reader: strings.NewReader(`{"object":"user","id":"user-synthetic"}`)} + authStatusTestTransport(t, func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: body}, nil + }) + root := authStatusTestRoot(authStatusFailWriter{err: syscall.EPIPE}) + err := root.Run(t.Context(), []string{"openai", "auth", "status", "--format=json", "--verify", "--credential-kind=api"}) + require.ErrorIs(t, err, syscall.EPIPE) + require.Equal(t, status == http.StatusOK, isOutputBrokenPipe(err), "lookup failure must prevent broken-pipe suppression") + require.True(t, body.closed) + require.NotContains(t, err.Error(), "synthetic-api") + }) + } +} + +func TestAuthStatusIdentityCommandReuseClearsVerification(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api", "OPENAI_ADMIN_KEY": "synthetic-admin"}) + var output bytes.Buffer + root := authStatusTestRoot(&output) + var authorization []string + authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { + authorization = append(authorization, request.Header.Get("Authorization")) + id := "user-api" + if request.Header.Get("Authorization") == "Bearer synthetic-admin" { + id = "user-admin" + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"object":"user","id":"` + id + `"}`))}, nil + }) + for _, tc := range []struct { + kind string + calls int + }{{"api", 1}, {"", 1}, {"admin", 2}} { + output.Reset() + args := []string{"openai", "auth", "status", "--format=json"} + if tc.kind != "" { + args = append(args, "--verify", "--credential-kind="+tc.kind) + } + require.NoError(t, root.Run(t.Context(), args)) + var report map[string]any + require.NoError(t, json.Unmarshal(output.Bytes(), &report)) + require.Len(t, authorization, tc.calls) + if tc.kind == "" { + require.Equal(t, true, report["offline"]) + require.Equal(t, false, report["verified"]) + require.NotContains(t, report, "verified_credential_kind") + authStatusUnknownIdentity(t, report, "api", "not_requested") + authStatusUnknownIdentity(t, report, "admin", "not_requested") + } else { + require.Equal(t, false, report["offline"]) + require.Equal(t, true, report["verified"]) + require.Equal(t, tc.kind, report["verified_credential_kind"]) + require.Equal(t, "user-"+tc.kind, report[tc.kind+"_identity"].(map[string]any)["id"]) + } + } + require.Equal(t, []string{"Bearer synthetic-api", "Bearer synthetic-admin"}, authorization) +} From 7ce5bd12f618eca02d8d8448cf121dd02043649a Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 15:26:29 -0400 Subject: [PATCH 24/36] Close identity transport and credential state gaps --- .github/workflows/help-compatibility.yml | 348 +++++++++++++++++++ cmd/openai/auth_status_test.go | 419 ++++++++++++++++++++++- docs/auth-status.md | 6 +- pkg/custom/auth_status.go | 111 +++++- pkg/custom/auth_status_test.go | 297 ++++++++++++++++ pkg/custom/mtls.go | 9 +- pkg/custom/mtls_client_flag.go | 14 +- pkg/custom/mtls_client_flag_test.go | 205 +++++++++++ pkg/custom/root_request_flags.go | 14 +- pkg/custom/root_request_flags_test.go | 202 ++++++++++- 10 files changed, 1577 insertions(+), 48 deletions(-) diff --git a/.github/workflows/help-compatibility.yml b/.github/workflows/help-compatibility.yml index b77141a7..0a682669 100644 --- a/.github/workflows/help-compatibility.yml +++ b/.github/workflows/help-compatibility.yml @@ -213,6 +213,120 @@ jobs: go test -p 1 ./cmd/openai -count=1 -timeout=5m -v -json -run '^(TestMainAPIErrorsFormatPrecedenceAndExtraction|TestMainAPIErrorsPreserveJSONDetailsAndExtraction|TestMainAPIErrorsStructuredAcrossCommands|TestMainAPIErrorsWithUnexpectedJSONShapes|TestMainAPIErrorsWithoutJSONDetails|TestMainCommandGroupsHaveWorkflowDescriptions|TestMainCommandSubgroupsCoverGeneratedRoutes|TestMainCommandSubgroupsHelpAndCompletion|TestMainCommandSubgroupsLegacyPrefixCompletion|TestMainCommandSubgroupsPreserveAudioAndErrors|TestMainCommandSubgroupsPreserveBodyFlagScope|TestMainCommandSubgroupsPreserveRequestsAndOutput|TestMainCommandSubgroupsStreamingStaysIncremental|TestMainLocalErrorGuidance|TestMainLocalErrorsStayOnStderr|TestMainNestedHelpErrorFormatPrecedence|TestMainPreservesWrappedExitCode|TestMainReadableAPIArgumentGuidance|TestMainReadableDefaultAndExplicitText|TestMainReadableErrorsStayOnStderr|TestMainReadablePreservesExplicitFormatsAndExtraction|TestMainReadableRawOutputStreamsBeforeNextEvent|TestMainSensitiveLocalErrors|TestMainStreamErrorsPreserveEventDetails|TestMainTaskAdminRoutesPreserveCredentialAndFlagScope|TestMainTaskAudioUploadsPreserveRequestsAndOutput|TestMainTaskFileUploadPreservesMultipartAndOutput|TestMainTaskResourceRoutesPreserveAPIErrorsAndExit|TestMainTaskRouteCompletionProtocols|TestMainTaskRouteHelpStaysLocal|TestMainTaskRoutesPreserveAPIErrorsAndExit|TestMainTaskRoutesPreserveRequiredFlags|TestMainTaskSpeechPreservesJSONInputAndBinaryOutput|TestMainTaskWindowsAudioInterruptClosesStreams)$' | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'native-command-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop exit $LASTEXITCODE + - name: Test authentication, profiles, and project links + if: runner.os != 'Windows' + shell: bash + timeout-minutes: 4 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=3m -v -json -run '^(TestMainAuthStatusOfflineOutputAndDiagnostics|TestMainAuthStatusCredentialAndContextPrecedence|TestMainAuthStatusPreservesStdin|TestMainAuthStatusDoesNotLoadMTLS|TestMainAuthStatusSanitizesURLsAndTerminalControls|TestMainAuthStatusErrorsRemainPrivate|TestMainAuthStatusIdentitySelectionAndRequestBoundary|TestMainAuthStatusIdentityProviderAndCredentialRejections|TestMainAuthStatusIdentitySelectionRequiresExplicitVerification|TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate|TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints|TestMainAuthStatusIdentityAuthorizationPrecedence|TestMainAuthStatusIdentityTextEscapesTerminalControls|TestMainAuthStatusIdentityDeadline|TestMainProfileLifecycleAndExactSchemas|TestMainProfileEditsNeverSaveAmbientValues|TestMainProfileStatusMatchesRequestContext|TestMainProfileProjectBypassesMalformedFolderRegistry|TestMainProfileMissingSelectionPreservesCommittedFolderLink|TestMainProfileCancellationPreservesCommittedOutcomes|TestMainProjectLinkOfflineLifecycle|TestMainProjectLinkQuietPreservesInspectionAndJSON|TestMainProjectLinkRequestPrecedence|TestMainProjectLinkDirectoryScope|TestMainProjectLinkMalformedRegistryFailsBeforeRequests|TestMainAuthStatusIdentitySpacedBearerRemainsPrivate)$' \ + | tee "$RUNNER_TEMP/auth-profile-tests.jsonl" + - name: Test Windows authentication, profiles, and project links + if: runner.os == 'Windows' + shell: pwsh + timeout-minutes: 4 + env: + GOMAXPROCS: '2' + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=3m -v -json -run '^(TestMainAuthStatusOfflineOutputAndDiagnostics|TestMainAuthStatusCredentialAndContextPrecedence|TestMainAuthStatusPreservesStdin|TestMainAuthStatusDoesNotLoadMTLS|TestMainAuthStatusSanitizesURLsAndTerminalControls|TestMainAuthStatusErrorsRemainPrivate|TestMainAuthStatusIdentitySelectionAndRequestBoundary|TestMainAuthStatusIdentityProviderAndCredentialRejections|TestMainAuthStatusIdentitySelectionRequiresExplicitVerification|TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate|TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints|TestMainAuthStatusIdentityAuthorizationPrecedence|TestMainAuthStatusIdentityTextEscapesTerminalControls|TestMainAuthStatusIdentityDeadline|TestMainProfileLifecycleAndExactSchemas|TestMainProfileEditsNeverSaveAmbientValues|TestMainProfileStatusMatchesRequestContext|TestMainProfileProjectBypassesMalformedFolderRegistry|TestMainProfileMissingSelectionPreservesCommittedFolderLink|TestMainProfileCancellationPreservesCommittedOutcomes|TestMainProjectLinkOfflineLifecycle|TestMainProjectLinkQuietPreservesInspectionAndJSON|TestMainProjectLinkRequestPrecedence|TestMainProjectLinkDirectoryScope|TestMainProjectLinkMalformedRegistryFailsBeforeRequests|TestMainAuthStatusIdentitySpacedBearerRemainsPrivate)$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'auth-profile-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + exit $LASTEXITCODE + - name: Test identity transport and repeated request configuration + if: runner.os != 'Windows' + shell: bash + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./pkg/custom -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAuthStatusIdentityMTLSReaderRegularAndCancellation|TestAuthStatusIdentityVerifiedTLSAndMTLS|TestRootRequestFlagPreParseResetsInvocationState|TestRootRequestFlagsResetProvenanceAcrossRuns|TestRootRequestFlagPreParsePreservesCurrentDefinition|TestRootRequestFlagsReuseMatchesStatusAndSDKRequests|TestAuthStatusIdentityCommandReuseClearsVerification|TestAuthStatusIdentityRejectsReflectedSpacedBearer|TestMTLSClientFlagPreParsePreservesCurrentDefinition|TestMTLSClientFlagsReuseEnvironmentForTLS)$' \ + | tee "$RUNNER_TEMP/auth-identity-tls-tests.jsonl" + - name: Test Windows identity transport and repeated request configuration + if: runner.os == 'Windows' + shell: pwsh + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./pkg/custom -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAuthStatusIdentityMTLSReaderRegularAndCancellation|TestAuthStatusIdentityVerifiedTLSAndMTLS|TestRootRequestFlagPreParseResetsInvocationState|TestRootRequestFlagsResetProvenanceAcrossRuns|TestRootRequestFlagPreParsePreservesCurrentDefinition|TestRootRequestFlagsReuseMatchesStatusAndSDKRequests|TestAuthStatusIdentityCommandReuseClearsVerification|TestAuthStatusIdentityRejectsReflectedSpacedBearer|TestMTLSClientFlagPreParsePreservesCurrentDefinition|TestMTLSClientFlagsReuseEnvironmentForTLS)$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'auth-identity-tls-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + exit $LASTEXITCODE + - name: Test Unix identity credential symlinks and special files + if: runner.os != 'Windows' + shell: bash + timeout-minutes: 3 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./pkg/custom -parallel=1 -count=1 -timeout=1m -v -json -run '^TestAuthStatusIdentityMTLSReaderSymlinks$' \ + | tee "$RUNNER_TEMP/auth-identity-symlink-tests.jsonl" + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=1m -v -json -run '^TestMainAuthStatusIdentityMTLSFIFOs$' \ + | tee "$RUNNER_TEMP/auth-identity-fifo-tests.jsonl" + - name: Test identity lookup signals + if: runner.os != 'Windows' + shell: bash + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=1m -v -json -run '^TestMainAuthStatusIdentitySignals$' \ + | tee "$RUNNER_TEMP/auth-identity-signal-tests.jsonl" + - name: Test Linux standalone identity transport + if: runner.os == 'Linux' + shell: bash + timeout-minutes: 6 + env: + GOMAXPROCS: '2' + GOFLAGS: '-p=2' + run: | + set -euo pipefail + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=5m -v -json -run '^TestBuiltCLIAuthStatusIdentityTransportLinux$' \ + | tee "$RUNNER_TEMP/auth-identity-built-cli-tests.jsonl" + - name: Test Linux case-sensitive project links + if: runner.os == 'Linux' + shell: bash + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=1m -v -json \ + -run '^(TestMainProjectLinkDistinctCaseFoldDirectories|TestMainProjectLinkRetargetedAliasDoesNotMoveSavedLink)$' \ + | tee "$RUNNER_TEMP/project-link-case-sensitive-tests.jsonl" + - name: Test Unix profile command native shells + if: runner.os != 'Windows' + shell: bash + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + case "$RUNNER_OS" in + Linux) export OPENAI_CLI_REQUIRE_NATIVE_SHELLS=bash,zsh,fish; selected_shells='bash|zsh|fish' ;; + macOS) export OPENAI_CLI_REQUIRE_NATIVE_SHELLS=bash,zsh; selected_shells='bash|zsh' ;; + *) exit 1 ;; + esac + go test -p 1 ./pkg/custom -parallel=1 -count=1 -timeout=1m -v -json \ + -run "^TestImagePickerProfileCommandNativeShellRoundTrip$/^($selected_shells)$" \ + | tee "$RUNNER_TEMP/profile-native-shell-tests.jsonl" + - name: Test Windows profile command native PowerShell + if: runner.os == 'Windows' + shell: pwsh + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + OPENAI_CLI_REQUIRE_NATIVE_SHELLS: pwsh + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./pkg/custom -parallel=1 -count=1 -timeout=1m -v -json -run '^TestImagePickerProfileCommandNativeShellRoundTrip$/^pwsh$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'profile-native-shell-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + exit $LASTEXITCODE - name: Test help if: runner.os != 'Windows' run: go test ./internal/clihelp ./internal/requestflag -count=1 -v @@ -582,6 +696,223 @@ jobs: return {root} | {f"{root}/{child}" for child in children} strict_packages = {} + suites["auth-identity-tls-tests.jsonl"] = required_cases( + "TestAuthStatusIdentityMTLSReaderRegularAndCancellation", + ["certificate", "key", "directory", "pre-canceled", "canceled_between_PEM_files"], + ) | required_cases( + "TestAuthStatusIdentityVerifiedTLSAndMTLS", + ["TLS_API", "mTLS_Admin", "untrusted_server", "wrong_server_name", "TLS_closes_response", "mTLS_closes_response"], + ) + identity_regression_cases = { + "TestRootRequestFlagPreParseResetsInvocationState": [], + "TestRootRequestFlagsResetProvenanceAcrossRuns": ["api-key", "admin-api-key", "organization", "project", "webhook-secret"], + "TestRootRequestFlagPreParsePreservesCurrentDefinition": [], + "TestRootRequestFlagsReuseMatchesStatusAndSDKRequests": [], + "TestAuthStatusIdentityCommandReuseClearsVerification": [], + "TestAuthStatusIdentityRejectsReflectedSpacedBearer": ["flag_api_id", "flag_api_name", "flag_api_email", "flag_admin_id", "flag_admin_name", "flag_admin_email", "environment_api_id", "environment_api_name", "environment_api_email", "environment_admin_id", "environment_admin_name", "environment_admin_email", "key_api_id", "key_api_name", "key_api_email", "key_admin_id", "key_admin_name", "key_admin_email"], + "TestMTLSClientFlagPreParsePreservesCurrentDefinition": [], + "TestMTLSClientFlagsReuseEnvironmentForTLS": ["SDK", "identity"], + } + suites["auth-identity-tls-tests.jsonl"] |= set().union(*( + required_cases(root, children) for root, children in identity_regression_cases.items() + )) + strict_packages["auth-identity-tls-tests.jsonl"] = "github.com/openai/openai-cli/pkg/custom" + if os.environ["RUNNER_OS"] != "Windows": + suites["auth-identity-symlink-tests.jsonl"] = required_cases( + "TestAuthStatusIdentityMTLSReaderSymlinks", ["certificate", "key"], + ) + strict_packages["auth-identity-symlink-tests.jsonl"] = "github.com/openai/openai-cli/pkg/custom" + suites["auth-identity-fifo-tests.jsonl"] = required_cases( + "TestMainAuthStatusIdentityMTLSFIFOs", ["certificate", "key"], + ) + strict_packages["auth-identity-fifo-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" + auth_profile_cases = { + "TestMainAuthStatusOfflineOutputAndDiagnostics": [ + "auto", + "text", + "json" + ], + "TestMainAuthStatusCredentialAndContextPrecedence": [ + "missing", + "environment", + "equal_flag_value", + "empty_flag", + "last_empty_flag", + "last_nonempty_flag", + "environment_key_overrides_authorization", + "environment_authorization_only", + "flag_authorization", + "empty_authorization", + "before_command", + "between_commands", + "after_command" + ], + "TestMainAuthStatusPreservesStdin": [ + "auth_status_--format_json", + "auth_status_--help", + "auth_status_--format_yaml_--format-error_json" + ], + "TestMainAuthStatusDoesNotLoadMTLS": [ + "incomplete_environment", + "missing_environment_files", + "incomplete_flag", + "missing_flag_files" + ], + "TestMainAuthStatusSanitizesURLsAndTerminalControls": [ + "text", + "json", + "explicit_empty_endpoint" + ], + "TestMainAuthStatusErrorsRemainPrivate": [ + "unsupported_format", + "unknown_format", + "transform", + "raw_output_false", + "extra_argument", + "malformed_header", + "header_control", + "invalid_environment_URL", + "invalid_flag_URL" + ], + "TestMainAuthStatusIdentitySelectionAndRequestBoundary": [ + "api", + "admin" + ], + "TestMainAuthStatusIdentityProviderAndCredentialRejections": [ + "http://api.openai.com/v1/", + "https://example.invalid/v1/", + "https://api.openai.com.example.invalid/v1/", + "https://api.openai.com:444/v1/", + "https://api.openai.com/", + "https://api.openai.com/v1/other", + "https://api.openai.com/%76%31/", + "https://api.openai.com/v1/?", + "https://api.openai.com/v1/?token=synthetic-private-query", + "https://api.openai.com/v1/#synthetic-private-fragment", + "https://synthetic-private-user:synthetic-private-password@api.openai.com/v1/", + "missing_selected_admin", + "empty_selected_admin_override", + "unsupported_authorization", + "incomplete_TLS" + ], + "TestMainAuthStatusIdentitySelectionRequiresExplicitVerification": [ + "--verify", + "--verify_--credential-kind=other", + "--credential-kind=api", + "--verify=false_--credential-kind=admin" + ], + "TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate": [ + "unauthorized", + "forbidden", + "missing_provider", + "rate_limited", + "server_failure", + "same-origin_redirect", + "cross-origin_redirect", + "transport_failure", + "wrong_content_type", + "malformed_JSON", + "missing_ID", + "wrong_principal", + "wrong_name_type", + "duplicate_ID", + "duplicate_unknown_field", + "trailing_JSON", + "credential_echoed_in_ID", + "credential_echoed_in_name", + "credential_echoed_in_email" + ], + "TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints": [ + "https://api.openai.com/v1", + "https://api.openai.com/v1/", + "https://api.openai.com:443/v1", + "https://api.openai.com:443/v1/" + ], + "TestMainAuthStatusIdentityAuthorizationPrecedence": [ + "selected_API_flag", + "selected_admin_flag", + "literal_authorization", + "inherited_authorization" + ], + "TestMainAuthStatusIdentityTextEscapesTerminalControls": [], + "TestMainAuthStatusIdentitySpacedBearerRemainsPrivate": ["flag_api_text", "flag_api_json", "flag_admin_text", "flag_admin_json", "environment_api_text", "environment_api_json", "environment_admin_text", "environment_admin_json", "key_api_text", "key_api_json", "key_admin_text", "key_admin_json"], + "TestMainAuthStatusIdentityDeadline": [], + "TestMainProfileLifecycleAndExactSchemas": [ + "json", + "jsonl" + ], + "TestMainProfileEditsNeverSaveAmbientValues": [], + "TestMainProfileStatusMatchesRequestContext": [ + "saved_default", + "explicit_root_over_custom_headers", + "root_over_environment", + "empty_root", + "environment_over_profile", + "empty_environment", + "empty_profile_fields", + "absent_profile_fields", + "empty_flag_selector", + "empty_environment_selector", + "flag_selector_over_environment", + "custom_header_fallback", + "literal_headers", + "empty_literal_headers" + ], + "TestMainProfileProjectBypassesMalformedFolderRegistry": [ + "work", + "empty" + ], + "TestMainProfileMissingSelectionPreservesCommittedFolderLink": [ + "text", + "json" + ], + "TestMainProfileCancellationPreservesCommittedOutcomes": [ + "text", + "json" + ], + "TestMainProjectLinkOfflineLifecycle": [ + "without_credentials", + "invalid_remote_configuration" + ], + "TestMainProjectLinkQuietPreservesInspectionAndJSON": [], + "TestMainProjectLinkRequestPrecedence": [ + "saved_folder", + "environment", + "empty_environment", + "root_flag_before", + "root_flag_between", + "root_flag_after", + "empty_root_flag", + "explicit_header" + ], + "TestMainProjectLinkDirectoryScope": [], + "TestMainProjectLinkMalformedRegistryFailsBeforeRequests": [] + } + suites["auth-profile-tests.jsonl"] = set().union(*( + required_cases(root, children) for root, children in auth_profile_cases.items() + )) + strict_packages["auth-profile-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" + if os.environ["RUNNER_OS"] != "Windows": + suites["auth-identity-signal-tests.jsonl"] = required_cases( + "TestMainAuthStatusIdentitySignals", ["SIGINT", "SIGTERM", "SIGINT_body", "SIGTERM_body"], + ) + strict_packages["auth-identity-signal-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" + if os.environ["RUNNER_OS"] == "Linux": + suites["auth-identity-built-cli-tests.jsonl"] = required_cases( + "TestBuiltCLIAuthStatusIdentityTransportLinux", + ["offline", "api", "admin", "untrusted_tls", "invalid_json", "unauthorized", "same_origin_redirect", "cross_origin_redirect"], + ) + strict_packages["auth-identity-built-cli-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" + suites["project-link-case-sensitive-tests.jsonl"] = { + "TestMainProjectLinkDistinctCaseFoldDirectories", + "TestMainProjectLinkRetargetedAliasDoesNotMoveSavedLink", + } + strict_packages["project-link-case-sensitive-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" + profile_shells = {"Linux": ["bash", "zsh", "fish"], "macOS": ["bash", "zsh"], "Windows": ["pwsh"]} + suites["profile-native-shell-tests.jsonl"] = required_cases( + "TestImagePickerProfileCommandNativeShellRoundTrip", profile_shells[os.environ["RUNNER_OS"]], + ) + strict_packages["profile-native-shell-tests.jsonl"] = "github.com/openai/openai-cli/pkg/custom" if os.environ["CHECK_IMAGE_STATE"] == "true": preference_cases = { 'TestImagePreferenceDefaultAndReplacement': [], @@ -1364,6 +1695,23 @@ jobs: print(f"{filename}: {len(passed) - children} roots and {children} children passed; no skips") PY + - name: Retain authentication and profile test evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: auth-profile-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/auth-profile-tests.jsonl + ${{ runner.temp }}/auth-identity-signal-tests.jsonl + ${{ runner.temp }}/auth-identity-tls-tests.jsonl + ${{ runner.temp }}/auth-identity-built-cli-tests.jsonl + ${{ runner.temp }}/auth-identity-symlink-tests.jsonl + ${{ runner.temp }}/auth-identity-fifo-tests.jsonl + ${{ runner.temp }}/project-link-case-sensitive-tests.jsonl + ${{ runner.temp }}/profile-native-shell-tests.jsonl + if-no-files-found: warn + retention-days: 7 + wsl1-pager: if: github.event_name != 'push' needs: source diff --git a/cmd/openai/auth_status_test.go b/cmd/openai/auth_status_test.go index fde3cdc8..c3ba1a9f 100644 --- a/cmd/openai/auth_status_test.go +++ b/cmd/openai/auth_status_test.go @@ -1,11 +1,19 @@ package main import ( + "bufio" "bytes" "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "io" + "math/big" + "net" "net/http" "os" "os/exec" @@ -13,6 +21,7 @@ import ( "runtime" "slices" "strings" + "sync" "syscall" "testing" "time" @@ -407,6 +416,7 @@ type authStatusIdentityFixture struct { Location string `json:"location"` Error string `json:"error"` Wait bool `json:"wait"` + WaitBody bool `json:"wait_body"` } type authStatusIdentityRequest struct { @@ -463,8 +473,24 @@ func TestMainAuthStatusIdentityProcess(t *testing.T) { if fixture.Location != "" { headers.Set("Location", fixture.Location) } - return &http.Response{StatusCode: fixture.Status, Header: headers, - Body: io.NopCloser(strings.NewReader(fixture.Body)), Request: request}, nil + var body io.ReadCloser = io.NopCloser(strings.NewReader(fixture.Body)) + if fixture.WaitBody { + reader, writer := io.Pipe() + body = reader + go func() { + // Pipe.Write returns only after the decoder consumes this incomplete JSON. + _, err := io.WriteString(writer, `{"object":"user","id":"synthetic-private-response-body`) + if err == nil { + err = os.WriteFile(os.Getenv("OPENAI_CLI_AUTH_IDENTITY_BODY_READY"), []byte("body-decoding\n"), 0600) + } + if err == nil { + <-request.Context().Done() + err = request.Context().Err() + } + _ = writer.CloseWithError(err) + }() + } + return &http.Response{StatusCode: fixture.Status, Header: headers, Body: body, Request: request}, nil }) separator := slices.Index(os.Args, "--") require.GreaterOrEqual(t, separator, 0) @@ -474,15 +500,21 @@ func TestMainAuthStatusIdentityProcess(t *testing.T) { } type authStatusIdentityProcess struct { - command *exec.Cmd - stdout bytes.Buffer - stderr bytes.Buffer - context context.Context - done chan error - requests string + command *exec.Cmd + stdout bytes.Buffer + stderr bytes.Buffer + context context.Context + done chan error + requests string + bodyReady string } func startAuthStatusIdentity(t *testing.T, fixture authStatusIdentityFixture, env []string, args ...string) *authStatusIdentityProcess { + t.Helper() + return startAuthStatusIdentityWithTimeout(t, 25*time.Second, fixture, env, args...) +} + +func startAuthStatusIdentityWithTimeout(t *testing.T, timeout time.Duration, fixture authStatusIdentityFixture, env []string, args ...string) *authStatusIdentityProcess { t.Helper() binary, err := os.Executable() require.NoError(t, err) @@ -491,8 +523,9 @@ func startAuthStatusIdentity(t *testing.T, fixture authStatusIdentityFixture, en data, err := json.Marshal(fixture) require.NoError(t, err) require.NoError(t, os.WriteFile(fixturePath, data, 0600)) - ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) - process := &authStatusIdentityProcess{context: ctx, done: make(chan error, 1), requests: filepath.Join(directory, "requests.jsonl")} + ctx, cancel := context.WithTimeout(context.Background(), timeout) + process := &authStatusIdentityProcess{context: ctx, done: make(chan error, 1), requests: filepath.Join(directory, "requests.jsonl"), + bodyReady: filepath.Join(directory, "body-ready")} argv := append([]string{"-test.run=^TestMainAuthStatusIdentityProcess$", "--", "openai", "auth", "status"}, args...) process.command = exec.CommandContext(ctx, binary, argv...) for _, entry := range os.Environ() { @@ -504,7 +537,7 @@ func startAuthStatusIdentity(t *testing.T, fixture authStatusIdentityFixture, en process.command.Env = append(process.command.Env, authStatusEnvironment(t)...) process.command.Env = append(process.command.Env, "OPENAI_CLI_AUTH_IDENTITY_PROCESS=1", "OPENAI_CLI_AUTH_IDENTITY_FIXTURE="+fixturePath, - "OPENAI_CLI_AUTH_IDENTITY_REQUESTS="+process.requests, "COMPLETION_STYLE=bash", + "OPENAI_CLI_AUTH_IDENTITY_REQUESTS="+process.requests, "OPENAI_CLI_AUTH_IDENTITY_BODY_READY="+process.bodyReady, "COMPLETION_STYLE=bash", "OPENAI_BASE_URL=https://api.openai.com/v1/") process.command.Env = append(process.command.Env, env...) process.command.Stdout, process.command.Stderr = &process.stdout, &process.stderr @@ -607,6 +640,53 @@ func TestMainAuthStatusIdentitySelectionAndRequestBoundary(t *testing.T) { } } +func TestMainAuthStatusIdentitySpacedBearerRemainsPrivate(t *testing.T) { + const secret = "synthetic-private-spaced-bearer" + const authorization = "Bearer " + secret + for _, source := range []string{"flag", "environment", "key"} { + for _, kind := range []string{"api", "admin"} { + for _, format := range []string{"text", "json"} { + t.Run(source+"_"+kind+"_"+format, func(t *testing.T) { + args := []string{"--verify", "--credential-kind=" + kind, "--format=" + format, "--format-error=json", "--debug"} + var env []string + switch source { + case "flag": + args = append(args, "--header=Authorization: "+authorization) + case "environment": + env = append(env, "OPENAI_CUSTOM_HEADERS=Authorization: "+authorization) + case "key": + variable := "OPENAI_API_KEY" + if kind == "admin" { + variable = "OPENAI_ADMIN_KEY" + } + env = append(env, variable+"= "+secret) + } + fixture := authStatusIdentityFixture{Status: http.StatusOK, ContentType: "application/json", + Body: `{"object":"user","id":"user-synthetic","name":"prefix ` + secret + ` suffix"}`} + got, requests := runAuthStatusIdentity(t, fixture, env, args...) + require.NotZero(t, got.code) + require.Len(t, requests, 1) + require.Equal(t, authorization, requests[0].Headers.Get("Authorization")) + if format == "json" { + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Verified) + identity := status.APIIdentity + if kind == "admin" { + identity = status.AdminIdentity + } + require.Equal(t, "unknown", identity.Status) + require.Equal(t, "invalid_response", identity.Reason) + } else { + require.Contains(t, got.stdout, "unknown (invalid_response)") + } + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, secret) + }) + } + } + } +} + func TestMainAuthStatusIdentityProviderAndCredentialRejections(t *testing.T) { for _, endpoint := range []string{ "http://api.openai.com/v1/", "https://example.invalid/v1/", "https://api.openai.com.example.invalid/v1/", @@ -798,14 +878,26 @@ func TestMainAuthStatusIdentitySignals(t *testing.T) { name string signal os.Signal code int - }{{"SIGINT", os.Interrupt, 130}, {"SIGTERM", syscall.Signal(15), 143}} { + body bool + }{ + {"SIGINT", os.Interrupt, 130, false}, + {"SIGTERM", syscall.Signal(15), 143, false}, + {"SIGINT body", os.Interrupt, 130, true}, + {"SIGTERM body", syscall.Signal(15), 143, true}, + } { t.Run(tc.name, func(t *testing.T) { - process := startAuthStatusIdentity(t, authStatusIdentityFixture{Wait: true}, []string{"OPENAI_API_KEY=synthetic-private-api-token"}, + process := startAuthStatusIdentity(t, authStatusIdentityFixture{Wait: !tc.body, WaitBody: tc.body, Status: http.StatusOK, ContentType: "application/json"}, []string{"OPENAI_API_KEY=synthetic-private-api-token"}, "--verify", "--credential-kind=api", "--format=json", "--format-error=json", "--debug") require.Eventually(t, func() bool { data, err := os.ReadFile(process.requests) return err == nil && json.Valid(bytes.TrimSpace(data)) }, 5*time.Second, time.Millisecond, "request must start before the signal") + if tc.body { + require.Eventually(t, func() bool { + data, err := os.ReadFile(process.bodyReady) + return err == nil && string(data) == "body-decoding\n" + }, 5*time.Second, time.Millisecond, "decoder must consume the partial body before the signal") + } started := time.Now() require.NoError(t, process.command.Process.Signal(tc.signal)) got, requests := finishAuthStatusIdentity(t, process) @@ -814,7 +906,306 @@ func TestMainAuthStatusIdentitySignals(t *testing.T) { require.Empty(t, got.stdout) require.Len(t, requests, 1) decodeMainStructuredError(t, "json", got.stderr) - assertAuthStatusPrivate(t, got, "synthetic-private-api-token") + assertAuthStatusPrivate(t, got, "synthetic-private-api-token", "synthetic-private-response-body") + }) + } +} + +func TestMainAuthStatusIdentityMTLSFIFOs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix FIFO paths require a Unix native runner") + } + mkfifo, err := exec.LookPath("mkfifo") + require.NoError(t, err, "supported Unix runners must provide mkfifo") + for _, kind := range []string{"certificate", "key"} { + t.Run(kind, func(t *testing.T) { + directory := t.TempDir() + certFile := filepath.Join(directory, "synthetic-private-certificate.pem") + keyFile := filepath.Join(directory, "synthetic-private-key.pem") + fifo, regular := certFile, keyFile + if kind == "key" { + fifo, regular = keyFile, certFile + } + const placeholder = "synthetic-private-PEM-placeholder" + require.NoError(t, os.WriteFile(regular, []byte(placeholder), 0600)) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + require.NoError(t, exec.CommandContext(ctx, mkfifo, fifo).Run()) + require.NoError(t, ctx.Err()) + require.NoError(t, os.Chmod(fifo, 0600)) + process := startAuthStatusIdentityWithTimeout(t, 3*time.Second, authStatusIdentitySuccessFixture(), + []string{"OPENAI_API_KEY=synthetic-private-api-token"}, + "--verify", "--credential-kind=api", "--format=json", "--format-error=json", "--debug", + "--mtls-client-cert-file="+certFile, "--mtls-client-key-file="+keyFile) + got, requests := finishAuthStatusIdentity(t, process) + require.NotZero(t, got.code) + require.Empty(t, requests, "special files must fail before the provider request") + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Offline) + require.False(t, status.Verified) + require.Equal(t, "unknown", status.APIIdentity.Status) + require.Equal(t, "invalid_configuration", status.APIIdentity.Reason) + require.Equal(t, "not_configured", status.AdminIdentity.Reason) + decodeMainStructuredError(t, "json", got.stderr) + assertAuthStatusPrivate(t, got, "synthetic-private-api-token", placeholder, certFile, keyFile) + }) + } +} + +// Linux reads SSL_CERT_FILE and SSL_CERT_DIR without changing system trust. +// Exercise the standalone CLI: no test transport is installed in its process. +func TestBuiltCLIAuthStatusIdentityTransportLinux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("isolated certificate roots require the Linux trust loader") + } + binary, err := shellTestCLI() + require.NoError(t, err) + certificate, roots := authStatusProxyCertificate(t) + caFile := filepath.Join(t.TempDir(), "synthetic-ca.pem") + require.NoError(t, os.WriteFile(caFile, roots, 0o600)) + emptyCA := filepath.Join(t.TempDir(), "empty-ca.pem") + require.NoError(t, os.WriteFile(emptyCA, nil, 0o600)) + const api = "synthetic-private-built-api" + const admin = "synthetic-private-built-admin" + const private = "synthetic-private-provider-body" + const success = `{"object":"user","id":"user-built-synthetic","name":null,"email":null}` + for _, tc := range []struct { + name, kind, reason, body, location string + status int + }{ + {name: "offline"}, + {name: "api", kind: "api", status: 200, body: success}, + {name: "admin", kind: "admin", status: 200, body: success}, + {name: "untrusted_tls", kind: "api", reason: "request_failed", status: 200, body: success}, + {name: "invalid_json", kind: "api", reason: "invalid_response", status: 200, body: `{"object":"user","id":7}`}, + {name: "unauthorized", kind: "api", reason: "unauthorized", status: 401, body: private + api + admin}, + {name: "same_origin_redirect", kind: "api", reason: "redirect", status: 302, body: private, + location: "https://api.openai.com/v1/synthetic-private-redirect"}, + {name: "cross_origin_redirect", kind: "api", reason: "redirect", status: 307, body: private, + location: "https://example.invalid/synthetic-private-redirect"}, + } { + t.Run(tc.name, func(t *testing.T) { + proxy, finish := authStatusLoopbackProxy(t, certificate, tc.status, tc.body, tc.location) + trust := caFile + if tc.name == "untrusted_tls" { + trust = emptyCA + } + env := append(authStatusEnvironment(t), "OPENAI_API_KEY="+api, "OPENAI_ADMIN_KEY="+admin, + "OPENAI_BASE_URL=https://api.openai.com/v1/", "HTTPS_PROXY="+proxy, "NO_PROXY=", + "SSL_CERT_FILE="+trust, "SSL_CERT_DIR="+t.TempDir(), "GODEBUG=", + "OPENAI_ORG_ID=org_synthetic", "OPENAI_PROJECT_ID=proj_synthetic", + "OPENAI_CUSTOM_HEADERS=X-Private: synthetic-private-custom-header") + args := []string{"auth", "status", "--format=json", "--format-error=json", "--debug"} + if tc.kind != "" { + args = append(args, "--verify", "--credential-kind="+tc.kind) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + child := exec.CommandContext(ctx, binary, args...) + child.Env = env + child.WaitDelay = time.Second + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + err := child.Run() + observed := finish() + require.NoError(t, ctx.Err(), "standalone CLI exceeded its bound") + code := 0 + if err != nil { + var exit *exec.ExitError + require.ErrorAs(t, err, &exit) + code = exit.ExitCode() + } + got := mainDispatchResult{code, stdout.String(), stderr.String()} + assertAuthStatusPrivate(t, got, api, admin, private, "synthetic-private-custom-header", "synthetic-private-redirect") + if tc.kind == "" { + decodeAuthStatus(t, got) + require.Empty(t, observed, "offline status must not connect to the proxy") + return + } + require.Len(t, observed, 1, "lookup must not retry or follow any redirect") + require.Equal(t, "api.openai.com:443", observed[0].authority) + require.Empty(t, observed[0].connectHeaders.Get("Authorization")) + for _, values := range observed[0].connectHeaders { + for _, value := range values { + require.NotContains(t, value, api) + require.NotContains(t, value, admin) + } + } + require.Equal(t, "api.openai.com", observed[0].serverName) + if tc.name == "untrusted_tls" { + require.True(t, observed[0].tlsRejected) + require.Nil(t, observed[0].request, "untrusted TLS must receive no credential-bearing HTTP request") + } else { + require.Empty(t, observed[0].failure) + request := observed[0].request + require.NotNil(t, request) + require.Equal(t, http.MethodGet, request.Method) + require.Equal(t, "api.openai.com", request.Host) + require.Equal(t, "/v1/me", request.URL.String()) + require.Zero(t, request.ContentLength) + require.Empty(t, request.TransferEncoding) + credential, unselected := api, admin + if tc.kind == "admin" { + credential, unselected = admin, api + } + require.Equal(t, []string{"Bearer " + credential}, request.Header.Values("Authorization")) + for _, values := range request.Header { + for _, value := range values { + require.NotContains(t, value, unselected) + } + } + require.Equal(t, "application/json", request.Header.Get("Accept")) + require.Equal(t, "org_synthetic", request.Header.Get("OpenAI-Organization")) + require.Equal(t, "proj_synthetic", request.Header.Get("OpenAI-Project")) + require.Empty(t, request.Header.Get("X-Private")) + } + status := decodeAuthStatusJSON(t, got.stdout) + require.False(t, status.Offline) + selected, other := status.APIIdentity, status.AdminIdentity + if tc.kind == "admin" { + selected, other = other, selected + } + require.Equal(t, "unknown", other.Status) + require.Equal(t, "not_requested", other.Reason) + if tc.reason == "" { + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.True(t, status.Verified) + require.Equal(t, tc.kind, status.VerifiedKind) + require.Equal(t, "resolved", selected.Status) + require.Equal(t, "openai_me_v1", selected.Provider) + require.Equal(t, "user-built-synthetic", selected.ID) + require.Nil(t, selected.Name) + require.Nil(t, selected.Email) + } else { + require.Equal(t, 1, got.code) + require.False(t, status.Verified) + require.Equal(t, "unknown", selected.Status) + require.Equal(t, tc.reason, selected.Reason) + decodeMainStructuredError(t, "json", got.stderr) + } + }) + } +} + +func authStatusProxyCertificate(t *testing.T) (tls.Certificate, []byte) { + t.Helper() + caPublic, caPrivate, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + ca := &x509.Certificate{SerialNumber: big.NewInt(1), IsCA: true, BasicConstraintsValid: true, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageCertSign} + caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, caPublic, caPrivate) + require.NoError(t, err) + public, private, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), DNSNames: []string{"api.openai.com"}, + NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} + leafDER, err := x509.CreateCertificate(rand.Reader, leaf, ca, public, caPrivate) + require.NoError(t, err) + return tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: private}, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) +} + +type authStatusProxyObservation struct { + authority, serverName, failure string + connectHeaders http.Header + request *http.Request + tlsRejected bool +} + +// Terminate CONNECT locally. This proxy never resolves or dials its target. +func authStatusLoopbackProxy(t *testing.T, certificate tls.Certificate, status int, body, location string) (string, func() []authStatusProxyObservation) { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + var mutex sync.Mutex + var active net.Conn + var closing bool + var observed []authStatusProxyObservation + done := make(chan struct{}) + go func() { + defer close(done) + for { + connection, err := listener.Accept() + if err != nil { + return + } + mutex.Lock() + if closing { + mutex.Unlock() + connection.Close() + return + } + active = connection + mutex.Unlock() + observation := authStatusProxyObservation{} + func() { + defer connection.Close() + if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + observation.failure = "set connection deadline" + return + } + request, err := http.ReadRequest(bufio.NewReader(connection)) + if err != nil { + observation.failure = "read CONNECT" + return + } + observation.authority, observation.connectHeaders = request.Host, request.Header.Clone() + if request.Method != http.MethodConnect || request.Host != "api.openai.com:443" || request.RequestURI != request.Host { + observation.failure = "rejected CONNECT target" + return + } + if _, err := io.WriteString(connection, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + observation.failure = "write CONNECT response" + return + } + secure := tls.Server(connection, &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{certificate}}) + err = secure.Handshake() + observation.serverName = secure.ConnectionState().ServerName + if err != nil { + observation.tlsRejected = true + return + } + request, err = http.ReadRequest(bufio.NewReader(secure)) + if err != nil { + observation.failure = "read identity request" + return + } + observation.request = request + response := &http.Response{StatusCode: status, ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), ContentLength: int64(len(body)), Close: true} + if location != "" { + response.Header.Set("Location", location) + } + if err := response.Write(secure); err != nil { + observation.failure = "write identity response" + } + }() + observed = append(observed, observation) + mutex.Lock() + active = nil + mutex.Unlock() + } + }() + var once sync.Once + finish := func() []authStatusProxyObservation { + once.Do(func() { + listener.Close() + mutex.Lock() + closing = true + if active != nil { + active.Close() + } + mutex.Unlock() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("local identity proxy did not stop") + } }) + return observed } + t.Cleanup(func() { finish() }) + return "http://" + listener.Addr().String(), finish } diff --git a/docs/auth-status.md b/docs/auth-status.md index 1d4becb1..c115ec01 100644 --- a/docs/auth-status.md +++ b/docs/auth-status.md @@ -36,6 +36,8 @@ The provider accepts `https://api.openai.com/v1` and `https://api.openai.com/v1/ Other endpoints, paths, ports, URL credentials, queries, and fragments produce `unsupported_provider` without a request. The command never replaces an unsupported endpoint with production. Certificate options require an explicit supported base URL, even when a profile selects that URL. +Identity lookup accepts regular certificate and key files, including symlinks to regular files. +It rejects directories and named pipes before reading. The provider returns an authenticated principal ID, plus nullable name and email fields. It does not identify the active key resource, distinguish humans from service accounts, or establish membership or permissions. @@ -57,7 +59,9 @@ Invalid local credentials or TLS configuration produce `invalid_configuration` w Missing selected credentials produce `not_configured` without fallback. The command discards provider error bodies and never includes them in diagnostics, including debug output. -The lookup has a 15-second deadline, follows no redirects, and performs no automatic retries. +The lookup uses a 15-second context deadline, follows no redirects, and performs no automatic retries. +The deadline starts before TLS setup. File reads check cancellation between chunks. +Filesystem calls and certificate parsing can delay cancellation. Interrupt and termination signals stop the lookup without printing a status report. Their exit statuses are 130 and 143, respectively. The command never caches identity, saves credentials, or stores credential hashes. diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index eee4756a..f8460b9f 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -10,7 +10,9 @@ import ( "net/http" "net/url" "os" + "path/filepath" "strings" + "syscall" "time" "github.com/openai/openai-cli/internal/readable" @@ -275,10 +277,12 @@ func resolveAuthStatusSnapshot(root *cli.Command, lookup func(string) (string, b } snapshot.apiAuthorization = authorization("api-key", "OPENAI_API_KEY") snapshot.adminAuthorization = authorization("admin-api-key", "OPENAI_ADMIN_KEY") - for _, header := range []string{literal.Get("Authorization"), inherited.Get("Authorization")} { + for _, header := range []string{literal.Get("Authorization"), inherited.Get("Authorization"), snapshot.apiAuthorization, snapshot.adminAuthorization} { snapshot.secrets = append(snapshot.secrets, header) if _, token, ok := strings.Cut(header, " "); ok { - snapshot.secrets = append(snapshot.secrets, token) + // A Bearer separator can contain several spaces. Keep request bytes + // unchanged while rejecting responses that echo the credential alone. + snapshot.secrets = append(snapshot.secrets, token, strings.TrimSpace(token)) } } snapshot.report = authStatusReport{ @@ -330,10 +334,26 @@ func authStatusLookup(ctx context.Context, root *cli.Command, snapshot authStatu if !bearer || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(token) == "" { return failure("invalid_configuration", "Identity lookup requires a Bearer credential. No request was sent.") } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + requestFailure := func() (authStatusIdentity, error) { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return failure("timed_out", "Identity lookup timed out. Identity remains unknown.") + } + if ctx.Err() != nil { + return failure("canceled", "Identity lookup canceled.") + } + return failure("request_failed", "Could not resolve identity. Check your connection and retry.") + } cert, _ := root.Value(mtlsClientCertFileFlag).(string) key, _ := root.Value(mtlsClientKeyFileFlag).(string) - client, err := newMTLSHTTPClient(cert, key, snapshot.explicitBaseURL) + client, err := newMTLSHTTPClientWithReader(cert, key, snapshot.explicitBaseURL, func(kind, path string) ([]byte, error) { + return authStatusReadMTLSFile(ctx, kind, path) + }) if err != nil { + if ctx.Err() != nil { + return requestFailure() + } return failure("invalid_configuration", "Could not configure identity lookup TLS. Check the certificate, key, and explicit base URL.") } if client == nil { @@ -342,10 +362,18 @@ func authStatusLookup(ctx context.Context, root *cli.Command, snapshot authStatu client.Transport = transport.Clone() } } + // Each lookup owns a fresh transport. Keep it on HTTP/1 so HTTP/2 cannot + // retry a refused stream. Clear inherited ALPN and alternate protocol hooks. + if transport, ok := client.Transport.(*http.Transport); ok { + transport.Protocols = &http.Protocols{} + transport.Protocols.SetHTTP1(true) + transport.TLSNextProto = nil + if transport.TLSClientConfig != nil { + transport.TLSClientConfig.NextProtos = []string{"http/1.1"} + } + } defer client.CloseIdleConnections() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - ctx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() endpoint.Path = "/v1/me" request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { @@ -356,15 +384,7 @@ func authStatusLookup(ctx context.Context, root *cli.Command, snapshot authStatu request.Header.Set("OpenAI-Organization", snapshot.report.Organization.Value) request.Header.Set("OpenAI-Project", snapshot.report.Project.Value) response, err := client.Do(request) - requestFailure := func() (authStatusIdentity, error) { - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return failure("timed_out", "Identity lookup timed out. Identity remains unknown.") - } - if ctx.Err() != nil { - return failure("canceled", "Identity lookup canceled.") - } - return failure("request_failed", "Could not resolve identity. Check your connection and retry.") - } + if err != nil { return requestFailure() } @@ -411,6 +431,69 @@ func authStatusLookup(ctx context.Context, root *cli.Command, snapshot authStatu return identity, nil } +// Verification reads only regular files from the validated opened descriptor. +// Cancellation is cooperative between operations; it cannot interrupt kernel I/O. +func authStatusReadMTLSFile(ctx context.Context, kind, path string) (_ []byte, err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + resolved, err = filepath.Abs(resolved) + if err != nil { + return nil, err + } + before, err := os.Lstat(resolved) + if err != nil { + return nil, err + } + if !before.Mode().IsRegular() { + return nil, errors.New("mTLS client " + kind + " must be a regular file") + } + if err := ctx.Err(); err != nil { + return nil, err + } + // O_NONBLOCK prevents a replaced Unix FIFO from waiting for a writer. + file, err := os.OpenFile(resolved, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + defer file.Close() + opened, err := file.Stat() + if err != nil { + return nil, err + } + if !opened.Mode().IsRegular() || !os.SameFile(before, opened) { + return nil, errors.New("mTLS client " + kind + " file changed before reading") + } + var data []byte + defer func() { + if err != nil { + clear(data) + } + }() + buffer := make([]byte, 32<<10) + defer clear(buffer) + for { + if err := ctx.Err(); err != nil { + return nil, err + } + n, readErr := file.Read(buffer) + if err := ctx.Err(); err != nil { + return nil, err + } + data = append(data, buffer[:n]...) + if errors.Is(readErr, io.EOF) { + return data, nil + } + if readErr != nil { + return nil, readErr + } + } +} + func decodeAuthStatusIdentity(body io.Reader) (authStatusIdentity, error) { decoder := json.NewDecoder(body) invalid := errors.New("invalid identity response") diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go index 3ddb87bd..138d263f 100644 --- a/pkg/custom/auth_status_test.go +++ b/pkg/custom/auth_status_test.go @@ -3,13 +3,22 @@ package custom import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "io" + "log" + "math/big" + "net" "net/http" "net/http/httptest" "os" + "path/filepath" + "runtime" "strings" + "sync/atomic" "syscall" "testing" "time" @@ -452,6 +461,49 @@ func TestAuthStatusIdentityRejectsInvalidOrReflectedResponse(t *testing.T) { } } +func TestAuthStatusIdentityRejectsReflectedSpacedBearer(t *testing.T) { + const secret = "synthetic-spaced-bearer-secret" + const header = "Bearer " + secret + for _, source := range []string{"flag", "environment", "key"} { + for _, kind := range []string{"api", "admin"} { + for _, field := range []string{"id", "name", "email"} { + t.Run(source+"_"+kind+"_"+field, func(t *testing.T) { + environment := map[string]string{} + args := []string{"--verify", "--credential-kind=" + kind} + switch source { + case "flag": + args = append(args, "--header=Authorization: "+header) + case "environment": + environment["OPENAI_CUSTOM_HEADERS"] = "Authorization: " + header + case "key": + variable := "OPENAI_API_KEY" + if kind == "admin" { + variable = "OPENAI_ADMIN_KEY" + } + environment[variable] = " " + secret + } + authStatusTestEnvironment(t, environment) + body, err := json.Marshal(map[string]any{"object": "user", "id": "user-synthetic", field: "prefix " + secret + " suffix"}) + require.NoError(t, err) + calls := 0 + authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { + calls++ + require.Equal(t, header, request.Header.Get("Authorization"), "redaction must preserve request bytes") + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body))}, nil + }) + report, output, err := authStatusRunJSON(t, t.Context(), args...) + require.Error(t, err) + require.Equal(t, 1, calls) + authStatusUnknownIdentity(t, report, kind, "invalid_response") + require.Equal(t, false, report["verified"]) + require.NotContains(t, output+err.Error(), secret) + }) + } + } + } +} + func TestAuthStatusIdentityStatusErrorsAndRedirects(t *testing.T) { for _, status := range []int{http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect, http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable} { t.Run(http.StatusText(status), func(t *testing.T) { @@ -676,3 +728,248 @@ func TestAuthStatusIdentityCommandReuseClearsVerification(t *testing.T) { } require.Equal(t, []string{"Bearer synthetic-api", "Bearer synthetic-admin"}, authorization) } + +func TestAuthStatusIdentityMTLSReaderRegularAndCancellation(t *testing.T) { + pki := newMTLSTestPKI(t) + certFile, keyFile := writeMTLSClientFiles(t, pki.clientChainPEM, pki.clientKeyPEM) + for _, tc := range []struct { + kind, path string + want []byte + }{{"certificate", certFile, pki.clientChainPEM}, {"key", keyFile, pki.clientKeyPEM}} { + t.Run(tc.kind, func(t *testing.T) { + data, err := authStatusReadMTLSFile(t.Context(), tc.kind, tc.path) + require.NoError(t, err) + require.Equal(t, tc.want, data) + clear(data) + }) + } + t.Run("directory", func(t *testing.T) { + data, err := authStatusReadMTLSFile(t.Context(), "certificate", t.TempDir()) + require.Error(t, err) + require.Empty(t, data) + }) + t.Run("pre-canceled", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + data, err := authStatusReadMTLSFile(ctx, "key", filepath.Join(t.TempDir(), "missing.pem")) + require.ErrorIs(t, err, context.Canceled, "cancellation must precede opening the missing file") + require.Empty(t, data) + }) + t.Run("canceled between PEM files", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var visited []string + var certificate []byte + client, err := newMTLSHTTPClientWithReader(certFile, keyFile, "https://api.openai.com/v1/", func(kind, path string) ([]byte, error) { + visited = append(visited, kind) + data, err := authStatusReadMTLSFile(ctx, kind, path) + if kind == "certificate" { + certificate = data + cancel() + } + return data, err + }) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, client) + require.Equal(t, []string{"certificate", "key"}, visited) + require.NotEmpty(t, certificate) + require.Equal(t, make([]byte, len(certificate)), certificate, "constructor must clear certificate bytes after key-read cancellation") + }) +} + +func TestAuthStatusIdentityMTLSReaderSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows symlink creation requires a separate native privilege fixture") + } + pki := newMTLSTestPKI(t) + certFile, keyFile := writeMTLSClientFiles(t, pki.clientChainPEM, pki.clientKeyPEM) + for _, tc := range []struct { + kind, path string + want []byte + }{{"certificate", certFile, pki.clientChainPEM}, {"key", keyFile, pki.clientKeyPEM}} { + t.Run(tc.kind, func(t *testing.T) { + link := filepath.Join(t.TempDir(), tc.kind+".pem") + require.NoError(t, os.Symlink(tc.path, link)) + data, err := authStatusReadMTLSFile(t.Context(), tc.kind, link) + require.NoError(t, err) + require.Equal(t, tc.want, data) + clear(data) + }) + } +} + +func TestAuthStatusIdentityVerifiedTLSAndMTLS(t *testing.T) { + for _, tc := range []struct { + name, kind string + mtls, trusted, matchingName, closeResponse bool + }{ + {"TLS API", "api", false, true, true, false}, + {"mTLS Admin", "admin", true, true, true, false}, + {"untrusted server", "api", false, false, true, false}, + {"wrong server name", "admin", true, true, false, false}, + {"TLS closes response", "api", false, true, true, true}, + {"mTLS closes response", "admin", true, true, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + pki := newMTLSTestPKI(t) + certFile, keyFile := writeMTLSClientFiles(t, pki.clientChainPEM, pki.clientKeyPEM) + now := time.Now() + name := "api.openai.com" + if !tc.matchingName { + name = "wrong.example.invalid" + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(101), DNSNames: []string{name}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + serverDER, serverCertificate, serverKey := createTestCertificate(t, template, template, nil) + serverKeyDER, err := x509.MarshalECPrivateKey(serverKey) + require.NoError(t, err) + serverPair, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: serverKeyDER})) + require.NoError(t, err) + clear(serverKeyDER) + clientRoots := x509.NewCertPool() + require.True(t, clientRoots.AppendCertsFromPEM(pki.rootPEM)) + type observation struct { + method, path, host, serverName, negotiatedProtocol string + protocolMajor int + headers http.Header + clientCertificates, verifiedChains int + clientLeaf []byte + } + observed := make(chan observation, 4) + var requestCount, dialCount atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requestCount.Add(1) + item := observation{method: request.Method, path: request.URL.Path, host: request.Host, + headers: request.Header.Clone(), serverName: request.TLS.ServerName, + negotiatedProtocol: request.TLS.NegotiatedProtocol, protocolMajor: request.ProtoMajor, + clientCertificates: len(request.TLS.PeerCertificates), verifiedChains: len(request.TLS.VerifiedChains)} + if len(request.TLS.PeerCertificates) != 0 { + item.clientLeaf = bytes.Clone(request.TLS.PeerCertificates[0].Raw) + } + select { + case observed <- item: + default: + } + if tc.closeResponse { + connection, _, err := http.NewResponseController(w).Hijack() + if err != nil { + http.Error(w, "synthetic fixture cannot close the connection", http.StatusInternalServerError) + return + } + _ = connection.Close() + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"object":"user","id":"user-synthetic-tls","name":null,"email":null}`) + })) + server.EnableHTTP2 = true + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.TLS = &tls.Config{Certificates: []tls.Certificate{serverPair}, MinVersion: tls.VersionTLS12} + if tc.mtls { + server.TLS.ClientAuth = tls.RequireAndVerifyClientCert + server.TLS.ClientCAs = clientRoots + } + server.StartTLS() + t.Cleanup(server.Close) + roots := x509.NewCertPool() + if tc.trusted { + roots.AddCert(serverCertificate) + } + original := http.DefaultTransport + base, ok := original.(*http.Transport) + require.True(t, ok) + transport := base.Clone() + transport.Proxy = nil + transport.DialTLSContext = nil + transport.Protocols = &http.Protocols{} + transport.Protocols.SetHTTP1(true) + transport.Protocols.SetHTTP2(true) + transport.TLSClientConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12, + NextProtos: []string{"h2", "http/1.1"}} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + if address != "api.openai.com:443" { + return nil, errors.New("synthetic TLS fixture refuses any other destination") + } + dialCount.Add(1) + return (&net.Dialer{Timeout: time.Second}).DialContext(ctx, network, server.Listener.Addr().String()) + } + http.DefaultTransport = transport + t.Cleanup(func() { transport.CloseIdleConnections(); http.DefaultTransport = original }) + t.Setenv(mtlsClientCertFileEnv, "") + t.Setenv(mtlsClientKeyFileEnv, "") + authStatusTestEnvironment(t, map[string]string{ + "OPENAI_API_KEY": "synthetic-private-tls-api", "OPENAI_ADMIN_KEY": "synthetic-private-tls-admin", + "OPENAI_BASE_URL": "https://api.openai.com/v1/", "OPENAI_ORG_ID": "org_tls", "OPENAI_PROJECT_ID": "proj_tls", + }) + var output bytes.Buffer + root := authStatusTestRoot(&output) + root.Flags = append(root.Flags, mtlsClientFlags()...) + args := []string{"openai", "auth", "status", "--verify", "--credential-kind=" + tc.kind, "--format=json", + "--header=Cookie: synthetic-private-cookie", "--header=X-Synthetic: synthetic-private-header"} + if tc.mtls { + args = append(args, "--mtls-client-cert-file="+certFile, "--mtls-client-key-file="+keyFile) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + err = root.Run(ctx, args) + require.NoError(t, ctx.Err()) + require.True(t, transport.Protocols.HTTP1()) + require.True(t, transport.Protocols.HTTP2(), "identity protocol selection must not change ordinary callers") + require.Equal(t, []string{"h2", "http/1.1"}, transport.TLSClientConfig.NextProtos) + require.Equal(t, int32(1), dialCount.Load(), "lookup must perform one TLS connection without fallback") + var report map[string]any + require.NoError(t, json.Unmarshal(output.Bytes(), &report)) + for _, secret := range []string{"synthetic-private-tls-api", "synthetic-private-tls-admin", "synthetic-private-cookie", "synthetic-private-header", certFile, keyFile, string(pki.clientKeyPEM)} { + require.NotContains(t, output.String(), secret) + if err != nil { + require.NotContains(t, err.Error(), secret) + } + } + if !tc.trusted || !tc.matchingName { + require.Error(t, err, "server certificate validation must remain enabled") + require.Zero(t, requestCount.Load()) + authStatusUnknownIdentity(t, report, tc.kind, "request_failed") + return + } + require.Equal(t, int32(1), requestCount.Load()) + item := <-observed + require.Equal(t, 1, item.protocolMajor, "identity lookup must not enter HTTP/2 stream retry handling") + require.Equal(t, "http/1.1", item.negotiatedProtocol, "inherited h2 ALPN must not override the selected protocol") + require.Equal(t, http.MethodGet, item.method) + require.Equal(t, "/v1/me", item.path) + require.Equal(t, "api.openai.com", item.host) + require.Equal(t, "api.openai.com", item.serverName) + credential := "Bearer synthetic-private-tls-api" + if tc.kind == "admin" { + credential = "Bearer synthetic-private-tls-admin" + } + require.Equal(t, credential, item.headers.Get("Authorization")) + require.Equal(t, "org_tls", item.headers.Get("OpenAI-Organization")) + require.Equal(t, "proj_tls", item.headers.Get("OpenAI-Project")) + require.Equal(t, "application/json", item.headers.Get("Accept")) + require.Empty(t, item.headers.Values("Cookie")) + require.Empty(t, item.headers.Values("X-Synthetic")) + if tc.mtls { + leaf, _ := pem.Decode(pki.clientLeafPEM) + require.NotNil(t, leaf) + require.Equal(t, leaf.Bytes, item.clientLeaf) + require.Equal(t, 2, item.clientCertificates) + require.Positive(t, item.verifiedChains, "server must verify the requested client certificate") + } else { + require.Zero(t, item.clientCertificates) + } + if tc.closeResponse { + require.Error(t, err, "a failed fresh HTTP/1 connection must not be retried") + authStatusUnknownIdentity(t, report, tc.kind, "request_failed") + return + } + require.NoError(t, err) + require.Equal(t, true, report["verified"]) + require.Equal(t, tc.kind, report["verified_credential_kind"]) + }) + } +} diff --git a/pkg/custom/mtls.go b/pkg/custom/mtls.go index 5fcc0c49..2f0a215e 100644 --- a/pkg/custom/mtls.go +++ b/pkg/custom/mtls.go @@ -76,6 +76,11 @@ func configureMTLS(ctx context.Context, cmd *cli.Command) (context.Context, erro } func newMTLSHTTPClient(certFile, keyFile, baseURL string) (*http.Client, error) { + return newMTLSHTTPClientWithReader(certFile, keyFile, baseURL, readMTLSFile) +} + +// Share TLS and transport policy while callers select their credential-file reader. +func newMTLSHTTPClientWithReader(certFile, keyFile, baseURL string, readFile func(string, string) ([]byte, error)) (*http.Client, error) { if certFile == "" && keyFile == "" { return nil, nil } @@ -87,13 +92,13 @@ func newMTLSHTTPClient(certFile, keyFile, baseURL string) (*http.Client, error) return nil, errors.New("mTLS requires an explicit HTTPS base URL") } - certPEM, err := readMTLSFile("certificate", certFile) + certPEM, err := readFile("certificate", certFile) if err != nil { return nil, err } defer clear(certPEM) - keyPEM, err := readMTLSFile("key", keyFile) + keyPEM, err := readFile("key", keyFile) if err != nil { return nil, err } diff --git a/pkg/custom/mtls_client_flag.go b/pkg/custom/mtls_client_flag.go index 05e954d6..f6afab6c 100644 --- a/pkg/custom/mtls_client_flag.go +++ b/pkg/custom/mtls_client_flag.go @@ -15,7 +15,19 @@ type mtlsClientFlag struct { func (f *mtlsClientFlag) PreParse() error { f.explicit = false - return f.StringFlag.PreParse() + // urfave retains source presence and duplicate counts across invocations. + // Preserve the current public definition and the metadata pointer in place. + flag := &f.StringFlag + *flag = cli.StringFlag{ + Name: flag.Name, Category: flag.Category, DefaultText: flag.DefaultText, + HideDefault: flag.HideDefault, Usage: flag.Usage, Sources: flag.Sources, + Required: flag.Required, Hidden: flag.Hidden, Deprecated: flag.Deprecated, + Local: flag.Local, Value: flag.Value, Destination: flag.Destination, + Aliases: flag.Aliases, TakesFile: flag.TakesFile, Action: flag.Action, + Config: flag.Config, OnlyOnce: flag.OnlyOnce, Validator: flag.Validator, + ValidateDefaults: flag.ValidateDefaults, + } + return flag.PreParse() } func (f *mtlsClientFlag) Set(name, value string) error { diff --git a/pkg/custom/mtls_client_flag_test.go b/pkg/custom/mtls_client_flag_test.go index 7ba1067f..406a9cee 100644 --- a/pkg/custom/mtls_client_flag_test.go +++ b/pkg/custom/mtls_client_flag_test.go @@ -1,7 +1,25 @@ package custom import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "io" + "log" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "reflect" "testing" + "time" + + "github.com/openai/openai-go/v3" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" @@ -29,3 +47,190 @@ func TestMTLSClientFlagEnvironmentDoesNotConsumeExplicitValue(t *testing.T) { require.Equal(t, "next-run.pem", flag.Get()) require.EqualError(t, flag.Set("certificate", "duplicate.pem"), "can't duplicate this flag") } + +func TestMTLSClientFlagPreParsePreservesCurrentDefinition(t *testing.T) { + var destination string + var validated []string + flag := &mtlsClientFlag{StringFlag: cli.StringFlag{ + Name: "certificate", Category: "Synthetic category", DefaultText: "Synthetic default", + HideDefault: true, Usage: "Synthetic usage", Sources: cli.EnvVars("OPENAI_TEST_MTLS_METADATA"), + Required: true, Hidden: true, Deprecated: "Synthetic deprecation", Local: true, + Value: "synthetic-default.pem", Destination: &destination, Aliases: []string{"cert"}, TakesFile: true, + Action: func(context.Context, *cli.Command, string) error { return nil }, + Config: cli.StringConfig{TrimSpace: true}, OnlyOnce: true, + Validator: func(value string) error { validated = append(validated, value); return nil }, ValidateDefaults: true, + }} + definition := flag.CLIStringFlag() + require.NoError(t, flag.PreParse()) + require.NoError(t, flag.Set("certificate", " synthetic-explicit.pem ")) + require.Equal(t, "synthetic-explicit.pem", destination) + definition.Usage, definition.Value = "Updated synthetic usage", "synthetic-updated.pem" + before := reflect.ValueOf(*definition) + require.NoError(t, flag.PreParse()) + require.Same(t, definition, flag.CLIStringFlag()) + after := reflect.ValueOf(*definition) + for index := 0; index < before.NumField(); index++ { + field := before.Type().Field(index) + if !field.IsExported() { + continue + } + if field.Type.Kind() == reflect.Func { + require.Equal(t, before.Field(index).Pointer(), after.Field(index).Pointer(), field.Name) + } else { + require.Equal(t, before.Field(index).Interface(), after.Field(index).Interface(), field.Name) + } + } + require.False(t, flag.IsSet()) + require.False(t, flag.explicit) + require.Zero(t, flag.Count()) + require.Equal(t, "synthetic-updated.pem", destination) + require.Equal(t, []string{"synthetic-default.pem", "synthetic-explicit.pem", "synthetic-updated.pem"}, validated) + require.NoError(t, flag.Set("cert", " synthetic-next.pem ")) + require.Equal(t, "synthetic-next.pem", destination) + require.EqualError(t, flag.Set("cert", "duplicate.pem"), "can't duplicate this flag") +} + +func TestMTLSClientFlagsReuseEnvironmentForTLS(t *testing.T) { + projectLinkTestEnvironment(t) + authStatusTestEnvironment(t, map[string]string{ + "OPENAI_API_KEY": "synthetic-mtls-reuse-api", "OPENAI_BASE_URL": "https://api.openai.com/v1/", + "OPENAI_ORG_ID": "org_mtls_reuse", "OPENAI_PROJECT_ID": "proj_mtls_reuse", + }) + first, second := newMTLSTestPKI(t), newMTLSTestPKI(t) + firstCert, firstKey := writeMTLSClientFiles(t, first.clientChainPEM, first.clientKeyPEM) + secondCert, secondKey := writeMTLSClientFiles(t, second.clientChainPEM, second.clientKeyPEM) + firstLeaf, _ := pem.Decode(first.clientLeafPEM) + secondLeaf, _ := pem.Decode(second.clientLeafPEM) + require.NotNil(t, firstLeaf) + require.NotNil(t, secondLeaf) + clientRoots := x509.NewCertPool() + require.True(t, clientRoots.AppendCertsFromPEM(first.rootPEM)) + require.True(t, clientRoots.AppendCertsFromPEM(second.rootPEM)) + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(102), DNSNames: []string{"api.openai.com"}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + serverDER, serverCertificate, serverKey := createTestCertificate(t, template, template, nil) + serverKeyDER, err := x509.MarshalECPrivateKey(serverKey) + require.NoError(t, err) + serverPair, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: serverKeyDER})) + require.NoError(t, err) + clear(serverKeyDER) + type observation struct { + path, serverName string + headers http.Header + leaf []byte + verifiedChains int + } + observed := make(chan observation, 1) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + item := observation{path: request.URL.Path, serverName: request.TLS.ServerName, + headers: request.Header.Clone(), verifiedChains: len(request.TLS.VerifiedChains)} + if len(request.TLS.PeerCertificates) != 0 { + item.leaf = bytes.Clone(request.TLS.PeerCertificates[0].Raw) + } + select { + case observed <- item: + default: + t.Error("unexpected extra request") + } + w.Header().Set("Content-Type", "application/json") + if request.URL.Path == "/v1/me" { + _, _ = io.WriteString(w, `{"object":"user","id":"user-synthetic-mtls","name":null,"email":null}`) + } else { + _, _ = io.WriteString(w, `{"object":"list","data":[]}`) + } + })) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.Config.ReadHeaderTimeout, server.Config.WriteTimeout, server.Config.IdleTimeout = 2*time.Second, 2*time.Second, 2*time.Second + server.TLS = &tls.Config{Certificates: []tls.Certificate{serverPair}, ClientCAs: clientRoots, + ClientAuth: tls.VerifyClientCertIfGiven, MinVersion: tls.VersionTLS12} + server.StartTLS() + t.Cleanup(server.Close) + serverRoots := x509.NewCertPool() + serverRoots.AddCert(serverCertificate) + transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: serverRoots, MinVersion: tls.VersionTLS12}} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + if address != "api.openai.com:443" { + return nil, errors.New("synthetic TLS fixture refuses any other destination") + } + return (&net.Dialer{Timeout: time.Second}).DialContext(ctx, network, server.Listener.Addr().String()) + } + original := http.DefaultTransport + http.DefaultTransport = transport + t.Cleanup(func() { transport.CloseIdleConnections(); http.DefaultTransport = original }) + for _, mode := range []string{"SDK", "identity"} { + t.Run(mode, func(t *testing.T) { + var output bytes.Buffer + root := authStatusTestRoot(&output) + root.Commands = append(root.Commands, &cli.Command{Name: "models", Action: func(ctx context.Context, command *cli.Command) error { + if client, ok := command.Root().Metadata[mtlsHTTPClientMetadata].(*http.Client); ok { + defer client.CloseIdleConnections() + } + defer transport.CloseIdleConnections() + client := openai.NewClient(GetDefaultRequestOptions(command)...) + _, err := client.Models.List(ctx) + return err + }}) + ConfigureCommand(root) + for _, tc := range []struct { + name, cert, key string + args []string + leaf []byte + removed bool + }{ + {name: "initial environment", cert: firstCert, key: firstKey, leaf: firstLeaf.Bytes}, + {name: "reused environment", cert: firstCert, key: firstKey, leaf: firstLeaf.Bytes}, + {name: "changed environment", cert: secondCert, key: secondKey, leaf: secondLeaf.Bytes}, + {name: "explicit override", cert: secondCert, key: secondKey, leaf: firstLeaf.Bytes, + args: []string{"--mtls-client-cert-file=" + firstCert, "--mtls-client-key-file=" + firstKey}}, + {name: "explicit empty", cert: secondCert, key: secondKey, + args: []string{"--mtls-client-cert-file=", "--mtls-client-key-file="}}, + {name: "restored environment", cert: secondCert, key: secondKey, leaf: secondLeaf.Bytes}, + {name: "empty environment"}, + {name: "removed environment", removed: true}, + {name: "final environment", cert: firstCert, key: firstKey, leaf: firstLeaf.Bytes}, + } { + t.Setenv(mtlsClientCertFileEnv, tc.cert) + t.Setenv(mtlsClientKeyFileEnv, tc.key) + if tc.removed { + require.NoError(t, os.Unsetenv(mtlsClientCertFileEnv)) + require.NoError(t, os.Unsetenv(mtlsClientKeyFileEnv)) + } + output.Reset() + args := []string{"openai", "models"} + path := "/v1/models" + if mode == "identity" { + args = []string{"openai", "auth", "status", "--verify", "--credential-kind=api", "--format=json"} + path = "/v1/me" + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + err := root.Run(ctx, append(args, tc.args...)) + cancel() + require.NoError(t, err, tc.name) + select { + case item := <-observed: + require.Equal(t, path, item.path, tc.name) + require.Equal(t, "api.openai.com", item.serverName, tc.name) + require.Equal(t, tc.leaf, item.leaf, tc.name) + require.Equal(t, len(tc.leaf) != 0, item.verifiedChains != 0, tc.name) + require.Equal(t, "Bearer synthetic-mtls-reuse-api", item.headers.Get("Authorization"), tc.name) + require.Equal(t, "org_mtls_reuse", item.headers.Get("OpenAI-Organization"), tc.name) + require.Equal(t, "proj_mtls_reuse", item.headers.Get("OpenAI-Project"), tc.name) + default: + t.Fatalf("%s did not send a request", tc.name) + } + if mode == "identity" { + var report authStatusReport + require.NoError(t, json.Unmarshal(output.Bytes(), &report), tc.name) + require.True(t, report.Verified, tc.name) + require.Equal(t, "resolved", report.APIIdentity.Status, tc.name) + require.Equal(t, "user-synthetic-mtls", report.APIIdentity.ID, tc.name) + } + } + }) + } +} diff --git a/pkg/custom/root_request_flags.go b/pkg/custom/root_request_flags.go index 197a0989..36349df0 100644 --- a/pkg/custom/root_request_flags.go +++ b/pkg/custom/root_request_flags.go @@ -33,7 +33,19 @@ func (*rootRequestFlag) IsLocal() bool { return false } func (f *rootRequestFlag) PreParse() error { f.explicitlySet = false - return f.Flag.PreParse() + // Request flags retain presence and counts across parsing. Reset only these + // root wrappers, preserving their current public definition and pointer. + flag := f.Flag + *flag = requestflag.Flag[string]{ + Name: flag.Name, Category: flag.Category, DefaultText: flag.DefaultText, + HideDefault: flag.HideDefault, Usage: flag.Usage, Sources: flag.Sources, + Required: flag.Required, Hidden: flag.Hidden, Default: flag.Default, + Aliases: flag.Aliases, Validator: flag.Validator, + QueryPath: flag.QueryPath, HeaderPath: flag.HeaderPath, BodyPath: flag.BodyPath, + BodyRoot: flag.BodyRoot, PathParam: flag.PathParam, Const: flag.Const, + FileInput: flag.FileInput, DataAliases: flag.DataAliases, + } + return flag.PreParse() } // Environment fallback calls the embedded Flag.Set directly. Only parser or diff --git a/pkg/custom/root_request_flags_test.go b/pkg/custom/root_request_flags_test.go index fc811f1f..f1f33161 100644 --- a/pkg/custom/root_request_flags_test.go +++ b/pkg/custom/root_request_flags_test.go @@ -8,10 +8,13 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "reflect" "testing" "github.com/openai/openai-cli/internal/requestflag" "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" ) @@ -92,7 +95,7 @@ func TestRootRequestFlagsExplicitAssignmentProvenance(t *testing.T) { } } -func TestRootRequestFlagPreParseResetsOnlyProvenance(t *testing.T) { +func TestRootRequestFlagPreParseResetsInvocationState(t *testing.T) { wrapped := &rootRequestFlag{Flag: &requestflag.Flag[string]{Name: "project", Default: "default-project"}} require.NoError(t, wrapped.PreParse()) require.False(t, wrapped.explicitlySet) @@ -103,26 +106,195 @@ func TestRootRequestFlagPreParseResetsOnlyProvenance(t *testing.T) { require.NoError(t, wrapped.PreParse()) require.False(t, wrapped.explicitlySet) - // Preserve the embedded flag's existing state across repeated parsing. - require.True(t, wrapped.IsSet()) - require.Equal(t, 1, wrapped.Count()) + require.False(t, wrapped.IsSet()) + require.Zero(t, wrapped.Count()) require.Equal(t, "default-project", wrapped.Get()) } func TestRootRequestFlagsResetProvenanceAcrossRuns(t *testing.T) { - var explicit []bool - root := &cli.Command{ - Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, - Flags: []cli.Flag{&requestflag.Flag[string]{Name: "project"}}, - Action: func(_ context.Context, command *cli.Command) error { - explicit = append(explicit, command.Flags[0].(*rootRequestFlag).explicitlySet) + for _, name := range []string{"api-key", "admin-api-key", "organization", "project", "webhook-secret"} { + t.Run(name, func(t *testing.T) { + const variable = "OPENAI_TEST_ROOT_REQUEST_REUSE" + t.Setenv(variable, "") + rejected := errors.New("rejected synthetic value") + definition := &requestflag.Flag[string]{Name: name, Aliases: []string{"p"}, Sources: cli.EnvVars(variable), + Validator: func(value string) error { + if value == "invalid" { + return rejected + } + return nil + }} + root := &cli.Command{Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{definition}, Commands: []*cli.Command{{Name: "models", Commands: []*cli.Command{{Name: "list"}}}}, + } + configureRootRequestFlags(root) + wrapped := root.Flags[0].(*rootRequestFlag) + for _, tc := range []struct { + name, environment, want string + args []string + absent, explicit, set bool + count int + err error + }{ + {name: "initial environment", environment: "synthetic-first", want: "synthetic-first", set: true, count: 1}, + {name: "changed environment", environment: "synthetic-second", want: "synthetic-second", set: true, count: 1}, + {name: "explicit repeated alias", environment: "synthetic-second", args: []string{"--" + name + "=synthetic-first", "-p", "synthetic-last"}, want: "synthetic-last", explicit: true, set: true, count: 2}, + {name: "environment after explicit", environment: "synthetic-third", want: "synthetic-third", set: true, count: 1}, + {name: "explicit empty", environment: "synthetic-third", args: []string{"--" + name + "="}, explicit: true, set: true, count: 1}, + {name: "present empty environment", set: true}, + {name: "removed environment", absent: true}, + {name: "validation failure", environment: "synthetic-third", args: []string{"--" + name + "=invalid"}, err: rejected}, + {name: "recovery after failure", environment: "synthetic-recovered", want: "synthetic-recovered", set: true, count: 1}, + } { + t.Setenv(variable, tc.environment) + if tc.absent { + require.NoError(t, os.Unsetenv(variable)) + } + called := false + root.Command("models").Command("list").Action = func(_ context.Context, command *cli.Command) error { + called = true + require.Same(t, definition, wrapped.RequestFlag(), tc.name) + require.Equal(t, tc.explicit, wrapped.ExplicitRequestFlag(), tc.name) + require.Equal(t, tc.set, root.IsSet(name), tc.name) + require.Equal(t, tc.set, command.IsSet(name), tc.name) + require.Equal(t, tc.want, root.String(name), tc.name) + require.Equal(t, tc.want, command.String(name), tc.name) + require.Equal(t, tc.count, wrapped.Count(), tc.name) + return nil + } + args := append([]string{"openai", "models", "list"}, tc.args...) + err := root.Run(t.Context(), args) + if tc.err != nil { + require.ErrorContains(t, err, tc.err.Error(), tc.name) + require.False(t, called, tc.name) + } else { + require.NoError(t, err, tc.name) + require.True(t, called, tc.name) + } + } + }) + } +} + +func TestRootRequestFlagPreParsePreservesCurrentDefinition(t *testing.T) { + var validated []string + definition := &requestflag.Flag[string]{ + Name: "project", Category: "Synthetic category", DefaultText: "Synthetic default", + HideDefault: true, Usage: "Synthetic usage", Sources: cli.EnvVars("OPENAI_TEST_ROOT_METADATA"), + Required: true, Hidden: true, Default: "synthetic-default", Aliases: []string{"p"}, + Validator: func(value string) error { validated = append(validated, value); return nil }, + QueryPath: "synthetic_query", HeaderPath: "X-Synthetic", BodyPath: "synthetic_body", BodyRoot: true, + PathParam: "synthetic_path", Const: true, FileInput: true, DataAliases: []string{"synthetic_alias"}, + } + wrapped := &rootRequestFlag{Flag: definition} + require.NoError(t, wrapped.PreParse()) + require.NoError(t, wrapped.Set("project", "synthetic-explicit")) + definition.Usage, definition.Default = "Updated synthetic usage", "synthetic-updated" + before := reflect.ValueOf(*definition) + require.NoError(t, wrapped.PreParse()) + require.Same(t, definition, wrapped.RequestFlag()) + after := reflect.ValueOf(*definition) + for index := 0; index < before.NumField(); index++ { + field := before.Type().Field(index) + if !field.IsExported() { + continue + } + if field.Type.Kind() == reflect.Func { + require.Equal(t, before.Field(index).Pointer(), after.Field(index).Pointer(), field.Name) + } else { + require.Equal(t, before.Field(index).Interface(), after.Field(index).Interface(), field.Name) + } + } + require.Equal(t, []string{"synthetic-default", "synthetic-explicit", "synthetic-updated"}, validated) + require.Equal(t, "synthetic-updated", wrapped.Get()) + require.True(t, wrapped.IsSet(), "Const remains set without an invocation assignment") + require.Zero(t, wrapped.Count()) + require.False(t, wrapped.ExplicitRequestFlag()) +} + +func TestRootRequestFlagsReuseMatchesStatusAndSDKRequests(t *testing.T) { + projectLinkTestEnvironment(t) + authStatusTestEnvironment(t, nil) + t.Setenv("OPENAI_PROFILE", "work") + path, err := profileStorePath() + require.NoError(t, err) + organization, project := "org_profile", "proj_profile" + _, err = updateProfiles(t.Context(), path, func(registry *profileRegistry) error { + registry.Profiles["work"] = profileValues{Organization: &organization, Project: &project} + return nil + }) + require.NoError(t, err) + root := profileContextTestRoot(t) + root.Commands = append(root.Commands, &cli.Command{Name: "probe"}) + for _, tc := range []struct { + name, api, admin, organization, project, source string + args []string + remove bool + }{ + {name: "environment", api: "synthetic-api-first", admin: "synthetic-admin-first", organization: "org_first", project: "proj_first", source: "environment"}, + {name: "explicit", api: "synthetic-api-flag", admin: "synthetic-admin-flag", organization: "org_flag", project: "proj_flag", source: "flag", + args: []string{"--api-key=synthetic-api-flag", "--admin-api-key=synthetic-admin-flag", "--organization=org_flag", "--project=proj_flag"}}, + {name: "changed environment", api: "synthetic-api-second", admin: "synthetic-admin-second", organization: "org_second", project: "proj_second", source: "environment"}, + {name: "explicit empty", source: "flag", args: []string{"--api-key=", "--admin-api-key=", "--organization=", "--project="}}, + {name: "environment restored", api: "synthetic-api-second", admin: "synthetic-admin-second", organization: "org_second", project: "proj_second", source: "environment"}, + {name: "removed environment", organization: "org_profile", project: "proj_profile", source: "profile", remove: true}, + } { + if tc.source == "environment" { + for name, value := range map[string]string{"OPENAI_API_KEY": tc.api, "OPENAI_ADMIN_KEY": tc.admin, "OPENAI_ORG_ID": tc.organization, "OPENAI_PROJECT_ID": tc.project} { + t.Setenv(name, value) + } + } + if tc.remove { + for _, name := range []string{"OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID"} { + require.NoError(t, os.Unsetenv(name)) + } + } + called := false + root.Command("probe").Action = func(ctx context.Context, command *cli.Command) error { + called = true + report, err := resolveAuthStatus(root, os.LookupEnv) + require.NoError(t, err, tc.name) + require.Equal(t, tc.api != "", report.API.Configured, tc.name) + require.Equal(t, tc.admin != "", report.Admin.Configured, tc.name) + apiSource, adminSource := "unset", "unset" + if tc.source == "environment" { + apiSource, adminSource = "environment:OPENAI_API_KEY", "environment:OPENAI_ADMIN_KEY" + } else if tc.source == "flag" { + apiSource, adminSource = "flag:--api-key", "flag:--admin-api-key" + } + require.Equal(t, apiSource, report.API.Source, tc.name) + require.Equal(t, adminSource, report.Admin.Source, tc.name) + require.Equal(t, tc.organization, report.Organization.Value, tc.name) + require.Equal(t, tc.project, report.Project.Value, tc.name) + wantSource := tc.source + ":" + for _, source := range []string{report.Organization.Source, report.Project.Source} { + require.Contains(t, source, wantSource, tc.name) + } + var requests []http.Header + options := append(GetDefaultRequestOptions(command), option.WithHTTPClient(profileContextTransport(func(request *http.Request) { + requests = append(requests, request.Header.Clone()) + }))) + client := openai.NewClient(options...) + _, err = client.Models.List(ctx) + require.NoError(t, err, tc.name) + _, err = client.Admin.Organization.Projects.List(ctx, openai.AdminOrganizationProjectListParams{}) + require.NoError(t, err, tc.name) + require.Len(t, requests, 2, tc.name) + for index, key := range []string{tc.api, tc.admin} { + authorization := "" + if key != "" { + authorization = "Bearer " + key + } + require.Equal(t, authorization, requests[index].Get("Authorization"), tc.name) + require.Equal(t, tc.organization, requests[index].Get("OpenAI-Organization"), tc.name) + require.Equal(t, tc.project, requests[index].Get("OpenAI-Project"), tc.name) + } return nil - }, + } + args := append([]string{"openai", "probe"}, tc.args...) + require.NoError(t, root.Run(t.Context(), args), tc.name) + require.True(t, called, tc.name) } - configureRootRequestFlags(root) - require.NoError(t, root.Run(t.Context(), []string{"openai", "--project=synthetic-project"})) - require.NoError(t, root.Run(t.Context(), []string{"openai"})) - require.Equal(t, []bool{true, false}, explicit) } func TestRootRequestFlagsPreserveMTLSPlacement(t *testing.T) { From 6554cc08aacf2a075e895beb4d9cb9be50595e12 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 15:27:33 -0400 Subject: [PATCH 25/36] Isolate the mTLS reuse fixture from profile selectors --- pkg/custom/mtls_client_flag_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/custom/mtls_client_flag_test.go b/pkg/custom/mtls_client_flag_test.go index 406a9cee..dc7d20fa 100644 --- a/pkg/custom/mtls_client_flag_test.go +++ b/pkg/custom/mtls_client_flag_test.go @@ -92,6 +92,7 @@ func TestMTLSClientFlagPreParsePreservesCurrentDefinition(t *testing.T) { func TestMTLSClientFlagsReuseEnvironmentForTLS(t *testing.T) { projectLinkTestEnvironment(t) + t.Setenv("OPENAI_PROFILE", "") authStatusTestEnvironment(t, map[string]string{ "OPENAI_API_KEY": "synthetic-mtls-reuse-api", "OPENAI_BASE_URL": "https://api.openai.com/v1/", "OPENAI_ORG_ID": "org_mtls_reuse", "OPENAI_PROJECT_ID": "proj_mtls_reuse", From b8cdc7716b5e93a3d6d938bd2700a1077e07a61f Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 15:34:47 -0400 Subject: [PATCH 26/36] Correct identity transport and flag reuse test fixtures --- pkg/custom/auth_status_test.go | 3 ++- pkg/custom/root_request_flags_test.go | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go index 138d263f..4d3542ec 100644 --- a/pkg/custom/auth_status_test.go +++ b/pkg/custom/auth_status_test.go @@ -868,7 +868,8 @@ func TestAuthStatusIdentityVerifiedTLSAndMTLS(t *testing.T) { })) server.EnableHTTP2 = true server.Config.ErrorLog = log.New(io.Discard, "", 0) - server.TLS = &tls.Config{Certificates: []tls.Certificate{serverPair}, MinVersion: tls.VersionTLS12} + server.TLS = &tls.Config{Certificates: []tls.Certificate{serverPair}, MinVersion: tls.VersionTLS12, + NextProtos: []string{"h2", "http/1.1"}} if tc.mtls { server.TLS.ClientAuth = tls.RequireAndVerifyClientCert server.TLS.ClientCAs = clientRoots diff --git a/pkg/custom/root_request_flags_test.go b/pkg/custom/root_request_flags_test.go index f1f33161..8a671ce0 100644 --- a/pkg/custom/root_request_flags_test.go +++ b/pkg/custom/root_request_flags_test.go @@ -129,6 +129,7 @@ func TestRootRequestFlagsResetProvenanceAcrossRuns(t *testing.T) { } configureRootRequestFlags(root) wrapped := root.Flags[0].(*rootRequestFlag) + // Root sources precede descendant arguments, so Count includes source and explicit assignments. for _, tc := range []struct { name, environment, want string args []string @@ -138,9 +139,9 @@ func TestRootRequestFlagsResetProvenanceAcrossRuns(t *testing.T) { }{ {name: "initial environment", environment: "synthetic-first", want: "synthetic-first", set: true, count: 1}, {name: "changed environment", environment: "synthetic-second", want: "synthetic-second", set: true, count: 1}, - {name: "explicit repeated alias", environment: "synthetic-second", args: []string{"--" + name + "=synthetic-first", "-p", "synthetic-last"}, want: "synthetic-last", explicit: true, set: true, count: 2}, + {name: "explicit repeated alias", environment: "synthetic-second", args: []string{"--" + name + "=synthetic-first", "-p", "synthetic-last"}, want: "synthetic-last", explicit: true, set: true, count: 3}, {name: "environment after explicit", environment: "synthetic-third", want: "synthetic-third", set: true, count: 1}, - {name: "explicit empty", environment: "synthetic-third", args: []string{"--" + name + "="}, explicit: true, set: true, count: 1}, + {name: "explicit empty", environment: "synthetic-third", args: []string{"--" + name + "="}, explicit: true, set: true, count: 2}, {name: "present empty environment", set: true}, {name: "removed environment", absent: true}, {name: "validation failure", environment: "synthetic-third", args: []string{"--" + name + "=invalid"}, err: rejected}, From 490ab4dc924b5087809d34a2e167af20ff32682c Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 18:52:41 -0400 Subject: [PATCH 27/36] Preserve executable identity in profile recovery --- cmd/openai/profile_commands_test.go | 101 ++++++++++++++++++++++++++++ pkg/custom/profile_commands.go | 6 +- pkg/custom/profile_context.go | 2 +- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index 86cad71c..6e01fe34 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -196,6 +197,106 @@ func TestMainProfileValidationLeavesStateUnchanged(t *testing.T) { } } +func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("this copied-command fixture executes native Bash and zsh") + } + work, name := buildNativeShellCLI(t) + binary := filepath.Join(work, name) + invocation := "'" + strings.ReplaceAll(binary, "'", "'\\''") + "'" + server, requests := localUtilitiesRequestTrap(t) + required := strings.Split(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), ",") + for _, shell := range []nativeShell{ + {name: "bash", executable: "bash", args: []string{"--noprofile", "--norc", "-c"}}, + {name: "zsh", executable: "zsh", args: []string{"-f", "-c"}}, + } { + t.Run(shell.name, func(t *testing.T) { + path, err := exec.LookPath(shell.executable) + if err != nil { + if slices.Contains(required, shell.name) { + t.Fatalf("required shell is unavailable: %v", err) + } + t.Skipf("%s is unavailable", shell.name) + } + shell.executable = path + t.Setenv("COMPLETION_STYLE", shell.name) + for _, competing := range []bool{false, true} { + mode := "empty PATH" + if competing { + mode = "competing executable" + } + t.Run(mode, func(t *testing.T) { + decoy, sentinel := t.TempDir(), filepath.Join(t.TempDir(), "invoked") + if competing { + script := "#!/bin/sh\nprintf 'wrong executable' > '" + strings.ReplaceAll(sentinel, "'", "'\\''") + "'\nexit 99\n" + require.NoError(t, os.WriteFile(filepath.Join(decoy, "openai"), []byte(script), 0o700)) + t.Setenv("PATH", decoy) + } else { + t.Setenv("PATH", "") + } + for _, format := range []string{"text", "json", "jsonl"} { + t.Run(format, func(t *testing.T) { + for _, tc := range []struct{ name, args, recovery string }{ + {"missing get", "profile get missing", "list"}, + {"duplicate create", "profile create work", "set"}, + {"missing set", "profile set missing --project proj_new", "list"}, + {"missing activate", "profile activate missing", "list"}, + {"missing delete", "profile delete missing", "list"}, + {"missing status selection", "--profile missing auth status", "list"}, + {"missing switch selection", "--profile missing auth switch --to-project proj_new", "list"}, + } { + t.Run(tc.name, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + registry := profileRegistryPath(home) + before := []byte("{\"version\":1,\"profiles\":{\"work\":{\"project\":\"proj_original\"}}}\n") + require.NoError(t, os.MkdirAll(filepath.Dir(registry), 0o700)) + require.NoError(t, os.WriteFile(registry, before, 0o600)) + got := runNativeShell(t, shell, directory, home, server.URL, + invocation+" "+tc.args+" --format-error="+format) + require.Equal(t, 1, got.code, "%s", got.stderr) + require.Empty(t, got.stdout) + after, err := os.ReadFile(registry) + require.NoError(t, err) + require.Equal(t, before, after, "failed command changed saved profiles") + message := strings.TrimSpace(got.stderr) + if format != "text" { + var value map[string]string + require.NoError(t, json.Unmarshal([]byte(got.stderr), &value)) + require.Len(t, value, 1) + message = value["message"] + } + _, guidance, found := strings.Cut(message, "Use ") + require.True(t, found, "%s", message) + printedInvocation, _, found := strings.Cut(guidance, " profile "+tc.recovery) + require.True(t, found, "%s", guidance) + require.Equal(t, invocation, printedInvocation) + recovery := printedInvocation + " profile " + tc.recovery + if tc.recovery == "set" { + // The guidance names an edit prefix; supply the intended profile and edit. + recovery += " work --project proj_recovered" + } + copied := runNativeShell(t, shell, directory, home, server.URL, recovery+" --format=json") + if tc.recovery == "set" { + assertProfileJSON(t, copied, profileReceipt("set", "work", false, map[string]string{"project": "proj_recovered"})) + assertProfileJSON(t, profileCommand(t, home, directory, "profile", "get", "work"), profileRecord("work", false, map[string]string{"project": "proj_recovered"})) + } else { + assertProfileJSON(t, copied, []map[string]any{profileRecord("work", false, map[string]string{"project": "proj_original"})}) + after, err = os.ReadFile(registry) + require.NoError(t, err) + require.Equal(t, before, after) + } + require.NoFileExists(t, sentinel, "recovery invoked the competing executable") + require.Zero(t, requests.Load(), "profile recovery reached the API") + }) + } + }) + } + }) + } + }) + } +} + func TestMainProfileStatusMatchesRequestContext(t *testing.T) { home, parent := t.TempDir(), t.TempDir() child := filepath.Join(parent, "child") diff --git a/pkg/custom/profile_commands.go b/pkg/custom/profile_commands.go index 1a1e6312..aa6063fd 100644 --- a/pkg/custom/profile_commands.go +++ b/pkg/custom/profile_commands.go @@ -172,7 +172,7 @@ func handleProfileCommand(ctx context.Context, command *cli.Command) error { result = records } else { if _, exists := registry.Profiles[name]; !exists { - return profileFailure("That profile does not exist. Use openai profile list to inspect saved names.", nil) + return profileFailure("That profile does not exist. Use "+errorHelpInvocation(root)+" profile list to inspect saved names.", nil) } result = savedProfileRecord(registry, name) } @@ -193,13 +193,13 @@ func handleProfileCommand(ctx context.Context, command *cli.Command) error { values, exists := registry.Profiles[name] if action == "create" { if exists { - return profileFailure("That profile already exists. Use openai profile set to edit its saved fields.", nil) + return profileFailure("That profile already exists. Use "+errorHelpInvocation(root)+" profile set to edit its saved fields.", nil) } registry.Profiles[name] = profileValues{} return nil } if !exists { - return profileFailure("That profile does not exist. Use openai profile list to inspect saved names.", nil) + return profileFailure("That profile does not exist. Use "+errorHelpInvocation(root)+" profile list to inspect saved names.", nil) } switch action { case "set": diff --git a/pkg/custom/profile_context.go b/pkg/custom/profile_context.go index 973b7179..92be52a5 100644 --- a/pkg/custom/profile_context.go +++ b/pkg/custom/profile_context.go @@ -70,7 +70,7 @@ func selectedProfileName(root *cli.Command, lookup func(string) (string, bool), } if name != "" { if _, exists := registry.Profiles[name]; !exists { - return "", &localUtilityError{message: "Selected profile does not exist. Use openai profile list or create the profile first."} + return "", &localUtilityError{message: "Selected profile does not exist. Use " + errorHelpInvocation(root) + " profile list or create the profile first."} } } return name, nil From cc1e927251f21d50f54c75e2111c8d6f3bd774ff Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 16 Oct 2026 19:00:04 -0400 Subject: [PATCH 28/36] Use complete profile schema in recovery fixture --- cmd/openai/profile_commands_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index 6e01fe34..5b62ddec 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -248,7 +248,7 @@ func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { t.Run(tc.name, func(t *testing.T) { home, directory := t.TempDir(), t.TempDir() registry := profileRegistryPath(home) - before := []byte("{\"version\":1,\"profiles\":{\"work\":{\"project\":\"proj_original\"}}}\n") + before := []byte("{\"version\":1,\"default_profile\":null,\"profiles\":{\"work\":{\"project\":\"proj_original\"}}}\n") require.NoError(t, os.MkdirAll(filepath.Dir(registry), 0o700)) require.NoError(t, os.WriteFile(registry, before, 0o600)) got := runNativeShell(t, shell, directory, home, server.URL, From 652202dfcddb7d76fc7aa4585c1576a3193de302 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 17 Oct 2026 20:41:59 -0400 Subject: [PATCH 29/36] Preserve the invoked CLI in auth status recovery --- .github/workflows/help-compatibility.yml | 5 ++-- cmd/openai/profile_commands_test.go | 35 ++++++++++++++++-------- pkg/custom/auth_status.go | 2 +- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/help-compatibility.yml b/.github/workflows/help-compatibility.yml index 19fca13f..945a76e9 100644 --- a/.github/workflows/help-compatibility.yml +++ b/.github/workflows/help-compatibility.yml @@ -1023,6 +1023,7 @@ jobs: profile_recovery_cases = [ "missing_get", "duplicate_create", "missing_set", "missing_activate", "missing_delete", "missing_status_selection", "missing_switch_selection", + "status_positional_argument", ] profile_recovery_tests = {profile_recovery_root} for shell in ["bash", "zsh"]: @@ -1035,8 +1036,8 @@ jobs: profile_recovery_tests.update(required_cases( f"{mode_root}/{output_format}", profile_recovery_cases, )) - if len(profile_recovery_tests) != 103: - raise SystemExit("profile recovery inventory must contain one root and 102 children") + if len(profile_recovery_tests) != 115: + raise SystemExit("profile recovery inventory must contain one root and 114 children") suites["profile-copied-recovery-tests.jsonl"] = profile_recovery_tests strict_packages["profile-copied-recovery-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" if os.environ["RUNNER_OS"] == "Linux": diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index 1a6c91ae..e7b693de 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -237,13 +237,14 @@ func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { for _, format := range []string{"text", "json", "jsonl"} { t.Run(format, func(t *testing.T) { for _, tc := range []struct{ name, args, recovery string }{ - {"missing get", "profile get missing", "list"}, - {"duplicate create", "profile create work", "set"}, - {"missing set", "profile set missing --project proj_new", "list"}, - {"missing activate", "profile activate missing", "list"}, - {"missing delete", "profile delete missing", "list"}, - {"missing status selection", "--profile missing auth status", "list"}, - {"missing switch selection", "--profile missing auth switch --to-project proj_new", "list"}, + {"missing get", "profile get missing", "profile list"}, + {"duplicate create", "profile create work", "profile set"}, + {"missing set", "profile set missing --project proj_new", "profile list"}, + {"missing activate", "profile activate missing", "profile list"}, + {"missing delete", "profile delete missing", "profile list"}, + {"missing status selection", "--profile missing auth status", "profile list"}, + {"missing switch selection", "--profile missing auth switch --to-project proj_new", "profile list"}, + {"status positional argument", "auth status unexpected", "auth status --help"}, } { t.Run(tc.name, func(t *testing.T) { home, directory := t.TempDir(), t.TempDir() @@ -267,20 +268,30 @@ func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { } _, guidance, found := strings.Cut(message, "Use ") require.True(t, found, "%s", message) - printedInvocation, _, found := strings.Cut(guidance, " profile "+tc.recovery) + printedInvocation, _, found := strings.Cut(guidance, " "+tc.recovery) require.True(t, found, "%s", guidance) require.Equal(t, invocation, printedInvocation) - recovery := printedInvocation + " profile " + tc.recovery - if tc.recovery == "set" { + recovery := printedInvocation + " " + tc.recovery + if tc.recovery == "profile set" { // The guidance names an edit prefix; supply the intended profile and edit. recovery += " work --project proj_recovered" } - copied := runNativeShell(t, shell, directory, home, server.URL, recovery+" --format=json") - if tc.recovery == "set" { + if tc.recovery != "auth status --help" { + recovery += " --format=json" + } + copied := runNativeShell(t, shell, directory, home, server.URL, recovery) + if tc.recovery == "profile set" { assertProfileJSON(t, copied, profileReceipt("set", "work", false, map[string]string{"project": "proj_recovered"})) assertProfileJSON(t, profileCommand(t, home, directory, "profile", "get", "work"), profileRecord("work", false, map[string]string{"project": "proj_recovered"})) + } else if tc.recovery == "auth status --help" { + require.Zero(t, copied.code, "%s", copied.stderr) + require.Empty(t, copied.stderr) + require.Contains(t, copied.stdout, "Shows configured authentication offline by default.") + require.Contains(t, copied.stdout, invocation+" auth status") } else { assertProfileJSON(t, copied, []map[string]any{profileRecord("work", false, map[string]string{"project": "proj_original"})}) + } + if tc.recovery != "profile set" { after, err = os.ReadFile(registry) require.NoError(t, err) require.Equal(t, before, after) diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index 2e96ac8d..5b405891 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -103,7 +103,7 @@ func handleAuthStatus(ctx context.Context, command *cli.Command) error { } root := command.Root() if command.Args().Present() { - return &localcommands.LocalError{Message: "Auth status takes no positional arguments. Use openai auth status --help."} + return &localcommands.LocalError{Message: "Auth status takes no positional arguments. Use " + errorHelpInvocation(root) + " auth status --help."} } if root.IsSet("transform") || root.IsSet("raw-output") { return &localcommands.LocalError{Message: "Auth status does not support --transform or --raw-output. Use --format text or json."} From 1bbc7affe462c8e78bda049d1604171c0c39456b Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 17 Oct 2026 20:57:16 -0400 Subject: [PATCH 30/36] Use shared output contracts after CLI integration --- pkg/custom/auth_status.go | 3 ++- pkg/custom/auth_status_test.go | 3 ++- pkg/custom/profile_commands.go | 3 ++- pkg/custom/project_link.go | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index 5b405891..31baff18 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -16,6 +16,7 @@ import ( "time" localcommands "github.com/openai/openai-cli/internal/commands" + clioutput "github.com/openai/openai-cli/internal/output" "github.com/openai/openai-cli/internal/readable" "github.com/urfave/cli/v3" ) @@ -153,7 +154,7 @@ func handleAuthStatus(ctx context.Context, command *cli.Command) error { report.Verified, report.VerifiedCredentialKind = true, kind } } - out := outputWriter{ctx: ctx, out: root.Writer} + out := clioutput.Writer{Context: ctx, Sink: root.Writer} if format == "json" { err = json.NewEncoder(out).Encode(report) } else { diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go index 4d3542ec..a40d1bd9 100644 --- a/pkg/custom/auth_status_test.go +++ b/pkg/custom/auth_status_test.go @@ -23,6 +23,7 @@ import ( "testing" "time" + clioutput "github.com/openai/openai-cli/internal/output" "github.com/openai/openai-cli/internal/requestflag" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" @@ -680,7 +681,7 @@ func TestAuthStatusIdentityOutputFailurePreservesLookupFailure(t *testing.T) { root := authStatusTestRoot(authStatusFailWriter{err: syscall.EPIPE}) err := root.Run(t.Context(), []string{"openai", "auth", "status", "--format=json", "--verify", "--credential-kind=api"}) require.ErrorIs(t, err, syscall.EPIPE) - require.Equal(t, status == http.StatusOK, isOutputBrokenPipe(err), "lookup failure must prevent broken-pipe suppression") + require.Equal(t, status == http.StatusOK, clioutput.IsBrokenPipe(err), "lookup failure must prevent broken-pipe suppression") require.True(t, body.closed) require.NotContains(t, err.Error(), "synthetic-api") }) diff --git a/pkg/custom/profile_commands.go b/pkg/custom/profile_commands.go index af99ee0f..aa31f791 100644 --- a/pkg/custom/profile_commands.go +++ b/pkg/custom/profile_commands.go @@ -11,6 +11,7 @@ import ( "strings" localcommands "github.com/openai/openai-cli/internal/commands" + clioutput "github.com/openai/openai-cli/internal/output" "github.com/openai/openai-cli/internal/readable" "github.com/urfave/cli/v3" ) @@ -310,7 +311,7 @@ func savedProfileRecord(registry profileRegistry, name string) profileRecord { } func writeProfileResult(ctx context.Context, writer io.Writer, format string, result any) error { - out := outputWriter{ctx: ctx, out: writer} + out := clioutput.Writer{Context: ctx, Sink: writer} if format == "json" || format == "jsonl" { encoder := json.NewEncoder(out) if records, ok := result.([]profileRecord); ok && format == "jsonl" { diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index 4fdeabf3..0f651053 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -11,6 +11,7 @@ import ( "strings" localcommands "github.com/openai/openai-cli/internal/commands" + clioutput "github.com/openai/openai-cli/internal/output" "github.com/openai/openai-cli/internal/readable" "github.com/openai/openai-go/v3/option" "github.com/urfave/cli/v3" @@ -245,7 +246,7 @@ func handleProjectLink(ctx context.Context, command *cli.Command) error { } return err } - writer := outputWriter{ctx: ctx, out: root.Writer} + writer := clioutput.Writer{Context: ctx, Sink: root.Writer} if format == "json" { err = json.NewEncoder(writer).Encode(status) } else if status.Action == "inspect" || !quietOutput(ctx) { From 6b23bb5018fcf25cf3f4c0d0945258cb4821088c Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 17 Oct 2026 21:21:28 -0400 Subject: [PATCH 31/36] Test copied profile recovery in native Windows shells --- .github/workflows/help-compatibility.yml | 57 ++++++++------ cmd/openai/profile_commands_test.go | 94 +++++++++++++++++++----- 2 files changed, 111 insertions(+), 40 deletions(-) diff --git a/.github/workflows/help-compatibility.yml b/.github/workflows/help-compatibility.yml index 945a76e9..432279d6 100644 --- a/.github/workflows/help-compatibility.yml +++ b/.github/workflows/help-compatibility.yml @@ -355,6 +355,18 @@ jobs: go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=2m -v -json \ -run '^TestMainProfileCopiedRecoveryPreservesInvocation$' \ | tee "$RUNNER_TEMP/profile-copied-recovery-tests.jsonl" + - name: Test Windows copied profile recovery in native shells + if: runner.os == 'Windows' && matrix.shard != 'dispatch' + shell: pwsh + timeout-minutes: 6 + env: + GOMAXPROCS: '2' + OPENAI_CLI_REQUIRE_NATIVE_SHELLS: cmd,powershell,pwsh + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=5m -v -json -run '^TestMainProfileCopiedRecoveryPreservesInvocation$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'profile-copied-recovery-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + exit $LASTEXITCODE - name: Test Windows voice validation and upload ownership if: (matrix.native-commands && runner.os == 'Windows') && matrix.shard != 'dispatch' shell: pwsh @@ -1018,28 +1030,29 @@ jobs: "TestImagePickerProfileCommandNativeShellRoundTrip", profile_shells[os.environ["RUNNER_OS"]], ) strict_packages["profile-native-shell-tests.jsonl"] = "github.com/openai/openai-cli/pkg/custom" - if os.environ["RUNNER_OS"] != "Windows": - profile_recovery_root = "TestMainProfileCopiedRecoveryPreservesInvocation" - profile_recovery_cases = [ - "missing_get", "duplicate_create", "missing_set", "missing_activate", - "missing_delete", "missing_status_selection", "missing_switch_selection", - "status_positional_argument", - ] - profile_recovery_tests = {profile_recovery_root} - for shell in ["bash", "zsh"]: - shell_root = f"{profile_recovery_root}/{shell}" - profile_recovery_tests.add(shell_root) - for mode in ["empty_PATH", "competing_executable"]: - mode_root = f"{shell_root}/{mode}" - profile_recovery_tests.add(mode_root) - for output_format in ["text", "json", "jsonl"]: - profile_recovery_tests.update(required_cases( - f"{mode_root}/{output_format}", profile_recovery_cases, - )) - if len(profile_recovery_tests) != 115: - raise SystemExit("profile recovery inventory must contain one root and 114 children") - suites["profile-copied-recovery-tests.jsonl"] = profile_recovery_tests - strict_packages["profile-copied-recovery-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" + profile_recovery_root = "TestMainProfileCopiedRecoveryPreservesInvocation" + profile_recovery_cases = [ + "missing_get", "duplicate_create", "missing_set", "missing_activate", + "missing_delete", "missing_status_selection", "missing_switch_selection", + "status_positional_argument", + ] + profile_recovery_tests = {profile_recovery_root} + profile_recovery_shells = ["cmd", "powershell", "pwsh"] if os.environ["RUNNER_OS"] == "Windows" else ["bash", "zsh"] + for shell in profile_recovery_shells: + shell_root = f"{profile_recovery_root}/{shell}" + profile_recovery_tests.add(shell_root) + for mode in ["empty_PATH", "competing_executable"]: + mode_root = f"{shell_root}/{mode}" + profile_recovery_tests.add(mode_root) + for output_format in ["text", "json", "jsonl"]: + profile_recovery_tests.update(required_cases( + f"{mode_root}/{output_format}", profile_recovery_cases, + )) + expected_recovery_names = 172 if os.environ["RUNNER_OS"] == "Windows" else 115 + if len(profile_recovery_tests) != expected_recovery_names: + raise SystemExit(f"profile recovery inventory must contain {expected_recovery_names} names") + suites["profile-copied-recovery-tests.jsonl"] = profile_recovery_tests + strict_packages["profile-copied-recovery-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" if os.environ["RUNNER_OS"] == "Linux": fish_suggestion_cases = { 'TestFishFileCompletionPreservesAssignments': [ diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index e7b693de..74dbbf60 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -198,28 +198,68 @@ func TestMainProfileValidationLeavesStateUnchanged(t *testing.T) { } func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("this copied-command fixture executes native Bash and zsh") - } work, name := buildNativeShellCLI(t) binary := filepath.Join(work, name) - invocation := "'" + strings.ReplaceAll(binary, "'", "'\\''") + "'" server, requests := localUtilitiesRequestTrap(t) - required := strings.Split(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), ",") - for _, shell := range []nativeShell{ + shells := []nativeShell{ {name: "bash", executable: "bash", args: []string{"--noprofile", "--norc", "-c"}}, {name: "zsh", executable: "zsh", args: []string{"-f", "-c"}}, - } { + } + if runtime.GOOS == "windows" { + shells = []nativeShell{ + {name: "cmd", executable: "cmd.exe", args: []string{"/D", "/C"}}, + {name: "powershell", executable: "powershell.exe", args: []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-OutputFormat", "Text", "-Command"}}, + {name: "pwsh", executable: "pwsh.exe", args: []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-OutputFormat", "Text", "-Command"}}, + } + } + required := strings.Split(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), ",") + for _, name := range required { + if name != "" && !slices.ContainsFunc(shells, func(shell nativeShell) bool { return shell.name == name }) { + t.Fatalf("required shell %s is not configured on %s", name, runtime.GOOS) + } + } + // Resolve every shell before the cases replace PATH. + for i := range shells { + path, err := exec.LookPath(shells[i].executable) + if err != nil { + if runtime.GOOS == "windows" || slices.Contains(required, shells[i].name) { + t.Fatalf("required shell %s is unavailable: %v", shells[i].name, err) + } + shells[i].executable = "" + continue + } + path, err = filepath.Abs(path) + require.NoError(t, err) + shells[i].executable = path + } + if runtime.GOOS == "windows" { + // PowerShell needs an absolute command processor to execute the decoy. + t.Setenv("COMSPEC", shells[0].executable) + t.Setenv("PATHEXT", ".EXE;.COM;.BAT;.CMD") + } + for _, shell := range shells { t.Run(shell.name, func(t *testing.T) { - path, err := exec.LookPath(shell.executable) - if err != nil { - if slices.Contains(required, shell.name) { - t.Fatalf("required shell is unavailable: %v", err) - } + if shell.executable == "" { t.Skipf("%s is unavailable", shell.name) } - shell.executable = path - t.Setenv("COMPLETION_STYLE", shell.name) + invocation := "'" + strings.ReplaceAll(binary, "'", "'\\''") + "'" + if shell.name == "cmd" { + invocation = `"` + binary + `"` + } else if shell.name == "powershell" || shell.name == "pwsh" { + invocation = "& '" + strings.ReplaceAll(binary, "'", "''") + "'" + } else { + t.Setenv("COMPLETION_STYLE", shell.name) + } + run := func(t *testing.T, directory, home, script string) mainDispatchResult { + t.Helper() + if shell.name == "powershell" || shell.name == "pwsh" { + // Forward native stderr records without PowerShell's diagnostic decoration. + // The existing shell helper exits with the unchanged $LASTEXITCODE. + script = "$ErrorActionPreference = 'Continue'; $PSNativeCommandUseErrorActionPreference = $false; " + script + + " 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { [Console]::Error.WriteLine($_.Exception.Message) } else { [Console]::Out.WriteLine($_) } }" + } + return runNativeShell(t, shell, directory, home, server.URL, script) + } for _, competing := range []bool{false, true} { mode := "empty PATH" if competing { @@ -228,9 +268,27 @@ func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { t.Run(mode, func(t *testing.T) { decoy, sentinel := t.TempDir(), filepath.Join(t.TempDir(), "invoked") if competing { - script := "#!/bin/sh\nprintf 'wrong executable' > '" + strings.ReplaceAll(sentinel, "'", "'\\''") + "'\nexit 99\n" - require.NoError(t, os.WriteFile(filepath.Join(decoy, "openai"), []byte(script), 0o700)) + if runtime.GOOS == "windows" { + t.Setenv("PROFILE_RECOVERY_DECOY_SENTINEL", sentinel) + script := "@echo off\r\n> \"%PROFILE_RECOVERY_DECOY_SENTINEL%\" echo wrong executable\r\nexit /b 99\r\n" + require.NoError(t, os.WriteFile(filepath.Join(decoy, "openai.cmd"), []byte(script), 0o600)) + } else { + script := "#!/bin/sh\nprintf 'wrong executable' > '" + strings.ReplaceAll(sentinel, "'", "'\\''") + "'\nexit 99\n" + require.NoError(t, os.WriteFile(filepath.Join(decoy, "openai"), []byte(script), 0o700)) + } t.Setenv("PATH", decoy) + if runtime.GOOS == "windows" { + // Prove this shell selects the decoy before testing copied recovery. + control := run(t, t.TempDir(), t.TempDir(), "openai") + require.Equal(t, 99, control.code, "%s", control.stderr) + require.Empty(t, control.stdout) + require.Empty(t, control.stderr) + data, err := os.ReadFile(sentinel) + require.NoError(t, err) + require.Equal(t, "wrong executable", strings.TrimSpace(string(data))) + require.NoError(t, os.Remove(sentinel)) + require.Zero(t, requests.Load(), "decoy control reached the API") + } } else { t.Setenv("PATH", "") } @@ -252,7 +310,7 @@ func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { before := []byte("{\"version\":1,\"default_profile\":null,\"profiles\":{\"work\":{\"project\":\"proj_original\"}}}\n") require.NoError(t, os.MkdirAll(filepath.Dir(registry), 0o700)) require.NoError(t, os.WriteFile(registry, before, 0o600)) - got := runNativeShell(t, shell, directory, home, server.URL, + got := run(t, directory, home, invocation+" "+tc.args+" --format-error="+format) require.Equal(t, 1, got.code, "%s", got.stderr) require.Empty(t, got.stdout) @@ -279,7 +337,7 @@ func TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { if tc.recovery != "auth status --help" { recovery += " --format=json" } - copied := runNativeShell(t, shell, directory, home, server.URL, recovery) + copied := run(t, directory, home, recovery) if tc.recovery == "profile set" { assertProfileJSON(t, copied, profileReceipt("set", "work", false, map[string]string{"project": "proj_recovered"})) assertProfileJSON(t, profileCommand(t, home, directory, "profile", "get", "work"), profileRecord("work", false, map[string]string{"project": "proj_recovered"})) From f8264b5df77f23cd3139e4b3de91c55fe6e89534 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sun, 18 Oct 2026 23:52:44 -0700 Subject: [PATCH 32/36] Group shared request imports with project dependencies --- pkg/custom/auth_status.go | 4 ++-- pkg/custom/auth_status_test.go | 4 ++-- pkg/custom/mtls_client_flag_test.go | 2 +- pkg/custom/profile_commands.go | 2 +- pkg/custom/profile_context.go | 4 ++-- pkg/custom/project_link.go | 2 +- pkg/custom/project_link_errors.go | 2 +- pkg/custom/project_link_test.go | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index 709bfc1f..49b3a704 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -5,8 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/openai/openai-cli/internal/clihelp" - requestconfig "github.com/openai/openai-cli/internal/request" "io" "mime" "net/http" @@ -17,9 +15,11 @@ import ( "syscall" "time" + "github.com/openai/openai-cli/internal/clihelp" localcommands "github.com/openai/openai-cli/internal/commands" clioutput "github.com/openai/openai-cli/internal/output" "github.com/openai/openai-cli/internal/readable" + requestconfig "github.com/openai/openai-cli/internal/request" "github.com/urfave/cli/v3" ) diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go index c9585f4e..89a96c00 100644 --- a/pkg/custom/auth_status_test.go +++ b/pkg/custom/auth_status_test.go @@ -8,8 +8,6 @@ import ( "encoding/json" "encoding/pem" "errors" - requestconfig "github.com/openai/openai-cli/internal/request" - mtlstest "github.com/openai/openai-cli/internal/testutil/mtls" "io" "log" "math/big" @@ -26,7 +24,9 @@ import ( "time" clioutput "github.com/openai/openai-cli/internal/output" + requestconfig "github.com/openai/openai-cli/internal/request" "github.com/openai/openai-cli/internal/requestflag" + mtlstest "github.com/openai/openai-cli/internal/testutil/mtls" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/stretchr/testify/require" diff --git a/pkg/custom/mtls_client_flag_test.go b/pkg/custom/mtls_client_flag_test.go index d774e702..35db2972 100644 --- a/pkg/custom/mtls_client_flag_test.go +++ b/pkg/custom/mtls_client_flag_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "encoding/pem" "errors" - mtlstest "github.com/openai/openai-cli/internal/testutil/mtls" "io" "log" "math/big" @@ -20,6 +19,7 @@ import ( "testing" "time" + mtlstest "github.com/openai/openai-cli/internal/testutil/mtls" "github.com/openai/openai-go/v3" requestconfig "github.com/openai/openai-cli/internal/request" diff --git a/pkg/custom/profile_commands.go b/pkg/custom/profile_commands.go index ae4dafc9..fb496d31 100644 --- a/pkg/custom/profile_commands.go +++ b/pkg/custom/profile_commands.go @@ -5,12 +5,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/openai/openai-cli/internal/clihelp" "io" "os" "slices" "strings" + "github.com/openai/openai-cli/internal/clihelp" localcommands "github.com/openai/openai-cli/internal/commands" clioutput "github.com/openai/openai-cli/internal/output" "github.com/openai/openai-cli/internal/readable" diff --git a/pkg/custom/profile_context.go b/pkg/custom/profile_context.go index 4d7b47b1..f7b91bf3 100644 --- a/pkg/custom/profile_context.go +++ b/pkg/custom/profile_context.go @@ -2,15 +2,15 @@ package custom import ( "context" - "github.com/openai/openai-cli/internal/clihelp" - requestconfig "github.com/openai/openai-cli/internal/request" "net/http" "net/url" "os" "slices" "strings" + "github.com/openai/openai-cli/internal/clihelp" localcommands "github.com/openai/openai-cli/internal/commands" + requestconfig "github.com/openai/openai-cli/internal/request" "github.com/openai/openai-go/v3/option" "github.com/urfave/cli/v3" ) diff --git a/pkg/custom/project_link.go b/pkg/custom/project_link.go index b009e41d..0a345ac6 100644 --- a/pkg/custom/project_link.go +++ b/pkg/custom/project_link.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - requestconfig "github.com/openai/openai-cli/internal/request" "io" "os" "path/filepath" @@ -14,6 +13,7 @@ import ( localcommands "github.com/openai/openai-cli/internal/commands" clioutput "github.com/openai/openai-cli/internal/output" "github.com/openai/openai-cli/internal/readable" + requestconfig "github.com/openai/openai-cli/internal/request" "github.com/openai/openai-go/v3/option" "github.com/urfave/cli/v3" ) diff --git a/pkg/custom/project_link_errors.go b/pkg/custom/project_link_errors.go index 21f41e62..eb76583b 100644 --- a/pkg/custom/project_link_errors.go +++ b/pkg/custom/project_link_errors.go @@ -3,9 +3,9 @@ package custom import ( "context" "errors" - "github.com/openai/openai-cli/internal/clihelp" "os" + "github.com/openai/openai-cli/internal/clihelp" localcommands "github.com/openai/openai-cli/internal/commands" "github.com/urfave/cli/v3" ) diff --git a/pkg/custom/project_link_test.go b/pkg/custom/project_link_test.go index 566e111a..43e1fbaa 100644 --- a/pkg/custom/project_link_test.go +++ b/pkg/custom/project_link_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "errors" - requestconfig "github.com/openai/openai-cli/internal/request" "io" "net/http" "os" @@ -12,6 +11,7 @@ import ( "testing" localcommands "github.com/openai/openai-cli/internal/commands" + requestconfig "github.com/openai/openai-cli/internal/request" "github.com/openai/openai-cli/internal/requestflag" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" From 03af00b1468033fdb4533bae5c22a8b123ad8746 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Mon, 19 Oct 2026 00:12:40 -0700 Subject: [PATCH 33/36] docs: qualify profile registry replacement guarantees --- docs/profiles.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/profiles.md b/docs/profiles.md index bbf1da9e..20e4be02 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -177,8 +177,9 @@ The file uses a versioned schema: {"version":1,"default_profile":null,"profiles":{"work":{"project":"proj_demo"}}} ``` -Mutations lock the registry, validate its contents, and replace it atomically. +Mutations lock the registry and validate its contents before replacement. +Replacement is atomic on Unix; other platforms do not guarantee atomic replacement. The CLI rejects malformed schemas, duplicate keys, unknown fields, unsupported versions, and dangling defaults. -Failures preserve existing registry bytes. +Validation failures preserve existing registry bytes. Store no credentials or credential references in this file. The profile registry remains separate from `project-links.json`. From b95927563d1eacb88c6dcaf70c6710923c1c569d Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Mon, 19 Oct 2026 18:32:16 -0700 Subject: [PATCH 34/36] Repair auth helpers and actionable error guidance --- .github/workflows/help-compatibility.yml | 8 +- cmd/openai/auth_status_test.go | 37 +++++++ cmd/openai/profile_commands_test.go | 62 +++++++++++ pkg/custom/auth_status.go | 79 ++++++++++++-- pkg/custom/auth_status_test.go | 131 ++++++++++++++++++++--- pkg/custom/profile_context.go | 2 +- 6 files changed, 293 insertions(+), 26 deletions(-) diff --git a/.github/workflows/help-compatibility.yml b/.github/workflows/help-compatibility.yml index fd18603b..b5eb357b 100644 --- a/.github/workflows/help-compatibility.yml +++ b/.github/workflows/help-compatibility.yml @@ -239,7 +239,7 @@ jobs: GOMAXPROCS: '2' run: | set -euo pipefail - go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=3m -v -json -run '^(TestMainAuthStatusOfflineOutputAndDiagnostics|TestMainAuthStatusCredentialAndContextPrecedence|TestMainAuthStatusPreservesStdin|TestMainAuthStatusDoesNotLoadMTLS|TestMainAuthStatusSanitizesURLsAndTerminalControls|TestMainAuthStatusErrorsRemainPrivate|TestMainAuthStatusIdentitySelectionAndRequestBoundary|TestMainAuthStatusIdentityProviderAndCredentialRejections|TestMainAuthStatusIdentitySelectionRequiresExplicitVerification|TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate|TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints|TestMainAuthStatusIdentityAuthorizationPrecedence|TestMainAuthStatusIdentityTextEscapesTerminalControls|TestMainAuthStatusIdentityDeadline|TestMainProfileShellSuggestionFlagOwnership|TestMainProfileLifecycleAndExactSchemas|TestMainProfileEditsNeverSaveAmbientValues|TestMainProfileBaseURLUsesActualEffectiveEndpoint|TestMainProfileBaseURLRequestPolicy|TestMainProfileBaseURLPolicyPreservesAllowedEndpointsAndOverrides|TestMainProfileStatusMatchesRequestContext|TestMainProfileProjectBypassesMalformedFolderRegistry|TestMainProfileMissingSelectionPreservesCommittedFolderLink|TestMainProfileCancellationPreservesCommittedOutcomes|TestMainProjectLinkOfflineLifecycle|TestMainProjectLinkQuietPreservesInspectionAndJSON|TestMainProjectLinkRequestPrecedence|TestMainProjectLinkDirectoryScope|TestMainProjectLinkMalformedRegistryFailsBeforeRequests|TestMainProfileAdminEndpointPreflightMatchesSelection|TestMainAuthStatusIdentitySpacedBearerRemainsPrivate)$' \ + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=3m -v -json -run '^(TestMainAuthStatusOfflineOutputAndDiagnostics|TestMainAuthStatusCredentialAndContextPrecedence|TestMainAuthStatusPreservesStdin|TestMainAuthStatusDoesNotLoadMTLS|TestMainAuthStatusSanitizesURLsAndTerminalControls|TestMainAuthStatusErrorsRemainPrivate|TestMainAuthStatusIdentitySelectionAndRequestBoundary|TestMainAuthStatusIdentityProviderAndCredentialRejections|TestMainAuthStatusIdentitySelectionRequiresExplicitVerification|TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate|TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints|TestMainAuthStatusIdentityAuthorizationPrecedence|TestMainAuthStatusIdentityTextEscapesTerminalControls|TestMainAuthStatusIdentityDeadline|TestMainProfileShellSuggestionFlagOwnership|TestMainProfileLifecycleAndExactSchemas|TestMainProfileEditsNeverSaveAmbientValues|TestMainProfileBaseURLUsesActualEffectiveEndpoint|TestMainProfileBaseURLRequestPolicy|TestMainProfileBaseURLPolicyPreservesAllowedEndpointsAndOverrides|TestMainProfileStatusMatchesRequestContext|TestMainProfileProjectBypassesMalformedFolderRegistry|TestMainProfileMissingSelectionPreservesCommittedFolderLink|TestMainProfileReadFailureRecovery|TestMainProfileCancellationPreservesCommittedOutcomes|TestMainProjectLinkOfflineLifecycle|TestMainProjectLinkQuietPreservesInspectionAndJSON|TestMainProjectLinkRequestPrecedence|TestMainProjectLinkDirectoryScope|TestMainProjectLinkMalformedRegistryFailsBeforeRequests|TestMainProfileAdminEndpointPreflightMatchesSelection|TestMainAuthStatusIdentitySpacedBearerRemainsPrivate)$' \ | tee "$RUNNER_TEMP/auth-profile-tests.jsonl" - name: Test Windows authentication, profiles, and project links if: runner.os == 'Windows' && matrix.shard != 'dispatch' @@ -249,7 +249,7 @@ jobs: GOMAXPROCS: '2' run: | $ErrorActionPreference = 'Stop' - go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=3m -v -json -run '^(TestMainAuthStatusOfflineOutputAndDiagnostics|TestMainAuthStatusCredentialAndContextPrecedence|TestMainAuthStatusPreservesStdin|TestMainAuthStatusDoesNotLoadMTLS|TestMainAuthStatusSanitizesURLsAndTerminalControls|TestMainAuthStatusErrorsRemainPrivate|TestMainAuthStatusIdentitySelectionAndRequestBoundary|TestMainAuthStatusIdentityProviderAndCredentialRejections|TestMainAuthStatusIdentitySelectionRequiresExplicitVerification|TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate|TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints|TestMainAuthStatusIdentityAuthorizationPrecedence|TestMainAuthStatusIdentityTextEscapesTerminalControls|TestMainAuthStatusIdentityDeadline|TestMainProfileShellSuggestionFlagOwnership|TestMainProfileLifecycleAndExactSchemas|TestMainProfileEditsNeverSaveAmbientValues|TestMainProfileBaseURLUsesActualEffectiveEndpoint|TestMainProfileBaseURLRequestPolicy|TestMainProfileBaseURLPolicyPreservesAllowedEndpointsAndOverrides|TestMainProfileStatusMatchesRequestContext|TestMainProfileProjectBypassesMalformedFolderRegistry|TestMainProfileMissingSelectionPreservesCommittedFolderLink|TestMainProfileCancellationPreservesCommittedOutcomes|TestMainProjectLinkOfflineLifecycle|TestMainProjectLinkQuietPreservesInspectionAndJSON|TestMainProjectLinkRequestPrecedence|TestMainProjectLinkDirectoryScope|TestMainProjectLinkMalformedRegistryFailsBeforeRequests|TestMainProfileAdminEndpointPreflightMatchesSelection|TestMainAuthStatusIdentitySpacedBearerRemainsPrivate)$' | + go test -p 1 ./cmd/openai -parallel=1 -count=1 -timeout=3m -v -json -run '^(TestMainAuthStatusOfflineOutputAndDiagnostics|TestMainAuthStatusCredentialAndContextPrecedence|TestMainAuthStatusPreservesStdin|TestMainAuthStatusDoesNotLoadMTLS|TestMainAuthStatusSanitizesURLsAndTerminalControls|TestMainAuthStatusErrorsRemainPrivate|TestMainAuthStatusIdentitySelectionAndRequestBoundary|TestMainAuthStatusIdentityProviderAndCredentialRejections|TestMainAuthStatusIdentitySelectionRequiresExplicitVerification|TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate|TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints|TestMainAuthStatusIdentityAuthorizationPrecedence|TestMainAuthStatusIdentityTextEscapesTerminalControls|TestMainAuthStatusIdentityDeadline|TestMainProfileShellSuggestionFlagOwnership|TestMainProfileLifecycleAndExactSchemas|TestMainProfileEditsNeverSaveAmbientValues|TestMainProfileBaseURLUsesActualEffectiveEndpoint|TestMainProfileBaseURLRequestPolicy|TestMainProfileBaseURLPolicyPreservesAllowedEndpointsAndOverrides|TestMainProfileStatusMatchesRequestContext|TestMainProfileProjectBypassesMalformedFolderRegistry|TestMainProfileMissingSelectionPreservesCommittedFolderLink|TestMainProfileReadFailureRecovery|TestMainProfileCancellationPreservesCommittedOutcomes|TestMainProjectLinkOfflineLifecycle|TestMainProjectLinkQuietPreservesInspectionAndJSON|TestMainProjectLinkRequestPrecedence|TestMainProjectLinkDirectoryScope|TestMainProjectLinkMalformedRegistryFailsBeforeRequests|TestMainProfileAdminEndpointPreflightMatchesSelection|TestMainAuthStatusIdentitySpacedBearerRemainsPrivate)$' | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'auth-profile-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop exit $LASTEXITCODE - name: Test native Windows PowerShell literal reference completion @@ -1185,6 +1185,10 @@ jobs: "work", "empty" ], + "TestMainProfileReadFailureRecovery": [ + "malformed", + "unreadable_directory" + ], "TestMainProfileMissingSelectionPreservesCommittedFolderLink": [ "text", "json" diff --git a/cmd/openai/auth_status_test.go b/cmd/openai/auth_status_test.go index e3f1b7a0..7b4ab8c1 100644 --- a/cmd/openai/auth_status_test.go +++ b/cmd/openai/auth_status_test.go @@ -789,10 +789,46 @@ func TestMainAuthStatusIdentityFailuresRemainUnknownAndPrivate(t *testing.T) { require.Equal(t, "not_configured", status.AdminIdentity.Reason) decodeMainStructuredError(t, "json", got.stderr) assertAuthStatusPrivate(t, got, secret, "synthetic-private-response-body", "synthetic-private-transport-details", "synthetic-private-redirect") + require.Contains(t, got.stderr, "Options and examples: openai auth status --help") + if tc.name == "unauthorized" || tc.name == "malformed JSON" { + assertAuthStatusIdentityRecovery(t, got, "json") + text, textRequests := runAuthStatusIdentity(t, fixture, []string{"OPENAI_API_KEY=" + secret}, + "--verify", "--credential-kind=api", "--format=text", "--format-error=text", "--debug") + require.Equal(t, got.code, text.code) + require.Len(t, textRequests, 1) + require.Contains(t, text.stdout, "API identity: unknown ("+tc.reason+")") + assertAuthStatusPrivate(t, text, secret, "synthetic-private-response-body", "synthetic-private-transport-details", "synthetic-private-redirect") + assertAuthStatusIdentityRecovery(t, text, "text") + } }) } } +func assertAuthStatusIdentityRecovery(t *testing.T, got mainDispatchResult, format string) { + t.Helper() + message := strings.TrimSpace(got.stderr) + if format == "json" { + var value map[string]string + require.NoError(t, json.Unmarshal([]byte(got.stderr), &value)) + require.Len(t, value, 1) + message = value["message"] + } + _, recovery, found := strings.Cut(message, "\nOptions and examples: ") + require.True(t, found, "%s", message) + require.Equal(t, "openai auth status --help", recovery) + // Execute the printed arguments through main, with the same synthetic transport. + // The fixed prefix matches this helper's invocation and status route. + args := strings.Fields(recovery) + copied, requests := runAuthStatusIdentity(t, authStatusIdentitySuccessFixture(), + []string{"OPENAI_API_KEY=synthetic-private-recovery-token"}, args[3:]...) + require.Zero(t, copied.code, "%s", copied.stderr) + require.Empty(t, copied.stderr) + require.Empty(t, requests, "help recovery must not verify credentials") + require.Contains(t, copied.stdout, "Shows configured authentication offline by default.") + require.Contains(t, copied.stdout, "openai auth status") + assertAuthStatusPrivate(t, copied, "synthetic-private-recovery-token") +} + func TestMainAuthStatusIdentityNullableFieldsAndSupportedEndpoints(t *testing.T) { for _, endpoint := range []string{"https://api.openai.com/v1", "https://api.openai.com/v1/", "https://api.openai.com:443/v1", "https://api.openai.com:443/v1/"} { t.Run(endpoint, func(t *testing.T) { @@ -868,6 +904,7 @@ func TestMainAuthStatusIdentityDeadline(t *testing.T) { require.Equal(t, "timed_out", status.APIIdentity.Reason) decodeMainStructuredError(t, "json", got.stderr) assertAuthStatusPrivate(t, got, "synthetic-private-api-token") + assertAuthStatusIdentityRecovery(t, got, "json") } func TestMainAuthStatusIdentitySignals(t *testing.T) { diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go index f6962b37..50926c19 100644 --- a/cmd/openai/profile_commands_test.go +++ b/cmd/openai/profile_commands_test.go @@ -534,6 +534,68 @@ func TestMainProfileMissingSelectionPreservesCommittedFolderLink(t *testing.T) { require.Zero(t, requests.Load(), "folder links and profile selection must remain offline") } +func TestMainProfileReadFailureRecovery(t *testing.T) { + for _, failure := range []string{"malformed", "unreadable directory"} { + t.Run(failure, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + registry := profileRegistryPath(home) + require.NoError(t, os.MkdirAll(filepath.Dir(registry), 0700)) + saved := registry + if failure == "unreadable directory" { + require.NoError(t, os.Mkdir(registry, 0700)) + saved = filepath.Join(registry, "synthetic-private-preserved") + } + before := []byte("{synthetic-private-invalid-profile") + require.NoError(t, os.WriteFile(saved, before, 0600)) + info, err := os.Lstat(registry) + require.NoError(t, err) + for _, command := range [][]string{{"auth", "status"}, {"models", "list"}, {"admin", "organization", "projects", "list"}} { + for _, format := range []string{"text", "json"} { + args := append([]string{"--profile=work", "--format=" + format, "--format-error=" + format}, command...) + // The existing capture transport prevents any external request. + got := runProfileURLMain(t, home, directory, []string{"OPENAI_ADMIN_KEY=synthetic-admin-key"}, args) + require.Equal(t, 1, got.code, "%q: %s", args, got.stderr) + require.Empty(t, got.stdout) + require.NotContains(t, got.stderr, "captured-url=", "the transport must not execute") + message := strings.TrimSpace(got.stderr) + if format == "json" { + var value map[string]string + require.NoError(t, json.Unmarshal([]byte(got.stderr), &value)) + require.Len(t, value, 1) + message = value["message"] + } + require.Contains(t, message, "Could not read saved profiles. Existing profiles were kept.") + require.Contains(t, message, "Check the saved profile file in your user configuration directory.") + _, guidance, found := strings.Cut(message, "Use ") + require.True(t, found, "%s", message) + recovery, found := strings.CutSuffix(guidance, " for saved-profile options.") + require.True(t, found, "%s", guidance) + require.Equal(t, "openai profile --help", recovery) + copied := runProfileURLMain(t, home, directory, nil, strings.Fields(recovery)[1:]) + require.Zero(t, copied.code, "%s", copied.stderr) + require.Empty(t, copied.stderr, "help recovery must not execute the transport") + require.Contains(t, copied.stdout, "Profiles store nonsecret settings only.") + assertAuthStatusPrivate(t, got, home, "synthetic-private-invalid-profile", "synthetic-private-preserved", "synthetic-url-key", "synthetic-admin-key") + assertAuthStatusPrivate(t, copied, home, "synthetic-private-invalid-profile", "synthetic-private-preserved", "synthetic-url-key", "synthetic-admin-key") + } + } + after, err := os.ReadFile(saved) + require.NoError(t, err) + require.Equal(t, before, after) + current, err := os.Lstat(registry) + require.NoError(t, err) + require.True(t, os.SameFile(info, current), "recovery must preserve the stored object") + require.Equal(t, info.Mode(), current.Mode()) + if info.IsDir() { + entries, err := os.ReadDir(registry) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, filepath.Base(saved), entries[0].Name()) + } + }) + } +} + func TestMainProfileEndpointProjectRemainsBodyField(t *testing.T) { home, directory := t.TempDir(), t.TempDir() createProfileFixture(t, home, directory, "work", "--project=proj_profile") diff --git a/pkg/custom/auth_status.go b/pkg/custom/auth_status.go index 49b3a704..46485be0 100644 --- a/pkg/custom/auth_status.go +++ b/pkg/custom/auth_status.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "syscall" "time" @@ -127,17 +128,18 @@ func handleAuthStatus(ctx context.Context, command *cli.Command) error { var lookupErr error if verify { var stop func() - ctx, stop = adminSetupSignalContext(ctx) + ctx, stop = clioutput.DownloadSignalContext(ctx) defer stop() report.Offline = false identity, failure := authStatusLookup(ctx, root, snapshot, kind) lookupErr = failure if ctx.Err() != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return &localcommands.LocalError{Message: "Identity lookup timed out. Identity remains unknown.", Cause: context.DeadlineExceeded} + return &localcommands.LocalError{Message: authStatusIdentityGuidance(root, "Identity lookup timed out. Identity remains unknown."), Cause: context.DeadlineExceeded} } code := 130 - if errors.Is(context.Cause(ctx), errAdminSetupTerminated) { + var signalErr *clioutput.DownloadSignalError + if errors.As(context.Cause(ctx), &signalErr) && signalErr != nil && signalErr.SignalExitCode() == 143 { code = 143 } return &authStatusLookupError{ @@ -322,10 +324,17 @@ func (e *authStatusLookupError) Unwrap() error { return e.localCancellationGuida func (e *authStatusLookupError) ExitCode() int { return e.code } +func authStatusIdentityGuidance(root *cli.Command, message string) string { + return message + "\nOptions and examples: " + clihelp.DisplayInvocation(root) + " auth status --help" +} + // The provider returns an authenticated principal, not a key ID or membership assertion. // Keep its request independent of SDK API/Admin fallback and API-body error rendering. func authStatusLookup(ctx context.Context, root *cli.Command, snapshot authStatusSnapshot, kind string) (authStatusIdentity, error) { failure := func(reason, message string) (authStatusIdentity, error) { + if reason != "canceled" { + message = authStatusIdentityGuidance(root, message) + } return authStatusIdentity{Status: "unknown", Reason: reason}, &localcommands.LocalError{Message: message} } endpoint, err := url.Parse(snapshot.baseURL) @@ -577,7 +586,7 @@ func skipContextFirstRunSetup(root *cli.Command, args []string) bool { return false } lineage := []*cli.Command{root} - flags, help := audioSetupParsesFlags(root), false + flags, help := contextSetupParsesFlags(root), false for index := 1; index < len(args); index++ { arg := args[index] if flags && strings.TrimSpace(arg) == "--" { @@ -587,7 +596,7 @@ func skipContextFirstRunSetup(root *cli.Command, args []string) bool { if flags && strings.HasPrefix(strings.TrimSpace(arg), "-") { token := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(arg), "-"), "-") name, _, assigned := strings.Cut(token, "=") - shape, ok := audioSetupFlag(lineage, name).(interface{ TakesValue() bool }) + shape, ok := contextSetupFlag(lineage, name).(interface{ TakesValue() bool }) if !ok { return false } @@ -600,7 +609,7 @@ func skipContextFirstRunSetup(root *cli.Command, args []string) bool { help = true if declared := root.Command("help"); declared != nil { lineage = append(lineage, declared) - flags = audioSetupParsesFlags(declared) + flags = contextSetupParsesFlags(declared) } continue } @@ -609,3 +618,61 @@ func skipContextFirstRunSetup(root *cli.Command, args []string) bool { } return false } + +// Read declarations before CLI startup without applying flags or changing command state. +func contextSetupParsesFlags(command *cli.Command) bool { + return !command.SkipFlagParsing && (command.StopOnNthArg == nil || *command.StopOnNthArg != 0) +} + +func contextSetupDeclaredFlags(command *cli.Command) []cli.Flag { + flags := append([]cli.Flag(nil), command.Flags...) + for _, group := range command.MutuallyExclusiveFlags { + for _, alternatives := range group.Flags { + flags = append(flags, alternatives...) + } + } + // The CLI adds its declared help flag during startup. Read its metadata + // without running setupCommandGraph or mutating these command declarations. + if !command.HideHelp && cli.HelpFlag != nil { + flags = append(flags, cli.HelpFlag) + } + return flags +} + +func contextSetupFlag(lineage []*cli.Command, name string) cli.Flag { + local := contextSetupDeclaredFlags(lineage[len(lineage)-1]) + for _, flag := range local { + if flag != nil && slices.Contains(flag.Names(), name) { + return flag + } + } + for index := len(lineage) - 2; index >= 0; index-- { + for _, flag := range contextSetupDeclaredFlags(lineage[index]) { + persistent, ok := flag.(cli.LocalFlag) + if !ok || persistent.IsLocal() { + continue + } + names := flag.Names() + if !slices.Contains(names, name) { + continue + } + // The parser suppresses an ancestor flag as a whole when any of its + // aliases belongs to the current command, not only the queried alias. + shadowed := false + for _, current := range local { + if current == nil { + continue + } + for _, alias := range current.Names() { + if slices.Contains(names, alias) { + shadowed = true + } + } + } + if !shadowed { + return flag + } + } + } + return nil +} diff --git a/pkg/custom/auth_status_test.go b/pkg/custom/auth_status_test.go index 89a96c00..913c1fa1 100644 --- a/pkg/custom/auth_status_test.go +++ b/pkg/custom/auth_status_test.go @@ -452,6 +452,7 @@ func TestAuthStatusIdentityRejectsInvalidOrReflectedResponse(t *testing.T) { }) report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") require.Error(t, err) + require.Contains(t, err.Error(), "Options and examples: openai auth status --help") require.Equal(t, 1, calls) require.True(t, responseBody.closed) authStatusUnknownIdentity(t, report, "api", "invalid_response") @@ -519,6 +520,7 @@ func TestAuthStatusIdentityStatusErrorsAndRedirects(t *testing.T) { }) report, output, err := authStatusRunJSON(t, t.Context(), "--verify", "--credential-kind=api") require.Error(t, err) + require.Contains(t, err.Error(), "Options and examples: openai auth status --help") require.Equal(t, 1, calls, "identity lookup must not redirect or retry") require.True(t, body.closed) authStatusUnknownIdentity(t, report, "api", "") @@ -561,20 +563,39 @@ func TestAuthStatusIdentityTransportFailureAndCancellation(t *testing.T) { require.NotContains(t, output+err.Error(), "synthetic-api") }) t.Run("in flight cancellation", func(t *testing.T) { - authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - calls := 0 - authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { - calls++ - cancel() - <-request.Context().Done() - return nil, request.Context().Err() - }) - _, output, err := authStatusRunJSON(t, ctx, "--verify", "--credential-kind=api") - require.ErrorIs(t, err, context.Canceled) - require.Equal(t, 1, calls) - require.NotContains(t, output+err.Error(), "synthetic-api") + for _, tc := range []struct { + name string + cause error + }{ + {"private cause", errors.New("synthetic-private-parent-cause")}, + {"zero-value signal cause", &clioutput.DownloadSignalError{}}, + {"typed-nil signal cause", (*clioutput.DownloadSignalError)(nil)}, + } { + t.Run(tc.name, func(t *testing.T) { + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "synthetic-api"}) + ctx, cancel := context.WithCancelCause(t.Context()) + defer cancel(nil) + calls := 0 + authStatusTestTransport(t, func(request *http.Request) (*http.Response, error) { + calls++ + cancel(tc.cause) + <-request.Context().Done() + return nil, request.Context().Err() + }) + _, output, err := authStatusRunJSON(t, ctx, "--verify", "--credential-kind=api") + require.ErrorIs(t, err, context.Canceled) + require.NotErrorIs(t, err, tc.cause) + var exit cli.ExitCoder + require.ErrorAs(t, err, &exit) + require.Equal(t, 130, exit.ExitCode()) + var signalCause *clioutput.DownloadSignalError + require.False(t, errors.As(err, &signalCause), "the safe error must not retain the parent cause") + require.Equal(t, 1, calls) + require.NotContains(t, output+err.Error(), "synthetic-private-parent-cause") + require.NotContains(t, output+err.Error(), "download canceled by a process signal") + require.NotContains(t, output+err.Error(), "synthetic-api") + }) + } }) } @@ -663,13 +684,24 @@ func TestAuthStatusIdentityParentDeadline(t *testing.T) { body = &authStatusDeadlineBody{ctx: request.Context()} return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: body}, nil }) - _, output, err := authStatusRunJSON(t, ctx, "--verify", "--credential-kind=api") + var output bytes.Buffer + root := authStatusTestRoot(&output) + if root.Metadata == nil { + root.Metadata = map[string]any{} + } + root.Metadata["help-invocation"] = "'/synthetic/CLI with spaces/openai'" + err := root.Run(ctx, []string{"openai", "auth", "status", "--format=json", "--verify", "--credential-kind=api"}) + if output.Len() > 0 { + var report map[string]any + require.NoError(t, json.Unmarshal(output.Bytes(), &report), "status output must remain valid JSON") + } require.ErrorIs(t, err, context.DeadlineExceeded) + require.Contains(t, err.Error(), "Options and examples: '/synthetic/CLI with spaces/openai' auth status --help") require.NotErrorIs(t, err, context.Canceled) require.Equal(t, 1, calls) require.True(t, body.closed) - require.NotContains(t, output+err.Error(), "synthetic-api") - require.NotContains(t, output, `"status":"resolved"`) + require.NotContains(t, output.String()+err.Error(), "synthetic-api") + require.NotContains(t, output.String(), `"status":"resolved"`) } func TestAuthStatusIdentityOutputFailurePreservesLookupFailure(t *testing.T) { @@ -977,3 +1009,68 @@ func TestAuthStatusIdentityVerifiedTLSAndMTLS(t *testing.T) { }) } } + +func TestAuthStatusFirstRunDeclaredFlags(t *testing.T) { + t.Setenv("OPENAI_CONTEXT_SCAN_TEST", "synthetic-environment-value") + for _, tc := range []struct { + name string + args []string + change func(*cli.Command) + want bool + }{ + {name: "hidden alias", args: []string{"-s", "value", "auth"}, want: true}, + {name: "hidden operand", args: []string{"-s", "auth", "models"}}, + {name: "grouped alias", args: []string{"-g", "value", "auth"}, want: true}, + {name: "grouped operand", args: []string{"-g", "profile", "models"}}, + {name: "inherited hidden", args: []string{"help", "-s", "value", "auth"}, want: true}, + {name: "inherited grouped", args: []string{"help", "-g", "value", "profile"}, want: true}, + {name: "root local", args: []string{"--local", "value", "auth"}, want: true}, + {name: "ancestor local", args: []string{"help", "--local", "value", "auth"}}, + {name: "shadowed ancestor name", args: []string{"help", "--ancestor", "value", "auth"}}, + {name: "shadowed ancestor alias", args: []string{"help", "-a", "value", "auth"}}, + {name: "local alias", args: []string{"help", "--shadow", "auth"}, want: true}, + {name: "command alias", args: []string{"identity", "status"}, want: true}, + {name: "help flag", args: []string{"-h", "auth"}, want: true}, + {name: "hidden help", args: []string{"-h", "auth"}, change: func(root *cli.Command) { root.HideHelp = true }}, + {name: "skip flag parsing", args: []string{"--hidden", "value", "auth"}, change: func(root *cli.Command) { root.SkipFlagParsing = true }}, + {name: "zero flag positions", args: []string{"--hidden", "value", "auth"}, change: func(root *cli.Command) { zero := 0; root.StopOnNthArg = &zero }}, + {name: "help skips flags", args: []string{"help", "--hidden", "value", "auth"}, change: func(root *cli.Command) { root.Command("help").SkipFlagParsing = true }}, + } { + t.Run(tc.name, func(t *testing.T) { + destination := "synthetic-original-destination" + hidden := &cli.StringFlag{Name: "hidden", Aliases: []string{"s"}, Hidden: true, Value: "declared", Destination: &destination, Sources: cli.EnvVars("OPENAI_CONTEXT_SCAN_TEST")} + grouped := &cli.StringFlag{Name: "grouped", Aliases: []string{"g"}, Hidden: true} + help := &cli.Command{Name: "help", Flags: []cli.Flag{&cli.BoolFlag{Name: "child", Aliases: []string{"shadow"}}}} + root := &cli.Command{Name: "openai", Flags: []cli.Flag{ + hidden, + &cli.StringFlag{Name: "ancestor", Aliases: []string{"a", "shadow"}}, + &cli.StringFlag{Name: "local", Local: true}, + }, Commands: []*cli.Command{ + {Name: "auth", Aliases: []string{"identity"}, Hidden: true}, + {Name: "profile"}, {Name: "models"}, help, + }, MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{{Category: "grouped declarations", Flags: [][]cli.Flag{{grouped}}}}} + if tc.change != nil { + tc.change(root) + } + hiddenBefore, groupedBefore := *hidden, *grouped + flagsBefore := append([]cli.Flag(nil), root.Flags...) + helpFlagsBefore := append([]cli.Flag(nil), help.Flags...) + commandsBefore := append([]*cli.Command(nil), root.Commands...) + args := append([]string{"openai"}, tc.args...) + argsBefore := append([]string(nil), args...) + for attempt := 0; attempt < 2; attempt++ { + require.Equal(t, tc.want, skipContextFirstRunSetup(root, args)) + require.Equal(t, argsBefore, args) + require.Equal(t, flagsBefore, root.Flags) + require.Equal(t, helpFlagsBefore, help.Flags) + require.Equal(t, commandsBefore, root.Commands) + require.Equal(t, hiddenBefore, *hidden) + require.Equal(t, groupedBefore, *grouped) + require.Equal(t, "synthetic-original-destination", destination) + require.Same(t, help, help.Root(), "the scan must not initialize command parents") + require.Nil(t, root.Metadata) + require.Nil(t, help.Metadata) + } + }) + } +} diff --git a/pkg/custom/profile_context.go b/pkg/custom/profile_context.go index dbf04e8f..d2c06d4d 100644 --- a/pkg/custom/profile_context.go +++ b/pkg/custom/profile_context.go @@ -100,7 +100,7 @@ func resolveSelectedProfile(ctx context.Context, root *cli.Command, lookup func( } registry, err := loadProfiles(ctx, path) if err != nil { - return "", profileValues{}, &localcommands.LocalError{Message: "Could not read saved profiles. Existing profiles were kept.", Cause: err} + return "", profileValues{}, &localcommands.LocalError{Message: "Could not read saved profiles. Existing profiles were kept. Check the saved profile file in your user configuration directory. Use " + clihelp.DisplayInvocation(root) + " profile --help for saved-profile options.", Cause: err} } name, err := selectedProfileName(root, lookup, registry) return name, registry.Profiles[name], err From ae9db24513cfa4df8ac3ae8bbf7415b68a1c923b Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Mon, 19 Oct 2026 18:44:26 -0700 Subject: [PATCH 35/36] Correct profile validation inventory counts --- .github/workflows/help-compatibility.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/help-compatibility.yml b/.github/workflows/help-compatibility.yml index b5eb357b..8b91bf45 100644 --- a/.github/workflows/help-compatibility.yml +++ b/.github/workflows/help-compatibility.yml @@ -1220,7 +1220,7 @@ jobs: required_cases(root, children) for root, children in auth_profile_cases.items() )) strict_packages["auth-profile-tests.jsonl"] = "github.com/openai/openai-cli/cmd/openai" - if len(auth_profile_cases) != 31 or len(suites["auth-profile-tests.jsonl"]) != 246: + if len(auth_profile_cases) != 32 or len(suites["auth-profile-tests.jsonl"]) != 249: raise SystemExit("auth-profile-tests.jsonl: unexpected Admin integration inventory") if os.environ["RUNNER_OS"] != "Windows": suites["auth-identity-signal-tests.jsonl"] = required_cases( From 93b11c2bb0dd03bc670f22194d26c2d716a5052d Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Mon, 19 Oct 2026 21:56:28 -0700 Subject: [PATCH 36/36] Keep link setup tests with application admission --- pkg/custom/project_link_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/custom/project_link_test.go b/pkg/custom/project_link_test.go index 43e1fbaa..f9caba25 100644 --- a/pkg/custom/project_link_test.go +++ b/pkg/custom/project_link_test.go @@ -76,7 +76,7 @@ func TestProjectLinkSkipsFirstRunShellSetup(t *testing.T) { {"openai", "--project=proj_work", "link"}, {"openai", "unlink"}, {"openai", "help", "link"}, {"openai", "unlink", "--help"}, } { - require.False(t, imagePickerFirstRunEligible(args, func(string) string { return "" }, true, true, true, "bash")) + require.True(t, skipContextFirstRunSetup(nil, args)) } }