diff --git a/.github/workflows/help-compatibility.yml b/.github/workflows/help-compatibility.yml index a6890f97..2612d7ac 100644 --- a/.github/workflows/help-compatibility.yml +++ b/.github/workflows/help-compatibility.yml @@ -231,6 +231,281 @@ 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' && matrix.shard != 'dispatch' + 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|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' + 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|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 + if: runner.os == 'Windows' && matrix.shard != 'dispatch' + shell: pwsh + timeout-minutes: 4 + env: + GOMAXPROCS: '2' + run: | + $ErrorActionPreference = 'Stop' + Get-Command pwsh -CommandType Application -ErrorAction Stop | Out-Null + $temporary = [IO.Path]::GetFullPath((Join-Path $env:RUNNER_TEMP 'powershell-reference-tmp')) + New-Item -ItemType Directory -Path $temporary -Force | Out-Null + $env:GOTMPDIR = $temporary + $log = Join-Path $env:RUNNER_TEMP 'powershell-reference-tests.jsonl' + $receipt = Join-Path $env:RUNNER_TEMP 'powershell-reference-receipt.json' + go test -p 1 ./internal/autocomplete -parallel=1 -count=1 -timeout=3m -v -json -run '^TestPowerShellReferenceCompletionUsesLiteralPaths$' | + Tee-Object -FilePath $log -ErrorAction Stop + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + @' + import hashlib, json, os, re, sys + from collections import Counter + from pathlib import Path + + log, temporary, receipt = map(Path, sys.argv[1:]) + root = "TestPowerShellReferenceCompletionUsesLiteralPaths" + package = "github.com/openai/openai-cli/internal/autocomplete" + fixed = ["'@safe", "@file://safe", "@data://safe", "text@safe", "--format=@safe", "@nested/safe", "'@nested/safe", "\"@nested/safe", "'@bracket[dir]/safe", "'@tick`dir/safe", "'@apostrophe''s/safe", "'@missing[dir]/safe"] + if os.name != "nt" or not temporary.is_absolute(): + raise SystemExit("PowerShell reference evidence requires a native Windows absolute temporary directory") + if any(ord(char) < 32 or ord(char) > 126 for char in str(temporary)): + raise SystemExit("PowerShell reference temporary directory must have a printable ASCII name") + # Go 1.26 TempDir uses the test name, a uint32 decimal suffix, and numbered children. + # t.Run replaces spaces with underscores in its reported name. + temporary_name = str(temporary).replace(" ", "_") + dynamic_pattern = re.compile( + re.escape(root + "/'@file://" + temporary_name + "\\" + root) + + r"[0-9]{1,10}\\001\\nested\\safe" + ) + counts, names = Counter(), set() + for line in log.read_text(encoding="utf-8-sig").splitlines(): + if not line.strip(): + continue + event = json.loads(line) + action, name = event.get("Action"), event.get("Test", "") + if action in {"fail", "skip", "build-fail"}: + raise SystemExit("PowerShell reference evidence contains a failure or skip") + if action == "build-output": + continue + if event.get("Package") != package: + raise SystemExit("PowerShell reference evidence contains another package") + if action not in {"start", "run", "pass", "output"}: + raise SystemExit("PowerShell reference evidence contains an unexpected event") + if name: + names.add(name) + if action in {"start", "run", "pass"}: + counts[action, name] += 1 + required = {root} | {root + "/" + child for child in fixed} + dynamic = {name for name in names if dynamic_pattern.fullmatch(name)} + if len(dynamic) != 1: + raise SystemExit("PowerShell reference evidence requires one exact temporary-path case") + required |= dynamic + expected = Counter({("start", ""): 1, ("pass", ""): 1}) + expected.update({(action, name): 1 for action in ("run", "pass") for name in required}) + if len(required) != 14 or names != required or counts != expected: + raise SystemExit("PowerShell reference evidence has missing, duplicate, or unexpected names") + record = { + "state": "EXACT_NATIVE_INVENTORY_PASSED", + "package": package, "root": root, "fixed_subcases": fixed, + "dynamic_absolute_path_cases": 1, "named_results": 14, + "skips": 0, "failures": 0, + "runner_os": os.environ.get("RUNNER_OS"), + "runner_arch": os.environ.get("RUNNER_ARCH"), + "raw_log_sha256": hashlib.sha256(log.read_bytes()).hexdigest(), + "test_source_sha256": hashlib.sha256(Path("internal/autocomplete/powershell_reference_test.go").read_bytes()).hexdigest(), + "script_source_sha256": hashlib.sha256(Path("internal/autocomplete/shellscripts/pwsh_autocomplete.ps1").read_bytes()).hexdigest(), + } + receipt.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8") + print("PowerShell reference: 14 exact named results passed; no skips") + '@ | python - $log $temporary $receipt + exit $LASTEXITCODE + - name: Retain native PowerShell reference receipt + if: always() && runner.os == 'Windows' && matrix.shard != 'dispatch' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: powershell-reference-${{ matrix.goarch }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/powershell-reference-receipt.json + ${{ runner.temp }}/powershell-reference-tests.jsonl + if-no-files-found: warn + retention-days: 7 + - name: Test identity transport and local command error contracts + if: runner.os != 'Windows' && matrix.shard != 'dispatch' + shell: bash + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./internal/app -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAuthStatusIdentityMTLSReaderRegularAndCancellation|TestAuthStatusIdentityVerifiedTLSAndMTLS|TestRootRequestFlagPreParseResetsInvocationState|TestRootRequestFlagsResetProvenanceAcrossRuns|TestRootRequestFlagPreParsePreservesCurrentDefinition|TestRootRequestFlagsReuseMatchesStatusAndSDKRequests|TestAuthStatusIdentityCommandReuseClearsVerification|TestAuthStatusIdentityRejectsReflectedSpacedBearer|TestMTLSClientFlagPreParsePreservesCurrentDefinition|TestMTLSClientFlagsReuseEnvironmentForTLS|TestProjectLinkCancellationGuidanceRetainsErrorBoundary)$' \ + | tee "$RUNNER_TEMP/auth-identity-tls-tests.jsonl" + - name: Test Windows identity transport and local command error contracts + if: runner.os == 'Windows' && matrix.shard != 'dispatch' + shell: pwsh + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./internal/app -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAuthStatusIdentityMTLSReaderRegularAndCancellation|TestAuthStatusIdentityVerifiedTLSAndMTLS|TestRootRequestFlagPreParseResetsInvocationState|TestRootRequestFlagsResetProvenanceAcrossRuns|TestRootRequestFlagPreParsePreservesCurrentDefinition|TestRootRequestFlagsReuseMatchesStatusAndSDKRequests|TestAuthStatusIdentityCommandReuseClearsVerification|TestAuthStatusIdentityRejectsReflectedSpacedBearer|TestMTLSClientFlagPreParsePreservesCurrentDefinition|TestMTLSClientFlagsReuseEnvironmentForTLS|TestProjectLinkCancellationGuidanceRetainsErrorBoundary)$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'auth-identity-tls-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + exit $LASTEXITCODE + - name: Test Unix Audio profile preview contracts + if: runner.os != 'Windows' && matrix.shard != 'dispatch' + shell: bash + timeout-minutes: 3 + env: + GOMAXPROCS: '2' + OPENAI_CLI_REQUIRE_NATIVE_SHELLS: bash,zsh + run: | + set -euo pipefail + go test -p 1 ./internal/commands/audio -parallel=1 -count=1 -timeout=2m -v -json -run '^(TestAudioProfileRequestContext|TestAudioCopiedCommandsPreserveProfileSelector)$' \ + | tee "$RUNNER_TEMP/audio-profile-tests.jsonl" + - name: Test Windows Audio profile preview privacy + if: runner.os == 'Windows' && matrix.shard != 'dispatch' + shell: pwsh + timeout-minutes: 3 + env: + GOMAXPROCS: '2' + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./internal/commands/audio -parallel=1 -count=1 -timeout=2m -v -json -run '^TestAudioProfileRequestContext$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'audio-profile-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + exit $LASTEXITCODE + - name: Test Unix Admin profile endpoint contracts + if: runner.os != 'Windows' && matrix.shard != 'dispatch' + shell: bash + timeout-minutes: 3 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./internal/app -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAdminCredentialsProfileEndpointsMatchRequests|TestAdminCredentialsProfileResolutionFailures|TestAdminCredentialsProfileEndpointSnapshot|TestAdminProfileEndpointDoesNotAuthorizeMTLS)$' \ + | tee "$RUNNER_TEMP/admin-profile-credentials-tests.jsonl" + go test -p 1 ./internal/commands/admin -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAdminSetupProfileEndpointOptions|TestAdminSetupProfileEndpointRejectsUnsafeOverrides|TestAdminSetupProfileResolutionFailures|TestAdminSetupProfileEndpointLoopback|TestAdminSetupRegisteredEndpointPolicy)$' \ + | tee "$RUNNER_TEMP/admin-profile-setup-tests.jsonl" + - name: Test Windows Admin profile endpoint contracts + if: runner.os == 'Windows' && matrix.shard != 'dispatch' + shell: pwsh + timeout-minutes: 3 + env: + GOMAXPROCS: '2' + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./internal/app -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAdminCredentialsProfileEndpointsMatchRequests|TestAdminCredentialsProfileResolutionFailures|TestAdminCredentialsProfileEndpointSnapshot|TestAdminProfileEndpointDoesNotAuthorizeMTLS)$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'admin-profile-credentials-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test -p 1 ./internal/commands/admin -parallel=1 -count=1 -timeout=1m -v -json -run '^(TestAdminSetupProfileEndpointOptions|TestAdminSetupProfileEndpointRejectsUnsafeOverrides|TestAdminSetupProfileResolutionFailures|TestAdminSetupProfileEndpointLoopback|TestAdminSetupRegisteredEndpointPolicy)$' | + Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'admin-profile-setup-tests.jsonl') -Encoding utf8NoBOM -ErrorAction Stop + exit $LASTEXITCODE + - name: Test Unix identity credential symlinks and special files + if: runner.os != 'Windows' && matrix.shard != 'dispatch' + shell: bash + timeout-minutes: 3 + env: + GOMAXPROCS: '2' + run: | + set -euo pipefail + go test -p 1 ./internal/app -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' && matrix.shard != 'dispatch' + 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' && matrix.shard != 'dispatch' + 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' && matrix.shard != 'dispatch' + 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' && matrix.shard != 'dispatch' + 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 ./internal/commands/images -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' && matrix.shard != 'dispatch' + shell: pwsh + timeout-minutes: 2 + env: + GOMAXPROCS: '2' + OPENAI_CLI_REQUIRE_NATIVE_SHELLS: pwsh + run: | + $ErrorActionPreference = 'Stop' + go test -p 1 ./internal/commands/images -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 Unix copied profile recovery in native shells + if: runner.os != 'Windows' && matrix.shard != 'dispatch' + shell: bash + timeout-minutes: 3 + env: + GOMAXPROCS: '2' + OPENAI_CLI_REQUIRE_NATIVE_SHELLS: bash,zsh + run: | + set -euo pipefail + 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 @@ -678,6 +953,319 @@ jobs: return {root} | {f"{root}/{child}" for child in children} strict_packages = {} + audio_profile_cases = { + "TestAudioProfileRequestContext": ["environment_credentials", "explicit_profile", "explicit_empty_profile", "profile_and_request_selection", "request_selection", "explicit_empty_project", "same_inherited_key", "different_key", "empty_key_override", "admin_key_override", "webhook_secret_override", "header_override", "certificate_override", "private_key_override", "URL_user_info", "URL_query", "URL_fragment", "URL_empty_query", "invalid_project", "invalid_profile_NUL", "invalid_profile_UTF-8", "profile_with_secret_override", "profile_with_private_URL"], + } + if os.environ["RUNNER_OS"] != "Windows": + audio_profile_cases["TestAudioCopiedCommandsPreserveProfileSelector"] = ["bash", "bash/explicit", "bash/explicit_empty", "bash/inherited", "bash/inherited_empty", "bash/child_ownership", "bash/root_and_child_ownership", "zsh", "zsh/explicit", "zsh/explicit_empty", "zsh/inherited", "zsh/inherited_empty", "zsh/child_ownership", "zsh/root_and_child_ownership"] + suites["audio-profile-tests.jsonl"] = set().union(*( + required_cases(root, children) for root, children in audio_profile_cases.items() + )) + expected_audio_profile_names = 24 if os.environ["RUNNER_OS"] == "Windows" else 39 + if len(suites["audio-profile-tests.jsonl"]) != expected_audio_profile_names: + raise SystemExit("audio-profile-tests.jsonl: unexpected migration inventory") + strict_packages["audio-profile-tests.jsonl"] = "github.com/openai/openai-cli/internal/commands/audio" + admin_profile_credentials_cases = { + "TestAdminCredentialsProfileEndpointsMatchRequests": ["absent_profile_endpoint", "disabled_environment_selector", "disabled_profile", "empty_environment_retains_credential_guard", "empty_flag_retains_profile", "empty_literal_header_overrides_admin_key", "empty_profile_endpoint", "environment_gateway", "environment_replaces_profile", "explicit_selector", "flag_gateway", "flag_replaces_environment_and_profile", "saved_gateway", "standard_mtls_profile", "standard_profile", "standard_profile_admin_key", "standard_profile_literal_header"], + "TestAdminCredentialsProfileResolutionFailures": ["malformed=false", "malformed=true"], + "TestAdminCredentialsProfileEndpointSnapshot": [], + "TestAdminProfileEndpointDoesNotAuthorizeMTLS": ["admin", "setup"], + } + suites["admin-profile-credentials-tests.jsonl"] = set().union(*( + required_cases(root, children) for root, children in admin_profile_credentials_cases.items() + )) + if len(suites["admin-profile-credentials-tests.jsonl"]) != 25: + raise SystemExit("admin-profile-credentials-tests.jsonl: unexpected inventory size") + strict_packages["admin-profile-credentials-tests.jsonl"] = "github.com/openai/openai-cli/internal/app" + admin_profile_setup_cases = { + "TestAdminSetupProfileEndpointOptions": ["absent_saved_endpoint", "disabled_default", "empty_flag_retains_profile", "empty_saved_endpoint", "environment_overrides_profile", "environment_selector", "escaped_saved_origin", "flag_overrides_environment", "saved_destination"], + "TestAdminSetupProfileEndpointRejectsUnsafeOverrides": ["embedded_credential", "empty_environment", "environment_HTTP", "fragment", "profile_HTTP", "query"], + "TestAdminSetupProfileResolutionFailures": ["malformed", "missing"], + "TestAdminSetupProfileEndpointLoopback": ["Forbidden", "Found", "OK"], + "TestAdminSetupRegisteredEndpointPolicy": ["empty_endpoint", "callback_error", "nil_callback", "unrelated_metadata"], + } + suites["admin-profile-setup-tests.jsonl"] = set().union(*( + required_cases(root, children) for root, children in admin_profile_setup_cases.items() + )) + if len(suites["admin-profile-setup-tests.jsonl"]) != 29: + raise SystemExit("admin-profile-setup-tests.jsonl: unexpected inventory size") + strict_packages["admin-profile-setup-tests.jsonl"] = "github.com/openai/openai-cli/internal/commands/admin" + 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"], + "TestProjectLinkCancellationGuidanceRetainsErrorBoundary": ["direct", "wrapped", "plain_outer", "plain_first_join", "guidance_first_join"], + } + 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/internal/app" + 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/internal/app" + 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" + profile_request_commands = ["models/list", "admin/organization/projects/list"] + profile_request_policy_endpoints = [ + "http://private-profile.invalid/private-path", + "http://192.0.2.1/private-path", + "http://[2001:db8::1]/private-path", + ] + 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": [], + "TestMainProfileShellSuggestionFlagOwnership": ["root_selection", "local_startup_path", "local_empty_startup_path"], + "TestMainProfileLifecycleAndExactSchemas": [ + "json", + "jsonl" + ], + "TestMainProfileEditsNeverSaveAmbientValues": [], + "TestMainProfileBaseURLUsesActualEffectiveEndpoint": [ + "profile", "empty_profile_production_reset", "empty_flag_keeps_profile", + "empty_flag_keeps_production_reset", "environment", "empty_flag_keeps_environment", + "root_overrides_environment", "empty_environment_remains_empty", + ], + "TestMainProfileBaseURLRequestPolicy": profile_request_policy_endpoints + [ + f"{endpoint}/{command}/{selection}/{output_format}" + for endpoint in profile_request_policy_endpoints + for command in profile_request_commands + for selection in ["default", "environment", "flag"] + for output_format in ["text", "json"] + ], + "TestMainProfileBaseURLPolicyPreservesAllowedEndpointsAndOverrides": [ + f"{command}/{source}/{endpoint}" + for command in profile_request_commands + for endpoint in [ + "https://allowed.invalid/v1", "http://localhost:8888/v1", + "http://127.0.0.1:8888/v1", "http://[::1]:8888/v1", + ] + for source in ["profile", "flag", "environment"] + ], + "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" + ], + "TestMainProfileReadFailureRecovery": [ + "malformed", + "unreadable_directory" + ], + "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": [] + } + auth_profile_cases["TestMainProfileAdminEndpointPreflightMatchesSelection"] = ["saved_gateway", "standard_profile", "empty_profile_endpoint", "empty_selector", "environment_selector", "empty_environment_selector", "flag_selector_overrides_environment", "standard_flag_overrides_profile", "standard_environment_overrides_profile", "gateway_flag_overrides_standard_profile", "gateway_environment_overrides_standard_profile", "empty_flag_retains_profile", "empty_environment_retains_credential_guard", "unknown_profile_preserves_selection_error"] + 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 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( + "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/internal/commands/images" + 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", "saved_endpoint_request_policy", + ] + 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 = 190 if os.environ["RUNNER_OS"] == "Windows" else 127 + 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': [ @@ -1646,6 +2234,27 @@ jobs: print(f"{filename}: {len(passed) - children} roots and {children} children passed; no skips") PY + - name: Retain authentication and profile test evidence + if: always() && matrix.shard != 'dispatch' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: auth-profile-${{ matrix.os }}-${{ matrix.shard || 'all' }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/auth-profile-tests.jsonl + ${{ runner.temp }}/admin-profile-credentials-tests.jsonl + ${{ runner.temp }}/admin-profile-setup-tests.jsonl + ${{ runner.temp }}/audio-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 + ${{ runner.temp }}/profile-copied-recovery-tests.jsonl + if-no-files-found: warn + retention-days: 7 + - name: Retain Windows voice validation evidence if: always() && matrix.native-commands && runner.os == 'Windows' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/cmd/openai/auth_status_test.go b/cmd/openai/auth_status_test.go new file mode 100644 index 00000000..7b4ab8c1 --- /dev/null +++ b/cmd/openai/auth_status_test.go @@ -0,0 +1,1249 @@ +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" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "syscall" + "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"` + 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 { + 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) + 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", output) + require.ErrorIs(t, decoder.Decode(new(any)), io.EOF) + var fields map[string]json.RawMessage + 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) + } + 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 +} + +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" { + 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\nforged-row\tcolumn" + 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") + 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) + 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()) +} + +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"` + WaitBody bool `json:"wait_body"` +} + +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) + } + 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) + 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 + 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) + 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(), 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() { + 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, "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 + 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 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/", + "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") + 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) { + 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") + assertAuthStatusIdentityRecovery(t, got, "json") +} + +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 + 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: !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) + 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) + diagnostic := decodeMainStructuredError(t, "json", got.stderr) + require.Equal(t, "Identity lookup canceled.", diagnostic["message"]) + 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/cmd/openai/command_subgroups_test.go b/cmd/openai/command_subgroups_test.go index 50d7c9a0..56edd66e 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 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.", @@ -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}, diff --git a/cmd/openai/profile_commands_test.go b/cmd/openai/profile_commands_test.go new file mode 100644 index 00000000..86bfa87b --- /dev/null +++ b/cmd/openai/profile_commands_test.go @@ -0,0 +1,1202 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "time" + + "github.com/openai/openai-cli/generated/commands" + "github.com/openai/openai-cli/internal/app" + "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 TestMainProfileCopiedRecoveryPreservesInvocation(t *testing.T) { + work, name := buildNativeShellCLI(t) + binary := filepath.Join(work, name) + server, requests := localUtilitiesRequestTrap(t) + 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) { + if shell.executable == "" { + t.Skipf("%s is unavailable", 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 { + mode = "competing executable" + } + t.Run(mode, func(t *testing.T) { + decoy, sentinel := t.TempDir(), filepath.Join(t.TempDir(), "invoked") + if competing { + 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", "") + } + 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", "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"}, + {"saved endpoint request policy", "--profile work --api-key synthetic-profile-policy-key models list", "profile set --help"}, + } { + t.Run(tc.name, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + registry := profileRegistryPath(home) + before := []byte("{\"version\":1,\"default_profile\":null,\"profiles\":{\"work\":{\"project\":\"proj_original\"}}}\n") + prefix := "" + if tc.recovery == "profile set --help" { + before = []byte("{\"version\":1,\"default_profile\":null,\"profiles\":{\"work\":{\"project\":\"proj_original\",\"base_url\":\"http://private-profile.invalid/private-path\"}}}\n") + // Remove the helper's endpoint override. Trap any regression through a local proxy. + switch shell.name { + case "cmd": + prefix = "set \"OPENAI_BASE_URL=\"\r\nset \"HTTP_PROXY=" + server.URL + "\"\r\nset \"NO_PROXY=\"\r\n" + case "powershell", "pwsh": + prefix = "Remove-Item Env:OPENAI_BASE_URL -ErrorAction SilentlyContinue; $env:HTTP_PROXY = '" + server.URL + "'; $env:NO_PROXY = ''; " + default: + prefix = "unset OPENAI_BASE_URL; export HTTP_PROXY='" + server.URL + "'; export NO_PROXY=''; " + } + } + require.NoError(t, os.MkdirAll(filepath.Dir(registry), 0o700)) + require.NoError(t, os.WriteFile(registry, before, 0o600)) + got := run(t, directory, home, + prefix+invocation+" "+tc.args+" --format-error="+format) + require.Equal(t, 1, got.code, "%s", got.stderr) + require.Empty(t, got.stdout) + require.Zero(t, requests.Load(), "failed command reached the API") + 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"] + } + if tc.recovery == "profile set --help" { + require.Contains(t, message, "Saved profile base URL must use HTTPS, except for localhost or a loopback IP address.") + assertAuthStatusPrivate(t, got, "private-profile.invalid", "private-path", "synthetic-profile-policy-key", home) + } + _, guidance, found := strings.Cut(message, "Use ") + require.True(t, found, "%s", message) + printedInvocation, _, found := strings.Cut(guidance, " "+tc.recovery) + require.True(t, found, "%s", guidance) + require.Equal(t, invocation, printedInvocation) + 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" + } + if !strings.HasSuffix(tc.recovery, " --help") { + recovery += " --format=json" + } + copied := run(t, directory, home, prefix+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 if tc.recovery == "profile set --help" { + require.Zero(t, copied.code, "%s", copied.stderr) + require.Empty(t, copied.stderr) + require.Contains(t, copied.stdout, "Edit only explicit saved profile fields") + require.Contains(t, copied.stdout, invocation+" profile set") + } 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) + } + 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") + 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 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 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") + 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 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") + 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 TestMainProfileShellSuggestionFlagOwnership(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "work", "--project=proj_saved") + registryBefore, 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)) + server, requests := localUtilitiesRequestTrap(t) + environment := []string{ + "OPENAI_PROFILE=missing", "OPENAI_BASE_URL=" + server.URL, + "OPENAI_API_KEY=synthetic-suggestion-key", "OPENAI_PICKER_SHELL=off", "ZDOTDIR=" + home, + } + assertUnchanged := func(t *testing.T) { + t.Helper() + registryAfter, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, registryBefore, registryAfter, "suggestions must not change saved profiles") + startupAfter, err := os.ReadFile(startup) + require.NoError(t, err) + require.Equal(t, original, string(startupAfter), "suggestions must not change the startup file") + require.Zero(t, requests.Load(), "suggestions must remain offline") + } + baseline := runProjectLinkMain(t, home, directory, environment, "", "@completion", "zsh", "--suggest-executable") + require.Zero(t, baseline.code, "%s", baseline.stderr) + require.Empty(t, baseline.stderr) + require.Contains(t, baseline.stdout, "openai_suggestions_disable") + assertUnchanged(t) + + t.Run("root_selection", func(t *testing.T) { + got := runProjectLinkMain(t, home, directory, environment, "", + "--profile=work", "@completion", "zsh", "--suggest-executable") + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr) + require.Equal(t, baseline.stdout, got.stdout, "root selection must not change the standalone suggestion script") + assertUnchanged(t) + }) + for _, tc := range []struct { + name string + args []string + }{ + {"local_startup_path", []string{"--profile", startup}}, + {"local_empty_startup_path", []string{"--profile="}}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{"--profile=work", "@completion", "zsh", "--suggest-executable"}, tc.args...) + got := runProjectLinkMain(t, home, directory, environment, "", args...) + require.Equal(t, 2, got.code, "%s", got.stderr) + require.Empty(t, got.stdout) + require.Contains(t, got.stderr, "Print executable suggestions separately from picker setup.") + assertAuthStatusPrivate(t, got, startup, "synthetic-suggestion-key") + assertUnchanged(t) + }) + } +} + +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_TEST_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() + 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=^" + processTest + "$", "--", "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_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 + 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()} +} + +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()) + commands.Command.Writer = profileCancelReceiptWriter{cancel: cancel} + if os.Getenv("OPENAI_CLI_PROFILE_CANCEL_BEFORE") == "1" { + cancel() + } + os.Exit(app.Run(ctx, os.Args[separator+1:], commands.Command)) +} + +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") + createProfileFixture(t, home, directory, "empty", "--base-url=") + for _, tc := range []struct { + name, profile, baseURL, source, requestURL string + redacted bool + env, flags []string + }{ + // 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, 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) + 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": ""})) +} + +func TestMainProfileBaseURLRequestPolicy(t *testing.T) { + for _, endpoint := range []string{ + "http://private-profile.invalid/private-path", "http://192.0.2.1/private-path", "http://[2001:db8::1]/private-path", + } { + t.Run(endpoint, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + createProfileFixture(t, home, directory, "private-profile", "--base-url="+endpoint) + require.Zero(t, profileCommand(t, home, directory, "profile", "activate", "private-profile").code) + before, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + for _, command := range [][]string{{"models", "list"}, {"admin", "organization", "projects", "list"}} { + for _, selection := range []string{"default", "environment", "flag"} { + for _, format := range []string{"text", "json"} { + t.Run(strings.Join(command, "/")+"/"+selection+"/"+format, func(t *testing.T) { + env := []string{"OPENAI_ADMIN_KEY=synthetic-admin-key"} + args := []string{"--format=raw", "--format-error=" + format} + if selection == "environment" { + env = append(env, "OPENAI_PROFILE=private-profile") + } else if selection == "flag" { + args = append(args, "--profile=private-profile") + } + // The subprocess replaces the transport before main. No destination can receive a request. + got := runProfileURLMain(t, home, directory, env, append(args, command...)) + require.Equal(t, 1, got.code) + require.Empty(t, got.stdout) + require.Contains(t, got.stderr, "Saved profile base URL must use HTTPS") + require.Contains(t, got.stderr, "except for localhost or a loopback IP address.") + require.Contains(t, got.stderr, "openai profile set --help") + require.NotContains(t, got.stderr, "captured-url=", "the transport must not execute") + assertAuthStatusPrivate(t, got, endpoint, "private-profile", "private-path", "synthetic-url-key", "synthetic-admin-key", home) + if format == "json" { + require.True(t, json.Valid([]byte(got.stderr)), "%s", got.stderr) + } + }) + } + } + } + for _, command := range [][]string{{"auth", "status"}, {"profile", "get", "private-profile"}, {"profile", "set", "--help"}} { + got := runProfileURLMain(t, home, directory, nil, command) + require.Zero(t, got.code, "%s", got.stderr) + require.Empty(t, got.stderr, "offline inspection and the printed recovery command must work") + } + after, err := os.ReadFile(profileRegistryPath(home)) + require.NoError(t, err) + require.Equal(t, before, after) + got := profileCommand(t, home, directory, "profile", "set", "private-profile", "--base-url=https://repaired.invalid/v1") + require.Zero(t, got.code, "%s", got.stderr) + got = runProfileURLMain(t, home, directory, nil, []string{"models", "list", "--format=raw"}) + require.Zero(t, got.code, "%s", got.stderr) + require.Equal(t, "captured-url=https://repaired.invalid/v1/models\n", got.stderr) + }) + } +} + +func TestMainProfileBaseURLPolicyPreservesAllowedEndpointsAndOverrides(t *testing.T) { + for _, command := range [][]string{{"models", "list"}, {"admin", "organization", "projects", "list"}} { + for _, endpoint := range []string{"https://allowed.invalid/v1", "http://localhost:8888/v1", "http://127.0.0.1:8888/v1", "http://[::1]:8888/v1"} { + for _, source := range []string{"profile", "flag", "environment"} { + t.Run(strings.Join(command, "/")+"/"+source+"/"+endpoint, func(t *testing.T) { + home, directory := t.TempDir(), t.TempDir() + saved := "http://unused-profile.invalid/v1" + if source == "profile" { + saved = endpoint + } + createProfileFixture(t, home, directory, "work", "--base-url="+saved) + env := []string{"OPENAI_ADMIN_KEY=synthetic-admin-key"} + args := []string{"--profile=work", "--format=raw"} + if source == "flag" { + args = append(args, "--base-url="+endpoint) + } else if source == "environment" { + env = append(env, "OPENAI_BASE_URL="+endpoint) + } + got := runProfileURLMain(t, home, directory, env, append(args, command...)) + require.Zero(t, got.code, "%s", got.stderr) + suffix := "/models" + if command[0] == "admin" { + suffix = "/organization/projects" + } + require.Equal(t, "captured-url="+endpoint+suffix+"\n", got.stderr) + require.Equal(t, globalFlagsPage+"\n", got.stdout) + }) + } + } + } +} + +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/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/cmd/openai/project_link_inspection_recovery_test.go b/cmd/openai/project_link_inspection_recovery_test.go new file mode 100644 index 00000000..453a955f --- /dev/null +++ b/cmd/openai/project_link_inspection_recovery_test.go @@ -0,0 +1,261 @@ +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"}} + // 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 --format-error=text") + 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/cmd/openai/project_link_test.go b/cmd/openai/project_link_test.go new file mode 100644 index 00000000..18dd8f9e --- /dev/null +++ b/cmd/openai/project_link_test.go @@ -0,0 +1,518 @@ +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 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 { + 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 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") + 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{""}}, + {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{""}}, + } { + 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/auth-status.md b/docs/auth-status.md new file mode 100644 index 00000000..c115ec01 --- /dev/null +++ b/docs/auth-status.md @@ -0,0 +1,107 @@ +# Authentication status + +Run `openai auth status` to inspect configuration without contacting OpenAI. +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 +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. +`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. +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. +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 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. +Each lookup uses the same configuration snapshot that supplies the 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. +- 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 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`, `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. + +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 offline report does not test network connectivity or account access. diff --git a/docs/profiles.md b/docs/profiles.md new file mode 100644 index 00000000..224d4e04 --- /dev/null +++ b/docs/profiles.md @@ -0,0 +1,188 @@ +# 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. +Requests require HTTPS, except for HTTP endpoints on localhost or a loopback IP address. +Offline inspection and editing remain available for older saved endpoints. +An explicit flag or environment override takes precedence over the saved endpoint. + +## 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 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. +Validation 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 new file mode 100644 index 00000000..ae2f155f --- /dev/null +++ b/docs/project-linking.md @@ -0,0 +1,158 @@ +# 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. +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: + +```sh +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 +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. 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, 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, 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` 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. + +## 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. +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. +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. +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. + +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 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. + +## 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: `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`. +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. + +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 7276ac5e..779f4a0a 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/urfave/cli/v3 v3.14.0 golang.org/x/image v0.47.0 golang.org/x/sys v0.49.0 + golang.org/x/text v0.43.0 ) require ( @@ -46,5 +47,4 @@ require ( github.com/xo/terminfo v1.0.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/sync v0.24.0 // indirect - golang.org/x/text v0.43.0 // indirect ) diff --git a/internal/app/admin_credentials_test.go b/internal/app/admin_credentials_test.go index 6988da04..62b2e8d3 100644 --- a/internal/app/admin_credentials_test.go +++ b/internal/app/admin_credentials_test.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "testing" "github.com/openai/openai-cli/internal/apiquery" @@ -19,12 +20,14 @@ import ( requestconfig "github.com/openai/openai-cli/internal/request" 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" "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", requestconfig.MTLSClientCertFileEnv, requestconfig.MTLSClientKeyFileEnv, @@ -221,6 +224,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 *admin.CredentialsError + 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 *localcommands.LocalError + require.ErrorAs(t, err, &local) + require.NotContains(t, err.Error(), "synthetic-sensitive") + var missing *admin.CredentialsError + 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 *admin.CredentialsError + 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/internal/app/auth_status.go b/internal/app/auth_status.go new file mode 100644 index 00000000..00937eb4 --- /dev/null +++ b/internal/app/auth_status.go @@ -0,0 +1,688 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "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" +) + +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 offline by default. +Use --verify --credential-kind api or admin to look up one authenticated principal. + +` + cli.CommandHelpTemplate + localcommands.LocalUtilityGlobalHelp + root.Commands = append(root.Commands, &cli.Command{ + Name: "auth", Usage: "Inspect authentication configuration and identity", HideHelpCommand: true, + Metadata: map[string]any{localcommands.LocalUtilityMetadata: true, "help-command-section": "Local tools"}, + Commands: []*cli.Command{{ + 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. " + + "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"` + Source string `json:"source"` +} + +type authStatusValue struct { + Value string `json:"value"` + Source string `json:"source"` +} + +type authStatusEndpoint struct { + authStatusValue + 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"` + 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 { + if err := ctx.Err(); err != nil { + return err + } + root := command.Root() + if command.Args().Present() { + return &localcommands.LocalError{Message: "Auth status takes no positional arguments. Use " + clihelp.DisplayInvocation(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."} + } + format := strings.ToLower(root.String("format")) + if format != "auto" && format != "text" && format != "json" { + return &localcommands.LocalError{Message: "Auth status supports --format auto, text, or json."} + } + verify, kind := command.Bool("verify"), command.String("credential-kind") + if verify && kind != "api" && kind != "admin" || !verify && command.IsSet("credential-kind") { + return &localcommands.LocalError{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 = 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: authStatusIdentityGuidance(root, "Identity lookup timed out. Identity remains unknown."), Cause: context.DeadlineExceeded} + } + code := 130 + var signalErr *clioutput.DownloadSignalError + if errors.As(context.Cause(ctx), &signalErr) && signalErr != nil && signalErr.SignalExitCode() == 143 { + code = 143 + } + return &authStatusLookupError{ + localCancellationGuidanceError: &localCancellationGuidanceError{ + LocalError: &localcommands.LocalError{Message: "Identity lookup canceled.", Cause: context.Canceled}, + }, + code: code, + } + } + if kind == "api" { + report.APIIdentity = identity + } else { + report.AdminIdentity = identity + } + if failure == nil { + report.Verified, report.VerifiedCredentialKind = true, kind + } + } + out := clioutput.Writer{Context: ctx, Sink: 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)" + } + 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 &localcommands.LocalError{Message: "Could not write auth status. Check the output file or pipe before rerunning.", Cause: errors.Join(err, lookupErr)} + } + 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 { + if value.Source == "unset" { + return "(unset)" + } + if value.Value == "" { + return "(empty)" + } + // 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. +// 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 := requestconfig.Headers(root) + if err != nil { + return authStatusSnapshot{}, &localcommands.LocalError{Message: "Invalid --header. Use 'Name: Value' without control characters."} + } + inherited := inheritedContextHeaders(environment) + setting := func(flag, variable string) authStatusValue { + return requestContextSetting(root, environment, flag, variable) + } + resolved, err := resolveRequestContext(requestResolutionContext(root), root, environment, literal) + if err != nil { + return authStatusSnapshot{}, err + } + base := resolved.BaseURL + endpoint, basic, err := authStatusBaseURL(base) + if err != nil { + return authStatusSnapshot{}, 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 + } + 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.apiAuthorization, snapshot.adminAuthorization} { + snapshot.secrets = append(snapshot.secrets, header) + if _, token, ok := strings.Cut(header, " "); ok { + // 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{ + 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, + } + 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 { + *localCancellationGuidanceError + code int +} + +// Keep the guidance wrapper in the error chain. +func (e *authStatusLookupError) Unwrap() error { return e.localCancellationGuidanceError } + +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) + 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.") + } + 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(requestconfig.MTLSClientCertFileFlag).(string) + key, _ := root.Value(requestconfig.MTLSClientKeyFileFlag).(string) + client, err := requestconfig.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 { + client = &http.Client{Transport: http.DefaultTransport} + if transport, ok := http.DefaultTransport.(*http.Transport); ok { + 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 } + 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) + + 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 +} + +// 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") + 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) { + 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, &localcommands.LocalError{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 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 skipContextFirstRunSetup(root *cli.Command, args []string) bool { + if len(args) < 2 { + return false + } + // Preserve the conservative token exclusion before optional setup starts. + // This also applies without a root and never parses or rewrites arguments. + for _, argument := range args[1:] { + if argument == "link" || argument == "unlink" { + return true + } + } + if root == nil || root.Command("auth") == nil && root.Command("profile") == nil { + return false + } + lineage := []*cli.Command{root} + flags, help := contextSetupParsesFlags(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 := contextSetupFlag(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 = contextSetupParsesFlags(declared) + } + continue + } + selected := root.Command(arg) + return selected != nil && (selected == root.Command("auth") || selected == root.Command("profile")) + } + 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/internal/app/auth_status_test.go b/internal/app/auth_status_test.go new file mode 100644 index 00000000..5472dbfa --- /dev/null +++ b/internal/app/auth_status_test.go @@ -0,0 +1,1076 @@ +package app + +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" + + 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" + "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])}) + } + requestconfig.ConfigureRootFlags(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\tcolumn"}) + 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\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"}} { + 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 := requestconfig.Headers(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")) + } + }) + } +} + +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.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") + 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 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) { + 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.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", "") + 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) { + 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") + }) + } + }) +} + +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 + }) + 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.String()+err.Error(), "synthetic-api") + require.NotContains(t, output.String(), `"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, clioutput.IsBrokenPipe(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) +} + +func TestAuthStatusIdentityMTLSReaderRegularAndCancellation(t *testing.T) { + pki := mtlstest.NewPKI(t) + certFile, keyFile := mtlstest.WriteClientFiles(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 := requestconfig.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 := mtlstest.NewPKI(t) + certFile, keyFile := mtlstest.WriteClientFiles(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 := mtlstest.NewPKI(t) + certFile, keyFile := mtlstest.WriteClientFiles(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 := mtlstest.CreateCertificate(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, + NextProtos: []string{"h2", "http/1.1"}} + 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(requestconfig.MTLSClientCertFileEnv, "") + t.Setenv(requestconfig.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, requestconfig.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"]) + }) + } +} + +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/internal/app/cmdutil.go b/internal/app/cmdutil.go index 9e7d68e5..fb919485 100644 --- a/internal/app/cmdutil.go +++ b/internal/app/cmdutil.go @@ -41,7 +41,8 @@ func ValidateBaseURL(value, source string) error { // GetDefaultRequestOptions keeps feature middleware composition with its current owner. func GetDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { - return requestconfig.DefaultOptions(cmd, Version, audio.CaptureText, captureSaveReceiptPolicy(cmd)) + opts := requestconfig.DefaultOptions(cmd, Version, audio.CaptureText, captureSaveReceiptPolicy(cmd)) + return append(opts, resolvedRequestOptions(cmd)...) } func isTerminal(w io.Writer) bool { return terminal.IsWriterTerminal(w) } diff --git a/internal/app/command.go b/internal/app/command.go index 7daa34b8..79cd3b5a 100644 --- a/internal/app/command.go +++ b/internal/app/command.go @@ -45,13 +45,20 @@ func ConfigureCommand(root *cli.Command) { audio.ConfigureText(root) configureReadableSpeech(root) registerAdminSetup(root) - admin.ConfigureCredentials(root) + admin.ConfigureCredentials(root, func(command *cli.Command) (string, error) { + resolved, err := resolvedCommandRequestContext(command) + return resolved.BaseURL.Value, err + }) localcommands.ConfigureSubgroups(root) localcommands.ConfigureTasks(root) registerAudioCommands(root) filecommands.Configure(root) tokenize.Register(root) codex.Register(root) + registerProjectLinkCommands(root) + registerAuthCommands(root) + configureProfileContext(root) + registerProfileCommands(root) localcommands.ConfigurePresentation(root) manpages.Configure(root, func(command *cli.Command, message string) error { return ReportSaveReceipt(command, os.Stderr, message, nil) diff --git a/internal/app/image_picker_first_run.go b/internal/app/image_picker_first_run.go index ffe28028..8e1a0528 100644 --- a/internal/app/image_picker_first_run.go +++ b/internal/app/image_picker_first_run.go @@ -27,7 +27,11 @@ 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 && audiocommands.SkipFirstRunSetup(roots[0], args) { + var root *cli.Command + if len(roots) > 0 { + root = roots[0] + } + if audiocommands.SkipFirstRunSetup(root, args) || skipContextFirstRunSetup(root, args) { return } args = append([]string(nil), args...) diff --git a/internal/app/local_errors.go b/internal/app/local_errors.go index f9709340..00e26cc3 100644 --- a/internal/app/local_errors.go +++ b/internal/app/local_errors.go @@ -72,6 +72,11 @@ func localErrorMessage(root *cli.Command, failure error) string { if errors.As(failure, &imageFailure) && imageFailure.PreserveOnCancel() { return imageFailure.Error() } + var guidance *localCancellationGuidanceError + // An outer local failure owns its cancellation guidance. + if errors.As(failure, &utilityFailure) && errors.As(failure, &guidance) && guidance.LocalError == utilityFailure { + return utilityFailure.Message + } return "Request canceled." case errors.As(failure, &utilityFailure): return utilityFailure.Error() diff --git a/internal/app/local_utilities.go b/internal/app/local_utilities.go index b450dd98..18b69127 100644 --- a/internal/app/local_utilities.go +++ b/internal/app/local_utilities.go @@ -6,6 +6,13 @@ import ( "github.com/urfave/cli/v3" ) +// Preserve authored cancellation guidance without changing the shared error contract. +type localCancellationGuidanceError struct { + *localcommands.LocalError +} + +func (e *localCancellationGuidanceError) Unwrap() error { return e.LocalError } + // IsLocalUtilityCommand identifies a registered utility after root flag parsing. // Request values and unregistered command names never select the local path. func IsLocalUtilityCommand(command *cli.Command) bool { diff --git a/internal/app/mtls_client_flag_test.go b/internal/app/mtls_client_flag_test.go index 8fb5dbae..8d75d7b3 100644 --- a/internal/app/mtls_client_flag_test.go +++ b/internal/app/mtls_client_flag_test.go @@ -1,7 +1,26 @@ package app 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" + + mtlstest "github.com/openai/openai-cli/internal/testutil/mtls" + "github.com/openai/openai-go/v3" requestconfig "github.com/openai/openai-cli/internal/request" "github.com/stretchr/testify/require" @@ -30,3 +49,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 := &requestconfig.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.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) + 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", + }) + first, second := mtlstest.NewPKI(t), mtlstest.NewPKI(t) + firstCert, firstKey := mtlstest.WriteClientFiles(t, first.ClientChainPEM, first.ClientKeyPEM) + secondCert, secondKey := mtlstest.WriteClientFiles(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 := mtlstest.CreateCertificate(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[requestconfig.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(requestconfig.MTLSClientCertFileEnv, tc.cert) + t.Setenv(requestconfig.MTLSClientKeyFileEnv, tc.key) + if tc.removed { + require.NoError(t, os.Unsetenv(requestconfig.MTLSClientCertFileEnv)) + require.NoError(t, os.Unsetenv(requestconfig.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/internal/app/profile_commands.go b/internal/app/profile_commands.go new file mode 100644 index 00000000..b6c1a205 --- /dev/null +++ b/internal/app/profile_commands.go @@ -0,0 +1,401 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "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" + "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 + } + 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{ + &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{ + &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 { + 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{ + &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) + } +} + +func profileCommand(name, usage, example string) *cli.Command { + help := `{{$bin := or (index .Root.Metadata "help-invocation") "openai"}}EXAMPLE + {{$bin}} ` + example + "\n\n" + cli.CommandHelpTemplate + localcommands.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{ + localcommands.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 "+clihelp.DisplayInvocation(root)+" 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 "+clihelp.DisplayInvocation(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 "+clihelp.DisplayInvocation(root)+" 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." + } + failure := &localcommands.LocalError{ + Message: message + " Inspect with " + clihelp.DisplayInvocation(root) + " profile list.", + Cause: err, + } + if committed { + return &localCancellationGuidanceError{LocalError: failure} + } + return failure + } + 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.(*profileStringFlag) + 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 := clioutput.Writer{Context: ctx, Sink: 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 *localcommands.LocalError + 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 &localcommands.LocalError{Message: message, Cause: cause} +} diff --git a/internal/app/profile_commands_test.go b/internal/app/profile_commands_test.go new file mode 100644 index 00000000..8cf16913 --- /dev/null +++ b/internal/app/profile_commands_test.go @@ -0,0 +1,437 @@ +package app + +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 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{ + {"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) +} + +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)) + 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/internal/app/profile_context.go b/internal/app/profile_context.go new file mode 100644 index 00000000..87095cf9 --- /dev/null +++ b/internal/app/profile_context.go @@ -0,0 +1,277 @@ +package app + +import ( + "context" + "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" +) + +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 + // 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() +} + +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 "", &localcommands.LocalError{Message: "Selected profile does not exist. Use " + clihelp.DisplayInvocation(root) + " 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{}, &localcommands.LocalError{Message: "Could not locate saved profiles. Check your user configuration directory.", Cause: err} + } + registry, err := loadProfiles(ctx, path) + if err != nil { + 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 +} + +// 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 resolvedCommandRequestContext(command *cli.Command) (resolvedRequestContext, error) { + 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 = requestconfig.Headers(root) + if err == nil { + result, err = resolveRequestContext(requestResolutionContext(root), root, snapshotContextEnvironment(root, os.LookupEnv), literal) + } + if selected != nil { + selected.resolved, selected.err = &result, err + } + } + return result, err +} + +func resolvedRequestOptions(command *cli.Command) []option.RequestOption { + result, err := resolvedCommandRequestContext(command) + // Validate only the endpoint used for a request. Offline inspection and + // repair must remain available, including when another source overrides it. + if err == nil && strings.HasPrefix(result.BaseURL.Source, "profile:") { + if invalid := requestconfig.ValidateBaseURL(result.BaseURL.Value, "Saved profile base URL"); invalid != nil { + err = &localcommands.LocalError{ + Message: "Saved profile base URL must use HTTPS, except for localhost or a loopback IP address. Use " + + clihelp.DisplayInvocation(command.Root()) + " profile set --help for update options.", + Cause: invalid, + } + } + } + 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/internal/app/profile_context_test.go b/internal/app/profile_context_test.go new file mode 100644 index 00000000..5b75d296 --- /dev/null +++ b/internal/app/profile_context_test.go @@ -0,0 +1,752 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + "unicode" + + localcommands "github.com/openai/openai-cli/internal/commands" + audiocommands "github.com/openai/openai-cli/internal/commands/audio" + batchcommands "github.com/openai/openai-cli/internal/commands/batches" + filecommands "github.com/openai/openai-cli/internal/commands/files" + clioutput "github.com/openai/openai-cli/internal/output" + transformers "github.com/openai/openai-cli/internal/output/transform" + requestconfig "github.com/openai/openai-cli/internal/request" + "github.com/openai/openai-cli/internal/requestflag" + cliterminal "github.com/openai/openai-cli/internal/terminal" + "github.com/tidwall/gjson" + + "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) + } +} + +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 + var wantExplicit bool + var wantSelection string + root.Command("auth").Command("status").Action = func(_ context.Context, _ *cli.Command) error { + require.Equal(t, wantExplicit, root.IsSet("profile")) + require.Equal(t, wantSelection, root.String("profile")) + report, err := resolveAuthStatus(root, os.LookupEnv) + got = append(got, report.Project.Value) + return err + } + for _, invocation := range []struct { + args []string + explicit bool + selected string + }{ + {[]string{"openai", "--profile=work", "auth", "status"}, true, "work"}, + {[]string{"openai", "auth", "status"}, false, ""}, + {[]string{"openai", "--profile=", "auth", "status"}, true, ""}, + {[]string{"openai", "auth", "status"}, false, ""}, + } { + wantExplicit, wantSelection = invocation.explicit, invocation.selected + require.NoError(t, root.Run(t.Context(), invocation.args)) + } + require.Equal(t, []string{work, fallback, "", fallback}, got) + }) + } +} + +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) + } + } + } +} + +func TestProfileContextBatchHint(t *testing.T) { + for _, tc := range []struct { + name, environment string + args []string + needsOptions bool + }{ + {"environment profile", "profile_environment", nil, false}, + {"empty environment profile", "", nil, false}, + {"explicit profile", "profile_environment", []string{"--profile", "profile_override"}, true}, + {"matching profile", "profile_environment", []string{"--profile", "profile_environment"}, true}, + {"empty profile override", "profile_environment", []string{"--profile="}, true}, + {"empty profile without environment value", "", []string{"--profile="}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("OPENAI_PROFILE", tc.environment) + var output bytes.Buffer + root := &cli.Command{Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Commands: []*cli.Command{{Name: "batches", Commands: []*cli.Command{{ + Name: "create", Action: func(ctx context.Context, command *cli.Command) error { + return batchcommands.ShowNextCommand(gjson.Parse(`{"id":"batch-synthetic"}`), batchcommands.HintOptions{ + Context: ctx, Route: "(resource) batches > (method) create", Response: true, + Terminal: true, Format: "text", Stderr: &output, + HumanTerminal: func(context.Context, *cli.Command) bool { return true }, + }) + }, + }}}}, + } + configureProfileContext(root) + batchcommands.Configure(root, batchcommands.Options{}) + args := append([]string{"openai"}, tc.args...) + require.NoError(t, root.Run(t.Context(), append(args, "batches", "create"))) + if tc.needsOptions { + require.Equal(t, "To wait for this batch, reuse the same connection and authentication options with batches retrieve --wait.\n", output.String()) + } else { + require.Equal(t, "Follow this batch:\n openai batches retrieve --batch-id=batch-synthetic --wait\n", output.String()) + } + require.NotContains(t, output.String(), "profile_environment") + require.NotContains(t, output.String(), "profile_override") + }) + } +} + +// Byte slices preserve malformed UTF-8 and NUL fixtures across the subprocess boundary. +type profileReceiptFixture struct { + Arguments [][]byte + Environment string + Executable string + ChildProfile bool + BlockForCancellation bool +} + +func profileReceiptArguments(args ...string) [][]byte { + result := make([][]byte, len(args)) + for i, arg := range args { + result[i] = []byte(arg) + } + return result +} + +// The action replaces the API response, while public configuration captures the invocation. +func profileContextFileReceipt(t *testing.T, fixture profileReceiptFixture) ([]string, bool, string) { + t.Helper() + authStatusTestEnvironment(t, map[string]string{"OPENAI_API_KEY": "sk-fake-inherited"}) + t.Setenv("OPENAI_PROFILE", fixture.Environment) + for _, name := range []string{"OPENAI_WEBHOOK_SECRET", requestconfig.MTLSClientCertFileEnv, requestconfig.MTLSClientKeyFileEnv} { + t.Setenv(name, "") + require.NoError(t, os.Unsetenv(name)) + } + actionRan := false + var requestArgs []string + var omitHint bool + var receipt bytes.Buffer + create := &cli.Command{Name: "create", Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "file", Required: true, BodyPath: "file", FileInput: true}, + &requestflag.Flag[string]{Name: "purpose", Required: true, BodyPath: "purpose"}, + }, Action: func(ctx context.Context, command *cli.Command) error { + requestArgs, omitHint = requestconfig.NextCommandOptions(command.Root()) + actionRan = true + handled, err := filecommands.ShowResult(gjson.Parse(`{"object":"file","id":"file-example","filename":"upload space.txt","purpose":"user_data","bytes":13}`), filecommands.ResultOptions{ + ReadableOptions: clioutput.ReadableOptions{ + Context: ctx, Route: transformers.Route{Operation: "(resource) files > (method) create", OutputKind: transformers.OutputResponse}, + FormatOptions: clioutput.FormatOptions{Format: "text"}, Terminal: true, Stdout: &receipt, Stderr: io.Discard, + }, + }) + require.True(t, handled, "the public Files presenter must handle the upload") + return err + }} + if fixture.ChildProfile { + create.Flags = append(create.Flags, &cli.StringFlag{Name: "profile"}) + } + root := &cli.Command{Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Metadata: map[string]any{"help-invocation": cliterminal.ShellQuote(fixture.Executable)}, + Flags: []cli.Flag{&cli.StringFlag{Name: "base-url"}, requestconfig.NewRequestHeaderFlag()}, + Commands: []*cli.Command{{Name: "files", Commands: []*cli.Command{create}}}, + } + for _, pair := range [][2]string{{"api-key", "OPENAI_API_KEY"}, {"admin-api-key", "OPENAI_ADMIN_KEY"}, {"webhook-secret", "OPENAI_WEBHOOK_SECRET"}, {"organization", "OPENAI_ORG_ID"}, {"project", "OPENAI_PROJECT_ID"}} { + root.Flags = append(root.Flags, &requestflag.Flag[string]{Name: pair[0], Sources: cli.EnvVars(pair[1])}) + } + root.Flags = append(root.Flags, requestconfig.MTLSClientFlags()...) + configureProfileContext(root) + requestconfig.ConfigureRootFlags(root) + localcommands.ConfigureTasks(root) + filecommands.Configure(root) + args := []string{fixture.Executable} + for _, arg := range fixture.Arguments { + args = append(args, string(arg)) + } + require.NoError(t, root.Run(t.Context(), args)) + require.True(t, actionRan, "upload action must run") + return requestArgs, omitHint, receipt.String() +} + +// The shell owns the helper PID and records its wait before the command returns. +func profileContextFileReceiptCommand(t *testing.T, ctx context.Context, shell string, fixture profileReceiptFixture) (*exec.Cmd, string, string) { + t.Helper() + shellPath := "/bin/" + shell + if _, err := os.Stat(shellPath); err != nil { + if strings.Contains(","+os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS")+",", ","+shell+",") { + t.Fatalf("required native shell %s is unavailable", shell) + } + t.Skipf("native %s is unavailable", shell) + } + testBinary, err := os.Executable() + require.NoError(t, err) + directory := t.TempDir() + fixturePath, receiptPath := filepath.Join(directory, "fixture.json"), filepath.Join(directory, "receipt.txt") + encoded, err := json.Marshal(fixture) + require.NoError(t, err) + require.NoError(t, os.WriteFile(fixturePath, encoded, 0600)) + reapedPath, childPath, cancelPath := filepath.Join(directory, "reaped"), filepath.Join(directory, "child"), filepath.Join(directory, "cancel") + const script = `helper= +cleanup() { + trap '' TERM INT + : > "$4" + helper=${helper:-$!} + if [ -n "$helper" ]; then + printf '%s\n' "$helper" > "$3" + wait "$helper" 2>/dev/null + [ "$?" -ne 127 ] || exit 131 + printf '%s\n' "$helper" > "$2" + fi + exit 130 +} +trap 'cleanup "$@"' TERM INT +"$1" -test.run='^TestFileCopiedCommandsPreserveProfileSelector$' -test.count=1 -test.timeout=5s & +helper=$! +printf '%s\n' "$helper" > "$3" +wait "$helper" +helper_status=$? +[ "$helper_status" -ne 127 ] || exit 131 +printf '%s\n' "$helper" > "$2" +trap - TERM INT +exit "$helper_status"` + args := []string{"--noprofile", "--norc", "-c", script, "profile-receipt-helper", testBinary, reapedPath, childPath, cancelPath} + if shell == "zsh" { + args = []string{"-f", "-c", script, "profile-receipt-helper", testBinary, reapedPath, childPath, cancelPath} + } + process := exec.CommandContext(ctx, shellPath, args...) + process.WaitDelay = time.Second + process.Cancel = func() error { + // The fallback runs inside the owned helper. Never signal a stored helper PID. + markerErr := os.WriteFile(cancelPath, nil, 0600) + signalErr := process.Process.Signal(syscall.SIGTERM) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + child, childErr := os.ReadFile(childPath) + reaped, reapErr := os.ReadFile(reapedPath) + pid, pidErr := strconv.Atoi(strings.TrimSpace(string(child))) + if childErr == nil && reapErr == nil && pidErr == nil && pid > 0 && bytes.Equal(child, reaped) { + return nil + } + time.Sleep(10 * time.Millisecond) + } + return fmt.Errorf("receipt shell did not confirm helper reaping: marker=%v signal=%v", markerErr, signalErr) + } + process.Dir = directory + process.Env = []string{"PATH=/usr/bin:/bin", "LANG=en_US.UTF-8", "HOME=" + directory, + "XDG_CONFIG_HOME=" + directory, "TMPDIR=" + directory, "GOMAXPROCS=2", + "OPENAI_CLI_PROFILE_RECEIPT_FIXTURE=" + fixturePath, "OPENAI_CLI_PROFILE_RECEIPT_OUTPUT=" + receiptPath, + "OPENAI_CLI_PROFILE_RECEIPT_CANCEL=" + cancelPath} + return process, receiptPath, reapedPath +} + +// A real parent shell selects Files' receipt quoting. Keep it alive until the helper exits. +func profileContextNativeFileReceipt(t *testing.T, shell string, fixture profileReceiptFixture) string { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + process, receiptPath, reapedPath := profileContextFileReceiptCommand(t, ctx, shell, fixture) + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + require.NoError(t, process.Run(), "shell=%s stdout=%q stderr=%q", shell, stdout.String(), stderr.String()) + require.NoError(t, ctx.Err()) + require.Empty(t, stderr.String()) + reaped, err := os.ReadFile(reapedPath) + require.NoError(t, err) + require.NotEmpty(t, bytes.TrimSpace(reaped), "the shell must reap its helper") + child, err := os.ReadFile(filepath.Join(filepath.Dir(reapedPath), "child")) + require.NoError(t, err) + require.Equal(t, string(child), string(reaped), "the shell must wait for its own helper") + result, err := os.ReadFile(receiptPath) + require.NoError(t, err) + return string(result) +} + +func TestProfileContextReceiptCancellation(t *testing.T) { + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + process, readyPath, reapedPath := profileContextFileReceiptCommand(t, ctx, shell, profileReceiptFixture{BlockForCancellation: true}) + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + require.NoError(t, process.Start()) + done := make(chan struct{}) + var waitErr error + go func() { waitErr = process.Wait(); close(done) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(4 * time.Second): + t.Error("receipt shell did not stop after cancellation") + } + }() + var ready []byte + require.Eventually(t, func() bool { + ready, _ = os.ReadFile(readyPath) + return len(bytes.TrimSpace(ready)) != 0 + }, 2*time.Second, 10*time.Millisecond, "helper did not report readiness") + cancel() + select { + case <-done: + case <-time.After(4 * time.Second): + t.Fatal("receipt shell did not finish bounded cleanup") + } + require.Error(t, waitErr) + require.ErrorIs(t, ctx.Err(), context.Canceled) + reaped, err := os.ReadFile(reapedPath) + require.NoError(t, err) + require.Equal(t, string(ready), string(reaped), "the shell must wait for the ready helper") + pid, err := strconv.Atoi(strings.TrimSpace(string(ready))) + require.NoError(t, err) + helper, err := os.FindProcess(pid) + require.NoError(t, err) + err = helper.Signal(syscall.Signal(0)) + require.True(t, errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH), "helper remains active or unreaped: %v", err) + }) + } +} + +func TestFileReceiptRequestContext(t *testing.T) { + for _, tc := range []struct { + name string + flags []string + want []string + 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}, + {"different key", []string{"--api-key=sk-fake-override"}, nil, true}, + {"empty key override", []string{"--api-key="}, nil, true}, + {"admin key override", []string{"--admin-api-key=sk-fake-admin"}, nil, true}, + {"webhook secret override", []string{"--webhook-secret=fake-secret"}, nil, true}, + {"header override", []string{"-H", "Authorization: Bearer fake-secret"}, nil, true}, + {"certificate override", []string{"--mtls-client-cert-file=/synthetic/private/cert.pem"}, nil, true}, + {"private key override", []string{"--mtls-client-key-file=/synthetic/private/key.pem"}, nil, true}, + {"URL user info", []string{"--base-url=https://fake:secret@example.invalid"}, nil, true}, + {"URL query", []string{"--base-url=https://example.invalid/v1?key=fake-secret"}, nil, true}, + {"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) { + args := append([]string{"files", "upload", "source.txt", "--purpose=user_data"}, tc.flags...) + fixture := profileReceiptFixture{Arguments: profileReceiptArguments(args...), Environment: "inherited-profile", Executable: "openai"} + requestArgs, omitHint, receipt := profileContextFileReceipt(t, fixture) + require.Equal(t, tc.want, requestArgs) + require.Equal(t, tc.omit, omitHint) + require.Contains(t, receipt, "Uploaded upload space.txt (13 B)") + if tc.omit { + require.NotContains(t, receipt, "Inspect it:") + } + for _, secret := range []string{"sk-fake", "fake-secret", "/synthetic/private/"} { + require.NotContains(t, receipt, secret) + } + t.Run("native receipt", func(t *testing.T) { + receipt := profileContextNativeFileReceipt(t, "bash", fixture) + require.Equal(t, !tc.omit, strings.Contains(receipt, "Inspect it:")) + for _, secret := range []string{"sk-fake", "fake-secret", "/synthetic/private/", "inherited-profile"} { + require.NotContains(t, receipt, secret) + } + }) + }) + } +} + +func TestFileCopiedCommandsPreserveProfileSelector(t *testing.T) { + if path := os.Getenv("OPENAI_CLI_PROFILE_RECEIPT_FIXTURE"); path != "" { + // Cooperative fallback also stops a blocked helper if its parent trap is delayed. + done := make(chan struct{}) + defer close(done) + go func() { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(os.Getenv("OPENAI_CLI_PROFILE_RECEIPT_CANCEL")); err == nil { + os.Exit(130) + } + select { + case <-done: + return + case <-ticker.C: + } + } + }() + data, err := os.ReadFile(path) + require.NoError(t, err) + var fixture profileReceiptFixture + require.NoError(t, json.Unmarshal(data, &fixture)) + if fixture.BlockForCancellation { + ready := strconv.Itoa(os.Getpid()) + "\n" + require.NoError(t, os.WriteFile(os.Getenv("OPENAI_CLI_PROFILE_RECEIPT_OUTPUT"), []byte(ready), 0600)) + time.Sleep(time.Minute) + t.Fatal("blocked receipt helper survived cancellation") + } + // Configure captures os.Args[0], as it does for the actual CLI process. + previous := os.Args + os.Args = []string{fixture.Executable} + defer func() { os.Args = previous }() + _, _, receipt := profileContextFileReceipt(t, fixture) + require.NoError(t, os.WriteFile(os.Getenv("OPENAI_CLI_PROFILE_RECEIPT_OUTPUT"), []byte(receipt), 0600)) + return + } + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + binary, directory := profilePreviewExecutable(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) { + var args []string + 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) + } + fixture := profileReceiptFixture{Arguments: profileReceiptArguments(args...), Environment: tc.environment, + Executable: binary, ChildProfile: tc.childFlag != ""} + requestArgs, omitHint, _ := profileContextFileReceipt(t, fixture) + require.Equal(t, tc.want, requestArgs) + require.False(t, omitHint) + receipt := profileContextNativeFileReceipt(t, shell, fixture) + _, copiedFile, found := strings.Cut(receipt, "\nInspect it: ") + require.True(t, found, receipt) + fileCapture := executeProfilePreview(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) + + require.NotContains(t, copiedFile, "/synthetic/private/") + require.NotContains(t, copiedFile, "inherited-profile") + for _, character := range strings.TrimSpace(copiedFile) { + require.False(t, unicode.IsControl(character) || unicode.Is(unicode.Cf, character), "literal control in copied command") + } + }) + } + }) + } +} + +func profilePreviewExecutable(t *testing.T, name string) (string, string) { + t.Helper() + directory := filepath.Join(t.TempDir(), "CLI preview's directory") + require.NoError(t, os.Mkdir(directory, 0700)) + path := filepath.Join(directory, name) + // This executable records shell arguments and stdin. It cannot call the API. + const script = `#!/bin/sh +set -eu +printf '%s\000' "$@" > "$AUDIO_PREVIEW_ARGS" +printf '%s' "${OPENAI_UNTRUSTED_STDIN-unset}" > "$AUDIO_PREVIEW_PROTECTED" +/bin/cat > "$AUDIO_PREVIEW_STDIN" +` + require.NoError(t, os.WriteFile(path, []byte(script), 0700)) + return path, directory +} + +type profilePreviewCapture struct { + args []string + stdin []byte + protected string +} + +func executeProfilePreview(t *testing.T, shell, command, pathDirectory string) profilePreviewCapture { + t.Helper() + path := "/bin/" + shell + if _, err := os.Stat(path); err != nil { + if strings.Contains(","+os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS")+",", ","+shell+",") { + t.Fatalf("required native shell %s is unavailable", shell) + } + t.Skipf("native %s is unavailable", shell) + } + directory := t.TempDir() + argsPath, stdinPath, protectedPath := filepath.Join(directory, "argv"), filepath.Join(directory, "stdin"), filepath.Join(directory, "protected") + args := []string{"--noprofile", "--norc", "-c", command} + if shell == "zsh" { + args = []string{"-f", "-c", command} + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + process := exec.CommandContext(ctx, path, args...) + process.WaitDelay = 250 * time.Millisecond + process.Dir = t.TempDir() + process.Env = []string{"PATH=" + pathDirectory + ":/usr/bin:/bin", "LANG=en_US.UTF-8", + "AUDIO_PREVIEW_ARGS=" + argsPath, "AUDIO_PREVIEW_STDIN=" + stdinPath, + "AUDIO_PREVIEW_PROTECTED=" + protectedPath} + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + require.NoError(t, process.Run(), "shell=%s stderr=%q", shell, stderr.String()) + require.NoError(t, ctx.Err()) + require.Empty(t, stdout.String()) + require.Empty(t, stderr.String()) + rawArgs, err := os.ReadFile(argsPath) + require.NoError(t, err) + require.True(t, bytes.HasSuffix(rawArgs, []byte{0})) + input, err := os.ReadFile(stdinPath) + require.NoError(t, err) + protected, err := os.ReadFile(protectedPath) + require.NoError(t, err) + return profilePreviewCapture{strings.Split(string(rawArgs[:len(rawArgs)-1]), "\x00"), input, string(protected)} +} + +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}, + {[]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, skipContextFirstRunSetup(root, tc.args), "%q", tc.args) + } + require.False(t, skipContextFirstRunSetup(nil, []string{"openai", "auth", "status"})) + require.False(t, skipContextFirstRunSetup(&cli.Command{}, []string{"openai", "auth", "status"})) +} + +func TestContextFirstRunPreservesLinkTokenExclusion(t *testing.T) { + declared := authStatusTestRoot(nil) + configureProfileContext(declared) + registerProfileCommands(declared) + declared.Commands = append(declared.Commands, + &cli.Command{Name: "models", Commands: []*cli.Command{{Name: "list"}}}, + &cli.Command{Name: "audio", Commands: []*cli.Command{ + {Name: "voices", Metadata: map[string]any{audiocommands.LocalMetadata: true}}, + {Name: "generate"}, + }}, + ) + for _, root := range []struct { + name string + command *cli.Command + }{ + {"absent", nil}, + {"empty", &cli.Command{}}, + {"declared", declared}, + } { + for _, test := range []struct { + args []string + want bool + }{ + {nil, false}, + {[]string{"openai"}, false}, + {[]string{"link"}, false}, + {[]string{"unlink"}, false}, + {[]string{"openai", "link"}, true}, + {[]string{"openai", "unlink"}, true}, + {[]string{"openai", "--project", "proj_fixture", "link"}, true}, + {[]string{"openai", "--project", "proj_fixture", "unlink"}, true}, + {[]string{"openai", "--api-key", "link", "models", "list"}, true}, + {[]string{"openai", "--api-key", "unlink", "models", "list"}, true}, + {[]string{"openai", "models", "list", "link"}, true}, + {[]string{"openai", "models", "list", "unlink"}, true}, + {[]string{"openai", "--api-key=link", "models", "list"}, false}, + {[]string{"openai", "--api-key=unlink", "models", "list"}, false}, + {[]string{"openai", "--api-key", "synthetic-link", "models", "list"}, false}, + {[]string{"openai", "models", "list", "linked"}, false}, + {[]string{"openai", "models", "list", "unlink-extra"}, false}, + {[]string{"openai", "models", "list"}, false}, + } { + require.Equal(t, test.want, skipContextFirstRunSetup(root.command, test.args), "%s: %q", root.name, test.args) + } + } + localAudio := []string{"openai", "audio", "voices"} + require.True(t, audiocommands.SkipFirstRunSetup(declared, localAudio)) + require.False(t, skipContextFirstRunSetup(declared, localAudio)) + require.False(t, audiocommands.SkipFirstRunSetup(nil, localAudio)) + require.False(t, audiocommands.SkipFirstRunSetup(declared, []string{"openai", "audio", "generate"})) +} diff --git a/internal/app/profile_store.go b/internal/app/profile_store.go new file mode 100644 index 00000000..d10f9cab --- /dev/null +++ b/internal/app/profile_store.go @@ -0,0 +1,344 @@ +package app + +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/internal/app/profile_store_test.go b/internal/app/profile_store_test.go new file mode 100644 index 00000000..7838964f --- /dev/null +++ b/internal/app/profile_store_test.go @@ -0,0 +1,545 @@ +package app + +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/internal/app/project_link.go b/internal/app/project_link.go new file mode 100644 index 00000000..7eb292b0 --- /dev/null +++ b/internal/app/project_link.go @@ -0,0 +1,351 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + 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" +) + +// 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 { + *requestconfig.RootRequestFlag + explicit bool + 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 + } + f.explicit = true + return nil +} + +func registerProjectLinkCommands(root *cli.Command) { + for i, flag := range root.Flags { + if project, ok := flag.(*requestconfig.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, 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" + 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 + localcommands.LocalUtilityGlobalHelp + root.Commands = append(root.Commands, &cli.Command{ + Name: name, Usage: usage, Description: description, HideHelpCommand: true, + CustomHelpTemplate: help, + Metadata: map[string]any{ + localcommands.LocalUtilityMetadata: true, "local-help-full": help, "help-command-section": "Local tools", + "completion-root-flag-values": map[string][]string{"format": {"auto", "text", "json"}}, + }, + 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 + } + if !filepath.IsAbs(directory) { + return "", errors.New("folder links require an absolute user configuration directory") + } + 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 +} + +// 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 { + 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 { + return "", nil + } + links, err := loadProjectLinks(ctx, path) + if err == nil && len(links) == 0 { + return "", nil + } + var directory, project string + if err == nil { + directory, err = projectLinkDirectory() + } + if err == nil { + _, project, err = nearestProjectLink(ctx, links, directory) + } + if err != nil { + return "", projectLinkStoreFailure(root, "resolve", err) + } + return project, nil +} + +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 projectLinkStoreFailure(root, status.Action, err) + } + 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. + 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 + } + if err := ctx.Err(); err != nil { + if status.Action != "inspect" { + return projectLinkOutputFailure(root, status.Action, err) + } + return err + } + writer := clioutput.Writer{Context: ctx, Sink: 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 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(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, ":") + if ok && strings.EqualFold(strings.TrimSpace(name), "OpenAI-Project") { + project, source = strings.TrimSpace(value), "custom_headers" + } + } + 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" + } + if root.IsSet("header") { + if headers, err := requestconfig.Headers(root); err == nil { + if values := headers.Values("OpenAI-Project"); len(values) != 0 { + project, source = values[0], "header" + } + } + } + return project, source, 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 == "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 == "" { + 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)) + } + } 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() +} + +func projectLinkFailure(message string, cause error) error { + return &localcommands.LocalError{Message: message, Cause: cause} +} diff --git a/internal/app/project_link_aliases.go b/internal/app/project_link_aliases.go new file mode 100644 index 00000000..bab1f58e --- /dev/null +++ b/internal/app/project_link_aliases.go @@ -0,0 +1,114 @@ +package app + +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/internal/app/project_link_errors.go b/internal/app/project_link_errors.go new file mode 100644 index 00000000..5d9d0654 --- /dev/null +++ b/internal/app/project_link_errors.go @@ -0,0 +1,94 @@ +package app + +import ( + "context" + "errors" + "os" + + "github.com/openai/openai-cli/internal/clihelp" + localcommands "github.com/openai/openai-cli/internal/commands" + "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. " + } + message := operation + projectLinkRecovery(cause) + "This command did not change saved links." + if action == "resolve" { + message += " Inspect with " + clihelp.DisplayInvocation(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): + 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_. " + } + 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. " + } + if errors.Is(cause, context.Canceled) { + return &localCancellationGuidanceError{LocalError: &localcommands.LocalError{ + Message: outcome + "The command was canceled. Inspect saved links before retrying.", + Cause: cause, + }} + } + var utility *localcommands.LocalError + 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. + recovery = utility.Error() + } + return &localCancellationGuidanceError{LocalError: &localcommands.LocalError{Message: outcome + recovery, Cause: 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." + } + failure := &localcommands.LocalError{ + Message: message + " Inspect with " + clihelp.DisplayInvocation(root) + " link.", + Cause: cause, + } + if action == "link" || action == "unlink" { + return &localCancellationGuidanceError{LocalError: failure} + } + return failure +} diff --git a/internal/app/project_link_errors_test.go b/internal/app/project_link_errors_test.go new file mode 100644 index 00000000..729bc873 --- /dev/null +++ b/internal/app/project_link_errors_test.go @@ -0,0 +1,168 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "testing" + + localcommands "github.com/openai/openai-cli/internal/commands" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestProjectLinkCancellationGuidanceRetainsErrorBoundary(t *testing.T) { + committed := projectLinkCommittedSelectionFailure("link", context.Canceled) + plain := &localcommands.LocalError{Message: "A different operation failed.", Cause: context.Canceled} + for _, test := range []struct { + name string + failure error + want string + }{ + {"direct", committed, "The folder link was saved."}, + {"wrapped", fmt.Errorf("synthetic-private: %w", committed), "The folder link was saved."}, + {"plain_outer", &localcommands.LocalError{Message: "An outer operation failed.", Cause: committed}, "Request canceled."}, + {"plain_first_join", errors.Join(plain, committed), "Request canceled."}, + {"guidance_first_join", errors.Join(committed, plain), "The folder link was saved."}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorIs(t, test.failure, context.Canceled) + var shared *localcommands.LocalError + require.ErrorAs(t, test.failure, &shared) + var diagnostic bytes.Buffer + require.NoError(t, ShowCommandError(&cli.Command{Name: "openai"}, test.failure, &diagnostic)) + require.Contains(t, diagnostic.String(), test.want) + require.NotContains(t, diagnostic.String(), "synthetic-private") + }) + } +} + +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}, + {"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}, + {"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 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 + 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]) + }) + } +} + +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") + } +} diff --git a/internal/app/project_link_lock_other.go b/internal/app/project_link_lock_other.go new file mode 100644 index 00000000..ab2975b9 --- /dev/null +++ b/internal/app/project_link_lock_other.go @@ -0,0 +1,17 @@ +//go:build !darwin && !linux && !freebsd && !openbsd && !netbsd && !dragonfly && !windows + +package app + +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/internal/app/project_link_lock_unix.go b/internal/app/project_link_lock_unix.go new file mode 100644 index 00000000..000eb38d --- /dev/null +++ b/internal/app/project_link_lock_unix.go @@ -0,0 +1,26 @@ +//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly + +package app + +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/internal/app/project_link_lock_windows.go b/internal/app/project_link_lock_windows.go new file mode 100644 index 00000000..712524b1 --- /dev/null +++ b/internal/app/project_link_lock_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package app + +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/internal/app/project_link_store.go b/internal/app/project_link_store.go new file mode 100644 index 00000000..4e9b65a0 --- /dev/null +++ b/internal/app/project_link_store.go @@ -0,0 +1,347 @@ +package app + +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") + 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 { + 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, errProjectLinkFileUnsafe + } + 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, errProjectLinksTooLarge + } + 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 := errProjectLinksInvalid + 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, errProjectLinkLockUnsafe + } + 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 errProjectLinkLockChanged + } + 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, errProjectLinkInputInvalid + } + root, name, err := openProjectLinkDirectory(path, true) + if err != nil { + 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 + } + defer lock.Close() + links, err := readProjectLinks(ctx, root, name) + if err != nil { + return nil, err + } + 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 { + return nil, err + } + data = append(data, '\n') + if len(data) > maxProjectLinkBytes { + return nil, errProjectLinksTooLarge + } + 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 + } + 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 + } + 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, "", errProjectLinkDirectoryUnsafe + } + 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 = errProjectLinkDirectoryChanged + } + 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, "", errProjectLinkDirectoryUnsafe + } + return root, name, nil +} diff --git a/internal/app/project_link_store_test.go b/internal/app/project_link_store_test.go new file mode 100644 index 00000000..c121dabe --- /dev/null +++ b/internal/app/project_link_store_test.go @@ -0,0 +1,463 @@ +package app + +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 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 != "" { + 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/internal/app/project_link_store_unix_test.go b/internal/app/project_link_store_unix_test.go new file mode 100644 index 00000000..8239eabc --- /dev/null +++ b/internal/app/project_link_store_unix_test.go @@ -0,0 +1,115 @@ +//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly + +package app + +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/internal/app/project_link_test.go b/internal/app/project_link_test.go new file mode 100644 index 00000000..39d00b61 --- /dev/null +++ b/internal/app/project_link_test.go @@ -0,0 +1,168 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "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" + "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) {}, + } + requestconfig.ConfigureRootFlags(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", "") + 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() + 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]) + } +} + +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.True(t, skipContextFirstRunSetup(nil, args)) + } +} + +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[localcommands.LocalUtilityMetadata]) + } +} + +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/internal/app/root_request_flags_test.go b/internal/app/root_request_flags_test.go index 6f33b5ee..7243b3ab 100644 --- a/internal/app/root_request_flags_test.go +++ b/internal/app/root_request_flags_test.go @@ -8,12 +8,15 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "reflect" "testing" 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" "github.com/urfave/cli/v3" ) @@ -43,6 +46,260 @@ 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].(*requestconfig.RootRequestFlag) + require.Equal(t, tc.explicit, wrapped.ExplicitRequestFlag()) + 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 + }, + }}}}, + } + requestconfig.ConfigureRootFlags(root) + require.NoError(t, root.Run(t.Context(), append([]string{"openai"}, tc.args...))) + require.True(t, called) + }) + } + }) + } +} + +func TestRootRequestFlagPreParseResetsInvocationState(t *testing.T) { + wrapped := &requestconfig.RootRequestFlag{Flag: &requestflag.Flag[string]{Name: "project", Default: "default-project"}} + require.NoError(t, wrapped.PreParse()) + require.False(t, wrapped.ExplicitRequestFlag()) + require.False(t, wrapped.IsSet()) + require.NoError(t, wrapped.Set("project", "explicit-project")) + require.True(t, wrapped.ExplicitRequestFlag()) + require.Equal(t, 1, wrapped.Count()) + + require.NoError(t, wrapped.PreParse()) + require.False(t, wrapped.ExplicitRequestFlag()) + require.False(t, wrapped.IsSet()) + require.Zero(t, wrapped.Count()) + require.Equal(t, "default-project", wrapped.Get()) +} + +func TestRootRequestFlagsResetProvenanceAcrossRuns(t *testing.T) { + 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"}}}}, + } + requestconfig.ConfigureRootFlags(root) + wrapped := root.Flags[0].(*requestconfig.RootRequestFlag) + // Root sources precede descendant arguments, so Count includes source and explicit assignments. + 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: 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: 2}, + {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 := &requestconfig.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) + } +} + func TestRootRequestFlagsPreserveMTLSPlacement(t *testing.T) { t.Setenv(requestconfig.MTLSClientCertFileEnv, "/missing/synthetic-env-certificate") t.Setenv(requestconfig.MTLSClientKeyFileEnv, "/missing/synthetic-env-key") diff --git a/internal/commands/admin/admin_credentials.go b/internal/commands/admin/admin_credentials.go index 415d6c71..381c163b 100644 --- a/internal/commands/admin/admin_credentials.go +++ b/internal/commands/admin/admin_credentials.go @@ -1,6 +1,7 @@ package admin import ( + "errors" "net/url" "os" "strings" @@ -12,6 +13,10 @@ import ( // CredentialsError provides fixed guidance for missing admin authentication. type CredentialsError struct{ invocation string } +type baseURLResolver func(*cli.Command) (string, error) + +const baseURLResolverMetadata = "openai-admin-base-url-resolver" + // CredentialsRequiredMetadata marks admin operations before command-tree cloning. const CredentialsRequiredMetadata = "admin-credentials-required" @@ -30,9 +35,14 @@ For key creation and manual setup instructions, run: ` + invocation + ` help setup admin` } -// ConfigureCredentials marks canonical generated operations before subgroup cloning. Request setup -// checks credentials after positional and required-input validation. -func ConfigureCredentials(root *cli.Command) { +// ConfigureCredentials installs endpoint selection and marks generated operations before subgroup cloning. +// resolveBaseURL is required and must preserve the request snapshot and safe resolution errors. +// Request setup checks credentials after positional and required-input validation. +func ConfigureCredentials(root *cli.Command, resolveBaseURL func(*cli.Command) (string, error)) { + if root.Metadata == nil { + root.Metadata = map[string]any{} + } + root.Metadata[baseURLResolverMetadata] = baseURLResolver(resolveBaseURL) for _, resource := range root.Commands { if resource.Category != "API RESOURCE" || (!strings.HasPrefix(resource.Name, "admin:") && resource.Name != "fine-tuning:checkpoints:permissions") { @@ -59,7 +69,11 @@ func CheckCredentials(command *cli.Command) error { missing := &CredentialsError{invocation: invocation} // Custom endpoints own their authentication contracts and server errors. // Do not infer whether an arbitrary gateway requires credentials. - if !usesStandardAdminService(command) { + baseURL, err := effectiveBaseURL(command) + if err != nil { + return err + } + if !usesStandardAdminService(baseURL) { return nil } // Validate headers before considering authentication, matching FlagOptions. @@ -100,12 +114,9 @@ func CheckCredentials(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(baseURL string) bool { if baseURL == "" { + // Keep the guard without replacing the explicitly empty request endpoint. return true } endpoint, err := url.Parse(baseURL) @@ -117,3 +128,23 @@ func usesStandardAdminService(command *cli.Command) bool { (strings.EqualFold(endpoint.Hostname(), "api.openai.com") || strings.EqualFold(endpoint.Hostname(), "mtls.api.openai.com")) && (endpoint.Port() == "" || endpoint.Port() == "443") } + +// effectiveBaseURL shares endpoint selection between credential checks and interactive setup. +func effectiveBaseURL(command *cli.Command) (string, error) { + if value, present := command.Root().Metadata[baseURLResolverMetadata]; present { + resolve, ok := value.(baseURLResolver) + if !ok || resolve == nil { + return "", errors.New("Admin endpoint selection is unavailable.") + } + return resolve(command) + } + // Standalone Admin helpers retain their pre-profile flag and environment behavior. + baseURL := command.String("base-url") + if baseURL == "" { + baseURL = os.Getenv("OPENAI_BASE_URL") + } + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + return baseURL, nil +} diff --git a/internal/commands/admin/admin_setup_interactive.go b/internal/commands/admin/admin_setup_interactive.go index d8317c26..511a29bb 100644 --- a/internal/commands/admin/admin_setup_interactive.go +++ b/internal/commands/admin/admin_setup_interactive.go @@ -39,7 +39,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. @@ -157,12 +157,9 @@ func handleAdminSetup(ctx context.Context, command *cli.Command, policy Options) } func adminSetupRequestOptions(command *cli.Command, defaultOptions func(*cli.Command) []option.RequestOption) ([]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" + baseURL, err := effectiveBaseURL(command) + if err != nil { + return nil, "", err } endpoint, err := url.Parse(baseURL) if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { diff --git a/internal/commands/admin/admin_setup_origin_test.go b/internal/commands/admin/admin_setup_origin_test.go index a11180ae..f3041393 100644 --- a/internal/commands/admin/admin_setup_origin_test.go +++ b/internal/commands/admin/admin_setup_origin_test.go @@ -2,15 +2,23 @@ package admin_test import ( "context" + "encoding/json" + "errors" "io" "net/http" + "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" + "sync/atomic" "testing" + appconfig "github.com/openai/openai-cli/internal/app" + localcommands "github.com/openai/openai-cli/internal/commands" "github.com/openai/openai-cli/internal/commands/admin" requestconfig "github.com/openai/openai-cli/internal/request" - appconfig "github.com/openai/openai-cli/internal/app" + "github.com/openai/openai-go/v3/option" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" ) @@ -54,6 +62,381 @@ 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) { + isolateAdminProfileEnvironment(t) + expected, err := url.Parse(tc.wantURL) + require.NoError(t, err) + requests := 0 + root := adminCredentialsTestCommand(t, nil) + bridge := func(ctx context.Context, command *cli.Command) error { + options, display, err := admin.TestRequestOptions(command, appconfig.GetDefaultRequestOptions) + if err != nil { + return err + } + require.Equal(t, tc.wantDisplay, display) + return admin.TestVerifySetup(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) + } + values := map[string]string{} + if !tc.absentEndpoint { + values["base_url"] = tc.endpoint + } + writeAdminProfileFixture(t, values) + root.Metadata = map[string]any{requestconfig.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 + }), + }} + bridgeCalls := configureAdminSetupProfileProbe(t, root, bridge, root.Metadata[requestconfig.MTLSHTTPClientMetadata].(*http.Client)) + 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, "setup", "admin"))) + require.Equal(t, 1, *bridgeCalls) + 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) { + isolateAdminProfileEnvironment(t) + root := adminCredentialsTestCommand(t, nil) + helperCalls := 0 + probeOptions := func(command *cli.Command) ([]option.RequestOption, string, error) { + helperCalls++ + return admin.TestRequestOptions(command, appconfig.GetDefaultRequestOptions) + } + bridge := func(_ context.Context, command *cli.Command) error { + options, display, err := probeOptions(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) + } + writeAdminProfileFixture(t, map[string]string{"base_url": tc.profileURL}) + bridgeCalls := configureAdminSetupProfileProbe(t, root, bridge, nil) + root.ExitErrHandler = func(context.Context, *cli.Command, error) {} + publicError := root.Run(t.Context(), []string{root.Name, "setup", "admin"}) + require.Error(t, publicError) + require.NotContains(t, publicError.Error(), "synthetic-secret") + require.NotContains(t, publicError.Error(), "gateway.example.test") + wantBridgeCalls := 1 + if tc.name == "environment HTTP" { + wantBridgeCalls = 0 + } + require.Equal(t, wantBridgeCalls, *bridgeCalls) + require.Equal(t, wantBridgeCalls, helperCalls) + options, display, err := probeOptions(root) + require.Equal(t, wantBridgeCalls+1, helperCalls) + require.Nil(t, options) + require.Empty(t, display) + var setup *admin.SetupError + 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) { + isolateAdminProfileEnvironment(t) + root := adminCredentialsTestCommand(t, nil) + helperCalls := 0 + probeOptions := func(command *cli.Command) ([]option.RequestOption, string, error) { + helperCalls++ + return admin.TestRequestOptions(command, appconfig.GetDefaultRequestOptions) + } + bridge := func(_ context.Context, command *cli.Command) error { + options, display, err := probeOptions(command) + require.Nil(t, options) + require.Empty(t, display) + return err + } + t.Setenv("OPENAI_PROFILE", "missing") + if malformed { + writeAdminProfileRegistry(t, []byte(`{"synthetic-sensitive-field":"synthetic-sensitive-value"}`)) + } + bridgeCalls := configureAdminSetupProfileProbe(t, root, bridge, nil) + err := root.Run(t.Context(), []string{root.Name, "--base-url=https://gateway.example.test/v1/", "setup", "admin"}) + require.Equal(t, 1, *bridgeCalls) + require.Equal(t, 1, helperCalls) + var local *localcommands.LocalError + require.ErrorAs(t, err, &local) + require.NotContains(t, err.Error(), "synthetic-sensitive") + options, display, helperError := probeOptions(root) + require.Equal(t, 2, helperCalls) + require.Nil(t, options) + require.Empty(t, display) + require.ErrorAs(t, helperError, &local) + require.NotContains(t, helperError.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) { + isolateAdminProfileEnvironment(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, nil) + bridge := func(ctx context.Context, command *cli.Command) error { + options, display, err := admin.TestRequestOptions(command, appconfig.GetDefaultRequestOptions) + if err != nil { + return err + } + require.Equal(t, server.URL, display) + return admin.TestVerifySetup(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") + writeAdminProfileFixture(t, map[string]string{ + "base_url": server.URL + "/saved/v1", "organization": "org_profile", "project": "proj_profile", + }) + client := server.Client() + redirects := 0 + client.CheckRedirect = func(*http.Request, []*http.Request) error { redirects++; return nil } + root.Metadata = map[string]any{requestconfig.MTLSHTTPClientMetadata: client} + bridgeCalls := configureAdminSetupProfileProbe(t, root, bridge, root.Metadata[requestconfig.MTLSHTTPClientMetadata].(*http.Client)) + root.ExitErrHandler = func(context.Context, *cli.Command, error) {} + err := root.Run(t.Context(), []string{root.Name, "setup", "admin"}) + require.Equal(t, 1, *bridgeCalls) + 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) + }) + } +} + +func TestAdminSetupRegisteredEndpointPolicy(t *testing.T) { + for _, name := range []string{"empty endpoint", "callback error", "nil callback", "unrelated metadata"} { + t.Run(name, func(t *testing.T) { + root := adminCredentialsTestCommand(t, nil) + t.Setenv("OPENAI_BASE_URL", "https://environment.example.test/v1") + marker := &struct{ value string }{"synthetic-unrelated-metadata"} + requests := 0 + client := &http.Client{Transport: adminSetupOriginTransport(func(request *http.Request) (*http.Response, error) { + requests++ + require.Equal(t, "https://policy.example.test/v1/organization/projects?limit=1", request.URL.String()) + 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 + })} + root.Metadata = map[string]any{ + "synthetic-unrelated": marker, requestconfig.MTLSHTTPClientMetadata: client, + admin.CredentialsRequiredMetadata: true, + } + callbackError := errors.New("synthetic safe endpoint resolution failure") + resolverCalls, defaultsCalls := 0, 0 + var resolve func(*cli.Command) (string, error) + if name != "nil callback" { + resolve = func(*cli.Command) (string, error) { + resolverCalls++ + if name == "callback error" { + return "", callbackError + } + if name == "empty endpoint" { + return "", nil + } + return "https://policy.example.test/v1", nil + } + } + admin.ConfigureCredentials(root, resolve) + admin.ConfigureCredentials(root, resolve) + require.Same(t, marker, root.Metadata["synthetic-unrelated"]) + require.Same(t, client, root.Metadata[requestconfig.MTLSHTTPClientMetadata]) + root.Action = func(ctx context.Context, command *cli.Command) error { + credentialError := admin.CheckCredentials(command) + options, display, setupError := admin.TestRequestOptions(command, func(*cli.Command) []option.RequestOption { + defaultsCalls++ + return nil + }) + switch name { + case "empty endpoint": + var missing *admin.CredentialsError + require.ErrorAs(t, credentialError, &missing) + var invalid *admin.SetupError + require.ErrorAs(t, setupError, &invalid) + require.Equal(t, 3, invalid.ExitCode()) + case "callback error": + require.ErrorIs(t, credentialError, callbackError) + require.ErrorIs(t, setupError, callbackError) + case "nil callback": + require.EqualError(t, credentialError, "Admin endpoint selection is unavailable.") + require.EqualError(t, setupError, "Admin endpoint selection is unavailable.") + default: + require.NoError(t, credentialError) + require.NoError(t, setupError) + require.Equal(t, "https://policy.example.test", display) + require.NoError(t, admin.TestVerifySetup(ctx, options, []byte("synthetic-entered-key"))) + } + if name != "unrelated metadata" { + require.Nil(t, options) + require.Empty(t, display) + } + return nil + } + require.NoError(t, root.Run(t.Context(), []string{root.Name, "--base-url=https://flag.example.test/v1"})) + if name == "nil callback" { + require.Zero(t, resolverCalls) + } else { + require.Equal(t, 2, resolverCalls) + } + if name == "unrelated metadata" { + require.Equal(t, 1, defaultsCalls) + require.Equal(t, 1, requests) + } else { + require.Zero(t, defaultsCalls) + require.Zero(t, requests) + } + require.Same(t, marker, root.Metadata["synthetic-unrelated"]) + require.Same(t, client, root.Metadata[requestconfig.MTLSHTTPClientMetadata]) + }) + } +} + +// Keep production root preparation. The bridge exercises setup request options after it. +func configureAdminSetupProfileProbe(t *testing.T, root *cli.Command, bridge cli.ActionFunc, client *http.Client) *int { + t.Helper() + appconfig.ConfigureCommand(root) + setup := root.Command("setup") + require.NotNil(t, setup) + command := setup.Command("admin") + require.NotNil(t, command) + calls := 0 + command.Action = func(ctx context.Context, command *cli.Command) error { + calls++ + if client != nil { + // Root preparation can clear this request-scoped client. Restore only this entry. + command.Root().Metadata[requestconfig.MTLSHTTPClientMetadata] = client + require.Same(t, client, command.Root().Metadata[requestconfig.MTLSHTTPClientMetadata]) + } + return bridge(ctx, command) + } + return &calls +} + +// Isolate the public profile schema without importing private profile helpers. +func isolateAdminProfileEnvironment(t *testing.T) { + t.Helper() + home := t.TempDir() + for _, name := range []string{"HOME", "USERPROFILE", "APPDATA", "XDG_CONFIG_HOME"} { + t.Setenv(name, home) + } + for _, name := range []string{"OPENAI_PROFILE", "OPENAI_PROJECT_ID", "OPENAI_ORG_ID"} { + t.Setenv(name, "") + require.NoError(t, os.Unsetenv(name)) + } +} + +func writeAdminProfileFixture(t *testing.T, values map[string]string) { + t.Helper() + data, err := json.Marshal(map[string]any{ + "version": 1, "default_profile": "work", "profiles": map[string]any{"work": values}, + }) + require.NoError(t, err) + writeAdminProfileRegistry(t, data) +} + +func writeAdminProfileRegistry(t *testing.T, data []byte) { + t.Helper() + directory, err := os.UserConfigDir() + require.NoError(t, err) + path := filepath.Join(directory, "openai", "profiles.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, data, 0600)) +} + type adminSetupOriginTransport func(*http.Request) (*http.Response, error) func (transport adminSetupOriginTransport) RoundTrip(request *http.Request) (*http.Response, error) { diff --git a/internal/commands/audio/audio_command_preview_test.go b/internal/commands/audio/audio_command_preview_test.go index fd48bf1b..574a56e6 100644 --- a/internal/commands/audio/audio_command_preview_test.go +++ b/internal/commands/audio/audio_command_preview_test.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/openai/openai-cli/internal/audiohistory" requestconfig "github.com/openai/openai-cli/internal/request" + "github.com/openai/openai-cli/internal/requestflag" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" ) @@ -438,3 +439,134 @@ func TestAudioCommandPrintCancelsBlockedContextWriter(t *testing.T) { t.Fatal("print did not stop after cancellation") } } + +// Parse the same public root-selection contract consumed by Audio previews. +// Files tests retain the real profile selector; these tests exercise Audio's consumer. +func audioProfilePreviewModel(t *testing.T, invocation, environment string, childProfile bool, arguments []string) *audioWorkbench { + t.Helper() + t.Setenv("OPENAI_PROFILE", environment) + t.Setenv("OPENAI_API_KEY", "sk-fake-inherited") + for _, name := range []string{"OPENAI_ADMIN_KEY", "OPENAI_WEBHOOK_SECRET", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS", requestconfig.MTLSClientCertFileEnv, requestconfig.MTLSClientKeyFileEnv} { + t.Setenv(name, "") + require.NoError(t, os.Unsetenv(name)) + } + var model *audioWorkbench + leaf := &cli.Command{Name: "probe", Action: func(ctx context.Context, command *cli.Command) error { + model = newAudioWorkbench(ctx, command, nil, audiohistory.Store{}) + return nil + }} + if childProfile { + leaf.Flags = append(leaf.Flags, &cli.StringFlag{Name: "profile"}) + } + root := &cli.Command{Name: "synthetic-profile-preview", Writer: io.Discard, ErrWriter: io.Discard, + Metadata: map[string]any{"help-invocation": invocation}, + ExitErrHandler: func(context.Context, *cli.Command, error) {}, + Flags: []cli.Flag{&cli.StringFlag{Name: "profile"}, &cli.StringFlag{Name: "base-url"}, requestconfig.NewRequestHeaderFlag()}, + Commands: []*cli.Command{leaf}, + } + for _, pair := range [][2]string{{"api-key", "OPENAI_API_KEY"}, {"admin-api-key", "OPENAI_ADMIN_KEY"}, {"webhook-secret", "OPENAI_WEBHOOK_SECRET"}, {"organization", "OPENAI_ORG_ID"}, {"project", "OPENAI_PROJECT_ID"}} { + root.Flags = append(root.Flags, &requestflag.Flag[string]{Name: pair[0], Sources: cli.EnvVars(pair[1])}) + } + root.Flags = append(root.Flags, requestconfig.MTLSClientFlags()...) + requestconfig.ConfigureRootFlags(root) + require.NoError(t, root.Run(t.Context(), append([]string{"synthetic-profile-preview"}, arguments...))) + require.NotNil(t, model, "Audio preview action must run") + return model +} + +func TestAudioProfileRequestContext(t *testing.T) { + for _, tc := range []struct { + name string + flags []string + want []string + 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}, + {"different key", []string{"--api-key=sk-fake-override"}, nil, true}, + {"empty key override", []string{"--api-key="}, nil, true}, + {"admin key override", []string{"--admin-api-key=sk-fake-admin"}, nil, true}, + {"webhook secret override", []string{"--webhook-secret=fake-secret"}, nil, true}, + {"header override", []string{"-H", "Authorization: Bearer fake-secret"}, nil, true}, + {"certificate override", []string{"--mtls-client-cert-file=/synthetic/private/cert.pem"}, nil, true}, + {"private key override", []string{"--mtls-client-key-file=/synthetic/private/key.pem"}, nil, true}, + {"URL user info", []string{"--base-url=https://fake:secret@example.invalid"}, nil, true}, + {"URL query", []string{"--base-url=https://example.invalid/v1?key=fake-secret"}, nil, true}, + {"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) { + model := audioProfilePreviewModel(t, "openai", "inherited-profile", false, append([]string{"probe"}, tc.flags...)) + requestArgs, omit := requestconfig.NextCommandOptions(model.command.Root()) + require.Equal(t, tc.want, requestArgs) + require.Equal(t, tc.omit, omit) + model.input.SetValue("SYNTHETIC_PRIVATE_EDITOR_TEXT") + preview := model.commandPreview() + if tc.omit { + require.Equal(t, "Command preview unavailable with private request overrides.", preview) + require.NotContains(t, preview, "SYNTHETIC_PRIVATE_EDITOR_TEXT") + } 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) + } + }) + } +} + +func TestAudioCopiedCommandsPreserveProfileSelector(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) { + var args []string + if tc.rootFlag != "" { + args = append(args, tc.rootFlag) + } + args = append(args, "probe") + if tc.childFlag != "" { + args = append(args, tc.childFlag) + } + model := audioProfilePreviewModel(t, audioShellQuote(binary), tc.environment, tc.childFlag != "", args) + requestArgs, omit := requestconfig.NextCommandOptions(model.command.Root()) + require.Equal(t, tc.want, requestArgs) + require.False(t, omit) + model.settings.Format = "wav" + model.input.SetValue("@synthetic 'literal' text") + preview := model.commandPreview() + require.NotContains(t, preview, "/synthetic/private/") + require.NotContains(t, preview, "inherited-profile") + for _, character := range strings.TrimSpace(preview) { + require.False(t, unicode.IsControl(character) || unicode.Is(unicode.Cf, character), "literal control in copied command") + } + capture := executeAudioPreview(t, shell, preview, directory) + require.Equal(t, append(append([]string{}, tc.want...), "audio", "speak", "--output", "speech.wav"), capture.args) + require.Equal(t, "true", capture.protected) + assertAudioPreviewJSON(t, model, capture.stdin) + }) + } + }) + } +} diff --git a/internal/commands/batches/batch_wait.go b/internal/commands/batches/batch_wait.go index 1ab3541f..96f7ce3d 100644 --- a/internal/commands/batches/batch_wait.go +++ b/internal/commands/batches/batch_wait.go @@ -318,7 +318,8 @@ func ShowNextCommand(batch gjson.Result, opts HintOptions) 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/internal/commands/images/image_picker.go b/internal/commands/images/image_picker.go index 80ce3e06..7e93d07c 100644 --- a/internal/commands/images/image_picker.go +++ b/internal/commands/images/image_picker.go @@ -27,6 +27,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 @@ -42,6 +43,7 @@ type imagePickerResult struct { ExitCode int settings imagePickerSettings shell string + profile *string changed bool draftSaved bool } @@ -162,6 +164,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 } @@ -211,6 +214,7 @@ type imagePicker struct { settings imagePickerSettings page, field string shell string + profile *string focus string returnPage string returnSelected int @@ -247,6 +251,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/internal/commands/images/image_picker_command.go b/internal/commands/images/image_picker_command.go index ab329f49..c90d4a9f 100644 --- a/internal/commands/images/image_picker_command.go +++ b/internal/commands/images/image_picker_command.go @@ -20,3 +20,12 @@ 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...) +} diff --git a/internal/commands/images/image_picker_generate.go b/internal/commands/images/image_picker_generate.go index 345c3872..595938c8 100644 --- a/internal/commands/images/image_picker_generate.go +++ b/internal/commands/images/image_picker_generate.go @@ -51,6 +51,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 @@ -72,7 +76,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/internal/commands/images/image_picker_generate_test.go b/internal/commands/images/image_picker_generate_test.go index 81c46f3a..457e116a 100644 --- a/internal/commands/images/image_picker_generate_test.go +++ b/internal/commands/images/image_picker_generate_test.go @@ -135,6 +135,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}}}} + root.Flags = append(root.Flags, &cli.StringFlag{Name: "profile"}) + 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/internal/commands/images/image_picker_session.go b/internal/commands/images/image_picker_session.go index 5bfc3f8a..1c0213d5 100644 --- a/internal/commands/images/image_picker_session.go +++ b/internal/commands/images/image_picker_session.go @@ -98,7 +98,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 := cliterminal.FormatCommand(result.Args, result.shell) + command := cliterminal.FormatCommand(imagePickerDisplayArgs(result.Args, result.profile), result.shell) if command == "" { if result.PrintOnly { return imageSavingFailure(imagePickerUnsupportedShell, nil) diff --git a/internal/commands/images/image_picker_session_test.go b/internal/commands/images/image_picker_session_test.go index b10b29b4..6b760149 100644 --- a/internal/commands/images/image_picker_session_test.go +++ b/internal/commands/images/image_picker_session_test.go @@ -6,8 +6,11 @@ import ( "errors" "io" "os" + "os/exec" "path/filepath" + "strings" "testing" + "time" cliterminal "github.com/openai/openai-cli/internal/terminal" "github.com/stretchr/testify/require" @@ -77,6 +80,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 := cliterminal.FormatCommand(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, cliterminal.FormatCommand(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}}}} + root.Flags = append(root.Flags, &cli.StringFlag{Name: "profile"}) + 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/internal/commands/images/image_picker_view.go b/internal/commands/images/image_picker_view.go index 2cb8d223..a04b0a39 100644 --- a/internal/commands/images/image_picker_view.go +++ b/internal/commands/images/image_picker_view.go @@ -282,7 +282,7 @@ func (m *imagePicker) commandPreview(width, height int) ([]string, int, int) { } func (m *imagePicker) commandLines(width int) []string { - command := cliterminal.FormatCommand(m.settings.args(), m.shell) + command := cliterminal.FormatCommand(imagePickerDisplayArgs(m.settings.args(), m.profile), m.shell) if command == "" { command = imagePickerUnsupportedShell } diff --git a/internal/request/mtls.go b/internal/request/mtls.go index 138ca0ab..9e8a080d 100644 --- a/internal/request/mtls.go +++ b/internal/request/mtls.go @@ -17,6 +17,11 @@ const openAISDKResponseHeaderTimeout = 10 * time.Minute // NewMTLSHTTPClient constructs an endpoint-bound mTLS client using default transport settings. func NewMTLSHTTPClient(certFile, keyFile, baseURL string) (*http.Client, error) { + return NewMTLSHTTPClientWithReader(certFile, keyFile, baseURL, readMTLSFile) +} + +// NewMTLSHTTPClientWithReader shares 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 } @@ -28,13 +33,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/internal/request/mtls_client_flag.go b/internal/request/mtls_client_flag.go index 9d63f8a5..9dd2f709 100644 --- a/internal/request/mtls_client_flag.go +++ b/internal/request/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/internal/request/next_command.go b/internal/request/next_command.go index 7fd3c485..a2250238 100644 --- a/internal/request/next_command.go +++ b/internal/request/next_command.go @@ -28,7 +28,7 @@ func NextCommandOptions(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/internal/request/root_flags.go b/internal/request/root_flags.go index 1eb4a854..b1ffbe6b 100644 --- a/internal/request/root_flags.go +++ b/internal/request/root_flags.go @@ -27,9 +27,40 @@ func ConfigureRootFlags(root *cli.Command) { // RootRequestFlag preserves request metadata while sharing root options with subcommands. type RootRequestFlag struct { *requestflag.Flag[string] + explicitlySet bool } func (*RootRequestFlag) IsLocal() bool { return false } +func (f *RootRequestFlag) PreParse() error { + f.explicitlySet = false + // 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 +// 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 } + +// ExplicitRequestFlag preserves presence through wrappers that embed this flag. +func (f *RootRequestFlag) ExplicitRequestFlag() bool { return f.explicitlySet } diff --git a/internal/testutil/mtls/pki.go b/internal/testutil/mtls/pki.go index 85fb427d..152d0ee4 100644 --- a/internal/testutil/mtls/pki.go +++ b/internal/testutil/mtls/pki.go @@ -39,7 +39,7 @@ func NewPKI(t *testing.T) PKI { BasicConstraintsValid: true, IsCA: true, } - rootDER, rootCertificate, rootKey := createTestCertificate(t, rootTemplate, rootTemplate, nil) + rootDER, rootCertificate, rootKey := CreateCertificate(t, rootTemplate, rootTemplate, nil) intermediateTemplate := &x509.Certificate{ SerialNumber: big.NewInt(2), @@ -52,7 +52,7 @@ func NewPKI(t *testing.T) PKI { MaxPathLen: 0, MaxPathLenZero: true, } - intermediateDER, intermediateCertificate, intermediateKey := createTestCertificate( + intermediateDER, intermediateCertificate, intermediateKey := CreateCertificate( t, intermediateTemplate, rootCertificate, @@ -67,7 +67,7 @@ func NewPKI(t *testing.T) PKI { KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, } - clientDER, _, clientKey := createTestCertificate( + clientDER, _, clientKey := CreateCertificate( t, clientTemplate, intermediateCertificate, @@ -87,7 +87,7 @@ func NewPKI(t *testing.T) PKI { } } -func createTestCertificate( +func CreateCertificate( t *testing.T, template *x509.Certificate, parent *x509.Certificate, 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 diff --git a/scripts/demos/project-linking/README.md b/scripts/demos/project-linking/README.md new file mode 100644 index 00000000..5176ccb4 --- /dev/null +++ b/scripts/demos/project-linking/README.md @@ -0,0 +1,62 @@ +# 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. + +## 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..c765ac7e --- /dev/null +++ b/scripts/demos/project-linking/record-context-review.sh @@ -0,0 +1,78 @@ +#!/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 '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 +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" diff --git a/scripts/demos/project-linking/record.sh b/scripts/demos/project-linking/record.sh new file mode 100755 index 00000000..5e294fd5 --- /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 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 +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()