diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 97de1b18ad..3419603930 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -140,6 +140,45 @@ jobs: if: runner.os == 'Linux' run: go test -race -count=1 -timeout 90s ./e2e/framework/registryfixture + - name: test Linux GitHub API oracle + if: runner.os == 'Linux' + run: go test -race -count=1 -timeout 90s ./e2e/framework/ghfixture + + github-credential-host-tests: + name: GitHub Credential Host Tests (${{ matrix.runner }}) + needs: [changes] + if: needs.changes.outputs.go == 'true' + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + - name: record native host and available CLI versions + run: | + go env GOOS GOARCH + gh --version + - name: execute native credential discovery and process-bound regressions + env: + DEVSY_TEST_REAL_GH: "1" + DEVSY_TEST_NATIVE_KEYRING: ${{ runner.os == 'macOS' && '1' || '0' }} + run: >- + go test -race -count=1 -v -timeout 5m ./pkg/ghcredentials + -run '^(TestNative.*|TestHostLookupEnvironmentAndGuestEnvironmentAreIndependent|TestRegisteredGuestDeclinesAmbiguousKeyringResult)$' + - name: test strict fixture report validation + run: go test -race -count=1 -v ./e2e/framework/ghfixture + - name: test native credential service boundaries + run: >- + go test -race -count=1 -v -timeout 2m ./pkg/tunnel ./pkg/credentials + -run '^(TestCredentials.*Host.*|TestOptionalGitHubService.*|TestServicesStartup.*)$' + windows-transport-tests: name: Windows Transport Tests needs: [changes] @@ -272,6 +311,8 @@ jobs: args: build --id devsy-${{ steps.os.outputs.runner_os }} --snapshot env: DEVSY_CLI_VERSION: v0.0.0 + # Ubuntu 26.04 quotas /tmp: https://github.com/actions/runner-images/issues/14777 + GOTMPDIR: ${{ runner.temp }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -302,6 +343,23 @@ jobs: name: devsy-ci-microsandbox-linux path: ${{ runner.temp }}/devsy-ci-microsandbox + - name: build Linux GitHub credential fixtures + if: runner.os == 'Linux' + shell: bash + env: + CGO_ENABLED: 0 + run: | + for arch in amd64 arm64; do + GOOS=linux GOARCH="$arch" go build -trimpath -o "${RUNNER_TEMP}/ghfixture-linux-${arch}" ./e2e/cmd/ghfixture + done + + - name: upload Linux GitHub credential fixtures + if: runner.os == 'Linux' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ghfixture-linux + path: ${{ runner.temp }}/ghfixture-linux-* + - name: build e2e test binary if: matrix.runner == 'ubuntu-latest' || matrix.runner == 'windows-latest' shell: bash @@ -1016,6 +1074,13 @@ jobs: name: e2e-test-${{ steps.os.outputs.runner_os }} path: ./e2e/ + - name: download GitHub credential fixture + if: matrix.label == 'ssh' && runner.os == 'Linux' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ghfixture-linux + path: ./e2e/bin/ + - name: setup executable if: matrix.requires-secret == false || needs.can-read-secret.outputs.secret-set == 'true' shell: bash @@ -1293,6 +1358,7 @@ jobs: - lint - agent-skill - unit-tests + - github-credential-host-tests - windows-transport-tests - provider-update-windows - licenses @@ -1333,6 +1399,7 @@ jobs: check_result "lint" "${{ needs.lint.result }}" false || status=1 check_result "agent-skill" "${{ needs.agent-skill.result }}" false || status=1 check_result "unit-tests" "${{ needs.unit-tests.result }}" true || status=1 + check_result "github-credential-host-tests" "${{ needs.github-credential-host-tests.result }}" true || status=1 check_result "windows-transport-tests" "${{ needs.windows-transport-tests.result }}" true || status=1 check_result "provider-update-windows" "${{ needs.provider-update-windows.result }}" true || status=1 check_result "licenses" "${{ needs.licenses.result }}" true || status=1 diff --git a/cmd/internal/agentcontainer/container.go b/cmd/internal/agentcontainer/container.go index 57a130a973..9f9469802f 100644 --- a/cmd/internal/agentcontainer/container.go +++ b/cmd/internal/agentcontainer/container.go @@ -27,6 +27,7 @@ func NewContainerCmd(flags *flags.GlobalFlags) *cobra.Command { containerCmd.AddCommand(newBrowserAsyncCmd(b)) } containerCmd.AddCommand(NewCredentialsServerCmd(flags)) + containerCmd.AddCommand(NewCredentialsStatusCmd()) containerCmd.AddCommand(NewSetupDevsyPlatformAccessCmd(flags)) containerCmd.AddCommand(NewSSHServerCmd(flags)) return containerCmd diff --git a/cmd/internal/agentcontainer/credentials_port.go b/cmd/internal/agentcontainer/credentials_port.go new file mode 100644 index 0000000000..dc278273d4 --- /dev/null +++ b/cmd/internal/agentcontainer/credentials_port.go @@ -0,0 +1,49 @@ +package agentcontainer + +import ( + "context" + "errors" + "net" + "time" + + "github.com/devsy-org/devsy/pkg/credentials" +) + +func (cmd *CredentialsServerCmd) claimPort(ctx context.Context, port int) (net.Listener, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !cmd.ConfigureGitHubHelper { + return claimPort(port) + } + return claimPortAfterDrain(ctx, port) +} + +func claimPortAfterDrain(ctx context.Context, port int) (net.Listener, error) { + claimCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + if err := ctx.Err(); err != nil { + return nil, err + } + ln, err := claimPort(port) + if !errors.Is(err, errPortOwnedByAnotherSession) { + return ln, err + } + _, probeErr := credentials.ProbeReadiness( + claimCtx, + port, + credentials.Readiness{GitHubAllowed: true}, + ) + if probeErr == nil { + return nil, err + } + select { + case <-claimCtx.Done(): + return nil, errors.Join(err, ctx.Err()) + case <-ticker.C: + } + } +} diff --git a/cmd/internal/agentcontainer/credentials_port_test.go b/cmd/internal/agentcontainer/credentials_port_test.go new file mode 100644 index 0000000000..b408e16ec7 --- /dev/null +++ b/cmd/internal/agentcontainer/credentials_port_test.go @@ -0,0 +1,107 @@ +package agentcontainer + +import ( + "context" + "encoding/json" + "net" + "net/http" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitHubCredentialsClaimWaitsForDrainingListener(t *testing.T) { + ln, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + server := &http.Server{ReadHeaderTimeout: time.Second} + server.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _ = ln.Close() + }) + joined := make(chan struct{}) + go func() { defer close(joined); _ = server.Serve(ln) }() + t.Cleanup(func() { _ = server.Close(); <-joined }) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := &CredentialsServerCmd{ConfigureGitHubHelper: true} + claimed, err := cmd.claimPort(ctx, port) + require.NoError(t, err, "a draining prior session must not suppress the next service") + require.NoError(t, claimed.Close()) +} + +func TestGitHubCredentialsClaimPreservesLiveOwner(t *testing.T) { + ln, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + status := credentials.Readiness{ + Version: credentials.ReadinessVersion, Owner: "other-user", HostOwner: "other-host", + Instance: "live-instance", GitHubAllowed: true, GitHubState: "installed", + } + server := &http.Server{ + ReadHeaderTimeout: time.Second, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/ready", r.URL.Path) + _ = json.NewEncoder(w).Encode(status) + }), + } + joined := make(chan struct{}) + go func() { defer close(joined); _ = server.Serve(ln) }() + t.Cleanup(func() { _ = server.Close(); <-joined }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + cmd := &CredentialsServerCmd{User: "vscode", HostOwner: "new-host", ConfigureGitHubHelper: true} + claimed, err := cmd.claimPort(ctx, port) + require.Nil(t, claimed) + require.ErrorIs(t, err, errPortOwnedByAnotherSession) + _, err = credentials.ProbeReadiness(ctx, port, status) + require.NoError(t, err, "a live owner must remain untouched") +} + +func TestGitHubCredentialsClaimCancellationPreservesUnresponsiveListener(t *testing.T) { + ln, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + port := ln.Addr().(*net.TCPAddr).Port + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := &CredentialsServerCmd{ConfigureGitHubHelper: true} + done := make(chan error, 1) + go func() { + claimed, claimErr := cmd.claimPort(ctx, port) + if claimed != nil { + _ = claimed.Close() + } + done <- claimErr + }() + require.NoError(t, ln.(*net.TCPListener).SetDeadline(time.Now().Add(3*time.Second))) + conn, err := ln.Accept() + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + cancel() + err = <-done + require.ErrorIs(t, err, context.Canceled) + require.ErrorIs(t, err, errPortOwnedByAnotherSession) + _, err = claimPort(port) + require.ErrorIs(t, err, errPortOwnedByAnotherSession) +} + +func TestGitHubCredentialsClaimBoundsUnavailableListener(t *testing.T) { + ln, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := &CredentialsServerCmd{ConfigureGitHubHelper: true} + claimed, err := cmd.claimPort(ctx, ln.Addr().(*net.TCPAddr).Port) + require.Nil(t, claimed) + require.ErrorIs(t, err, errPortOwnedByAnotherSession) + require.NoError( + t, + ctx.Err(), + "an unavailable listener must not consume the caller's entire deadline", + ) +} diff --git a/cmd/internal/agentcontainer/credentials_server.go b/cmd/internal/agentcontainer/credentials_server.go index 8184c21519..a01068da0a 100644 --- a/cmd/internal/agentcontainer/credentials_server.go +++ b/cmd/internal/agentcontainer/credentials_server.go @@ -2,6 +2,7 @@ package agentcontainer import ( "context" + "crypto/rand" "encoding/base64" "encoding/json" "errors" @@ -20,6 +21,7 @@ import ( "github.com/devsy-org/devsy/pkg/dockercredentials" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/ghcredentials" "github.com/devsy-org/devsy/pkg/gitcredentials" "github.com/devsy-org/devsy/pkg/gitsshsigning" "github.com/devsy-org/devsy/pkg/log" @@ -31,9 +33,11 @@ import ( type CredentialsServerCmd struct { *flags.GlobalFlags - User string + User string + HostOwner string ConfigureGitHelper bool + ConfigureGitHubHelper bool ConfigureDockerHelper bool ForwardPorts bool @@ -60,6 +64,13 @@ func NewCredentialsServerCmd(flags *flags.GlobalFlags) *cobra.Command { } cliflags.Add( credentialsServerCmd, + cliflags.String(&cmd.HostOwner, "host-owner", "manual", "Nonsecret host connection owner"), + cliflags.Bool( + &cmd.ConfigureGitHubHelper, + "configure-github-helper", + false, + "If true will configure GitHub CLI authentication forwarding", + ), cliflags.Bool( &cmd.ConfigureGitHelper, names.ConfigureGitHelper, @@ -91,7 +102,7 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, port int) error { runCtx, cancel := context.WithCancel(ctx) defer cancel() - ln, err := claimPort(port) + ln, err := cmd.claimPort(ctx, port) if err != nil { if errors.Is(err, errPortOwnedByAnotherSession) { cmd.logPortOwnedByAnotherSession(ctx, port) @@ -111,26 +122,43 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, port int) error { } cmd.maybeForwardPorts(runCtx, tunnelClient) + githubState := "disabled" + if cmd.ConfigureGitHubHelper { + result, installErr := ghcredentials.Install(runCtx, port, cmd.User) + githubState = result.Status + if err := installErr; err != nil { + log.Warnf("GitHub CLI wrapper installation unavailable: %v", err) + } + } + status := credentials.Readiness{ + Version: credentials.ReadinessVersion, + Owner: cmd.User, + HostOwner: cmd.HostOwner, + Instance: rand.Text(), + GitHubAllowed: cmd.ConfigureGitHubHelper, + GitHubState: githubState, + } + serverDone := make(chan error, 1) + go func() { serverDone <- credentials.RunPreparedCredentialsServer(runCtx, ln, tunnelClient, status) }() if err := cmd.configureDockerHelper(port); err != nil { - return err + log.Warnf("Docker credentials setup unavailable: %v", err) } if err := configureGitUserLocally(runCtx, cmd.User, tunnelClient); err != nil { log.Warnf("error configuring git user: %v", err) - return err } cleanupGitHelper, err := cmd.configureGitCredentialHelper(runCtx, port) if err != nil { - return err + log.Warnf("Git credentials setup unavailable: %v", err) } defer cleanupGitHelper() cleanupGitSigning := cmd.configureGitSigningKey() defer cleanupGitSigning() - return credentials.RunCredentialsServerWithListener(runCtx, ln, tunnelClient, cmd.User) + return <-serverDone } // errPortOwnedByAnotherSession marks a bind failure as another session diff --git a/cmd/internal/agentcontainer/credentials_status.go b/cmd/internal/agentcontainer/credentials_status.go new file mode 100644 index 0000000000..a183cc382b --- /dev/null +++ b/cmd/internal/agentcontainer/credentials_status.go @@ -0,0 +1,52 @@ +package agentcontainer + +import ( + "context" + "encoding/json" + "time" + + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/spf13/cobra" +) + +func NewCredentialsStatusCmd() *cobra.Command { + var expected credentials.Readiness + cmd := &cobra.Command{ + Use: "credentials-status", Hidden: true, Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + port, err := credentials.GetPort() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(c.Context(), 10*time.Second) + defer cancel() + status := waitCredentialsStatus(ctx, port, expected) + return json.NewEncoder(c.OutOrStdout()).Encode(status) + }, + } + cmd.Flags().StringVar(&expected.Owner, "user", "", "Guest service owner") + cmd.Flags().StringVar(&expected.HostOwner, "host-owner", "", "Nonsecret host connection owner") + cmd.Flags(). + BoolVar(&expected.GitHubAllowed, "github-enabled", false, "Expected effective permission") + return cmd +} + +func waitCredentialsStatus( + ctx context.Context, + port int, + expected credentials.Readiness, +) credentials.Readiness { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + status, err := credentials.ProbeReadiness(ctx, port, expected) + if err == nil { + return status + } + select { + case <-ctx.Done(): + return credentials.Readiness{GitHubState: "unavailable"} + case <-ticker.C: + } + } +} diff --git a/cmd/internal/agentcontainer/credentials_status_test.go b/cmd/internal/agentcontainer/credentials_status_test.go new file mode 100644 index 0000000000..e6cec3a5c7 --- /dev/null +++ b/cmd/internal/agentcontainer/credentials_status_test.go @@ -0,0 +1,43 @@ +package agentcontainer + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/stretchr/testify/require" +) + +func TestCredentialsStatusWaitsThroughPriorSessionIdentity(t *testing.T) { + current := credentials.Readiness{ + Version: credentials.ReadinessVersion, Owner: "vscode", HostOwner: "current-host", + Instance: "current-instance", GitHubAllowed: true, GitHubState: "installed", + } + prior := current + prior.HostOwner, prior.Instance = "prior-host", "prior-instance" + prior.GitHubAllowed, prior.GitHubState = false, "disabled" + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + status := current + if requests.Add(1) == 1 { + status = prior + } + _ = json.NewEncoder(w).Encode(status) + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + status := waitCredentialsStatus(ctx, server.Listener.Addr().(*net.TCPAddr).Port, current) + require.True( + t, + status.Matches(current), + "a prior listener must not terminate preparation before the new session is ready", + ) + require.GreaterOrEqual(t, requests.Load(), int32(2)) +} diff --git a/cmd/internal/browser_tunnel.go b/cmd/internal/browser_tunnel.go index 1afbd4e092..1abcd2bb74 100644 --- a/cmd/internal/browser_tunnel.go +++ b/cmd/internal/browser_tunnel.go @@ -194,14 +194,11 @@ func (cmd *BrowserTunnelCmd) Run(ctx context.Context) error { return fmt.Errorf("forward gpg agent: %w", err) } } - - // Launch the probe-then-open goroutine before the blocking - // StartBrowserTunnel call so the TCP probe runs concurrently with - // SSH dial + listener bind. The probe observes ctx so it unwinds - // when the helper receives a signal. - if cmd.OpenBrowser && cmd.TargetURL != "" { - go opener.OpenBrowserWhenReachable(ctx, cmd.TargetURL) + preparation, err := opener.NewBrowserPreparation(client.Context(), client.Workspace()) + if err != nil { + return fmt.Errorf("identify browser preparation: %w", err) } + defer preparation.Close() return tunnel.StartBrowserTunnel(ctx, tunnel.BrowserTunnelParams{ DevsyConfig: devsyConfig, @@ -214,6 +211,9 @@ func (cmd *BrowserTunnelCmd) Run(ctx context.Context) error { GitSSHSigningKey: cmd.GitSSHSigningKey, GPGAgentForwarding: cmd.GPGAgentForwarding, ExtraListeners: extraListeners, + OnPrepared: func(ctx context.Context) { + cmd.openPreparedBrowser(ctx, preparation) + }, // Helper lifecycle is owned by opener.KillBrowserTunnel and // devsy stop/delete; the EXIT_AFTER_TIMEOUT idle shutdown // would otherwise close the port-forward shortly after the @@ -222,6 +222,18 @@ func (cmd *BrowserTunnelCmd) Run(ctx context.Context) error { }) } +func (cmd *BrowserTunnelCmd) openPreparedBrowser( + ctx context.Context, + preparation *opener.BrowserPreparation, +) { + if err := preparation.Mark(); err != nil { + pkglog.Debug("browser preparation marker unavailable") + } + if cmd.OpenBrowser && cmd.TargetURL != "" { + go opener.OpenBrowserWhenReachable(ctx, cmd.TargetURL) + } +} + // installSignalCancel registers a signal handler that cancels ctx on the // usual termination signals. The returned func should be deferred to stop // signal delivery. diff --git a/cmd/internal/gh.go b/cmd/internal/gh.go new file mode 100644 index 0000000000..9eec10a5d4 --- /dev/null +++ b/cmd/internal/gh.go @@ -0,0 +1,36 @@ +package cmdinternal + +import ( + "encoding/json" + "os" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/ghcredentials" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +func NewGHCmd() *cobra.Command { + return &cobra.Command{ + Use: "gh", + Hidden: true, + DisableFlagParsing: true, + PersistentPreRunE: ghPreRunE, + Run: func(_ *cobra.Command, args []string) { os.Exit(ghcredentials.Run(args)) }, + } +} + +func NewGHBackendCmd() *cobra.Command { + return &cobra.Command{ + Use: "gh-backend", Hidden: true, Args: cobra.NoArgs, + PersistentPreRunE: ghPreRunE, + RunE: func(c *cobra.Command, _ []string) error { + return json.NewEncoder(c.OutOrStdout()).Encode(ghcredentials.DiscoverBackend()) + }, + } +} + +func ghPreRunE(*cobra.Command, []string) error { + log.Init(log.Config{Level: os.Getenv(config.EnvLogLevel), DefaultLevel: log.DefaultLevel}) + return nil +} diff --git a/cmd/internal/gh_test.go b/cmd/internal/gh_test.go new file mode 100644 index 0000000000..bada9e0387 --- /dev/null +++ b/cmd/internal/gh_test.go @@ -0,0 +1,59 @@ +package cmdinternal + +import ( + "errors" + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestGHCommandForwardsArgumentsAndConfiguresLogging(t *testing.T) { + for _, level := range []string{"", "debug", "trace"} { + t.Run(level, func(t *testing.T) { + t.Setenv(config.EnvLogLevel, level) + t.Cleanup(func() { log.Init(log.Config{DefaultLevel: log.DefaultLevel}) }) + parent := NewInternalCmd(&flags.GlobalFlags{}) + parent.PersistentPreRunE = func(*cobra.Command, []string) error { + return errors.New("parent initialization must not run") + } + args := []string{ + "/usr/local/bin/gh", + "api", + "--context", + "repo", + "--help", + "--log-level", + "invalid", + "--", + "one two", + } + command, _, err := parent.Find(append([]string{"gh"}, args...)) + require.NoError(t, err) + require.Equal(t, "gh", command.Name()) + var got []string + command.Run = func(_ *cobra.Command, args []string) { got = args } + parent.SetArgs(append([]string{"gh"}, args...)) + require.NoError(t, parent.Execute()) + require.Equal(t, args, got) + want := level + if want == "" { + want = log.DefaultLevel + } + require.Equal(t, want, log.LevelString()) + }) + } +} + +func TestGHWrapperInstallCommandRejectsInvalidPorts(t *testing.T) { + for _, port := range []string{"0", "65536", "not-a-port"} { + t.Run(port, func(t *testing.T) { + cmd := NewInstallGHWrapperCmd() + cmd.SetArgs([]string{port}) + require.ErrorContains(t, cmd.Execute(), "invalid credentials server port") + }) + } +} diff --git a/cmd/internal/install_gh_wrapper.go b/cmd/internal/install_gh_wrapper.go new file mode 100644 index 0000000000..113a04c890 --- /dev/null +++ b/cmd/internal/install_gh_wrapper.go @@ -0,0 +1,28 @@ +package cmdinternal + +import ( + "errors" + "os" + "strconv" + + "github.com/devsy-org/devsy/pkg/ghcredentials" + "github.com/spf13/cobra" +) + +func NewInstallGHWrapperCmd() *cobra.Command { + return &cobra.Command{ + Use: "install-gh-wrapper ", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + port, err := strconv.Atoi(args[0]) + if err != nil || port < 1 || port > 65535 { + return errors.New("invalid credentials server port") + } + if os.Geteuid() != 0 { + return errors.New("GitHub CLI wrapper installation requires root") + } + return ghcredentials.InstallPrepared(c.Context(), port) + }, + } +} diff --git a/cmd/internal/internal.go b/cmd/internal/internal.go index 65c448396f..5d011c36cc 100644 --- a/cmd/internal/internal.go +++ b/cmd/internal/internal.go @@ -16,6 +16,8 @@ func NewInternalCmd(globalFlags *flags.GlobalFlags) *cobra.Command { } cmd.AddCommand(NewAgentCmd(globalFlags)) cmd.AddCommand(NewRuntimeSupervisorCmd()) + cmd.AddCommand(NewGHCmd()) + cmd.AddCommand(NewGHBackendCmd()) cmd.AddCommand(NewDaemonLocalCmd(globalFlags)) cmd.AddCommand(NewLogsDaemonCmd(globalFlags)) cmd.AddCommand(NewRunUserCommandsCmd(globalFlags)) @@ -48,6 +50,7 @@ func NewInternalCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd.AddCommand(withPreRun(NewGetImagePlatformsCmd(globalFlags))) cmd.AddCommand(withPreRun(NewBrowserTunnelCmd(globalFlags))) cmd.AddCommand(withPreRun(NewWidenSharedFileCmd(globalFlags))) + cmd.AddCommand(withPreRun(NewInstallGHWrapperCmd())) return cmd } diff --git a/cmd/machine/ssh.go b/cmd/machine/ssh.go index 700327ad49..8a21dccbee 100644 --- a/cmd/machine/ssh.go +++ b/cmd/machine/ssh.go @@ -59,6 +59,7 @@ var ( type SSHSessionOptions struct { TermMode string InstallTerminfo bool + EnvVars map[string]string } type StartSSHSessionOptions struct { @@ -242,6 +243,11 @@ func RunSSHSession(ctx context.Context, sshClient *ssh.Client, options RunSSHSes return err } defer func() { _ = session.Close() }() + for key, value := range options.SessionOptions.EnvVars { + if err := session.Setenv(key, value); err != nil { + return fmt.Errorf("set SSH session environment: %w", err) + } + } if err := configureAgentForwarding(sshClient, session, options.AgentForwarding); err != nil { return err diff --git a/cmd/workspace/ssh.go b/cmd/workspace/ssh.go index 3b8f9d2245..4458485f63 100644 --- a/cmd/workspace/ssh.go +++ b/cmd/workspace/ssh.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "maps" "os" "path" "strings" @@ -16,6 +17,7 @@ import ( client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/credentials" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -294,7 +296,7 @@ func (cmd *SSHCmd) jumpContainerTailscale( return err } - cmd.startServicesDaemon(ctx, devsyConfig, client, toolSSHClient) + githubEnv := cmd.startServicesDaemon(ctx, devsyConfig, client, toolSSHClient) gpgTunnel := newGPGTunnel(cmd, devsyConfig) defer runGPGTunnelInBackground(ctx, gpgTunnel, toolSSHClient)() @@ -318,6 +320,7 @@ func (cmd *SSHCmd) jumpContainerTailscale( SessionOptions: machine.SSHSessionOptions{ TermMode: cmd.TermMode, InstallTerminfo: cmd.InstallTerminfo, + EnvVars: githubEnv, }, Stderr: os.Stderr, }, @@ -329,26 +332,22 @@ func (cmd *SSHCmd) startServicesDaemon( devsyConfig *config.Config, client client2.DaemonClient, sshClient *ssh.Client, -) { +) map[string]string { if !cmd.StartServices { - return + return tunnel.GitHubSessionEnvironment(credentials.Readiness{GitHubState: "disabled"}) } - go func() { - err := clientimplementation.StartServicesDaemon( - ctx, - clientimplementation.StartServicesDaemonOptions{ - DevsyConfig: devsyConfig, - Client: client, - SSHClient: sshClient, - User: cmd.User, - ForwardPorts: false, - ExtraPorts: nil, - }, - ) - if err != nil { - log.Errorf("error starting services: %v", err) - } - }() + status := clientimplementation.StartPreparedServicesDaemon( + ctx, + clientimplementation.StartServicesDaemonOptions{ + DevsyConfig: devsyConfig, + Client: client, + SSHClient: sshClient, + User: cmd.User, + ForwardPorts: false, + ExtraPorts: nil, + }, + ) + return tunnel.GitHubSessionEnvironment(status) } func (cmd *SSHCmd) startProxyTunnel( @@ -445,7 +444,7 @@ func (cmd *SSHCmd) startTunnel( return err } - cmd.startTunnelServices(ctx, devsyConfig, containerClient, workspaceClient) + githubEnv := cmd.startTunnelServices(ctx, devsyConfig, containerClient, workspaceClient) writer, cleanup := cmd.setupTunnelWriter(ctx, devsyConfig, containerClient) defer cleanup() @@ -459,6 +458,7 @@ func (cmd *SSHCmd) startTunnel( if err != nil { return err } + maps.Copy(envVars, githubEnv) // Traffic is coming in from the outside, we need to forward it to the container if cmd.Stdio { @@ -541,6 +541,7 @@ func (cmd *SSHCmd) runInteractiveTunnelSession( SessionOptions: machine.SSHSessionOptions{ TermMode: cmd.TermMode, InstallTerminfo: cmd.InstallTerminfo, + EnvVars: params.envVars, }, Exec: func(ctx context.Context, stdin io.Reader, stdout io.Writer, _ io.Writer) error { if cmd.SSHKeepAliveInterval != DisableSSHKeepAlive { @@ -565,15 +566,18 @@ func (cmd *SSHCmd) startTunnelServices( devsyConfig *config.Config, containerClient *ssh.Client, workspaceClient client2.BaseWorkspaceClient, -) { +) map[string]string { if !cmd.StartServices { - return + return tunnel.GitHubSessionEnvironment(credentials.Readiness{GitHubState: "disabled"}) } opts := startServicesOptions{ ConfigureDockerCredentials: devsyConfig.ContextOption( config.ContextOptionSSHInjectDockerCredentials, ) == config.BoolTrue, + ConfigureGitHubCredentials: devsyConfig.ContextOption( + config.ContextOptionGitHubCLIForwarding, + ) == config.BoolTrue, ConfigureGitCredentials: devsyConfig.ContextOption( config.ContextOptionSSHInjectGitCredentials, ) == config.BoolTrue, @@ -582,7 +586,14 @@ func (cmd *SSHCmd) startTunnelServices( ) == config.BoolTrue, GitSSHSigningKey: cmd.GitSSHSigningKey, } - go cmd.startServices(ctx, devsyConfig, containerClient, workspaceClient.WorkspaceConfig(), opts) + status := cmd.startServices( + ctx, + devsyConfig, + containerClient, + workspaceClient.WorkspaceConfig(), + opts, + ) + return tunnel.GitHubSessionEnvironment(status) } func (cmd *SSHCmd) buildSSHServerCommand( @@ -656,6 +667,7 @@ func resolveMergedWorkspaceFolder( type startServicesOptions struct { ConfigureDockerCredentials bool ConfigureGitCredentials bool + ConfigureGitHubCredentials bool ConfigureGitSSHSignatureHelper bool GitSSHSigningKey string } @@ -666,9 +678,9 @@ func (cmd *SSHCmd) startServices( containerClient *ssh.Client, workspace *provider.Workspace, opts startServicesOptions, -) { +) credentials.Readiness { if cmd.User != "" { - err := tunnel.RunServices( + return tunnel.StartPreparedServices( ctx, tunnel.RunServicesOptions{ DevsyConfig: devsyConfig, @@ -680,14 +692,13 @@ func (cmd *SSHCmd) startServices( Workspace: workspace, ConfigureDockerCredentials: opts.ConfigureDockerCredentials, ConfigureGitCredentials: opts.ConfigureGitCredentials, + ConfigureGitHubCredentials: opts.ConfigureGitHubCredentials, ConfigureGitSSHSignatureHelper: opts.ConfigureGitSSHSignatureHelper, GitSSHSigningKey: opts.GitSSHSigningKey, }, ) - if err != nil { - log.Debugf("error running credential server: %v", err) - } } + return credentials.Readiness{GitHubState: "unavailable"} } type sshKeepAliveClient interface { diff --git a/desktop/e2e/contexts.e2e.ts b/desktop/e2e/contexts.e2e.ts index c28b8772a8..a2833eefae 100644 --- a/desktop/e2e/contexts.e2e.ts +++ b/desktop/e2e/contexts.e2e.ts @@ -1,11 +1,15 @@ +import { readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import type { ElectronApplication, Page } from "@playwright/test" import { expect, test } from "@playwright/test" -import { launchApp } from "./electron-app.js" +import { launchApp, resetMockState } from "./electron-app.js" let app: ElectronApplication let page: Page test.beforeAll(async () => { + resetMockState() ;({ app, page } = await launchApp()) await page.click('[data-sidebar="sidebar"] a[href="#/contexts"]') await page @@ -66,4 +70,51 @@ test.describe("Contexts Page", () => { const switchCount = await switches.count() expect(switchCount).toBeGreaterThanOrEqual(5) // At least: telemetry, docker creds, git creds, ssh agent, etc. }) + test("saves and reloads independent GitHub forwarding and retains edits on failed save", async () => { + const sheet = page.getByRole("dialog") + const github = sheet.getByRole("switch", { name: "GitHub CLI Credentials" }) + await expect(github).toBeVisible() + await expect(github).toHaveAttribute("aria-checked", "false") + await github.click() + await sheet.getByRole("button", { name: "Save", exact: true }).click() + await expect(sheet.getByText("Unsaved changes")).toBeHidden() + await page.keyboard.press("Escape") + await page + .locator('[data-slot="sidebar-inset"] main') + .getByRole("button", { name: "default" }) + .first() + .click() + await expect(github).toHaveAttribute("aria-checked", "true") + const statePath = join(tmpdir(), "devsy-mock-state.json") + const state = JSON.parse(readFileSync(statePath, "utf8")) + expect(state.contextOptions.default).toEqual({ + SSH_INJECT_GITHUB_CREDENTIALS: { value: "true" }, + }) + state.failContextSave = true + writeFileSync(statePath, JSON.stringify(state)) + try { + await github.click() + await sheet.getByRole("button", { name: "Save", exact: true }).click() + await expect(page.getByText(/Failed to save:/)).toBeVisible() + await expect(sheet.getByText("Unsaved changes")).toBeVisible() + expect( + JSON.parse(readFileSync(statePath, "utf8")).contextOptions.default + .SSH_INJECT_GITHUB_CREDENTIALS.value, + ).toBe("true") + await sheet.getByRole("button", { name: "Reset", exact: true }).click() + await expect(github).toHaveAttribute("aria-checked", "true") + await github.scrollIntoViewIfNeeded() + await github + .locator("..") + .screenshot({ path: "test-results/github-cli-context-row.png" }) + await page.screenshot({ + path: "test-results/github-cli-context.png", + fullPage: true, + }) + } finally { + const latest = JSON.parse(readFileSync(statePath, "utf8")) + latest.failContextSave = false + writeFileSync(statePath, JSON.stringify(latest)) + } + }) }) diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 8115980c94..2a92afe0bf 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -859,6 +859,7 @@ switch (cmd) { DOTFILES_SCRIPT: { value: "" }, SSH_INJECT_DOCKER_CREDENTIALS: { value: "true" }, SSH_INJECT_GIT_CREDENTIALS: { value: "true" }, + SSH_INJECT_GITHUB_CREDENTIALS: { value: "false" }, GIT_SSH_SIGNATURE_FORWARDING: { value: "true" }, SSH_AGENT_FORWARDING: { value: "true" }, SSH_ADD_PRIVATE_KEYS: { value: "true" }, @@ -869,10 +870,28 @@ switch (cmd) { EXIT_AFTER_TIMEOUT: { value: "true" }, SSH_CONFIG_PATH: { value: "" }, SSH_CONFIG_INCLUDE_PATH: { value: "" }, + ...(state.contextOptions?.[variableContext] || {}), }) break + case "set": { + if (state.failContextSave) { + process.stderr.write("fixture context save failure\n") + process.exit(1) + } + const name = extra || variableContext + state.contextOptions ??= {} + state.contextOptions[name] ??= {} + for (let i = 0; i < rawArgs.length; i++) { + if (rawArgs[i] === "-o") { + const [key, ...values] = rawArgs[++i].split("=") + state.contextOptions[name][key] = {value: values.join("=")} + } + } + saveState(state) + out("") + break + } case "use": - case "set": case "create": case "delete": out("") diff --git a/desktop/src/renderer/src/lib/components/context/ContextSheet.svelte b/desktop/src/renderer/src/lib/components/context/ContextSheet.svelte index 795d66f303..ecd572263d 100644 --- a/desktop/src/renderer/src/lib/components/context/ContextSheet.svelte +++ b/desktop/src/renderer/src/lib/components/context/ContextSheet.svelte @@ -41,6 +41,7 @@ let opts = $state({ dotfilesUrl: "", dotfilesScript: "", dockerCredentialForwarding: true, + githubCliCredentialForwarding: false, gitCredentialForwarding: true, gitSshSignatureForwarding: true, sshAgentForwarding: true, @@ -61,6 +62,7 @@ let initialOpts = $state({ dotfilesUrl: "", dotfilesScript: "", dockerCredentialForwarding: true, + githubCliCredentialForwarding: false, gitCredentialForwarding: true, gitSshSignatureForwarding: true, sshAgentForwarding: true, @@ -396,6 +398,14 @@ async function handleSaveAll() { toggleOption("gitCredentialForwarding")} disabled={saving || loading} /> +
+
+ +

Let workspaces use active host accounts on all GitHub hosts. Reconnect to apply.

+
+ toggleOption("githubCliCredentialForwarding")} disabled={saving || loading} /> +
+
diff --git a/desktop/src/renderer/src/lib/stores/settings.test.ts b/desktop/src/renderer/src/lib/stores/settings.test.ts index 479838fb35..c83dc18e13 100644 --- a/desktop/src/renderer/src/lib/stores/settings.test.ts +++ b/desktop/src/renderer/src/lib/stores/settings.test.ts @@ -3,8 +3,11 @@ import { beforeEach, describe, expect, it } from "vitest" import { applyTheme, + CONTEXT_OPTION_KEYS, cycleTheme, + DEFAULT_CONTEXT_OPTIONS, getWorkspaceRecoveryState, + parseContextOptions, setWorkspaceRecoveryState, theme, } from "./settings.js" @@ -107,3 +110,31 @@ describe("settings store", () => { }) }) }) + +describe("GitHub CLI context forwarding", () => { + it("defaults off for absent and empty options", () => { + expect(DEFAULT_CONTEXT_OPTIONS.githubCliCredentialForwarding).toBe(false) + expect(parseContextOptions({}).githubCliCredentialForwarding).toBe(false) + expect( + parseContextOptions({ SSH_INJECT_GITHUB_CREDENTIALS: { value: "" } }) + .githubCliCredentialForwarding, + ).toBe(false) + expect(CONTEXT_OPTION_KEYS.githubCliCredentialForwarding).toBe( + "SSH_INJECT_GITHUB_CREDENTIALS", + ) + }) + it("stays independent from Git and Docker", () => { + const enabled = parseContextOptions({ + SSH_INJECT_GITHUB_CREDENTIALS: { value: "true" }, + SSH_INJECT_GIT_CREDENTIALS: { value: "false" }, + SSH_INJECT_DOCKER_CREDENTIALS: { value: "false" }, + }) + expect(enabled.githubCliCredentialForwarding).toBe(true) + expect(enabled.gitCredentialForwarding).toBe(false) + expect(enabled.dockerCredentialForwarding).toBe(false) + expect( + parseContextOptions({ SSH_INJECT_GITHUB_CREDENTIALS: { value: "false" } }) + .githubCliCredentialForwarding, + ).toBe(false) + }) +}) diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index f7c97d3b99..0c0b234790 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -215,6 +215,7 @@ export interface ContextOptions { dotfilesUrl: string dotfilesScript: string dockerCredentialForwarding: boolean + githubCliCredentialForwarding: boolean gitCredentialForwarding: boolean gitSshSignatureForwarding: boolean sshAgentForwarding: boolean @@ -248,6 +249,7 @@ export const DEFAULT_CONTEXT_OPTIONS: ContextOptions = { dotfilesUrl: "", dotfilesScript: "", dockerCredentialForwarding: true, + githubCliCredentialForwarding: false, gitCredentialForwarding: true, gitSshSignatureForwarding: true, sshAgentForwarding: true, @@ -280,6 +282,7 @@ export const CONTEXT_OPTION_KEYS: Record = { dotfilesUrl: "DOTFILES_URL", dotfilesScript: "DOTFILES_SCRIPT", dockerCredentialForwarding: "SSH_INJECT_DOCKER_CREDENTIALS", + githubCliCredentialForwarding: "SSH_INJECT_GITHUB_CREDENTIALS", gitCredentialForwarding: "SSH_INJECT_GIT_CREDENTIALS", gitSshSignatureForwarding: "GIT_SSH_SIGNATURE_FORWARDING", sshAgentForwarding: "SSH_AGENT_FORWARDING", @@ -346,6 +349,8 @@ export function parseContextOptions( dotfilesUrl: str("DOTFILES_URL"), dotfilesScript: str("DOTFILES_SCRIPT"), dockerCredentialForwarding: bool("SSH_INJECT_DOCKER_CREDENTIALS", true), + githubCliCredentialForwarding: + str("SSH_INJECT_GITHUB_CREDENTIALS") === "true", gitCredentialForwarding: bool("SSH_INJECT_GIT_CREDENTIALS", true), gitSshSignatureForwarding: bool("GIT_SSH_SIGNATURE_FORWARDING", true), sshAgentForwarding: bool("SSH_AGENT_FORWARDING", true), diff --git a/e2e/cmd/ghfixture/main_linux.go b/e2e/cmd/ghfixture/main_linux.go new file mode 100644 index 0000000000..02aa3d7a1d --- /dev/null +++ b/e2e/cmd/ghfixture/main_linux.go @@ -0,0 +1,20 @@ +//go:build linux + +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/devsy-org/devsy/e2e/framework/ghfixture" +) + +func run() int { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + return ghfixture.Run(ctx, "/fixture", os.Args[1:], os.Stdout) +} + +func main() { os.Exit(run()) } diff --git a/e2e/framework/ghfixture/auth_race_extras_linux.go b/e2e/framework/ghfixture/auth_race_extras_linux.go new file mode 100644 index 0000000000..34d06d4e75 --- /dev/null +++ b/e2e/framework/ghfixture/auth_race_extras_linux.go @@ -0,0 +1,177 @@ +//go:build linux + +package ghfixture + +import ( + "strings" +) + +func (c *checks) checkIndependentHostRace() bool { + prefix := "auth race different hosts: " + base, ok := c.prepareAuthenticationRace("mixed", authRace{first: refreshOperation}) + if !c.verify(prefix+"alias prepared", ok) { + return false + } + p := c.pendingAlias(authRaceAlias) + defer p.stop() + if !c.verify(prefix+"mutation reached barrier", c.waitReady()) || + !c.verify( + prefix+"parallel login succeeds", + c.mutateAuthentication(loginOperation, Hosts[2]).code == 0, + ) { + return false + } + if !writeFixture(c.release, "") || + !c.verify(prefix+"pending command preserves exit", p.join()) { + return false + } + base[Hosts[0]] = c.expectedAuthentication(base[Hosts[0]], refreshOperation, Hosts[0]) + base[Hosts[2]] = c.expectedAuthentication(raceHost{}, loginOperation, Hosts[2]) + return c.checkIndependentHostResult(prefix, p, base) +} + +func (c *checks) checkIndependentHostResult( + prefix string, + p *pendingCommand, + base map[string]raceHost, +) bool { + return c.verify( + prefix+"no authentication conflict", + !strings.Contains(p.stderr.String(), "authentication_conflict"), + ) && + c.verify( + prefix+"both API identities preserved", + c.originalIdentity(Hosts[0], hostUser) && c.originalIdentity(Hosts[2], secondUser), + ) && + c.verify( + prefix+"complete authentication metadata preserved", + c.authenticationMatches(base), + ) && + c.verify(prefix+"forwarded credentials absent from artifacts", c.authArtifactsClean()) +} + +func (c *checks) checkIndependentKeyringRace() bool { + prefix := "auth race independent keyring: " + host := Hosts[0] + base, ok := c.prepareAuthenticationRace("mixed", authRace{first: refreshOperation}) + if !c.verify(prefix+"alias prepared", ok) { + return false + } + p := c.pendingAlias(authRaceAlias) + defer p.stop() + if !c.verify(prefix+"mutation reached barrier", c.waitReady()) { + return false + } + login := c.originalCommand( + c.accounts[host].Second, + "auth", + "login", + "--hostname", + host, + "--with-token", + ) + defer c.originalCommand("", "auth", "logout", "--hostname", host, "--user", secondUser) + if !c.verify(prefix+"parallel secure login succeeds", login.code == 0) || + !writeFixture(c.release, "") || + !c.verify(prefix+"pending command preserves exit", p.join()) { + return false + } + expected := base[host] + expected.User, expected.Token = secondUser, "" + expected.Users[secondUser] = raceAccount{} + base[host] = expected + return c.checkIndependentKeyringResult(prefix, p, base) +} + +func (c *checks) checkIndependentKeyringResult( + prefix string, + p *pendingCommand, + base map[string]raceHost, +) bool { + host := Hosts[0] + token := c.originalCommand("", "auth", "token", "--hostname", host) + return c.verify( + prefix+"conflict reported", + strings.Contains(p.stderr.String(), "authentication_conflict"), + ) && + c.verify( + prefix+"keyring token preserved", + token.code == 0 && strings.TrimSpace(token.stdout) == c.accounts[host].Second, + ) && + c.verify(prefix+"API identity preserved", c.originalIdentity(host, secondUser)) && + c.verify( + prefix+"complete authentication metadata preserved", + c.authenticationMatches(base), + ) && + c.verify(prefix+"forwarded credentials absent from artifacts", c.authArtifactsClean()) +} + +func (c *checks) originalCommand(input string, args ...string) processResult { + result := execute(c.ctx, processOptions{env: c.env, input: input}, realGH, args...) + c.last = result + c.stderr = append(c.stderr, result.stderr) + return result +} + +func (c *checks) checkLogoutRecovery() bool { + prefix := "auth race recovery after logout: " + base, ok := c.prepareAuthenticationRace("mixed", authRace{first: loginOperation}) + if !c.verify(prefix+"alias prepared", ok) { + return false + } + if !c.verify( + prefix+"pending preference prepared", + c.setAuthenticationAlias(loginOperation, "auth-race-pending"), + ) { + return false + } + config, err := readFixture(c.target) + if err != nil { + return false + } + defer restoreFixture(c.target, config, err) + if !c.checkFailedLogoutWriteback(prefix) || !writeFixture(c.target, string(config)) { + return false + } + return c.finishLogoutRecovery(prefix, base) +} + +func (c *checks) checkFailedLogoutWriteback(prefix string) bool { + p := c.pendingAlias(authRaceAlias) + defer p.stop() + if !c.verify(prefix+"mutation reached barrier", c.waitReady()) || + !writeFixture(c.target, "editor: [\n") || + !writeFixture(c.release, "") || + !c.verify(prefix+"failed writeback preserves exit", p.join()) { + return false + } + return c.verify( + prefix+"failed writeback remains sanitized", + c.hasSessions() && c.authArtifactsClean(), + ) +} + +func (c *checks) finishLogoutRecovery(prefix string, base map[string]raceHost) bool { + host := Hosts[0] + if !c.verify( + prefix+"later logout succeeds", + c.originalCommand("", "auth", "logout", "--hostname", host, "--user", secondUser).code == 0 && + c.originalCommand("", "auth", "logout", "--hostname", host, "--user", hostUser).code == 0, + ) { + return false + } + base[host] = raceHost{} + if !c.verify(prefix+"API unauthenticated before recovery", c.originalIdentity(host, "")) { + return false + } + return c.verify( + prefix+"recovery completes", + c.setting("editor") == "auth-race-pending" && !c.hasSessions(), + ) && + c.verify( + prefix+"API remains unauthenticated after recovery", + c.originalIdentity(host, ""), + ) && + c.verify(prefix+"logout metadata never resurrected", c.authenticationMatches(base)) && + c.verify(prefix+"forwarded credentials absent from artifacts", c.authArtifactsClean()) +} diff --git a/e2e/framework/ghfixture/auth_races_linux.go b/e2e/framework/ghfixture/auth_races_linux.go new file mode 100644 index 0000000000..33d148b58c --- /dev/null +++ b/e2e/framework/ghfixture/auth_races_linux.go @@ -0,0 +1,292 @@ +//go:build linux + +package ghfixture + +import ( + "crypto/sha256" + "encoding/hex" + "maps" + "os" + "path/filepath" + "reflect" + "strings" + + "al.essio.dev/pkg/shellescape" + "gopkg.in/yaml.v3" +) + +const ( + refreshOperation = "refresh" + switchOperation = "switch" + logoutOperation = "logout" + loginOperation = "login" + authRaceAlias = "auth-pending" +) + +type authRace struct{ first, second string } + +type ( + raceAccount struct { + Token string `yaml:"oauth_token,omitempty"` + } + raceHost struct { + User string `yaml:"user,omitempty"` + Token string `yaml:"oauth_token,omitempty"` + Users map[string]raceAccount `yaml:"users,omitempty"` + Protocol string `yaml:"git_protocol,omitempty"` + } +) + +func (c *checks) checkAuthenticationRaces() bool { + path := filepath.Join(c.original, "hosts.yml") + before, err := readFixture(path) + defer restoreFixture(path, before, err) + config, configErr := readFixture(c.target) + defer restoreFixture(c.target, config, configErr) + env := c.env + c.env = overlayEnvironment(env, map[string]string{envGHBrowser: "/bin/true"}) + defer func() { c.env = env }() + for _, mode := range []string{"all-local", "mixed"} { + for _, race := range []authRace{ + {refreshOperation, switchOperation}, + {switchOperation, refreshOperation}, + {refreshOperation, logoutOperation}, + {logoutOperation, refreshOperation}, + {loginOperation, logoutOperation}, + {logoutOperation, loginOperation}, + } { + if !c.checkAuthenticationRace(mode, race) { + return false + } + } + } + if !c.checkIndependentHostRace() || !c.checkIndependentKeyringRace() || + !c.checkLogoutRecovery() || !c.checkMalformedLatestHostsRecovery() { + return false + } + return c.gh("", nil, "alias", "delete", authRaceAlias).code == 0 +} + +func (c *checks) checkAuthenticationRace(mode string, race authRace) bool { + prefix := "auth race " + mode + " " + race.first + " then " + race.second + ": " + base, ok := c.prepareAuthenticationRace(mode, race) + if !c.verify(prefix+"alias prepared", ok) { + return false + } + p := c.pendingAlias(authRaceAlias) + defer p.stop() + if !c.verify(prefix+"mutation reached barrier", c.waitReady()) { + return false + } + parallel := c.mutateAuthentication(race.second, Hosts[0]) + if !c.verify(prefix+"parallel mutation succeeds", parallel.code == 0) { + return false + } + if !writeFixture(c.release, "") { + return false + } + completed := p.join() + c.stderr = append(c.stderr, p.stderr.String()) + if !c.verify(prefix+"pending command preserves exit", completed) { + return false + } + return c.checkAuthenticationRaceResult(prefix, race, base, p) +} + +func (c *checks) checkAuthenticationRaceResult( + prefix string, + race authRace, + base map[string]raceHost, + p *pendingCommand, +) bool { + if !c.verify( + prefix+"conflict reported", + strings.Contains(p.stderr.String(), "authentication_conflict"), + ) { + return false + } + expected := c.expectedAuthentication(base[Hosts[0]], race.second, Hosts[0]) + if !c.verify(prefix+"API identity preserved", c.originalIdentity(Hosts[0], expected.User)) { + return false + } + base[Hosts[0]] = expected + if !c.verify( + prefix+"complete authentication metadata preserved", + c.authenticationMatches(base), + ) { + return false + } + return c.verify(prefix+"forwarded credentials absent from artifacts", c.authArtifactsClean()) +} + +func (c *checks) prepareAuthenticationRace(mode string, race authRace) (map[string]raceHost, bool) { + c.ready = filepath.Join(c.root, "auth-race-ready") + c.release = filepath.Join(c.root, "auth-race-release") + _ = os.Remove(c.ready) + _ = os.Remove(c.release) + base := c.authenticationRaceBase(mode, race) + // #nosec G117 -- Generated dummy accounts seed the disposable CLI configuration. + data, err := yaml.Marshal(base) + if err != nil || !writeFixture(filepath.Join(c.original, "hosts.yml"), string(data)) { + return nil, false + } + if !writeFixture(filepath.Join(c.root, "auth-race-token"), c.accounts[Hosts[0]].Second) { + return nil, false + } + return base, c.setAuthenticationAlias(race.first, "") +} + +func (c *checks) setAuthenticationAlias(operation, editor string) bool { + command := shellescape.QuoteCommand( + append([]string{"gh"}, authenticationArguments(operation, Hosts[0])...), + ) + if operation == loginOperation { + command += " < " + shellescape.Quote(filepath.Join(c.root, "auth-race-token")) + } + if editor != "" { + command += " && " + shellescape.QuoteCommand( + []string{"gh", "config", "set", "editor", editor}, + ) + } + alias := "!" + command + ` && touch "$GH_TEST_READY" && while test ! -f "$GH_TEST_RELEASE"; do sleep 0.05; done` + return c.gh("", nil, "alias", "set", "--clobber", authRaceAlias, alias).code == 0 +} + +func authenticationArguments(operation, host string) []string { + args := []string{"auth", operation, "--hostname", host} + switch operation { + case refreshOperation: + return append(args, "--insecure-storage") + case switchOperation: + return append(args, "--user", secondUser) + case logoutOperation: + return append(args, "--user", hostUser) + case loginOperation: + return append(args, "--with-token", "--insecure-storage") + default: + return nil + } +} + +func (c *checks) mutateAuthentication(operation, host string) processResult { + input := "" + if operation == loginOperation { + input = c.accounts[host].Second + } + return c.gh(input, nil, authenticationArguments(operation, host)...) +} + +func (c *checks) expectedAuthentication(base raceHost, operation, host string) raceHost { + users := map[string]raceAccount{} + maps.Copy(users, base.Users) + base.Users = users + switch operation { + case refreshOperation: + base.User, base.Token = hostUser, c.accounts[host].Refresh + case switchOperation, loginOperation: + base.User, base.Token = secondUser, c.accounts[host].Second + case logoutOperation: + return raceHost{} + } + base.Users[base.User] = raceAccount{Token: base.Token} + return base +} + +func (c *checks) originalIdentity(host, user string) bool { + result := execute( + c.ctx, + processOptions{env: c.env}, + realGH, + commandAPI, + "user", + "--hostname", + host, + "--jq", + ".login", + ) + c.last = result + c.stderr = append(c.stderr, result.stderr) + if user == "" { + return result.code != 0 && + (strings.Contains(result.stderr, "HTTP 401") || strings.Contains(result.stderr, "gh auth login")) + } + return result.code == 0 && strings.TrimSpace(result.stdout) == user +} + +func (c *checks) authenticationMatches(expected map[string]raceHost) bool { + actual := map[string]raceHost{} + if yaml.Unmarshal([]byte(c.saved()), &actual) != nil { + return false + } + for _, host := range Hosts { + a, e := actual[host], expected[host] + a.Protocol, e.Protocol = "", "" + if len(a.Users) == 0 { + a.Users = nil + } + if len(e.Users) == 0 { + e.Users = nil + } + if !reflect.DeepEqual(a, e) { + return false + } + } + return true +} + +func (c *checks) authenticationRaceBase(mode string, race authRace) map[string]raceHost { + base := map[string]raceHost{} + for _, host := range Hosts { + if host == Hosts[0] || mode == "all-local" { + token := localRaceToken(c.accounts[host]) + base[host] = raceHost{ + User: hostUser, + Token: token, + Users: map[string]raceAccount{hostUser: {Token: token}}, + Protocol: "https", + } + } + } + if race.first == switchOperation || race.second == switchOperation { + host := base[Hosts[0]] + host.Users[secondUser] = raceAccount{Token: c.accounts[Hosts[0]].Second} + } + return base +} + +func localRaceToken(account Tokens) string { + fingerprint := sha256.Sum256([]byte("local-auth-race:" + account.Forwarded)) + return "ghp_" + hex.EncodeToString(fingerprint[:])[:40] +} + +func (c *checks) authArtifactsClean() bool { + for _, root := range []string{c.original, filepath.Join(c.root, "guest-cache", "devsy", "gh-relay")} { + if filepath.WalkDir(root, c.checkAuthArtifact) != nil { + return false + } + } + return true +} + +func (c *checks) checkAuthArtifact(path string, entry os.DirEntry, err error) error { + if os.IsNotExist(err) { + return nil + } + if err != nil || entry.IsDir() { + return err + } + target, resolveErr := filepath.EvalSymlinks(path) + if resolveErr != nil { + return resolveErr + } + b, readErr := readFixture(target) + if readErr != nil { + return readErr + } + for _, account := range c.accounts { + if strings.Contains(string(b), account.Forwarded) { + return os.ErrInvalid + } + } + return nil +} diff --git a/e2e/framework/ghfixture/authentication_linux.go b/e2e/framework/ghfixture/authentication_linux.go new file mode 100644 index 0000000000..a02182161e --- /dev/null +++ b/e2e/framework/ghfixture/authentication_linux.go @@ -0,0 +1,399 @@ +//go:build linux + +package ghfixture + +import ( + "os" + "path/filepath" + "strings" +) + +func (c *checks) checkAuthentication() bool { + for _, host := range Hosts { + if !c.verify( + "all hosts forwarded through real RPC and HTTP relay: "+host, + c.api(host, hostUser, nil), + ) { + return false + } + } + if !c.verify("real auth status", c.gh("", nil, "auth", "status").code == 0) { + return false + } + if !c.verify("forwarding never saved", c.noForwardedSaved()) { + return false + } + return c.checkEnvironmentAuthentication() +} + +func (c *checks) checkEnvironmentAuthentication() bool { + r := c.gh("", map[string]string{"GH_HOST": Hosts[2]}, commandAPI, "user", "--jq", ".login") + if !c.verify("gh chooses GH_HOST", r.code == 0 && strings.TrimSpace(r.stdout) == hostUser) { + return false + } + r = c.gh( + "", + map[string]string{envToken: c.accounts[Hosts[0]].Invalid, envLogLevel: debugLevel}, + commandAPI, + "user", + ) + if !c.verify( + "explicit invalid GH_TOKEN never falls back", + r.code != 0 && strings.Contains(r.stderr, "authentication source") && + strings.Contains(r.stderr, "environment"), + ) { + return false + } + if !c.verify( + "explicit invalid GITHUB_TOKEN never falls back", + !c.api(Hosts[0], hostUser, map[string]string{"GITHUB_TOKEN": c.accounts[Hosts[0]].Invalid}), + ) { + return false + } + if !c.verify( + "explicit enterprise environment never falls back", + !c.api( + Hosts[1], + hostUser, + map[string]string{envEnterpriseToken: c.accounts[Hosts[1]].Invalid}, + ), + ) { + return false + } + return c.checkAuthority() +} + +func (c *checks) checkAuthority() bool { + for _, host := range Hosts[1:] { + patch := map[string]string{envEnterpriseToken: c.accounts[Hosts[0]].Forwarded} + if !c.verify("wrong-host token rejected REST: "+host, !c.api(host, hostUser, patch)) { + return false + } + r := c.gh( + "", + patch, + commandAPI, + "graphql", + "--hostname", + host, + "-f", + "query={viewer{login}}", + ) + if !c.verify("wrong-host token rejected GraphQL: "+host, r.code != 0) { + return false + } + r = c.gh( + "", + nil, + commandAPI, + "graphql", + "--hostname", + host, + "-f", + "query={viewer{login}}", + "--jq", + ".data.viewer.login", + ) + if !c.verify( + "correct-host GraphQL identity: "+host, + r.code == 0 && strings.TrimSpace(r.stdout) == hostUser, + ) { + return false + } + } + return true +} + +func (c *checks) checkLocalAuthentication() bool { + host := Hosts[0] + r := c.gh( + c.accounts[host].Forwarded, + nil, + "auth", + "login", + "--hostname", + host, + "--with-token", + "--insecure-storage", + ) + if !c.verify( + "same token login stays forwarded", + r.code == 0 && c.noForwardedSaved() && c.api(host, hostUser, nil), + ) { + return false + } + if !c.verify( + "auth switch uses forwarded account", + c.gh("", nil, "auth", "switch", "--hostname", host, "--user", hostUser).code == 0 && + c.noForwardedSaved(), + ) { + return false + } + return c.checkRefreshedAuthentication() +} + +func (c *checks) checkRefreshedAuthentication() bool { + host := Hosts[0] + r := c.gh( + "", + map[string]string{envGHBrowser: "/bin/true"}, + "auth", + "refresh", + "--hostname", + host, + "--insecure-storage", + ) + if !c.verify( + "refresh saves a newly issued credential", + r.code == 0 && strings.Contains(c.saved(), c.accounts[host].Refresh) && + c.api(host, hostUser, nil), + ) { + return false + } + if !c.verify( + "logout refreshed credential", + c.gh("", nil, "auth", "logout", "--hostname", host, "--user", hostUser).code == 0 && + c.api(host, hostUser, nil), + ) { + return false + } + return c.checkFileLogin() +} + +func (c *checks) checkFileLogin() bool { + host := Hosts[0] + r := c.gh( + c.accounts[host].Local, + nil, + "auth", + "login", + "--hostname", + host, + "--with-token", + "--insecure-storage", + ) + if !c.verify( + "different token local login persists", + r.code == 0 && strings.Contains(c.saved(), c.accounts[host].Local) && + c.api(host, localUser, nil), + ) { + return false + } + return c.checkInvalidLocalCredential() +} + +func (c *checks) checkInvalidLocalCredential() bool { + r := c.gh("", map[string]string{envLogLevel: debugLevel}, commandAPI, "user", "--jq", ".login") + if !c.verify( + "local authentication preference explained at debug level", + r.code == 0 && r.stdout == "local-user\n" && strings.Contains(r.stderr, "local_config"), + ) { + return false + } + return c.checkInvalidLocalFile() +} + +func (c *checks) checkInvalidLocalFile() bool { + host := Hosts[0] + local := c.saved() + if !writeFixture( + filepath.Join(c.original, "hosts.yml"), + strings.ReplaceAll(local, c.accounts[host].Local, c.accounts[host].Invalid), + ) { + return false + } + if !c.verify("invalid local credential never falls back", !c.api(host, hostUser, nil)) { + return false + } + if !writeFixture(filepath.Join(c.original, "hosts.yml"), local) { + return false + } + r := c.gh("", nil, "auth", "logout", "--hostname", host, "--user", localUser) + return c.verify( + "logout restores forwarding on next invocation", + r.code == 0 && c.api(host, hostUser, nil) && c.noForwardedSaved(), + ) +} + +func (c *checks) checkKeyring() bool { + host := Hosts[1] + r := c.gh(c.accounts[host].Local, nil, "auth", "login", "--hostname", host, "--with-token") + if !c.verify( + "keyring login succeeds without plaintext token", + r.code == 0 && !strings.Contains(c.saved(), c.accounts[host].Local) && + c.api(host, localUser, nil), + ) { + return false + } + r = c.gh( + "", + map[string]string{envLogLevel: debugLevel}, + commandAPI, + "user", + "--hostname", + host, + "--jq", + ".login", + ) + if !c.verify( + "existing keyring credential excluded from forwarding", + r.code == 0 && strings.TrimSpace(r.stdout) == localUser && + strings.Contains(r.stderr, "keyring"), + ) { + return false + } + return c.checkKeyringAvailability() +} + +func (c *checks) checkKeyringAvailability() bool { + host := Hosts[1] + if !c.verify( + "nested lookup preserves keyring authentication", + c.nestedAPI(host, localUser, nil), + ) { + return false + } + if !c.verify( + "unavailable keyring preserves registered guest identity", + !c.api(host, hostUser, map[string]string{envSessionBus: "unix:path=/nonexistent"}), + ) { + return false + } + if !c.verify("unavailable backend leaves local keyring intact", c.api(host, localUser, nil)) { + return false + } + return c.checkKeyringStatus() +} + +func (c *checks) checkKeyringStatus() bool { + host := Hosts[1] + if !c.checkRejectedKeyring() { + return false + } + r := c.gh("", nil, "auth", "status", "--hostname", host) + if !c.verify( + "real auth status identifies keyring", + r.code == 0 && strings.Contains(r.stdout+r.stderr, "keyring"), + ) { + return false + } + if !c.verify( + "keyring logout restores forwarding", + c.gh("", nil, "auth", "logout", "--hostname", host, "--user", localUser).code == 0 && + c.api(host, hostUser, nil), + ) { + return false + } + return c.checkSameTokenKeyring() +} + +func (c *checks) checkSameTokenKeyring() bool { + host := Hosts[1] + r := c.gh(c.accounts[host].Forwarded, nil, "auth", "login", "--hostname", host, "--with-token") + if !c.verify( + "same token explicit keyring login retained", + r.code == 0 && strings.Contains(c.saved(), hostUser) && c.noForwardedSaved() && + c.api(host, hostUser, nil), + ) { + return false + } + return c.checkSameTokenKeyringProof() +} + +func (c *checks) checkSameTokenKeyringProof() bool { + host := Hosts[1] + lookup := execute( + c.ctx, + processOptions{env: c.env, input: ""}, + realGH, + "auth", + "token", + "--hostname", + host, + ) + if !c.verify( + "same token keyring verified through original real CLI", + lookup.code == 0 && strings.TrimSpace(lookup.stdout) == c.accounts[host].Forwarded, + ) { + return false + } + return c.verify( + "same token keyring logout restores forwarding", + c.gh("", nil, "auth", "logout", "--hostname", host, "--user", hostUser).code == 0 && + c.api(host, hostUser, nil), + ) +} + +func (c *checks) checkRejectedKeyring() bool { + host := Hosts[1] + revoked := filepath.Join(c.root, "revoke-local") + if !writeFixture(revoked, "") { + return false + } + valid := c.verify("expired keyring credential never falls back", !c.api(host, hostUser, nil)) && + c.verify( + "nested expired keyring credential never falls back", + !c.nestedAPI(host, hostUser, nil), + ) + _ = os.Remove(revoked) + if !valid { + return false + } + forbidden := filepath.Join(c.root, "forbid-local") + if !writeFixture(forbidden, "") { + return false + } + defer func() { _ = os.Remove(forbidden) }() + r := c.gh("", nil, commandAPI, "repos/fixture/private", "--hostname", host) + if !c.verify( + "insufficient keyring permission never falls back", + r.code != 0 && strings.Contains(r.stderr, "403"), + ) { + return false + } + r = c.nested("", nil, commandAPI, "repos/fixture/private", "--hostname", host) + return c.verify( + "nested insufficient keyring permission never falls back", + r.code != 0 && strings.Contains(r.stderr, "403"), + ) +} + +func (c *checks) checkExtensions() bool { + if !c.verify( + "alias saves", + c.gh("", nil, "alias", "set", "save-editor", "!gh config set editor nano").code == 0, + ) { + return false + } + if !c.verify( + "alias nested configuration persists", + c.gh("", nil, "save-editor").code == 0 && + strings.TrimSpace(c.gh("", nil, "config", "get", "editor").stdout) == "nano", + ) { + return false + } + ext := filepath.Join(c.root, "guest-data", "gh", "extensions", "gh-probe", "gh-probe") + if !executableFixture( + ext, + `#!/bin/sh +gh config set pager cat +gh api user --hostname enterprise.two.test --jq .login +printf 'extension-out\n' +printf 'extension-err\n' >&2 +exit 7 +`, + ) { + return false + } + r := c.gh("", nil, "probe") + if !c.verify( + "extension streams and failure exit pass unchanged", + r.code == 7 && r.stdout == "host-user\nextension-out\n" && r.stderr == "extension-err\n", + ) { + return false + } + return c.verify( + "extension change persists after failure", + strings.TrimSpace(c.gh("", nil, "config", "get", "pager").stdout) == "cat", + ) +} diff --git a/e2e/framework/ghfixture/cases.go b/e2e/framework/ghfixture/cases.go new file mode 100644 index 0000000000..6fde115739 --- /dev/null +++ b/e2e/framework/ghfixture/cases.go @@ -0,0 +1,286 @@ +package ghfixture + +var ExpectedChecks = []string{ + "relay rejects non-loopback Host: /ready", + "relay rejects non-loopback Host: /owner", + "relay rejects non-loopback Host: /github-credentials", + "relay rejects non-loopback Host: /git-credentials", + "malformed working hosts: alias prepared", + "malformed working hosts: child exit preserved", + "malformed working hosts: lost intent reported", + "malformed working hosts: original authentication preserved", + "malformed working hosts: forwarded credentials scrubbed", + "malformed working hosts: next invocation recovers", + "malformed latest hosts: alias prepared", + "malformed latest hosts: mutation reached barrier", + "malformed latest hosts: child exit preserved", + "malformed latest hosts: pending writeback sanitized", + "malformed latest hosts: repeated recovery preserves invalid original and CLI exit", + "malformed latest hosts: repaired original recovers", + "malformed latest hosts: deliberate local login preserved", + "malformed latest hosts: forwarded credentials absent from artifacts", + "auth race different hosts: alias prepared", + "auth race different hosts: mutation reached barrier", + "auth race different hosts: parallel login succeeds", + "auth race different hosts: pending command preserves exit", + "auth race different hosts: no authentication conflict", + "auth race different hosts: both API identities preserved", + "auth race different hosts: complete authentication metadata preserved", + "auth race different hosts: forwarded credentials absent from artifacts", + "auth race independent keyring: alias prepared", + "auth race independent keyring: mutation reached barrier", + "auth race independent keyring: parallel secure login succeeds", + "auth race independent keyring: pending command preserves exit", + "auth race independent keyring: conflict reported", + "auth race independent keyring: keyring token preserved", + "auth race independent keyring: API identity preserved", + "auth race independent keyring: complete authentication metadata preserved", + "auth race independent keyring: forwarded credentials absent from artifacts", + "auth race recovery after logout: alias prepared", + "auth race recovery after logout: pending preference prepared", + "auth race recovery after logout: mutation reached barrier", + "auth race recovery after logout: failed writeback preserves exit", + "auth race recovery after logout: failed writeback remains sanitized", + "auth race recovery after logout: later logout succeeds", + "auth race recovery after logout: API unauthenticated before recovery", + "auth race recovery after logout: recovery completes", + "auth race recovery after logout: API remains unauthenticated after recovery", + "auth race recovery after logout: logout metadata never resurrected", + "auth race recovery after logout: forwarded credentials absent from artifacts", + "auth race all-local refresh then switch: alias prepared", + "auth race all-local refresh then switch: mutation reached barrier", + "auth race all-local refresh then switch: parallel mutation succeeds", + "auth race all-local refresh then switch: pending command preserves exit", + "auth race all-local refresh then switch: conflict reported", + "auth race all-local refresh then switch: API identity preserved", + "auth race all-local refresh then switch: complete authentication metadata preserved", + "auth race all-local refresh then switch: forwarded credentials absent from artifacts", + "auth race all-local switch then refresh: alias prepared", + "auth race all-local switch then refresh: mutation reached barrier", + "auth race all-local switch then refresh: parallel mutation succeeds", + "auth race all-local switch then refresh: pending command preserves exit", + "auth race all-local switch then refresh: conflict reported", + "auth race all-local switch then refresh: API identity preserved", + "auth race all-local switch then refresh: complete authentication metadata preserved", + "auth race all-local switch then refresh: forwarded credentials absent from artifacts", + "auth race all-local refresh then logout: alias prepared", + "auth race all-local refresh then logout: mutation reached barrier", + "auth race all-local refresh then logout: parallel mutation succeeds", + "auth race all-local refresh then logout: pending command preserves exit", + "auth race all-local refresh then logout: conflict reported", + "auth race all-local refresh then logout: API identity preserved", + "auth race all-local refresh then logout: complete authentication metadata preserved", + "auth race all-local refresh then logout: forwarded credentials absent from artifacts", + "auth race all-local logout then refresh: alias prepared", + "auth race all-local logout then refresh: mutation reached barrier", + "auth race all-local logout then refresh: parallel mutation succeeds", + "auth race all-local logout then refresh: pending command preserves exit", + "auth race all-local logout then refresh: conflict reported", + "auth race all-local logout then refresh: API identity preserved", + "auth race all-local logout then refresh: complete authentication metadata preserved", + "auth race all-local logout then refresh: forwarded credentials absent from artifacts", + "auth race all-local login then logout: alias prepared", + "auth race all-local login then logout: mutation reached barrier", + "auth race all-local login then logout: parallel mutation succeeds", + "auth race all-local login then logout: pending command preserves exit", + "auth race all-local login then logout: conflict reported", + "auth race all-local login then logout: API identity preserved", + "auth race all-local login then logout: complete authentication metadata preserved", + "auth race all-local login then logout: forwarded credentials absent from artifacts", + "auth race all-local logout then login: alias prepared", + "auth race all-local logout then login: mutation reached barrier", + "auth race all-local logout then login: parallel mutation succeeds", + "auth race all-local logout then login: pending command preserves exit", + "auth race all-local logout then login: conflict reported", + "auth race all-local logout then login: API identity preserved", + "auth race all-local logout then login: complete authentication metadata preserved", + "auth race all-local logout then login: forwarded credentials absent from artifacts", + "auth race mixed refresh then switch: alias prepared", + "auth race mixed refresh then switch: mutation reached barrier", + "auth race mixed refresh then switch: parallel mutation succeeds", + "auth race mixed refresh then switch: pending command preserves exit", + "auth race mixed refresh then switch: conflict reported", + "auth race mixed refresh then switch: API identity preserved", + "auth race mixed refresh then switch: complete authentication metadata preserved", + "auth race mixed refresh then switch: forwarded credentials absent from artifacts", + "auth race mixed switch then refresh: alias prepared", + "auth race mixed switch then refresh: mutation reached barrier", + "auth race mixed switch then refresh: parallel mutation succeeds", + "auth race mixed switch then refresh: pending command preserves exit", + "auth race mixed switch then refresh: conflict reported", + "auth race mixed switch then refresh: API identity preserved", + "auth race mixed switch then refresh: complete authentication metadata preserved", + "auth race mixed switch then refresh: forwarded credentials absent from artifacts", + "auth race mixed refresh then logout: alias prepared", + "auth race mixed refresh then logout: mutation reached barrier", + "auth race mixed refresh then logout: parallel mutation succeeds", + "auth race mixed refresh then logout: pending command preserves exit", + "auth race mixed refresh then logout: conflict reported", + "auth race mixed refresh then logout: API identity preserved", + "auth race mixed refresh then logout: complete authentication metadata preserved", + "auth race mixed refresh then logout: forwarded credentials absent from artifacts", + "auth race mixed logout then refresh: alias prepared", + "auth race mixed logout then refresh: mutation reached barrier", + "auth race mixed logout then refresh: parallel mutation succeeds", + "auth race mixed logout then refresh: pending command preserves exit", + "auth race mixed logout then refresh: conflict reported", + "auth race mixed logout then refresh: API identity preserved", + "auth race mixed logout then refresh: complete authentication metadata preserved", + "auth race mixed logout then refresh: forwarded credentials absent from artifacts", + "auth race mixed login then logout: alias prepared", + "auth race mixed login then logout: mutation reached barrier", + "auth race mixed login then logout: parallel mutation succeeds", + "auth race mixed login then logout: pending command preserves exit", + "auth race mixed login then logout: conflict reported", + "auth race mixed login then logout: API identity preserved", + "auth race mixed login then logout: complete authentication metadata preserved", + "auth race mixed login then logout: forwarded credentials absent from artifacts", + "auth race mixed logout then login: alias prepared", + "auth race mixed logout then login: mutation reached barrier", + "auth race mixed logout then login: parallel mutation succeeds", + "auth race mixed logout then login: pending command preserves exit", + "auth race mixed logout then login: conflict reported", + "auth race mixed logout then login: API identity preserved", + "auth race mixed logout then login: complete authentication metadata preserved", + "auth race mixed logout then login: forwarded credentials absent from artifacts", + + "prepare independent keyring for relay loss", + "metadata mutation alias saves", + "tokenless metadata invocation reached mutation", + "host-setting delta preserves parallel user and host changes", + "host-setting delta persists at host and account positions", + "metadata alias removed", + "all hosts forwarded through real RPC and HTTP relay: github.com", + "all hosts forwarded through real RPC and HTTP relay: enterprise.one.test", + "all hosts forwarded through real RPC and HTTP relay: enterprise.two.test", + "real auth status", + "forwarding never saved", + "gh chooses GH_HOST", + "explicit invalid GH_TOKEN never falls back", + "explicit invalid GITHUB_TOKEN never falls back", + "explicit enterprise environment never falls back", + "wrong-host token rejected REST: enterprise.one.test", + "wrong-host token rejected REST: enterprise.two.test", + "wrong-host token rejected GraphQL: enterprise.one.test", + "wrong-host token rejected GraphQL: enterprise.two.test", + "correct-host GraphQL identity: enterprise.one.test", + "correct-host GraphQL identity: enterprise.two.test", + "same token login stays forwarded", + "auth switch uses forwarded account", + "refresh saves a newly issued credential", + "logout refreshed credential", + "different token local login persists", + "local authentication preference explained at debug level", + "invalid local credential never falls back", + "logout restores forwarding on next invocation", + "keyring login succeeds without plaintext token", + "existing keyring credential excluded from forwarding", + "nested lookup preserves keyring authentication", + "unavailable keyring preserves registered guest identity", + "unavailable backend leaves local keyring intact", + "real auth status identifies keyring", + "keyring logout restores forwarding", + "same token explicit keyring login retained", + "same token keyring verified through original real CLI", + "same token keyring logout restores forwarding", + "expired keyring credential never falls back", + "nested expired keyring credential never falls back", + "insufficient keyring permission never falls back", + "nested insufficient keyring permission never falls back", + "alias saves", + "alias nested configuration persists", + "extension streams and failure exit pass unchanged", + "extension change persists after failure", + "credentials server stays non-root", + "wrapper directory remains protected", + "SSH server predates wrapper installation", + "standard wrapper installed", + "real gh preserved in fixed backup", + "no dedicated profile installed", + "registered identity blocks ambiguous keyring: github.com", + "registered identity blocks ambiguous keyring: enterprise.one.test", + "registered identity blocks ambiguous keyring: enterprise.two.test", + "unchanged invocation preserves exact dotfiles registrations", + "dispatcher re-entry uses local-only lookup: github.com", + "dispatcher re-entry uses local-only lookup: enterprise.one.test", + "dispatcher re-entry uses local-only lookup: enterprise.two.test", + "dispatcher re-entry preserves real auth status", + "local-only lookup survives dispatcher config changes", + "nested lookup leaves original preferences unchanged", + "nested lookup retains explicit invalid environment authentication", + "nested local login retains precedence", + "nested invalid local token never falls back", + "nested local logout restores forwarding", + "nested alias preparation", + "local-only marker does not leak into aliases or their children", + "nested alias removed", + "Devsy envfile does not replace forwarded authentication", + "Devsy envfile does not replace explicit authentication", + "normal successful forwarding is silent", + "overlay failure preserves gh stdout and exit", + "overlay failure warns without config content", + "non-PTY SSH uses fixed real-gh backup", + "repo-selected gh precedes fixed backup", + "explicit wrapper uses backup outside PATH", + "setup-git accepts forwarded authentication", + "setup-git persists the relay wrapper", + "persisted Git helper retrieves forwarded authentication", + "self-path alias saves", + "self-path alias removed", + "host setting resave preserves registrations and user preference", + "forwarding preserves saved per-account settings", + "local login from tokenless preferences persists", + "local logout from preferences restores forwarding", + "concurrent alias saves", + "concurrent invocation reached user mutation", + "parallel gh saves unrelated pager", + "configuration directory alias prepared", + "directory aliases observe both concurrent preferences", + "parallel and dotfiles changes survive", + "prepare interrupted mutation", + "interrupted invocation reached mutation", + "next invocation recovers killed wrapper mutation", + "recovered session contains no forwarded credentials", + "prepare pending writeback", + "writeback failure reached user mutation", + "writeback failure does not change real gh exit", + "writeback failure emits actionable error", + "pending writeback recovered after original repaired", + "prepare live-child ownership", + "live child reached mutation", + "live child prevents premature recovery", + "completed orphan child changes recover", + "host auth unchanged by guest operations", + "no host accounts fall back without warning", + "no host accounts explained at debug level", + "host authentication failure warns without changing gh exit", + "host active account followed", + "non-root target user inherits wrapper and uses own HOME", + "no diagnostic files created", + "stderr logs contain no credential values", + "normal sessions cleaned", + "prepare relay-loss recovery", + "relay-loss mutation reached", + "real gh arguments pass through Cobra: --help", + "real gh arguments pass through Cobra: --version", + "real gh arguments pass through Cobra: api --devsy-unknown-fixture-flag", + "real gh arguments pass through Cobra: api --context sample", + "real gh arguments pass through Cobra: api --log-level invalid", + "real gh arguments pass through Cobra: api --log-output json", + "debug enables relay logs without changing stdout", + "trace enables relay logs without changing stdout", + "aliases inherit relay self-path", + "explicit self-path is preserved", + "explicit empty self-path is preserved", + "local config dir override", + "XDG config location", + "HOME config location", +} + +var ExpectedRelayLossChecks = []string{ + "original CLI retrieves same token from keyring after relay stops", + "original CLI authenticates after relay stops", + "wrapper preserves local keyring identity after relay stops", + "original CLI logout removes keyring credential", + "recovery leaves no forwarded session credentials", +} diff --git a/e2e/framework/ghfixture/checks_linux.go b/e2e/framework/ghfixture/checks_linux.go new file mode 100644 index 0000000000..b9e5b95695 --- /dev/null +++ b/e2e/framework/ghfixture/checks_linux.go @@ -0,0 +1,555 @@ +//go:build linux + +package ghfixture + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" +) + +func (c *checks) run() bool { + for _, phase := range []func() bool{ + c.checkInstallation, c.checkRelayHosts, c.checkGuestRegistrations, c.checkDispatcher, c.checkEnvfile, c.checkDispatch, + c.checkSelection, c.checkSelfReferences, c.checkPreferences, c.checkAuthentication, c.checkLocalAuthentication, + c.checkKeyring, c.checkExtensions, c.checkConcurrentUpdates, c.checkWriteback, + c.checkMalformedWorkingHosts, c.checkOrphanChildren, + c.checkAuthenticationRaces, c.checkHost, c.checkConfigLocations, c.checkRelayLoss, + } { + if !phase() { + return false + } + } + return true +} + +func (c *checks) checkInstallation() bool { + if !c.verify("credentials server stays non-root", nonRootCredentialsService()) { + return false + } + info, err := os.Stat("/usr/local/bin") + if !c.verify( + "wrapper directory remains protected", + err == nil && info.Mode().Perm()&0o022 == 0, + ) { + return false + } + return c.checkInstalledFiles() +} + +func (c *checks) checkInstalledFiles() bool { + before, _ := readFixture(filepath.Join(c.root, "pre-install-gh")) + if !c.verify( + "SSH server predates wrapper installation", + strings.TrimSpace(string(before)) == "/usr/local/bin/gh", + ) { + return false + } + wrapper, _ := readFixture("/usr/local/bin/gh") + if !c.verify( + "standard wrapper installed", + strings.HasPrefix(string(wrapper), "#!/bin/sh\n# devsy gh relay\n"), + ) { + return false + } + backup, _ := readFixture(realGH) + release, _ := readFixture(filepath.Join(c.root, "gh")) + if !c.verify( + "real gh preserved in fixed backup", + len(backup) > 0 && bytes.Equal(backup, release), + ) { + return false + } + if !c.verify("no dedicated profile installed", !exists("/etc/profile.d/devsy-gh.sh")) { + return false + } + c.hostBefore, _ = readFixture(filepath.Join(c.root, "host-gh", "hosts.yml")) + return os.MkdirAll(c.original, 0o700) == nil +} + +func (c *checks) checkGuestRegistrations() bool { + c.registrations = `github.com: + git_protocol: https + user: local-user + users: + local-user: +enterprise.one.test: + git_protocol: https + user: host-user + oauth_token: "" + users: + host-user: + oauth_token: "" +enterprise.two.test: + git_protocol: https + user: dotfiles-user + oauth_token: + users: + dotfiles-user: + oauth_token: + other-user: +` + + if !writeFixture(filepath.Join(c.original, "hosts.yml"), c.registrations) { + return false + } + for _, host := range Hosts { + if !c.verify( + "registered identity blocks ambiguous keyring: "+host, + !c.api(host, hostUser, nil), + ) { + return false + } + } + if !c.verify( + "unchanged invocation preserves exact dotfiles registrations", + c.saved() == c.registrations && c.noForwardedSaved(), + ) { + return false + } + return os.Remove(filepath.Join(c.original, "hosts.yml")) == nil +} + +func (c *checks) checkDispatcher() bool { + c.dispatcher = filepath.Join(c.root, "dispatcher") + if !executableFixture( + filepath.Join(c.dispatcher, "gh"), + `#!/bin/sh +export PATH=/usr/bin:/usr/local/bin:/bin +if [ -n "$GH_TEST_CONFIG_OVERRIDE" ]; then + export GH_CONFIG_DIR="$GH_TEST_CONFIG_OVERRIDE" +fi +exec /usr/local/bin/gh "$@" +`, + ) { + return false + } + for _, host := range Hosts { + if !c.verify( + "dispatcher re-entry uses local-only lookup: "+host, + c.nestedAPI(host, hostUser, nil), + ) { + return false + } + } + if !c.verify( + "dispatcher re-entry preserves real auth status", + c.nested("", nil, "auth", "status").code == 0, + ) { + return false + } + return c.checkDispatcherConfiguration() +} + +func (c *checks) checkDispatcherConfiguration() bool { + redirected := filepath.Join(c.root, "redirected-gh") + if os.MkdirAll(redirected, 0o700) != nil { + return false + } + if !c.verify( + "local-only lookup survives dispatcher config changes", + c.nestedAPI(Hosts[0], hostUser, map[string]string{"GH_TEST_CONFIG_OVERRIDE": redirected}), + ) { + return false + } + if !c.verify( + "nested lookup leaves original preferences unchanged", + c.saved() == "" && c.noForwardedSaved(), + ) { + return false + } + if !c.verify( + "nested lookup retains explicit invalid environment authentication", + !c.nestedAPI(Hosts[0], hostUser, map[string]string{envToken: c.accounts[Hosts[0]].Invalid}), + ) { + return false + } + return c.checkNestedLocalLogin() +} + +func (c *checks) checkNestedLocalLogin() bool { + r := c.nested( + c.accounts[Hosts[0]].Local, + nil, + "auth", + "login", + "--hostname", + Hosts[0], + "--with-token", + "--insecure-storage", + ) + if !c.verify( + "nested local login retains precedence", + r.code == 0 && c.nestedAPI(Hosts[0], localUser, nil), + ) { + return false + } + local := c.saved() + if !writeFixture( + filepath.Join(c.original, "hosts.yml"), + strings.ReplaceAll(local, c.accounts[Hosts[0]].Local, c.accounts[Hosts[0]].Invalid), + ) { + return false + } + if !c.verify( + "nested invalid local token never falls back", + !c.nestedAPI(Hosts[0], hostUser, nil), + ) { + return false + } + if !writeFixture(filepath.Join(c.original, "hosts.yml"), local) { + return false + } + return c.checkNestedLogout() +} + +func (c *checks) checkNestedLogout() bool { + r := c.nested("", nil, "auth", "logout", "--hostname", Hosts[0], "--user", localUser) + if !c.verify( + "nested local logout restores forwarding", + r.code == 0 && c.nestedAPI(Hosts[0], hostUser, nil), + ) { + return false + } + r = c.gh( + "", + nil, + "alias", + "set", + "probe-guard", + "!test -z \"$DEVSY_GH_AUTH_PROBE\" && gh api user --jq .login", + ) + if !c.verify("nested alias preparation", r.code == 0) { + return false + } + r = c.nested("", nil, "probe-guard") + if !c.verify( + "local-only marker does not leak into aliases or their children", + r.code == 0 && strings.TrimSpace(r.stdout) == hostUser, + ) { + return false + } + return c.verify( + "nested alias removed", + c.gh("", nil, "alias", "delete", "probe-guard").code == 0, + ) +} + +func (c *checks) checkEnvfile() bool { + path := "/etc/envfile.json" + before, err := readFixture(path) + defer restoreFixture(path, before, err) + b, _ := json.Marshal( + map[string]any{"env": map[string]string{envToken: c.accounts[Hosts[0]].Invalid}}, + ) + if !writeFixture(path, string(b)) { + return false + } + if !c.verify( + "Devsy envfile does not replace forwarded authentication", + c.api(Hosts[0], hostUser, nil), + ) { + return false + } + return c.verify( + "Devsy envfile does not replace explicit authentication", + c.api(Hosts[0], localUser, map[string]string{envToken: c.accounts[Hosts[0]].Local}), + ) +} + +func restoreFixture(path string, before []byte, readErr error) { + if os.IsNotExist(readErr) { + _ = os.Remove(path) + return + } + _ = writeFixture(path, string(before)) +} + +func (c *checks) checkDispatch() bool { + for _, args := range [][]string{ + {"--help"}, + {"--version"}, + {commandAPI, "--devsy-unknown-fixture-flag"}, + {commandAPI, "--context", "sample"}, + {commandAPI, "--log-level", "invalid"}, + {commandAPI, "--log-output", "json"}, + } { + expected := execute(c.ctx, processOptions{env: c.env, input: ""}, realGH, args...) + actual := c.gh("", nil, args...) + if !c.verify( + "real gh arguments pass through Cobra: "+strings.Join(args, " "), + actual == expected, + ) { + return false + } + } + return c.checkLogLevels() +} + +func (c *checks) checkLogLevels() bool { + for _, level := range []string{debugLevel, "trace"} { + r := c.gh("", map[string]string{envLogLevel: level}, commandAPI, "user", "--jq", ".login") + if !c.verify( + level+" enables relay logs without changing stdout", + r.code == 0 && r.stdout == "host-user\n" && strings.Contains(r.stderr, "gh relay: authentication source") && + strings.Contains(r.stderr, "forwarded"), + ) { + return false + } + } + r := c.gh("", nil, commandAPI, "user", "--jq", ".login") + return c.verify( + "normal successful forwarding is silent", + r.code == 0 && r.stdout == "host-user\n" && r.stderr == "", + ) +} + +func (c *checks) checkSelection() bool { + path := filepath.Join(c.original, "hosts.yml") + before, readErr := readFixture(path) + if !writeFixture(path, "[invalid yaml") { + return false + } + expected := execute(c.ctx, processOptions{env: c.env, input: ""}, realGH, "--version") + actual := c.gh("", nil, "--version") + if !c.verify( + "overlay failure preserves gh stdout and exit", + actual.code == expected.code && actual.stdout == expected.stdout, + ) { + return false + } + if !c.verify( + "overlay failure warns without config content", + strings.Contains(actual.stderr, "gh relay: forwarding unavailable") && + strings.Contains(actual.stderr, "schema_invalid"), + ) { + return false + } + restoreFixture(path, before, readErr) + return c.checkFixedBackup() +} + +func (c *checks) checkFixedBackup() bool { + r := c.gh("", map[string]string{envLogLevel: debugLevel}, commandAPI, "user", "--jq", ".login") + if !c.verify( + "non-PTY SSH uses fixed real-gh backup", + r.code == 0 && strings.TrimSpace(r.stdout) == hostUser && + strings.Contains(r.stderr, realGH), + ) { + return false + } + return c.checkPathSelection() +} + +func (c *checks) checkPathSelection() bool { + selected := filepath.Join(c.root, "selected-gh") + if os.MkdirAll(selected, 0o700) != nil { + return false + } + if os.Symlink("/usr/bin/gh", filepath.Join(selected, "gh")) != nil { + return false + } + r := execute( + c.ctx, + processOptions{ + env: overlayEnvironment( + c.env, + map[string]string{ + envPath: selected + ":/usr/local/bin:/usr/bin:/bin", + envLogLevel: debugLevel, + }, + ), + input: "", + }, + "/usr/local/bin/gh", + commandAPI, + "user", + "--jq", + ".login", + ) + c.stderr = append(c.stderr, r.stderr) + if !c.verify( + "repo-selected gh precedes fixed backup", + r.code == 0 && strings.TrimSpace(r.stdout) == hostUser && + strings.Contains(r.stderr, filepath.Join(selected, "gh")), + ) { + return false + } + return c.checkBackupOutsidePath() +} + +func (c *checks) checkBackupOutsidePath() bool { + isolated := filepath.Join(c.root, "isolated-path") + if os.MkdirAll(isolated, 0o700) != nil { + return false + } + r := execute( + c.ctx, + processOptions{ + env: overlayEnvironment( + c.env, + map[string]string{envPath: isolated, envLogLevel: debugLevel}, + ), + input: "", + }, + "/usr/local/bin/gh", + commandAPI, + "user", + "--jq", + ".login", + ) + c.stderr = append(c.stderr, r.stderr) + return c.verify( + "explicit wrapper uses backup outside PATH", + r.code == 0 && strings.TrimSpace(r.stdout) == hostUser && + strings.Contains(r.stderr, realGH), + ) +} + +func (c *checks) checkSelfReferences() bool { + home, _ := os.UserHomeDir() + path := filepath.Join(home, ".gitconfig") + before, err := readFixture(path) + defer restoreFixture(path, before, err) + if !c.verify( + "setup-git accepts forwarded authentication", + c.gh("", nil, "auth", "setup-git").code == 0, + ) { + return false + } + helper := execute( + c.ctx, + processOptions{env: c.env, input: ""}, + "git", + "config", + "--global", + "--get-all", + "credential.https://github.com.helper", + ) + if !c.verify( + "setup-git persists the relay wrapper", + helper.code == 0 && strings.Contains(helper.stdout, "/usr/local/bin/gh auth git-credential") && + !strings.Contains(helper.stdout, "gh.devsy-original"), + ) { + return false + } + credential := execute( + c.ctx, + processOptions{ + env: overlayEnvironment(c.env, map[string]string{"GIT_TERMINAL_PROMPT": "0"}), + input: "protocol=https\nhost=github.com\n\n", + }, + "git", + "credential", + "fill", + ) + if !c.verify( + "persisted Git helper retrieves forwarded authentication", + credential.code == 0 && + strings.Contains(credential.stdout, "password="+c.accounts[Hosts[0]].Forwarded), + ) { + return false + } + return c.checkAliasSelfReferences() +} + +func (c *checks) checkAliasSelfReferences() bool { + if !c.verify( + "self-path alias saves", + c.gh("", nil, "alias", "set", "self-path", "!printf \"%s\" \"$GH_PATH\"").code == 0, + ) { + return false + } + for _, tc := range []struct { + name string + env map[string]string + expected string + }{ + {"aliases inherit relay self-path", nil, "/usr/local/bin/gh"}, + {"explicit self-path is preserved", map[string]string{envGHPath: customConfigDirectory}, customConfigDirectory}, + {"explicit empty self-path is preserved", map[string]string{envGHPath: ""}, ""}, + } { + if !c.verify(tc.name, c.gh("", tc.env, "self-path").stdout == tc.expected) { + return false + } + } + return c.verify( + "self-path alias removed", + c.gh("", nil, "alias", "delete", "self-path").code == 0, + ) +} + +func (c *checks) checkPreferences() bool { + path := filepath.Join(c.original, "hosts.yml") + if !writeFixture(path, c.registrations) { + return false + } + r := c.gh("", nil, "config", "set", "git_protocol", "https", "--host", Hosts[2]) + if !c.verify( + "host setting resave preserves registrations and user preference", + r.code == 0 && strings.Contains(c.saved(), "user: dotfiles-user") && + strings.Contains(c.saved(), "other-user:"), + ) { + return false + } + if !c.verify( + "forwarding preserves saved per-account settings", + strings.TrimSpace( + c.gh("", nil, "config", "get", "git_protocol", "--host", Hosts[2]).stdout, + ) == "https", + ) { + return false + } + return c.checkPreferenceLogin() +} + +func (c *checks) checkPreferenceLogin() bool { + path := filepath.Join(c.original, "hosts.yml") + r := c.gh( + c.accounts[Hosts[0]].Local, + nil, + "auth", + "login", + "--hostname", + Hosts[0], + "--with-token", + "--insecure-storage", + ) + if !c.verify( + "local login from tokenless preferences persists", + r.code == 0 && c.api(Hosts[0], localUser, nil) && c.noForwardedSaved(), + ) { + return false + } + r = c.gh("", nil, "auth", "logout", "--hostname", Hosts[0], "--user", localUser) + if !c.verify( + "local logout from preferences restores forwarding", + r.code == 0 && c.api(Hosts[0], hostUser, nil), + ) { + return false + } + if os.Remove(path) != nil { + return false + } + return c.checkMetadataUpdates() +} + +func nonRootCredentialsService() bool { + pid, _ := readFixture("/home/vscode/gh-relay-server-pid") + status, _ := readFixture(filepath.Join("/proc", strings.TrimSpace(string(pid)), "status")) + nonroot := false + for line := range strings.SplitSeq(string(status), "\n") { + if strings.HasPrefix(line, "Uid:") { + nonroot = true + for _, uid := range strings.Fields(line)[1:] { + if uid == "0" { + nonroot = false + } + } + } + } + + return nonroot +} diff --git a/e2e/framework/ghfixture/command_linux.go b/e2e/framework/ghfixture/command_linux.go new file mode 100644 index 0000000000..333df8e4f6 --- /dev/null +++ b/e2e/framework/ghfixture/command_linux.go @@ -0,0 +1,461 @@ +//go:build linux + +package ghfixture + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "al.essio.dev/pkg/shellescape" +) + +const realGH = "/usr/local/bin/gh.devsy-original" + +var sshEnvironment = []string{ + "HOME", + envGHConfig, + envXDGConfig, + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + envToken, + "GITHUB_TOKEN", + envEnterpriseToken, + "GITHUB_ENTERPRISE_TOKEN", + "GH_HOST", + envGHPath, + "GH_PROMPT_DISABLED", + envGHBrowser, + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + envSessionBus, + envLogLevel, +} + +func sshCommand(command string) []string { + return []string{ + "-F", + "/dev/null", + "-T", + "-p", + "12050", + "-oStrictHostKeyChecking=no", + "-oUserKnownHostsFile=/dev/null", + "-oLogLevel=ERROR", + "-oBatchMode=yes", + "-oSendEnv=" + strings.Join(sshEnvironment, " "), + "root@127.0.0.1", + command, + } +} + +type processResult struct { + stdout, stderr string + code int +} + +type processOptions struct { + env []string + input string +} + +func execute( + ctx context.Context, + options processOptions, + binary string, + args ...string, +) processResult { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + //nolint:gosec // Commands and arguments come only from the isolated fixture scenarios. + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Env = options.env + cmd.Stdin = strings.NewReader(options.input) + cmd.WaitDelay = 250 * time.Millisecond + var stdout, stderr capturedOutput + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + code := 0 + if err != nil { + code = -1 + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + } + if stdout.overflow || stderr.overflow { + code = -1 + } + return processResult{stdout: stdout.String(), stderr: stderr.String(), code: code} +} + +func overlayEnvironment(base []string, patch map[string]string) []string { + out := make([]string, 0, len(base)+len(patch)) + for _, entry := range base { + name, _, _ := strings.Cut(entry, "=") + if _, ok := patch[name]; !ok { + out = append(out, entry) + } + } + for name, value := range patch { + out = append(out, name+"="+value) + } + return out +} + +func loadAccounts(root string) (Accounts, error) { + b, err := readFixture(filepath.Join(root, "accounts.json")) + if err != nil { + return nil, err + } + var accounts Accounts + if len(b) > 65536 || json.Unmarshal(b, &accounts) != nil { + return nil, errors.New("invalid fixture accounts") + } + return accounts, nil +} + +func Run(ctx context.Context, root string, args []string, out io.Writer) int { + if len(args) != 1 { + return 2 + } + accounts, err := loadAccounts(root) + if err != nil { + return 2 + } + commands := map[string]func() int{ + "server": func() int { return serveFixture(ctx, root, accounts, CertificatePath) }, + "server-public": func() int { return serveFixture(ctx, root, accounts, filepath.Join(root, "cert.pem")) }, + "probe": func() int { return probeSSH(ctx, root) }, + "check": func() int { return runFixtureChecks(ctx, root, accounts, out) }, + "after-relay": func() int { return runRelayLossChecks(ctx, root, accounts, out) }, + } + command := commands[args[0]] + if command == nil { + return 2 + } + return command() +} + +func probeSSH(ctx context.Context, root string) int { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + result := execute( + ctx, + processOptions{env: os.Environ(), input: ""}, + "ssh", + sshCommand("command -v gh")...) + if result.code == 0 { + if os.WriteFile( + filepath.Join(root, "pre-install-gh"), + []byte(result.stdout), + 0o600, + ) == nil { + return 0 + } + return 1 + } + select { + case <-ctx.Done(): + return 1 + case <-ticker.C: + } + } +} + +type checks struct { + ctx context.Context + root string + accounts Accounts + env []string + report Report + stderr []string + original string + dispatcher string + registrations string + hostBefore []byte + target string + ready, release string + last processResult +} + +func newChecks(ctx context.Context, root string, accounts Accounts) *checks { + bus, _ := readFixture(filepath.Join(root, "bus")) + env := overlayEnvironment(os.Environ(), map[string]string{ + "HTTPS_PROXY": "http://127.0.0.1:8123", + "HTTP_PROXY": "http://127.0.0.1:8123", + "NO_PROXY": "localhost,127.0.0.1", + "SSL_CERT_FILE": CertificatePath, + envGHConfig: filepath.Join( + root, + "guest-gh", + ), + "XDG_DATA_HOME": filepath.Join(root, "guest-data"), + "XDG_CACHE_HOME": filepath.Join(root, "guest-cache"), + "GH_PROMPT_DISABLED": "1", + envSessionBus: strings.TrimSpace(string(bus)), + }) + version := execute(ctx, processOptions{env: env, input: ""}, realGH, "--version") + return &checks{ + ctx: ctx, + root: root, + accounts: accounts, + env: env, + original: filepath.Join(root, "guest-gh"), + report: Report{ + Version: ReportVersion, + CLI: strings.TrimSpace(version.stdout), + Architecture: runtime.GOARCH, + }, + } +} + +func (c *checks) verify(name string, ok bool) bool { + c.report.Checks = append(c.report.Checks, Check{Name: name, OK: ok}) + if !ok { + c.report.Failure = &Failure{ + Exit: c.last.code, + TLS: strings.Contains(c.last.stderr, "certificate") || + strings.Contains(c.last.stderr, "tls"), + Lookup: strings.Contains(c.last.stderr, "lookup"), + Readiness: strings.Contains(c.last.stderr, "readiness"), + Unauthorized: strings.Contains(c.last.stderr, "401"), + EmptyStderr: c.last.stderr == "", + } + } + return ok +} + +func (c *checks) gh(input string, patch map[string]string, args ...string) processResult { + command := shellescape.QuoteCommand(append([]string{"gh"}, args...)) + r := execute( + c.ctx, + processOptions{env: overlayEnvironment(c.env, patch), input: input}, + "ssh", + sshCommand(command)...) + c.stderr = append(c.stderr, r.stderr) + c.last = r + return r +} + +func (c *checks) api(host, user string, patch map[string]string) bool { + r := c.gh("", patch, commandAPI, "user", "--hostname", host, "--jq", ".login") + return r.code == 0 && strings.TrimSpace(r.stdout) == user +} + +func (c *checks) nested(input string, patch map[string]string, args ...string) processResult { + env := overlayEnvironment( + c.env, + map[string]string{envPath: c.dispatcher + ":/usr/local/bin:/usr/bin:/bin"}, + ) + r := execute( + c.ctx, + processOptions{env: overlayEnvironment(env, patch), input: input}, + "/usr/local/bin/gh", + args...) + c.stderr = append(c.stderr, r.stderr) + c.last = r + return r +} + +func (c *checks) nestedAPI(host, user string, patch map[string]string) bool { + r := c.nested("", patch, commandAPI, "user", "--hostname", host, "--jq", ".login") + return r.code == 0 && strings.TrimSpace(r.stdout) == user +} + +func (c *checks) saved() string { + b, _ := readFixture(filepath.Join(c.original, "hosts.yml")) + return string(b) +} + +func (c *checks) noForwardedSaved() bool { + for _, record := range c.accounts { + if strings.Contains(c.saved(), record.Forwarded) { + return false + } + } + return true +} + +func writeFixture(path, text string) bool { + if os.MkdirAll(filepath.Dir(path), 0o700) != nil { + return false + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return false + } + defer func() { _ = root.Close() }() + return root.WriteFile(filepath.Base(path), []byte(text), 0o600) == nil +} + +func executableFixture(path, text string) bool { + if !writeFixture(path, text) { + return false + } + // #nosec G302 -- Executable fixture shim contains no credentials. + return os.Chmod(path, 0o700) == nil +} + +type pendingCommand struct { + cmd *exec.Cmd + stdout, stderr capturedOutput + done chan error + joined bool + result bool + orphan bool +} + +func (c *checks) pending() *pendingCommand { + return c.pendingAlias("pending-editor") +} + +func (c *checks) pendingAlias(alias string) *pendingCommand { + p := &pendingCommand{done: make(chan error, 1)} + //nolint:gosec // The fixture controls the alias name; it remains a separately quoted argument. + p.cmd = exec.CommandContext(c.ctx, "/bin/bash", "-lc", "exec gh "+shellescape.Quote(alias)) + p.cmd.Env = overlayEnvironment( + c.env, + map[string]string{"GH_TEST_READY": c.ready, "GH_TEST_RELEASE": c.release}, + ) + p.cmd.Stdout = &p.stdout + p.cmd.Stderr = &p.stderr + p.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + p.cmd.WaitDelay = 250 * time.Millisecond + if err := p.cmd.Start(); err != nil { + p.done <- err + return p + } + go func() { p.done <- p.cmd.Wait() }() + return p +} + +func (p *pendingCommand) kill(group bool) { + if p.cmd.Process == nil { + return + } + if group { + _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) + } else { + _ = p.cmd.Process.Kill() + } +} + +func (p *pendingCommand) join() bool { + return p.wait(20 * time.Second) +} + +func (p *pendingCommand) wait(timeout time.Duration) bool { + if p.joined { + return p.result + } + select { + case err := <-p.done: + p.joined = true + p.result = err == nil + case <-time.After(timeout): + p.kill(true) + select { + case <-p.done: + p.joined = true + case <-time.After(time.Second): + } + } + return p.result +} + +func (p *pendingCommand) stop() { + if p.joined && !p.orphan { + return + } + p.kill(true) + p.wait(2 * time.Second) +} + +func (c *checks) waitReady() bool { + ctx, cancel := context.WithTimeout(c.ctx, 10*time.Second) + defer cancel() + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + if exists(c.ready) { + return true + } + select { + case <-ctx.Done(): + return false + case <-ticker.C: + } + } +} + +func readFixture(path string) ([]byte, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + return root.ReadFile(filepath.Base(path)) +} + +func serveFixture(ctx context.Context, root string, accounts Accounts, certificatePath string) int { + server, err := StartAPI(root, certificatePath, "127.0.0.1:8123", accounts) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "fixture API startup failed: %v\n", err) + return 1 + } + if err := os.WriteFile(filepath.Join(root, "api-ready"), nil, 0o600); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "fixture API readiness publication failed: %v\n", err) + return 1 + } + <-ctx.Done() + cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if server.Close(cleanup) != nil { + return 1 + } + return 0 +} + +func runFixtureChecks(ctx context.Context, root string, accounts Accounts, out io.Writer) int { + checker := newChecks(ctx, root, accounts) + ok := checker.run() + checker.report.Completed = ok + if json.NewEncoder(out).Encode(checker.report) != nil { + return 1 + } + if !ok { + return 1 + } + return 0 +} + +type capturedOutput struct { + bytes.Buffer + overflow bool +} + +func (b *capturedOutput) Write(p []byte) (int, error) { + n := len(p) + if remaining := (1 << 20) - b.Len(); n > remaining { + p = p[:remaining] + b.overflow = true + } + _, err := b.Buffer.Write(p) + return n, err +} diff --git a/e2e/framework/ghfixture/constants_linux.go b/e2e/framework/ghfixture/constants_linux.go new file mode 100644 index 0000000000..7ad19ff82d --- /dev/null +++ b/e2e/framework/ghfixture/constants_linux.go @@ -0,0 +1,27 @@ +//go:build linux + +package ghfixture + +const ( + hostUser = "host-user" + localUser = "local-user" + secondUser = "second-user" + envLogLevel = "DEVSY_LOG_LEVEL" + envEnterpriseToken = "GH_ENTERPRISE_TOKEN" + envSessionBus = "DBUS_SESSION_BUS_ADDRESS" + envToken = "GH_TOKEN" + commandAPI = "api" + debugLevel = "debug" + envPath = "PATH" + customConfigDirectory = "/fixture/custom-gh" + pendingEditor = "pending" +) + +const ( + envGHPath = "GH_PATH" + envGHBrowser = "GH_BROWSER" + envGHConfig = "GH_CONFIG_DIR" + envXDGConfig = "XDG_CONFIG_HOME" + githubHost = "github.com" + githubAPIHost = "api.github.com" +) diff --git a/e2e/framework/ghfixture/host_guard_linux.go b/e2e/framework/ghfixture/host_guard_linux.go new file mode 100644 index 0000000000..08f7151bb3 --- /dev/null +++ b/e2e/framework/ghfixture/host_guard_linux.go @@ -0,0 +1,44 @@ +//go:build linux + +package ghfixture + +import ( + "context" + "net/http" + "time" +) + +func (c *checks) checkRelayHosts() bool { + client := &http.Client{ + Transport: &http.Transport{Proxy: nil}, + Timeout: 2 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + defer client.CloseIdleConnections() + for _, path := range []string{"/ready", "/owner", "/github-credentials", "/git-credentials"} { + ctx, cancel := context.WithTimeout(c.ctx, 2*time.Second) + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "http://127.0.0.1:12049"+path, + nil, + ) + if err != nil { + cancel() + return false + } + req.Host = "attacker.invalid:12049" + response, err := client.Do(req) + ok := err == nil && response.StatusCode == http.StatusForbidden + if response != nil { + _ = response.Body.Close() + } + cancel() + if !c.verify("relay rejects non-loopback Host: "+path, ok) { + return false + } + } + return true +} diff --git a/e2e/framework/ghfixture/malformed_recovery_linux.go b/e2e/framework/ghfixture/malformed_recovery_linux.go new file mode 100644 index 0000000000..ee02ee32e7 --- /dev/null +++ b/e2e/framework/ghfixture/malformed_recovery_linux.go @@ -0,0 +1,127 @@ +//go:build linux + +package ghfixture + +import ( + "path/filepath" + "strings" +) + +func (c *checks) checkMalformedWorkingHosts() bool { + path := filepath.Join(c.original, "hosts.yml") + before, err := readFixture(path) + defer restoreFixture(path, before, err) + alias := `!umask 077 +file="$GH_CONFIG_DIR/hosts.yml" +scratch="$GH_CONFIG_DIR/malformed-hosts" +printf '%s\n' 'invalid.test:' ' users: [alice, bob]' > "$scratch" && +cat "$file" >> "$scratch" && mv "$scratch" "$file"` + if !c.verify( + "malformed working hosts: alias prepared", + c.gh("", nil, "alias", "set", "malformed-hosts", alias).code == 0, + ) { + return false + } + r := c.gh("", nil, "malformed-hosts") + if !c.verify("malformed working hosts: child exit preserved", r.code == 0) || + !c.verify( + "malformed working hosts: lost intent reported", + strings.Contains(r.stderr, "configuration writeback pending") && + strings.Contains(r.stderr, "session.lost_intent"), + ) || + !c.verify( + "malformed working hosts: original authentication preserved", + c.saved() == string(before), + ) || + !c.verify( + "malformed working hosts: forwarded credentials scrubbed", + c.authArtifactsClean(), + ) { + return false + } + return c.verify( + "malformed working hosts: next invocation recovers", + c.gh("", nil, "alias", "delete", "malformed-hosts").code == 0 && !c.hasSessions(), + ) +} + +func (c *checks) checkMalformedLatestHostsRecovery() bool { + path := filepath.Join(c.original, "hosts.yml") + before, err := readFixture(path) + defer restoreFixture(path, before, err) + base, ok := c.prepareAuthenticationRace("mixed", authRace{first: loginOperation}) + if !c.verify("malformed latest hosts: alias prepared", ok) { + return false + } + baseline, err := readFixture(path) + if err != nil { + return false + } + if !c.failMalformedLatestWriteback(path) { + return false + } + return c.finishMalformedLatestRecovery(path, baseline, base) +} + +func (c *checks) failMalformedLatestWriteback(path string) bool { + p := c.pendingAlias(authRaceAlias) + defer p.stop() + if !c.verify("malformed latest hosts: mutation reached barrier", c.waitReady()) { + return false + } + if !writeFixture(path, malformedLatestHosts) || !writeFixture(c.release, "") || + !c.verify("malformed latest hosts: child exit preserved", p.join()) { + return false + } + c.stderr = append(c.stderr, p.stderr.String()) + return c.verify( + "malformed latest hosts: pending writeback sanitized", + c.hasSessions() && c.authArtifactsClean(), + ) +} + +func (c *checks) finishMalformedLatestRecovery( + path string, + baseline []byte, + base map[string]raceHost, +) bool { + patch := map[string]string{envGHConfig: filepath.Join(c.root, "recovery-probe-gh")} + if !c.checkRepeatedMalformedRecovery(patch) || !writeFixture(path, string(baseline)) { + return false + } + r := c.gh("", patch, "--version") + if !c.verify( + "malformed latest hosts: repaired original recovers", + r.code == 0 && !c.hasSessions(), + ) { + return false + } + base[Hosts[0]] = c.expectedAuthentication(base[Hosts[0]], loginOperation, Hosts[0]) + return c.verify( + "malformed latest hosts: deliberate local login preserved", + c.originalIdentity(Hosts[0], secondUser) && c.authenticationMatches(base), + ) && + c.verify( + "malformed latest hosts: forwarded credentials absent from artifacts", + c.authArtifactsClean(), + ) +} + +func (c *checks) checkRepeatedMalformedRecovery(patch map[string]string) bool { + for range 2 { + if c.gh("", patch, "--version").code != 0 || c.saved() != malformedLatestHosts || + !c.hasSessions() || + !c.authArtifactsClean() { + return c.verify( + "malformed latest hosts: repeated recovery preserves invalid original and CLI exit", + false, + ) + } + } + return c.verify( + "malformed latest hosts: repeated recovery preserves invalid original and CLI exit", + true, + ) +} + +const malformedLatestHosts = "github.com:\n users: [alice, bob]\n" diff --git a/e2e/framework/ghfixture/metadata_linux.go b/e2e/framework/ghfixture/metadata_linux.go new file mode 100644 index 0000000000..b46d320e27 --- /dev/null +++ b/e2e/framework/ghfixture/metadata_linux.go @@ -0,0 +1,70 @@ +//go:build linux + +package ghfixture + +import ( + "os" + "path/filepath" + "strings" +) + +func (c *checks) checkMetadataUpdates() bool { + path := filepath.Join(c.original, "hosts.yml") + if !writeFixture(path, c.registrations) { + return false + } + c.ready = filepath.Join(c.root, "metadata-ready") + c.release = filepath.Join(c.root, "metadata-release") + alias := `!gh config set git_protocol ssh --host enterprise.two.test; +touch "$GH_TEST_READY"; +while test ! -f "$GH_TEST_RELEASE"; do sleep 0.05; done` + + if !c.verify( + "metadata mutation alias saves", + c.gh("", nil, "alias", "set", "metadata-pending", alias).code == 0, + ) { + return false + } + p := c.pendingAlias("metadata-pending") + defer p.stop() + if !c.verify("tokenless metadata invocation reached mutation", c.waitReady()) { + return false + } + return c.checkConcurrentMetadata(p, path) +} + +func (c *checks) checkConcurrentMetadata(p *pendingCommand, path string) bool { + changed := strings.ReplaceAll( + c.registrations, + "user: dotfiles-user", + "user: parallel-user", + ) + "unrelated.test:\n git_protocol: ssh\n" + if !writeFixture(path, changed) || !writeFixture(c.release, "") { + return false + } + completed := p.join() + return c.checkMetadataResult(completed, path) +} + +func (c *checks) checkMetadataResult(completed bool, path string) bool { + if !c.verify( + "host-setting delta preserves parallel user and host changes", + completed && strings.Contains(c.saved(), "user: parallel-user") && strings.Contains(c.saved(), "unrelated.test:") && + c.noForwardedSaved(), + ) { + return false + } + if !c.verify( + "host-setting delta persists at host and account positions", + strings.Count(c.saved(), "git_protocol: ssh") >= 3, + ) { + return false + } + if !c.verify( + "metadata alias removed", + c.gh("", nil, "alias", "delete", "metadata-pending").code == 0, + ) { + return false + } + return os.Remove(path) == nil +} diff --git a/e2e/framework/ghfixture/recovery_linux.go b/e2e/framework/ghfixture/recovery_linux.go new file mode 100644 index 0000000000..4db7bf575f --- /dev/null +++ b/e2e/framework/ghfixture/recovery_linux.go @@ -0,0 +1,389 @@ +//go:build linux + +package ghfixture + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "time" +) + +func (c *checks) checkConcurrentUpdates() bool { + config := filepath.Join(c.original, "config.yml") + c.target = filepath.Join(c.root, "dotfiles-config.yml") + if os.Rename(config, c.target) != nil || os.Symlink(c.target, config) != nil { + return false + } + c.ready = filepath.Join(c.root, "command-ready") + c.release = filepath.Join(c.root, "command-release") + r := c.gh( + "", + nil, + "alias", + "set", + "pending-editor", + "!gh config set editor pending; touch \"$GH_TEST_READY\"; while test ! -f \"$GH_TEST_RELEASE\"; do sleep 0.05; done", + ) + if !c.verify("concurrent alias saves", r.code == 0) { + return false + } + p := c.pending() + defer p.stop() + if !c.verify("concurrent invocation reached user mutation", c.waitReady()) { + return false + } + return c.checkParallelConfigMutation(p) +} + +func (c *checks) checkParallelConfigMutation(p *pendingCommand) bool { + alias := filepath.Join(c.root, "config-directory-alias") + if !c.verify("configuration directory alias prepared", os.Symlink(c.original, alias) == nil) { + return false + } + defer func() { _ = os.Remove(alias) }() + if !c.verify( + "parallel gh saves unrelated pager", + c.gh("", map[string]string{envGHConfig: alias}, "config", "set", "pager", "less").code == 0, + ) { + return false + } + b, _ := readFixture(c.target) + if !writeFixture( + c.target, + strings.ReplaceAll(string(b), "editor: nano", "editor: emacs")+"\nprompt: disabled\n", + ) { + return false + } + if !writeFixture(c.release, "") { + return false + } + completed := p.join() + if !c.verify("directory aliases observe both concurrent preferences", completed && + c.setting("editor") == pendingEditor && + strings.TrimSpace( + c.gh("", map[string]string{envGHConfig: alias}, "config", "get", "pager").stdout, + ) == "less") { + return false + } + return c.checkParallelConfigResult(completed) +} + +func (c *checks) checkParallelConfigResult(completed bool) bool { + config := filepath.Join(c.original, "config.yml") + info, err := os.Lstat(config) + if !c.verify( + "parallel and dotfiles changes survive", + completed && + c.setting("editor") == pendingEditor && + c.setting("pager") == "less" && + c.setting("prompt") == "disabled" && err == nil && + info.Mode()&os.ModeSymlink != 0, + ) { + return false + } + return c.checkInterruptedMutation() +} + +func (c *checks) checkInterruptedMutation() bool { + _ = os.Remove(c.ready) + _ = os.Remove(c.release) + if !c.verify( + "prepare interrupted mutation", + c.gh("", nil, "config", "set", "editor", "vi").code == 0, + ) { + return false + } + p := c.pending() + defer p.stop() + if !c.verify("interrupted invocation reached mutation", c.waitReady()) { + return false + } + p.kill(true) + p.join() + if !c.verify( + "next invocation recovers killed wrapper mutation", + c.setting("editor") == pendingEditor, + ) { + return false + } + return c.verify("recovered session contains no forwarded credentials", c.noForwardedSaved()) +} + +func (c *checks) setting(key string) string { + return strings.TrimSpace(c.gh("", nil, "config", "get", key).stdout) +} + +func (c *checks) checkWriteback() bool { + _ = os.Remove(c.ready) + if !c.verify( + "prepare pending writeback", + c.gh("", nil, "config", "set", "editor", "vi").code == 0, + ) { + return false + } + p := c.pending() + defer p.stop() + if !c.verify("writeback failure reached user mutation", c.waitReady()) { + return false + } + latest, err := readFixture(c.target) + if err != nil { + return false + } + return c.checkCorruptWriteback(p, latest) +} + +func (c *checks) checkCorruptWriteback(p *pendingCommand, latest []byte) bool { + if !writeFixture(c.target, "[broken yaml") || !writeFixture(c.release, "") { + return false + } + completed := p.join() + stderr := p.stderr.String() + c.stderr = append(c.stderr, stderr) + restored := writeFixture(c.target, string(latest)) + if !c.verify("writeback failure does not change real gh exit", completed) { + return false + } + if !c.verify( + "writeback failure emits actionable error", + strings.Contains(stderr, "configuration writeback pending") && + strings.Contains(stderr, "schema_invalid"), + ) { + return false + } + if !restored { + return false + } + return c.checkRecoveredWriteback() +} + +func (c *checks) checkRecoveredWriteback() bool { + r := c.gh("", map[string]string{envLogLevel: debugLevel}, "config", "get", "editor") + return c.verify( + "pending writeback recovered after original repaired", + r.code == 0 && strings.TrimSpace(r.stdout) == pendingEditor && + strings.Contains(r.stderr, "configuration recovered"), + ) +} + +func (c *checks) checkOrphanChildren() bool { + _ = os.Remove(c.ready) + _ = os.Remove(c.release) + if !c.verify( + "prepare live-child ownership", + c.gh("", nil, "config", "set", "editor", "vi").code == 0, + ) { + return false + } + p := c.pending() + defer p.stop() + if !c.verify("live child reached mutation", c.waitReady()) { + return false + } + p.kill(false) + p.orphan = true + if !c.verify( + "live child prevents premature recovery", + c.setting("editor") == "vi" && c.hasSessions(), + ) { + return false + } + if !writeFixture(c.release, "") { + return false + } + p.join() + return c.checkCompletedOrphan(p) +} + +func (c *checks) checkCompletedOrphan(p *pendingCommand) bool { + ctxDone := time.After(5 * time.Second) + for { + if c.setting("editor") == pendingEditor { + p.orphan = false + return c.verify("completed orphan child changes recover", true) + } + select { + case <-ctxDone: + p.kill(true) + return c.verify("completed orphan child changes recover", false) + case <-time.After(20 * time.Millisecond): + } + } +} + +func (c *checks) hasSessions() bool { + entries, _ := filepath.Glob( + filepath.Join(c.root, "guest-cache", "devsy", "gh-relay", "session-*"), + ) + return len(entries) > 0 +} + +func (c *checks) checkHost() bool { + path := filepath.Join(c.root, "host-gh", "hosts.yml") + current, _ := readFixture(path) + if !c.verify( + "host auth unchanged by guest operations", + bytes.Equal(current, c.hostBefore) && + !exists(filepath.Join(c.root, "host-gh", "config.yml")), + ) { + return false + } + return c.checkEmptyHostAccounts(path) +} + +func (c *checks) checkEmptyHostAccounts(path string) bool { + if !writeFixture(path, "{}\n") { + return false + } + r := c.gh("", nil, "config", "get", "editor") + if !c.verify( + "no host accounts fall back without warning", + r.code == 0 && !strings.Contains(r.stderr, "gh relay:"), + ) { + return false + } + r = c.gh("", map[string]string{envLogLevel: debugLevel}, "config", "get", "editor") + if !c.verify( + "no host accounts explained at debug level", + r.code == 0 && strings.Contains(r.stderr, "no_active_accounts"), + ) { + return false + } + return c.checkBrokenHostAccounts(path) +} + +func (c *checks) checkBrokenHostAccounts(path string) bool { + if !writeFixture(path, "[broken yaml") { + return false + } + r := c.gh("", nil, "config", "get", "editor") + if !c.verify( + "host authentication failure warns without changing gh exit", + r.code == 0 && strings.Contains(r.stderr, "forwarding unavailable") && + strings.Contains(r.stderr, "config_decode_failed"), + ) { + return false + } + return c.checkSwitchedHostAccount(path) +} + +func (c *checks) checkSwitchedHostAccount(path string) bool { + text := strings.ReplaceAll(string(c.hostBefore), hostUser, secondUser) + for _, record := range c.accounts { + text = strings.ReplaceAll(text, record.Forwarded, record.Second) + } + if !writeFixture(path, text) { + return false + } + if !c.verify("host active account followed", c.api(Hosts[2], secondUser, nil)) { + return false + } + r := execute( + c.ctx, + processOptions{ + env: overlayEnvironment( + c.env, + map[string]string{envSessionBus: "unix:path=/nonexistent"}, + ), + input: "", + }, + "runuser", + "-u", + "vscode", + "--", + "env", + "HOME=/home/vscode", + "GH_CONFIG_DIR=", + "XDG_CONFIG_HOME=", + "XDG_CACHE_HOME=/home/vscode/.cache", + "/bin/bash", + "-lc", + "exec gh api user --hostname enterprise.two.test --jq .login", + ) + return c.verify( + "non-root target user inherits wrapper and uses own HOME", + r.code == 0 && strings.TrimSpace(r.stdout) == secondUser, + ) +} + +func (c *checks) checkConfigLocations() bool { + for _, tc := range []struct { + name string + env map[string]string + path string + }{ + {"local config dir override", map[string]string{envGHConfig: customConfigDirectory}, "custom-gh/config.yml"}, + { + "XDG config location", + map[string]string{envGHConfig: "", envXDGConfig: "/fixture/custom-xdg"}, + "custom-xdg/gh/config.yml", + }, + { + "HOME config location", + map[string]string{envGHConfig: "", envXDGConfig: "", "HOME": "/fixture/custom-home"}, + "custom-home/.config/gh/config.yml", + }, + } { + if !c.verify( + tc.name, + c.gh("", tc.env, "config", "set", "editor", "emacs").code == 0 && + exists(filepath.Join(c.root, tc.path)), + ) { + return false + } + } + if !c.verify( + "no diagnostic files created", + !exists(filepath.Join(c.root, "guest-cache", "devsy", "gh-relay", "diagnostics")), + ) { + return false + } + return c.checkDiagnosticPrivacy() +} + +func (c *checks) checkDiagnosticPrivacy() bool { + for _, stderr := range c.stderr { + for _, record := range c.accounts { + for _, token := range []string{record.Forwarded, record.Local, record.Second, record.Invalid, record.Refresh} { + if strings.Contains(stderr, token) { + return c.verify("stderr logs contain no credential values", false) + } + } + } + } + if !c.verify("stderr logs contain no credential values", true) { + return false + } + return c.verify("normal sessions cleaned", !c.hasSessions()) +} + +func (c *checks) checkRelayLoss() bool { + if !c.prepareIndependentKeyring() { + return false + } + _ = os.Remove(c.ready) + _ = os.Remove(c.release) + if !c.verify( + "prepare relay-loss recovery", + c.gh("", nil, "config", "set", "editor", "vi").code == 0, + ) { + return false + } + p := c.pending() + defer p.stop() + ready := c.waitReady() + p.kill(true) + p.join() + return c.verify("relay-loss mutation reached", ready) +} + +func (c *checks) prepareIndependentKeyring() bool { + host := Hosts[1] + r := c.gh(c.accounts[host].Forwarded, nil, "auth", "login", "--hostname", host, "--with-token") + return c.verify( + "prepare independent keyring for relay loss", + r.code == 0 && c.noForwardedSaved(), + ) +} diff --git a/e2e/framework/ghfixture/relay_loss_linux.go b/e2e/framework/ghfixture/relay_loss_linux.go new file mode 100644 index 0000000000..68981e3289 --- /dev/null +++ b/e2e/framework/ghfixture/relay_loss_linux.go @@ -0,0 +1,107 @@ +//go:build linux + +package ghfixture + +import ( + "context" + "encoding/json" + "io" + "strings" +) + +func runRelayLossChecks(ctx context.Context, root string, accounts Accounts, out io.Writer) int { + c := newChecks(ctx, root, accounts) + ok := c.checkRelayLossKeyring() + c.report.Completed = ok + if json.NewEncoder(out).Encode(c.report) != nil || !ok { + return 1 + } + return 0 +} + +func (c *checks) checkRelayLossKeyring() bool { + host := Hosts[1] + lookup := execute( + c.ctx, + processOptions{env: c.env}, + realGH, + "auth", + "token", + "--hostname", + host, + ) + if !c.verify( + "original CLI retrieves same token from keyring after relay stops", + lookup.code == 0 && strings.TrimSpace(lookup.stdout) == c.accounts[host].Forwarded, + ) { + return false + } + r := execute( + c.ctx, + processOptions{env: c.env}, + realGH, + commandAPI, + "user", + "--hostname", + host, + "--jq", + ".login", + ) + if !c.verify( + "original CLI authenticates after relay stops", + r.code == 0 && strings.TrimSpace(r.stdout) == hostUser, + ) { + return false + } + r = execute( + c.ctx, + processOptions{env: c.env}, + "/usr/local/bin/gh", + commandAPI, + "user", + "--hostname", + host, + "--jq", + ".login", + ) + if !c.verify( + "wrapper preserves local keyring identity after relay stops", + r.code == 0 && strings.TrimSpace(r.stdout) == hostUser, + ) { + return false + } + return c.checkRelayLossLogout(host) +} + +func (c *checks) checkRelayLossLogout(host string) bool { + logout := execute( + c.ctx, + processOptions{env: c.env}, + realGH, + "auth", + "logout", + "--hostname", + host, + "--user", + hostUser, + ) + lookup := execute( + c.ctx, + processOptions{env: c.env}, + realGH, + "auth", + "token", + "--hostname", + host, + ) + if !c.verify( + "original CLI logout removes keyring credential", + logout.code == 0 && lookup.code != 0 && lookup.stdout == "", + ) { + return false + } + return c.verify( + "recovery leaves no forwarded session credentials", + !c.hasSessions() && c.noForwardedSaved(), + ) +} diff --git a/e2e/framework/ghfixture/report.go b/e2e/framework/ghfixture/report.go new file mode 100644 index 0000000000..a901084bd1 --- /dev/null +++ b/e2e/framework/ghfixture/report.go @@ -0,0 +1,75 @@ +package ghfixture + +import ( + "encoding/json" + "errors" +) + +const ReportVersion = 1 + +type Check struct { + Name string `json:"check"` + OK bool `json:"ok"` +} + +type Report struct { + Version int `json:"version"` + Completed bool `json:"completed"` + CLI string `json:"cli"` + Architecture string `json:"architecture"` + Checks []Check `json:"checks"` + Failure *Failure `json:"failure,omitempty"` +} + +type Failure struct { + Exit int `json:"exit"` + TLS bool `json:"tls"` + Lookup bool `json:"lookup"` + Readiness bool `json:"readiness"` + Unauthorized bool `json:"unauthorized"` + EmptyStderr bool `json:"empty_stderr"` +} + +func ValidateReport(data []byte, expected []string) (Report, error) { + var report Report + if json.Unmarshal(data, &report) != nil || !report.complete() { + return Report{}, errors.New("incomplete GitHub fixture report") + } + wanted, err := expectedCases(expected) + if err != nil { + return Report{}, err + } + if len(report.Checks) != len(wanted) { + return Report{}, errors.New("GitHub fixture case count mismatch") + } + return report, validateCases(report.Checks, wanted) +} + +func (r Report) complete() bool { + return r.Version == ReportVersion && r.Completed && r.CLI != "" && r.Architecture != "" && + r.Failure == nil +} + +func expectedCases(expected []string) (map[string]bool, error) { + if len(expected) == 0 { + return nil, errors.New("empty expected GitHub fixture cases") + } + wanted := map[string]bool{} + for _, name := range expected { + if name == "" || wanted[name] { + return nil, errors.New("invalid expected GitHub fixture cases") + } + wanted[name] = true + } + return wanted, nil +} + +func validateCases(checks []Check, wanted map[string]bool) error { + for _, check := range checks { + if !check.OK || !wanted[check.Name] { + return errors.New("failed, duplicate or unexpected GitHub fixture case") + } + delete(wanted, check.Name) + } + return nil +} diff --git a/e2e/framework/ghfixture/report_test.go b/e2e/framework/ghfixture/report_test.go new file mode 100644 index 0000000000..83148f57fc --- /dev/null +++ b/e2e/framework/ghfixture/report_test.go @@ -0,0 +1,50 @@ +package ghfixture + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReportRejectsIncompleteMissingDuplicateUnexpectedAndFailedControls(t *testing.T) { + valid := Report{ + Version: ReportVersion, + Completed: true, + CLI: "gh fixture-version", + Architecture: "arm64", + Checks: []Check{ + {Name: firstReportCase, OK: true}, + {Name: secondReportCase, OK: true}, + }, + } + data, err := json.Marshal(valid) + require.NoError(t, err) + _, err = ValidateReport(data, []string{firstReportCase, secondReportCase}) + require.NoError(t, err) + for _, bad := range []string{"[]", "null", "{", string(data[:len(data)-1])} { + _, err := ValidateReport([]byte(bad), []string{firstReportCase, secondReportCase}) + require.Error(t, err) + } + for _, change := range []func(*Report){ + func(r *Report) { r.Completed = false }, func(r *Report) { r.Checks = r.Checks[:1] }, + func(r *Report) { r.Checks[1].Name = firstReportCase }, func(r *Report) { r.Checks[1].Name = "unexpected" }, + func(r *Report) { r.Checks[1].OK = false }, func(r *Report) { r.Version++ }, + func(r *Report) { r.Failure = &Failure{Exit: 1} }, + func(r *Report) { r.CLI = "" }, func(r *Report) { r.Architecture = "" }, + } { + candidate := valid + candidate.Checks = append([]Check(nil), valid.Checks...) + change(&candidate) + b, err := json.Marshal(candidate) + require.NoError(t, err) + _, err = ValidateReport(b, []string{firstReportCase, secondReportCase}) + require.Error(t, err) + } + _, err = ValidateReport(data, nil) + require.Error(t, err) +} + +const firstReportCase = "one" + +const secondReportCase = "two" diff --git a/e2e/framework/ghfixture/server_linux.go b/e2e/framework/ghfixture/server_linux.go new file mode 100644 index 0000000000..d47b48b470 --- /dev/null +++ b/e2e/framework/ghfixture/server_linux.go @@ -0,0 +1,329 @@ +//go:build linux + +package ghfixture + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var Hosts = []string{githubHost, "enterprise.one.test", "enterprise.two.test"} + +const CertificatePath = "/usr/local/share/ca-certificates/devsy-gh-fixture.crt" + +type Tokens struct { + Forwarded string `json:"forwarded"` + Local string `json:"local"` + Second string `json:"second"` + Invalid string `json:"invalid"` + Refresh string `json:"refresh"` +} + +type Accounts map[string]Tokens + +type Observation struct { + Host string `json:"host"` + Route string `json:"route"` + User string `json:"user"` + Status int `json:"status"` +} + +type APIServer struct { + root string + accounts Accounts + certificate tls.Certificate + listener net.Listener + server *http.Server + mu sync.Mutex + connections map[net.Conn]bool + observations []Observation + wg sync.WaitGroup + closing bool +} + +func StartAPI(root, certificatePath, address string, accounts Accounts) (*APIServer, error) { + certificate, pub, err := fixtureCertificate() + if err != nil { + return nil, err + } + // #nosec G306 -- This is the public fixture trust certificate, shared with the non-root guest. + if err := os.WriteFile(certificatePath, pub, 0o644); err != nil { + return nil, err + } + ln, err := net.Listen("tcp4", address) + if err != nil { + return nil, err + } + s := &APIServer{ + root: root, + accounts: accounts, + certificate: certificate, + listener: ln, + connections: map[net.Conn]bool{}, + } + s.server = &http.Server{ + Handler: http.HandlerFunc(s.connect), + ReadHeaderTimeout: 2 * time.Second, + } + s.wg.Go(func() { _ = s.server.Serve(ln) }) + return s, nil +} + +func (s *APIServer) Address() string { return s.listener.Addr().String() } + +func (s *APIServer) Close(ctx context.Context) error { + s.mu.Lock() + s.closing = true + for conn := range s.connections { + _ = conn.Close() + } + s.mu.Unlock() + err := s.server.Close() + done := make(chan struct{}) + go func() { s.wg.Wait(); close(done) }() + select { + case <-done: + return err + case <-ctx.Done(): + return errors.Join(err, ctx.Err()) + } +} + +func canonicalHost(authority string) string { + host, port, err := net.SplitHostPort(authority) + if err == nil { + if port != "443" { + return "" + } + authority = host + } + return strings.ToLower(authority) +} + +func apiHost(authority string) string { + host := canonicalHost(authority) + if host == githubAPIHost { + return githubHost + } + return host +} + +func allowedAuthority(authority string) bool { + switch canonicalHost(authority) { + case githubHost, githubAPIHost, "enterprise.one.test", "enterprise.two.test": + return true + default: + return false + } +} + +func (s *APIServer) connect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect || !allowedAuthority(r.Host) || + !strings.HasSuffix(r.Host, ":443") { + http.Error(w, "denied", http.StatusForbidden) + return + } + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "unavailable", 500) + return + } + conn, rw, err := hijacker.Hijack() + if err != nil { + return + } + s.mu.Lock() + if s.closing { + s.mu.Unlock() + _ = conn.Close() + return + } + s.connections[conn] = true + s.wg.Add(1) + s.mu.Unlock() + defer func() { _ = conn.Close(); s.mu.Lock(); delete(s.connections, conn); s.mu.Unlock(); s.wg.Done() }() + _, _ = rw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") + if rw.Flush() != nil { + return + } + wrapped := tls.Server( + &bufferedConnection{Conn: conn, reader: rw.Reader}, + &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{s.certificate}}, + ) + s.serveTunnel(wrapped, canonicalHost(r.Host)) +} + +type bufferedConnection struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConnection) Read(b []byte) (int, error) { return c.reader.Read(b) } + +func (s *APIServer) serveTunnel(conn *tls.Conn, authority string) { + reader := bufio.NewReader(conn) + for { + _ = conn.SetDeadline(time.Now().Add(20 * time.Second)) + r, err := http.ReadRequest(reader) + if err != nil { + return + } + w := httptest.NewRecorder() + if canonicalHost(r.Host) != authority { + http.Error(w, "authority mismatch", http.StatusForbidden) + } else { + s.handleAPI(w, r) + } + _ = r.Body.Close() + response := w.Result() + response.ContentLength = int64(w.Body.Len()) + if response.Write(conn) != nil { + _ = response.Body.Close() + return + } + _ = response.Body.Close() + if r.Close { + return + } + } +} + +func (s *APIServer) userFor(host, token string) string { + record, ok := s.accounts[apiHost(host)] + if !ok || token == "" { + return "" + } + switch token { + case record.Forwarded, record.Refresh, localRaceToken(record): + return hostUser + case record.Local: + return localUser + case record.Second: + return secondUser + default: + return "" + } +} + +func (s *APIServer) handleAPI(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1<<20)) + _, token, _ := strings.Cut(r.Header.Get("Authorization"), " ") + user := s.userFor(r.Host, token) + if user == localUser && exists(filepath.Join(s.root, "revoke-local")) { + user = "" + } + code, body := s.apiResponse(r, user) + s.mu.Lock() + s.observations = append( + s.observations, + Observation{Host: apiHost(r.Host), Route: apiRoute(r.URL.Path), User: user, Status: code}, + ) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-OAuth-Scopes", "repo, read:org, gist") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +func (s *APIServer) apiResponse(r *http.Request, user string) (int, any) { + if status, body := s.oauthResponse(r); status != 0 { + return status, body + } + if user == "" { + return 401, map[string]string{"message": "Bad credentials"} + } + if user == localUser && exists(filepath.Join(s.root, "forbid-local")) && + strings.Contains(r.URL.Path, "/repos/") { + return 403, map[string]string{"message": "Forbidden"} + } + return 200, map[string]any{ + "login": user, + "id": 123, + "data": map[string]any{"viewer": map[string]string{"login": user}}, + } +} + +func (s *APIServer) oauthResponse(r *http.Request) (int, any) { + if r.URL.Path == "/login/device/code" && apiHost(r.Host) == Hosts[0] { + return 200, map[string]any{ + "device_code": "fixture-device", + "user_code": "fixture-code", + "verification_uri": "https://github.com/login/device", + "expires_in": 600, + "interval": 1, + } + } + if r.URL.Path == "/login/oauth/access_token" && apiHost(r.Host) == Hosts[0] { + return 200, map[string]string{ + "access_token": s.accounts[Hosts[0]].Refresh, + "token_type": "bearer", + "scope": "repo,read:org,gist", + } + } + + return 0, nil +} + +func apiRoute(path string) string { + switch { + case strings.Contains(path, "graphql"): + return "graphql" + case strings.Contains(path, "/login/"): + return "oauth" + case strings.Contains(path, "/repos/"): + return "repository" + default: + return "user" + } +} + +func exists(path string) bool { _, err := os.Stat(path); return err == nil } + +func fixtureCertificate() (tls.Certificate, []byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, nil, err + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: githubHost}, + DNSNames: append([]string{githubAPIHost}, Hosts...), + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, nil, err + } + pub := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + private, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return tls.Certificate{}, nil, err + } + certificate, err := tls.X509KeyPair( + pub, + pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: private}), + ) + return certificate, pub, err +} diff --git a/e2e/framework/ghfixture/server_linux_test.go b/e2e/framework/ghfixture/server_linux_test.go new file mode 100644 index 0000000000..d63e141616 --- /dev/null +++ b/e2e/framework/ghfixture/server_linux_test.go @@ -0,0 +1,141 @@ +//go:build linux + +package ghfixture + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAPIOracleRejectsWrongHostTokensForRESTAndGraphQL(t *testing.T) { + root := t.TempDir() + cert := filepath.Join(root, "cert.pem") + accounts := Accounts{} + for _, host := range Hosts { + accounts[host] = Tokens{ + Forwarded: "dummy-" + host, + Local: "local-" + host, + Second: "second-" + host, + Invalid: "invalid-" + host, + Refresh: "refresh-" + host, + } + } + server, err := StartAPI(root, cert, "127.0.0.1:0", accounts) + require.NoError(t, err) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, server.Close(ctx)) + }) + proxy, err := url.Parse("http://" + server.Address()) + require.NoError(t, err) + pub, err := readFixture(cert) + require.NoError(t, err) + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(pub)) + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxy), + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + Timeout: time.Second, + } + t.Cleanup(client.CloseIdleConnections) + checkAPIResponses(t, client, accounts) + checkAPIAuthorities(t, client) + server.mu.Lock() + observations := append([]Observation(nil), server.observations...) + server.mu.Unlock() + data, err := json.Marshal(observations) + require.NoError(t, err) + for _, record := range accounts { + require.False( + t, + strings.Contains(string(data), record.Forwarded), + "oracle observation exposed a token", + ) + } +} + +func checkAPIAuthorities(t *testing.T, client *http.Client) { + t.Helper() + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, + "https://api.github.com/user", + nil, + ) + require.NoError(t, err) + req.Host = Hosts[1] + response, err := client.Do(req) + require.NoError(t, err) + _, readErr := io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + require.NoError(t, readErr) + require.Equal( + t, + http.StatusForbidden, + response.StatusCode, + "inner HTTPS authority must match CONNECT authority", + ) + req, err = http.NewRequestWithContext( + context.Background(), + http.MethodGet, + "https://unregistered.invalid/user", + nil, + ) + require.NoError(t, err) + response, err = client.Do(req) + if response != nil { + _ = response.Body.Close() + } + require.Error(t, err, "CONNECT must reject an authority outside the fixture allowlist") +} + +func checkAPIResponses(t *testing.T, client *http.Client, accounts Accounts) { + t.Helper() + for _, host := range Hosts { + for _, route := range []string{"/user", "/graphql"} { + for _, correct := range []bool{false, true} { + token := accounts[host].Forwarded + if !correct { + token = accounts[Hosts[0]].Forwarded + if host == Hosts[0] { + token = accounts[Hosts[1]].Forwarded + } + } + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + "https://"+host+route, + strings.NewReader(`{"query":"{viewer{login}}"}`), + ) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + response, err := client.Do(req) + require.NoError(t, err) + data, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + require.NoError(t, readErr) + var body map[string]any + require.NoError(t, json.Unmarshal(data, &body)) + want := http.StatusUnauthorized + if correct { + want = http.StatusOK + } + require.Equal(t, want, response.StatusCode) + } + } + } +} diff --git a/e2e/tests/ssh/credentials_server_race.go b/e2e/tests/ssh/credentials_server_race.go index a51b12650e..8cdd1a54e9 100644 --- a/e2e/tests/ssh/credentials_server_race.go +++ b/e2e/tests/ssh/credentials_server_race.go @@ -14,6 +14,7 @@ import ( const ( cmdWorkspace = "workspace" cmdSSH = "ssh" + flagCommand = "--command" ) var _ = ginkgo.Describe( @@ -64,7 +65,7 @@ var _ = ginkgo.Describe( ) defer cancelSSH() _, stderr, sshErr := f.ExecCommandCapture(sshCtx, []string{ - cmdWorkspace, cmdSSH, tempDir, "--command", "sleep 2", + cmdWorkspace, cmdSSH, tempDir, flagCommand, "sleep 2", }) stderrs[i] = stderr runErrs[i] = sshErr diff --git a/e2e/tests/ssh/gh_credentials.go b/e2e/tests/ssh/gh_credentials.go new file mode 100644 index 0000000000..0f5f04c157 --- /dev/null +++ b/e2e/tests/ssh/gh_credentials.go @@ -0,0 +1,412 @@ +package ssh + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/rand" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/e2e/framework/ghfixture" + "github.com/devsy-org/devsy/pkg/agent/tunnelserver" + "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/gh-relay/Dockerfile +var fixtureFiles embed.FS + +func testGitHubCLIForwarding(ctx context.Context) { + t := ginkgo.GinkgoT() + dir := t.TempDir() + fixture := prepareFixture(t, ctx, dir) + prepareHostAuthentication(t, dir) + stop, done := startFixture(t, ctx, dir, fixture) + defer stop() + waitFixture(t, ctx, fixture.name, done) + // stdout contains only boolean test results. Raw gh streams and credentials + // stay inside the disposable fixture and are never attached to failures. + //nolint:gosec // name is the generated test container. + check := exec.CommandContext( + ctx, + "docker", + "exec", + fixture.name, + "/fixture/ghfixture", + "check", + ) + report, err := check.CombinedOutput() + + require.NoError(t, err, "real gh checks: %s", report) + verifyRealGHReport(t, report) + + verifyRelayFallback(t, ctx, fixture.name, stop) + //nolint:gosec // The container name belongs to this generated, isolated fixture. + after := exec.CommandContext( + ctx, + "docker", + "exec", + fixture.name, + "/fixture/ghfixture", + "after-relay", + ) + proof, err := after.CombinedOutput() + require.NoError(t, err, "relay-stopped checks: %s", proof) + _, err = ghfixture.ValidateReport(proof, ghfixture.ExpectedRelayLossChecks) + require.NoError(t, err) +} + +func downloadGH(t ginkgo.GinkgoTInterface, ctx context.Context, dir, arch string) { + t.Helper() + const version = "2.101.0" + filename := "gh_" + version + "_linux_" + arch + ".tar.gz" + get := func(name string) []byte { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://github.com/cli/cli/releases/download/v"+version+"/"+name, + nil, + ) + require.NoError(t, err) + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = response.Body.Close() }() + require.Equal(t, http.StatusOK, response.StatusCode) + b, err := io.ReadAll(io.LimitReader(response.Body, 64<<20)) + require.NoError(t, err) + return b + } + archive := get(filename) + sums := string(get("gh_" + version + "_checksums.txt")) + hash := sha256.Sum256(archive) + require.Contains(t, sums, fmt.Sprintf("%x %s", hash, filename)) + z, err := gzip.NewReader(bytes.NewReader(archive)) + require.NoError(t, err) + defer func() { _ = z.Close() }() + tarReader := tar.NewReader(z) + for { + header, err := tarReader.Next() + require.NoError(t, err) + if strings.HasSuffix(header.Name, "/bin/gh") { + binary, err := io.ReadAll(io.LimitReader(tarReader, 128<<20)) + require.NoError(t, err) + // #nosec G306 -- Executable release binary, verified against its published checksum. + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh"), binary, 0o755)) + return + } + } +} + +func verifyRealGHReport(t ginkgo.GinkgoTInterface, report []byte) { + t.Helper() + results, err := ghfixture.ValidateReport(report, ghfixture.ExpectedChecks) + require.NoError(t, err) + for _, r := range results.Checks { + t.Log(r.Name) + } + t.Logf("guest CLI: %s; guest architecture: %s", results.CLI, results.Architecture) +} + +type ghRelayFixture struct { + name string + arch string +} + +func prepareFixture(t ginkgo.GinkgoTInterface, ctx context.Context, dir string) ghRelayFixture { + t.Helper() + agents, err := os.OpenRoot("bin") + require.NoError(t, err) + defer func() { _ = agents.Close() }() + arch := fixtureArchitecture(t, ctx) + agent := "devsy-linux-" + arch + data, err := agents.ReadFile(agent) + require.NoError(t, err) + root, err := os.OpenRoot(dir) + require.NoError(t, err) + defer func() { _ = root.Close() }() + require.NoError(t, root.WriteFile("devsy", data, 0o755)) + fixtureBinary, err := agents.ReadFile("ghfixture-linux-" + arch) + require.NoError(t, err) + require.NoError(t, root.WriteFile("ghfixture", fixtureBinary, 0o755)) + for _, name := range []string{"Dockerfile"} { + b, err := fixtureFiles.ReadFile("testdata/gh-relay/" + name) + require.NoError(t, err) + require.NoError(t, root.WriteFile(name, b, 0o600)) + } + random := make([]byte, 12) + _, err = rand.Read(random) + require.NoError(t, err) + name := "devsy-gh-test-" + hex.EncodeToString(random) + image := name + ":test" + downloadGH(t, ctx, dir, arch) + //nolint:gosec // image and directory are generated fixture resources. + buildImage := exec.CommandContext( + ctx, + "docker", + "build", + "--platform", + "linux/"+arch, + "-q", + "-t", + image, + dir, + ) + imageOut, err := buildImage.CombinedOutput() + require.NoError(t, err, "fixture image: %s", imageOut) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + //nolint:gosec // image is the random, test-owned tag. + _ = exec.CommandContext(cleanup, "docker", "image", "rm", image).Run() + }) + return ghRelayFixture{name: name, arch: arch} +} + +func fixtureArchitecture(t ginkgo.GinkgoTInterface, ctx context.Context) string { + t.Helper() + info := exec.CommandContext(ctx, "docker", "info", "--format", "{{.OSType}} {{.Architecture}}") + data, err := info.Output() + require.NoError(t, err) + fields := strings.Fields(string(data)) + require.Len(t, fields, 2) + require.Equal(t, "linux", fields[0], "GitHub forwarding fixtures require a Linux Docker engine") + arch := fields[1] + switch arch { + case "aarch64": + arch = "arm64" + case "x86_64": + arch = "amd64" + } + require.Contains(t, []string{"arm64", "amd64"}, arch, "unsupported Docker engine architecture") + t.Logf("native host: %s/%s; native Docker guest: linux/%s", runtime.GOOS, runtime.GOARCH, arch) + return arch +} + +func prepareHostAuthentication(t ginkgo.GinkgoTInterface, dir string) { + t.Helper() + type tokens struct { + Forwarded string `json:"forwarded"` + Local string `json:"local"` + Second string `json:"second"` + Invalid string `json:"invalid"` + Refresh string `json:"refresh"` + } + accounts := map[string]tokens{} + var hosts strings.Builder + for _, host := range []string{"github.com", "enterprise.one.test", "enterprise.two.test"} { + token := func() string { + b := make([]byte, 20) + _, err := rand.Read(b) + require.NoError(t, err) + return "ghp_" + hex.EncodeToString(b) + } + v := tokens{ + Forwarded: token(), + Local: token(), + Second: token(), + Invalid: token(), + Refresh: token(), + } + accounts[host] = v + fmt.Fprintf( + &hosts, + "%s:\n user: host-user\n oauth_token: %s\n", + host, + v.Forwarded, + ) + } + require.NoError(t, os.MkdirAll(filepath.Join(dir, "host-gh"), 0o700)) + require.NoError( + t, + os.WriteFile(filepath.Join(dir, "host-gh", "hosts.yml"), []byte(hosts.String()), 0o600), + ) + data, err := json.Marshal(accounts) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "accounts.json"), data, 0o600)) + for _, key := range []string{"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "GH_HOST"} { + t.Setenv(key, "") + } + t.Setenv("GH_CONFIG_DIR", filepath.Join(dir, "host-gh")) + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(dir, "no-gitconfig")) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") +} + +const ghRelayStartCommand = `cp /fixture/gh /usr/bin/gh +cp /fixture/gh /usr/local/bin/gh +cp /fixture/devsy /usr/local/bin/devsy +mkdir -p /root/.local/share/keyrings +export PATH=/usr/local/bin:/usr/bin:/bin +exec dbus-run-session -- sh -c ' + printf "%s" "$DBUS_SESSION_BUS_ADDRESS" > /fixture/bus + printf fixture-password | gnome-keyring-daemon --unlock >/dev/null 2>/tmp/keyring.log + /fixture/ghfixture server >/dev/null 2>/tmp/api.log & + /usr/local/bin/devsy internal agent container ssh-server --address 127.0.0.1:12050 >/tmp/ssh.log 2>&1 & + /fixture/ghfixture probe + /usr/sbin/runuser -u vscode -- sh -c " + echo \$\$ > /home/vscode/gh-relay-server-pid + exec /usr/local/bin/devsy internal agent container credentials-server --user vscode --configure-github-helper + " + sleep 600 +'` + +func startFixture( + t ginkgo.GinkgoTInterface, + ctx context.Context, + dir string, + fixture ghRelayFixture, +) (context.CancelFunc, <-chan error) { + t.Helper() + name := fixture.name + image := name + ":test" + + //nolint:gosec // All mounts, commands, and image names belong to this disposable fixture. + guest := exec.CommandContext( + ctx, + "docker", + "run", + "--rm", + "--user", + "root", + "--platform", + "linux/"+fixture.arch, + "--name", + name, + "--network", + "none", + "-i", + "-v", + dir+":/fixture", + image, + "sh", + "-c", + ghRelayStartCommand, + ) + reader, err := guest.StdoutPipe() + require.NoError(t, err) + writer, err := guest.StdinPipe() + require.NoError(t, err) + require.NoError(t, guest.Start()) + guestDone := make(chan struct{}) + go func() { _ = guest.Wait(); close(guestDone) }() + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + //nolint:gosec // name is the random, test-owned container name. + _ = exec.CommandContext(cleanup, "docker", "rm", "-f", name).Run() + select { + case <-guestDone: + case <-cleanup.Done(): + t.Error("fixture container process did not exit during cleanup") + } + }) + return startFixtureRelay(t, ctx, reader, writer) +} + +func startFixtureRelay( + t ginkgo.GinkgoTInterface, + ctx context.Context, + reader io.ReadCloser, + writer io.WriteCloser, +) (context.CancelFunc, <-chan error) { + tunnelCtx, tunnelCancel := context.WithCancel(ctx) + done := make(chan error, 1) + joined := make(chan struct{}) + go func() { + defer close(joined) + done <- tunnelserver.RunServicesServer(tunnelCtx, reader, writer, false, false, nil, nil, + tunnelserver.WithAllowGitHubCredentials(true)) + }() + + var once sync.Once + stop := func() { + once.Do(func() { + tunnelCancel() + _ = writer.Close() + _ = reader.Close() + select { + case <-joined: + case <-time.After(5 * time.Second): + t.Error("fixture relay did not exit during cleanup") + } + }) + } + // Registered after the environment cleanup, so no host authentication + // environment is restored while the relay can still resolve accounts. + t.Cleanup(stop) + return stop, done +} + +func waitFixture(t ginkgo.GinkgoTInterface, ctx context.Context, name string, done <-chan error) { + t.Helper() + ready := false + for range 150 { + //nolint:gosec // name is the generated test container. + c := exec.CommandContext( + ctx, + "docker", + "exec", + name, + "sh", + "-c", + "test -f /fixture/api-ready && test -f /usr/local/bin/gh.devsy-original", + ) + if c.Run() == nil { + ready = true + break + } + select { + case err := <-done: + t.Fatalf("relay ended before readiness: %v", err) + case <-time.After(100 * time.Millisecond): + } + } + require.True(t, ready, "fixture was not ready") +} + +func verifyRelayFallback( + t ginkgo.GinkgoTInterface, + ctx context.Context, + name string, + stop context.CancelFunc, +) { + t.Helper() + // A transport failure must execute normal gh once, without relayed auth. + stop() + //nolint:gosec // name is the test-owned container; only its relay process is stopped. + _ = exec.CommandContext(ctx, "docker", "exec", name, "sh", "-c", "pkill -x devsy || true"). + Run() + //nolint:gosec // name is the test-owned container, with an isolated configuration. + fallback := exec.CommandContext( + ctx, + "docker", + "exec", + name, + "bash", + "-lc", + "export XDG_CACHE_HOME=/fixture/guest-cache; "+ + "GH_CONFIG_DIR=/fixture/guest-gh gh config get editor && "+ + "GH_CONFIG_DIR=/fixture/fallback-gh gh config set editor nano && "+ + "GH_CONFIG_DIR=/fixture/fallback-gh gh config get editor", + ) + var stderr bytes.Buffer + fallback.Stderr = &stderr + plain, err := fallback.Output() + require.NoError(t, err, "fallback: %s", plain) + require.Equal(t, "pending\nnano\n", string(plain)) + require.Contains(t, stderr.String(), "gh relay: forwarding unavailable") + require.Contains(t, stderr.String(), "connection_refused") +} diff --git a/e2e/tests/ssh/gh_public.go b/e2e/tests/ssh/gh_public.go new file mode 100644 index 0000000000..2027d6262e --- /dev/null +++ b/e2e/tests/ssh/gh_public.go @@ -0,0 +1,173 @@ +package ssh + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/require" +) + +const publicGHAPICommand = "GH_CONFIG_DIR=/tmp/public-gh HTTPS_PROXY=http://127.0.0.1:8123 " + + "NO_PROXY= SSL_CERT_FILE=/fixture/cert.pem gh api user --jq .login" + +const publicGHUser = "host-user" + +const flagNoServices = "--start-services=false" + +// This exercises the public command with no credential-listener or wrapper +// polling. Only the independent, dummy GitHub API starts during workspace setup. +func testPublicGitHubCLIForwarding(ctx context.Context) { + t := ginkgo.GinkgoT() + dir := t.TempDir() + prepareHostAuthentication(t, dir) + fixture := prepareFixture(t, ctx, dir) + t.Setenv("DEVSY_HOME", publicTestHome(t)) + t.Setenv("DEVSY_CONFIG", filepath.Join(t.TempDir(), "config.yaml")) + bin, err := filepath.Abs("bin") + require.NoError(t, err) + f := framework.NewDefaultFramework(bin) + require.NoError(t, f.DevsyProviderAdd(ctx, "docker")) + require.NoError(t, f.DevsyProviderUse(ctx, "docker")) + setPublicForwarding(t, ctx, f, false) + for _, user := range []string{"root", "vscode"} { + t.Logf("public SSH guest user: %s", user) + (&publicGHFixture{f: f, dir: dir, fixture: fixture}).testUser(t, ctx, user) + } +} + +func setPublicForwarding( + t ginkgo.GinkgoTInterface, + ctx context.Context, + f *framework.Framework, + enabled bool, +) { + t.Helper() + value := "false" + if enabled { + value = "true" + } + _, _, err := f.ExecCommandCapture(ctx, []string{ + "context", "set", "default", "-o", "SSH_INJECT_GITHUB_CREDENTIALS=" + value, + "-o", "SSH_AGENT_FORWARDING=false", + "-o", "SSH_INJECT_GIT_CREDENTIALS=false", "-o", "SSH_INJECT_DOCKER_CREDENTIALS=false", + }) + require.NoError(t, err) +} + +type publicGHFixture struct { + f *framework.Framework + dir string + fixture ghRelayFixture +} + +func (p *publicGHFixture) testUser(t ginkgo.GinkgoTInterface, ctx context.Context, user string) { + t.Helper() + workspace := t.TempDir() + apiDir := p.apiDirectory(t) + configuration := map[string]any{ + "image": p.fixture.name + ":test", + "remoteUser": user, + "mounts": []string{"type=bind,source=" + apiDir + ",target=/fixture"}, + "postCreateCommand": `sudo -n sh -c 'nohup /opt/ghfixture server-public >/tmp/fixture-api.log 2>&1 /dev/null 2>&1; then exit 1; fi; gh config set editor nano && gh config get editor", + ) + require.NoError(t, err) + require.Equal(t, "nano", strings.TrimSpace(out)) + stop() + p.testMissingCLI(t, ctx, workspace) + require.NoError(t, p.f.DevsyWorkspaceDelete(ctx, workspace)) + deleted = true +} + +func (p *publicGHFixture) apiDirectory(t ginkgo.GinkgoTInterface) string { + t.Helper() + dir := t.TempDir() + root, err := os.OpenRoot(dir) + require.NoError(t, err) + defer func() { _ = root.Close() }() + accounts, err := os.ReadFile(filepath.Join(p.dir, "accounts.json")) + require.NoError(t, err) + path := filepath.Join(dir, "accounts.json") + require.NoError(t, root.WriteFile("accounts.json", accounts, 0o600)) + // #nosec G302 -- Only generated fixture resources are shared with the guest UID. + require.NoError(t, os.Chmod(dir, 0o755)) + // #nosec G302 -- The generated dummy API accounts must be readable by the guest UID. + require.NoError(t, os.Chmod(path, 0o644)) + return dir +} + +func (p *publicGHFixture) testMissingCLI( + t ginkgo.GinkgoTInterface, + ctx context.Context, + workspace string, +) { + t.Helper() + _, _, err := p.f.ExecCommandCapture(ctx, []string{ + cmdWorkspace, cmdSSH, workspace, + flagNoServices, flagCommand, + "sudo -n rm -f /usr/local/bin/gh /usr/local/bin/gh.devsy-original /usr/bin/gh", + }) + require.NoError(t, err) + setPublicForwarding(t, ctx, p.f, true) + _, err = p.f.DevsySSHOnce(ctx, workspace, "test ! -e /usr/local/bin/gh && ! command -v gh") + require.NoError(t, err, "missing backend must not manufacture a gh command") + _, err = p.f.DevsySSHOnce( + ctx, + workspace, + "sudo -n install -m 755 /opt/fixture-gh /usr/bin/gh && sudo -n install -m 755 /opt/fixture-gh /usr/local/bin/gh", + ) + require.NoError(t, err) + out, err := p.f.DevsySSHOnce(ctx, workspace, publicGHAPICommand) + require.NoError(t, err, "a later connection must retry installation after gh becomes available") + require.Equal(t, publicGHUser, strings.TrimSpace(out)) + setPublicForwarding(t, ctx, p.f, false) +} + +func publicTestHome(t ginkgo.GinkgoTInterface) string { + t.Helper() + base := "" + if runtime.GOOS == "darwin" { + base = "/tmp" + } + dir, err := os.MkdirTemp(base, "gh-public-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) + return dir +} diff --git a/e2e/tests/ssh/gh_public_diagnostics.go b/e2e/tests/ssh/gh_public_diagnostics.go new file mode 100644 index 0000000000..682243f9ea --- /dev/null +++ b/e2e/tests/ssh/gh_public_diagnostics.go @@ -0,0 +1,56 @@ +package ssh + +import ( + "bytes" + "context" + "sync" + "time" + + "github.com/onsi/ginkgo/v2" +) + +type publicSessionStderr struct { + mu sync.Mutex + buffer bytes.Buffer +} + +func (b *publicSessionStderr) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + n := len(p) + if remaining := 8192 - b.buffer.Len(); len(p) > remaining { + p = p[:remaining] + } + _, _ = b.buffer.Write(p) + return n, nil +} + +func (b *publicSessionStderr) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.String() +} + +func (p *publicGHFixture) registerDiagnostics( + t ginkgo.GinkgoTInterface, + workspace string, + deleted *bool, +) { + t.Helper() + t.Cleanup(func() { + if !t.Failed() || *deleted { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + out, _, err := p.f.ExecCommandCapture(ctx, []string{ + cmdWorkspace, + cmdSSH, + workspace, + flagNoServices, + flagCommand, + "head -c 4096 /tmp/fixture-api.log; pgrep -a ghfixture; stat -c '%a %u %g %n' /fixture/api-ready /fixture/cert.pem", + }) + t.Logf("public API fixture diagnostics: %s (error: %v)", out, err) + }) +} diff --git a/e2e/tests/ssh/gh_public_diagnostics_test.go b/e2e/tests/ssh/gh_public_diagnostics_test.go new file mode 100644 index 0000000000..8777384e1f --- /dev/null +++ b/e2e/tests/ssh/gh_public_diagnostics_test.go @@ -0,0 +1,18 @@ +package ssh + +import ( + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPublicSessionStderrKeepsBoundThroughIOCopy(t *testing.T) { + input := strings.Repeat("x", 32768) + var output publicSessionStderr + n, err := io.Copy(&output, io.LimitReader(strings.NewReader(input), int64(len(input)))) + require.NoError(t, err) + require.Equal(t, int64(len(input)), n) + require.Equal(t, input[:8192], output.String()) +} diff --git a/e2e/tests/ssh/gh_public_session.go b/e2e/tests/ssh/gh_public_session.go new file mode 100644 index 0000000000..1225bc145f --- /dev/null +++ b/e2e/tests/ssh/gh_public_session.go @@ -0,0 +1,88 @@ +package ssh + +import ( + "bufio" + "context" + "io" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/require" +) + +func (p *publicGHFixture) testLiveServiceReuse( + t ginkgo.GinkgoTInterface, + ctx context.Context, + workspace string, +) context.CancelFunc { + t.Helper() + keeper, cancel := context.WithCancel(ctx) + //nolint:gosec // Execute the built test CLI against the isolated test workspace. + cmd := exec.CommandContext(keeper, filepath.Join(p.f.DevsyBinDir, p.f.DevsyBinName), + cmdWorkspace, cmdSSH, workspace, flagCommand, publicGHAPICommand+" && sleep 60") + docker.PrepareForGroupCancellation(cmd) + cmd.WaitDelay = 250 * time.Millisecond + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + var stderr publicSessionStderr + cmd.Stderr = &stderr + require.NoError(t, cmd.Start()) + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + drained := make(chan struct{}) + stop := stopPublicSession(t, cancel, done, drained) + t.Cleanup(stop) + first := make(chan string, 1) + go func() { + defer close(drained) + reader := bufio.NewReader(io.LimitReader(stdout, 4096)) + line, _ := reader.ReadString('\n') + first <- strings.TrimSpace(line) + }() + select { + case line := <-first: + if line != publicGHUser { + stop() + require.Equal(t, publicGHUser, line, + "first public command must authenticate without a setup wait: %s", stderr.String()) + } + case <-time.After(20 * time.Second): + stop() + t.Fatalf("first public GitHub command did not complete: %s", stderr.String()) + } + out, err := p.f.DevsySSHOnce(ctx, workspace, publicGHAPICommand) + require.NoError(t, err, "second connection must reuse the still-live owner") + require.Equal(t, publicGHUser, strings.TrimSpace(out)) + return stop +} + +func stopPublicSession( + t ginkgo.GinkgoTInterface, + cancel context.CancelFunc, + done, drained <-chan struct{}, +) context.CancelFunc { + t.Helper() + var once sync.Once + return func() { + once.Do(func() { + cancel() + cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + select { + case <-done: + case <-cleanup.Done(): + t.Error("public SSH session did not exit during cleanup") + } + select { + case <-drained: + case <-cleanup.Done(): + t.Error("public SSH output reader did not exit during cleanup") + } + }) + } +} diff --git a/e2e/tests/ssh/ssh.go b/e2e/tests/ssh/ssh.go index 3ae113c9fc..b4d18ab5db 100644 --- a/e2e/tests/ssh/ssh.go +++ b/e2e/tests/ssh/ssh.go @@ -31,6 +31,20 @@ const ( sshOptForwardAgentNo = "ForwardAgent=no" ) +var _ = ginkgo.Describe( + "GitHub CLI credential forwarding", + ginkgo.Label(cmdSSH, "gh-forwarding"), + func() { + ginkgo.It( + "should forward host GitHub CLI authentication without a container login", + ginkgo.SpecTimeout(framework.TimeoutLong()), + testGitHubCLIForwarding, + ) + ginkgo.It("should prepare credentials before the first public workspace SSH command", + ginkgo.SpecTimeout(framework.TimeoutLong()), testPublicGitHubCLIForwarding) + }, +) + var _ = ginkgo.Describe("devsy ssh test suite", ginkgo.Label("ssh"), ginkgo.Ordered, func() { var initialDir string @@ -252,8 +266,8 @@ var _ = ginkgo.Describe("devsy ssh test suite", ginkgo.Label("ssh"), ginkgo.Orde // The signing key must be passed on each SSH invocation so the // credentials server can configure the helper inside the container. sshBase := []string{ - "workspace", - "ssh", + cmdWorkspace, + cmdSSH, "--agent-forwarding", "--start-services", "--git-ssh-signing-key", keyPath + ".pub", @@ -261,7 +275,7 @@ var _ = ginkgo.Describe("devsy ssh test suite", ginkgo.Label("ssh"), ginkgo.Orde } stdout, stderr, err := f.ExecCommandCapture(ctx, - append(sshBase, "--command", commitCmd), + append(sshBase, flagCommand, commitCmd), ) ginkgo.GinkgoWriter.Printf("commit stdout: %s\n", stdout) ginkgo.GinkgoWriter.Printf("commit stderr: %s\n", stderr) @@ -287,7 +301,7 @@ var _ = ginkgo.Describe("devsy ssh test suite", ginkgo.Label("ssh"), ginkgo.Orde }, " && ") stdout, stderr, err = f.ExecCommandCapture(ctx, - append(sshBase, "--command", verifyCmd), + append(sshBase, flagCommand, verifyCmd), ) ginkgo.GinkgoWriter.Printf("verify stdout: %s\n", stdout) ginkgo.GinkgoWriter.Printf("verify stderr: %s\n", stderr) @@ -303,7 +317,7 @@ var _ = ginkgo.Describe("devsy ssh test suite", ginkgo.Label("ssh"), ginkgo.Orde // And confirm the signature log shows the correct principal logCmd := "cd /tmp/test-sign-repo && git log --show-signature -1 2>&1" stdout, stderr, err = f.ExecCommandCapture(ctx, - append(sshBase, "--command", logCmd), + append(sshBase, flagCommand, logCmd), ) ginkgo.GinkgoWriter.Printf("log stdout: %s\n", stdout) ginkgo.GinkgoWriter.Printf("log stderr: %s\n", stderr) @@ -420,12 +434,12 @@ var _ = ginkgo.Describe("devsy ssh test suite", ginkgo.Label("ssh"), ginkgo.Orde }, " && ") stdout, stderr, err := f.ExecCommandCapture(ctx, []string{ - "workspace", - "ssh", + cmdWorkspace, + cmdSSH, "--agent-forwarding", "--start-services", tempDir, - "--command", commitCmd, + flagCommand, commitCmd, }) ginkgo.GinkgoWriter.Printf("error commit stdout: %s\n", stdout) ginkgo.GinkgoWriter.Printf("error commit stderr: %s\n", stderr) @@ -481,7 +495,7 @@ var _ = ginkgo.Describe("devsy ssh test suite", ginkgo.Label("ssh"), ginkgo.Orde defer serverCancel() // #nosec G204 -- test command with controlled arguments serverCmd := exec.CommandContext(serverCtx, f.DevsyBinDir+"/"+f.DevsyBinName, - "workspace", "ssh", tempDir, "--command", + cmdWorkspace, cmdSSH, tempDir, flagCommand, "go run /workspaces/"+filepath.Base(tempDir)+"/server.go "+strconv.Itoa(port), ) err = serverCmd.Start() diff --git a/e2e/tests/ssh/testdata/gh-relay/Dockerfile b/e2e/tests/ssh/testdata/gh-relay/Dockerfile new file mode 100644 index 0000000000..8ab187e60b --- /dev/null +++ b/e2e/tests/ssh/testdata/gh-relay/Dockerfile @@ -0,0 +1,7 @@ +FROM ghcr.io/devsy-org/test-images/base:ubuntu +USER root +RUN apt-get update && apt-get install -y --no-install-recommends dbus gnome-keyring ca-certificates && rm -rf /var/lib/apt/lists/* +COPY gh /opt/fixture-gh +COPY ghfixture /opt/ghfixture +RUN install -m 755 /opt/fixture-gh /usr/bin/gh && install -m 755 /opt/fixture-gh /usr/local/bin/gh +USER vscode diff --git a/mise.toml b/mise.toml index 16e90f9975..ddc8a952e7 100644 --- a/mise.toml +++ b/mise.toml @@ -125,7 +125,8 @@ description = "build devsy for e2e tests" run = [ { task = "cli:build:dev" }, "mkdir -p e2e/bin", - "cp dist/devsy-dev_linux_amd64_v1/devsy-linux-amd64 e2e/bin/devsy-linux-amd64", + 'for binary in dist/devsy-dev_linux_*/devsy-linux-*; do cp "$binary" e2e/bin/; done', + 'for arch in amd64 arm64; do CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -o "e2e/bin/ghfixture-linux-$arch" ./e2e/cmd/ghfixture; done', ''' host_os="$(go env GOOS)" host_arch="$(go env GOARCH)" diff --git a/pkg/agent/tunnel/tunnel.pb.go b/pkg/agent/tunnel/tunnel.pb.go index 90ec40140e..e9afcb320e 100644 --- a/pkg/agent/tunnel/tunnel.pb.go +++ b/pkg/agent/tunnel/tunnel.pb.go @@ -772,7 +772,7 @@ const file_tunnel_proto_rawDesc = "" + "\x04INFO\x10\x01\x12\b\n" + "\x04DONE\x10\x02\x12\v\n" + "\aWARNING\x10\x03\x12\t\n" + - "\x05ERROR\x10\x042\xad\a\n" + + "\x05ERROR\x10\x042\xe4\a\n" + "\x06Tunnel\x12&\n" + "\x04Ping\x12\r.tunnel.Empty\x1a\r.tunnel.Empty\"\x00\x12*\n" + "\x03Log\x12\x12.tunnel.LogMessage\x1a\r.tunnel.Empty\"\x00\x12/\n" + @@ -781,6 +781,7 @@ const file_tunnel_proto_rawDesc = "" + "SendResult\x12\x0f.tunnel.Message\x1a\r.tunnel.Empty\"\x00\x127\n" + "\x11DockerCredentials\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x124\n" + "\x0eGitCredentials\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x125\n" + + "\x11GitHubCredentials\x12\r.tunnel.Empty\x1a\x0f.tunnel.Message\"\x00\x125\n" + "\x0fGitSSHSignature\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x12+\n" + "\aGitUser\x12\r.tunnel.Empty\x1a\x0f.tunnel.Message\"\x00\x121\n" + "\vDevsyConfig\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x123\n" + @@ -836,36 +837,38 @@ var file_tunnel_proto_depIdxs = []int32{ 6, // 7: tunnel.Tunnel.SendResult:input_type -> tunnel.Message 6, // 8: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message 6, // 9: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message - 6, // 10: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message - 13, // 11: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty - 6, // 12: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message - 6, // 13: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message - 6, // 14: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message - 13, // 15: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty - 4, // 16: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest - 2, // 17: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest - 13, // 18: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty - 1, // 19: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest - 13, // 20: tunnel.Tunnel.StreamSnapshotVolumes:input_type -> tunnel.Empty - 13, // 21: tunnel.Tunnel.Ping:output_type -> tunnel.Empty - 13, // 22: tunnel.Tunnel.Log:output_type -> tunnel.Empty - 13, // 23: tunnel.Tunnel.Status:output_type -> tunnel.Empty - 13, // 24: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty - 6, // 25: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message - 6, // 26: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message - 6, // 27: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message - 6, // 28: tunnel.Tunnel.GitUser:output_type -> tunnel.Message - 6, // 29: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message - 6, // 30: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message - 6, // 31: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message - 8, // 32: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse - 5, // 33: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse - 3, // 34: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse - 9, // 35: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk - 9, // 36: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk - 9, // 37: tunnel.Tunnel.StreamSnapshotVolumes:output_type -> tunnel.Chunk - 21, // [21:38] is the sub-list for method output_type - 4, // [4:21] is the sub-list for method input_type + 13, // 10: tunnel.Tunnel.GitHubCredentials:input_type -> tunnel.Empty + 6, // 11: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message + 13, // 12: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty + 6, // 13: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message + 6, // 14: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message + 6, // 15: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message + 13, // 16: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty + 4, // 17: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest + 2, // 18: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest + 13, // 19: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty + 1, // 20: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest + 13, // 21: tunnel.Tunnel.StreamSnapshotVolumes:input_type -> tunnel.Empty + 13, // 22: tunnel.Tunnel.Ping:output_type -> tunnel.Empty + 13, // 23: tunnel.Tunnel.Log:output_type -> tunnel.Empty + 13, // 24: tunnel.Tunnel.Status:output_type -> tunnel.Empty + 13, // 25: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty + 6, // 26: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message + 6, // 27: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message + 6, // 28: tunnel.Tunnel.GitHubCredentials:output_type -> tunnel.Message + 6, // 29: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message + 6, // 30: tunnel.Tunnel.GitUser:output_type -> tunnel.Message + 6, // 31: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message + 6, // 32: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message + 6, // 33: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message + 8, // 34: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse + 5, // 35: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse + 3, // 36: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse + 9, // 37: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk + 9, // 38: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk + 9, // 39: tunnel.Tunnel.StreamSnapshotVolumes:output_type -> tunnel.Chunk + 22, // [22:40] is the sub-list for method output_type + 4, // [4:22] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name diff --git a/pkg/agent/tunnel/tunnel.proto b/pkg/agent/tunnel/tunnel.proto index 7e710ff2a1..36c6441768 100644 --- a/pkg/agent/tunnel/tunnel.proto +++ b/pkg/agent/tunnel/tunnel.proto @@ -13,6 +13,7 @@ service Tunnel { rpc DockerCredentials(Message) returns (Message) {} rpc GitCredentials(Message) returns (Message) {} + rpc GitHubCredentials(Empty) returns (Message) {} rpc GitSSHSignature(Message) returns (Message) {} rpc GitUser(Empty) returns (Message) {} rpc DevsyConfig(Message) returns (Message) {} diff --git a/pkg/agent/tunnel/tunnel_grpc.pb.go b/pkg/agent/tunnel/tunnel_grpc.pb.go index 5d735fe276..96f4cfaf6b 100644 --- a/pkg/agent/tunnel/tunnel_grpc.pb.go +++ b/pkg/agent/tunnel/tunnel_grpc.pb.go @@ -27,6 +27,7 @@ const ( Tunnel_SendResult_FullMethodName = "/tunnel.Tunnel/SendResult" Tunnel_DockerCredentials_FullMethodName = "/tunnel.Tunnel/DockerCredentials" Tunnel_GitCredentials_FullMethodName = "/tunnel.Tunnel/GitCredentials" + Tunnel_GitHubCredentials_FullMethodName = "/tunnel.Tunnel/GitHubCredentials" Tunnel_GitSSHSignature_FullMethodName = "/tunnel.Tunnel/GitSSHSignature" Tunnel_GitUser_FullMethodName = "/tunnel.Tunnel/GitUser" Tunnel_DevsyConfig_FullMethodName = "/tunnel.Tunnel/DevsyConfig" @@ -50,6 +51,7 @@ type TunnelClient interface { SendResult(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Empty, error) DockerCredentials(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) GitCredentials(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) + GitHubCredentials(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Message, error) GitSSHSignature(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) GitUser(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Message, error) DevsyConfig(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) @@ -131,6 +133,16 @@ func (c *tunnelClient) GitCredentials(ctx context.Context, in *Message, opts ... return out, nil } +func (c *tunnelClient) GitHubCredentials(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Message, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Message) + err := c.cc.Invoke(ctx, Tunnel_GitHubCredentials_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *tunnelClient) GitSSHSignature(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Message) @@ -278,6 +290,7 @@ type TunnelServer interface { SendResult(context.Context, *Message) (*Empty, error) DockerCredentials(context.Context, *Message) (*Message, error) GitCredentials(context.Context, *Message) (*Message, error) + GitHubCredentials(context.Context, *Empty) (*Message, error) GitSSHSignature(context.Context, *Message) (*Message, error) GitUser(context.Context, *Empty) (*Message, error) DevsyConfig(context.Context, *Message) (*Message, error) @@ -317,6 +330,9 @@ func (UnimplementedTunnelServer) DockerCredentials(context.Context, *Message) (* func (UnimplementedTunnelServer) GitCredentials(context.Context, *Message) (*Message, error) { return nil, status.Error(codes.Unimplemented, "method GitCredentials not implemented") } +func (UnimplementedTunnelServer) GitHubCredentials(context.Context, *Empty) (*Message, error) { + return nil, status.Error(codes.Unimplemented, "method GitHubCredentials not implemented") +} func (UnimplementedTunnelServer) GitSSHSignature(context.Context, *Message) (*Message, error) { return nil, status.Error(codes.Unimplemented, "method GitSSHSignature not implemented") } @@ -479,6 +495,24 @@ func _Tunnel_GitCredentials_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _Tunnel_GitHubCredentials_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TunnelServer).GitHubCredentials(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Tunnel_GitHubCredentials_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TunnelServer).GitHubCredentials(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + func _Tunnel_GitSSHSignature_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Message) if err := dec(in); err != nil { @@ -687,6 +721,10 @@ var Tunnel_ServiceDesc = grpc.ServiceDesc{ MethodName: "GitCredentials", Handler: _Tunnel_GitCredentials_Handler, }, + { + MethodName: "GitHubCredentials", + Handler: _Tunnel_GitHubCredentials_Handler, + }, { MethodName: "GitSSHSignature", Handler: _Tunnel_GitSSHSignature_Handler, diff --git a/pkg/agent/tunnelserver/github_credentials_test.go b/pkg/agent/tunnelserver/github_credentials_test.go new file mode 100644 index 0000000000..c29c8360c5 --- /dev/null +++ b/pkg/agent/tunnelserver/github_credentials_test.go @@ -0,0 +1,132 @@ +package tunnelserver + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/devsy-org/devsy/pkg/ghcredentials" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestGitHubCredentialsRequiresIndependentPermission(t *testing.T) { + for _, server := range []*tunnelServer{ + New(), + New(WithAllowGitCredentials(true), WithAllowDockerCredentials(true)), + New(WithAllowGitHubCredentials(false)), + } { + var lookups atomic.Int32 + server.githubAccounts = func(context.Context) ghcredentials.HostResult { + lookups.Add(1) + return ghcredentials.HostResult{} + } + response, err := server.GitHubCredentials(context.Background(), &tunnel.Empty{}) + require.ErrorContains(t, err, "forbidden") + require.Nil(t, response) + require.Zero(t, lookups.Load()) + } +} + +func TestGitHubCredentialsHTTPAndGRPCBoundaryUsesIndependentPermission(t *testing.T) { + for _, allowed := range []bool{false, true} { + t.Run(map[bool]string{false: "denied", true: "allowed"}[allowed], func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + fixture := startGitHubBoundary(t, ctx, allowed) + ready := fixture.ready + lookups := fixture.lookups + require.Zero(t, lookups.Load(), "readiness resolved a token") + client := credentials.LoopbackHTTPClient() + defer client.CloseIdleConnections() + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + "http://"+ready.Address+"/github-credentials", + nil, + ) + require.NoError(t, err) + request.Header.Set(credentials.HeaderCredentialsInstance, ready.Instance) + request.Header.Set(credentials.HeaderCredentialsHostOwner, ready.HostOwner) + response, err := client.Do(request) + require.NoError(t, err) + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + var result ghcredentials.HostResult + require.NoError(t, json.Unmarshal(body, &result)) + want := "rpc_denied" + calls := int32(0) + if allowed { + want = "no_active_accounts" + calls = 1 + } + require.Equal(t, want, result.Status) + require.Equal(t, calls, lookups.Load()) + }) + } +} + +type githubBoundary struct { + ready credentials.Readiness + lookups *atomic.Int32 +} + +func startGitHubBoundary(t *testing.T, ctx context.Context, allowed bool) githubBoundary { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := grpc.NewServer() + t.Cleanup(server.Stop) + impl := New( + WithAllowGitHubCredentials(allowed), + WithAllowGitCredentials(true), + WithAllowDockerCredentials(true), + ) + var lookups atomic.Int32 + impl.githubAccounts = func(context.Context) ghcredentials.HostResult { + lookups.Add(1) + return ghcredentials.HostResult{Status: "no_active_accounts"} + } + tunnel.RegisterTunnelServer(server, impl) + go func() { _ = server.Serve(ln) }() + conn, err := grpc.NewClient( + ln.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + httpLn, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + status := credentials.Readiness{ + Version: credentials.ReadinessVersion, + Owner: "vscode", + HostOwner: "host", + Instance: "instance", + GitHubAllowed: allowed, + GitHubState: "installed", + } + done := make(chan error, 1) + go func() { + done <- credentials.RunPreparedCredentialsServer(ctx, httpLn, tunnel.NewTunnelClient(conn), status) + }() + t.Cleanup(func() { + _ = httpLn.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Error("HTTP server failed to stop") + } + }) + ready, err := credentials.ProbeReadiness(ctx, httpLn.Addr().(*net.TCPAddr).Port, status) + require.NoError(t, err) + return githubBoundary{ready: ready, lookups: &lookups} +} diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index 50b89bf675..07fa6c0b37 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -13,6 +13,10 @@ import ( type Option func(*tunnelServer) *tunnelServer +func WithAllowGitHubCredentials(allow bool) Option { + return func(s *tunnelServer) *tunnelServer { s.allowGitHubCredentials = allow; return s } +} + func WithWorkspace(workspace *provider2.Workspace) Option { return func(s *tunnelServer) *tunnelServer { s.workspace = workspace diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 7386ad806b..86b6489506 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -21,6 +21,7 @@ import ( "github.com/devsy-org/devsy/pkg/devsyconfig" "github.com/devsy-org/devsy/pkg/dockercredentials" "github.com/devsy-org/devsy/pkg/extract" + "github.com/devsy-org/devsy/pkg/ghcredentials" "github.com/devsy-org/devsy/pkg/gitcredentials" "github.com/devsy-org/devsy/pkg/gitsshsigning" "github.com/devsy-org/devsy/pkg/gpg" @@ -31,7 +32,9 @@ import ( "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/stdio" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/reflection" + grpcstatus "google.golang.org/grpc/status" ) func RunServicesServer( @@ -140,6 +143,8 @@ type tunnelServer struct { forwarder netstat.Forwarder allowGitCredentials bool + allowGitHubCredentials bool + githubAccounts func(context.Context) ghcredentials.HostResult allowDockerCredentials bool allowKubeConfig bool allowPlatformOptions bool @@ -299,6 +304,24 @@ func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunne }, nil } +func (t *tunnelServer) GitHubCredentials( + ctx context.Context, + _ *tunnel.Empty, +) (*tunnel.Message, error) { + if !t.allowGitHubCredentials { + return nil, grpcstatus.Error(codes.PermissionDenied, "GitHub CLI credentials forbidden") + } + resolve := t.githubAccounts + if resolve == nil { + resolve = ghcredentials.HostAccounts + } + b, err := json.Marshal(resolve(ctx)) + if err != nil { + return nil, fmt.Errorf("encode GitHub CLI accounts") + } + return &tunnel.Message{Message: string(b)}, nil +} + func (t *tunnelServer) GitCredentials( ctx context.Context, message *tunnel.Message, diff --git a/pkg/client/clientimplementation/services.go b/pkg/client/clientimplementation/services.go index d6b8913dad..41082d2b93 100644 --- a/pkg/client/clientimplementation/services.go +++ b/pkg/client/clientimplementation/services.go @@ -6,7 +6,9 @@ import ( managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/credentials" daemon "github.com/devsy-org/devsy/pkg/daemon/platform" + "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/tunnel" "golang.org/x/crypto/ssh" ) @@ -24,38 +26,81 @@ type StartServicesDaemonOptions struct { type credentialConfig struct { docker bool git bool + github bool gitSSHSignature bool } // StartServicesDaemon starts the services daemon for credential forwarding and port forwarding. func StartServicesDaemon(ctx context.Context, opts StartServicesDaemonOptions) error { + services, err := daemonServiceOptions(ctx, opts) + if err != nil { + return err + } if opts.User == "" { return nil } + return tunnel.RunServices(ctx, services) +} - workspace, err := getWorkspace(ctx, opts.Client) +func StartPreparedServicesDaemon( + ctx context.Context, + opts StartServicesDaemonOptions, +) credentials.Readiness { + services, err := daemonServiceOptions(ctx, opts) + if err != nil { + log.Warn("credential services preparation unavailable") + return credentials.Readiness{GitHubState: "unavailable"} + } + if opts.User == "" { + return credentials.Readiness{GitHubState: "unavailable"} + } + return tunnel.StartPreparedServices(ctx, services) +} + +func RunPreparedServicesDaemon( + ctx context.Context, + opts StartServicesDaemonOptions, + prepared func(credentials.Readiness), +) error { + services, err := daemonServiceOptions(ctx, opts) if err != nil { return err } + if opts.User == "" { + return nil + } + return tunnel.RunPreparedServices(ctx, services, prepared) +} + +func daemonServiceOptions( + ctx context.Context, + opts StartServicesDaemonOptions, +) (tunnel.RunServicesOptions, error) { + if opts.User == "" { + return tunnel.RunServicesOptions{}, nil + } + + workspace, err := getWorkspace(ctx, opts.Client) + if err != nil { + return tunnel.RunServicesOptions{}, err + } credConfig := getCredentialConfig(opts.DevsyConfig, workspace) - return tunnel.RunServices( - ctx, - tunnel.RunServicesOptions{ - DevsyConfig: opts.DevsyConfig, - ContainerClient: opts.SSHClient, - User: opts.User, - ForwardPorts: opts.ForwardPorts, - ExtraPorts: opts.ExtraPorts, - PlatformOptions: nil, - Workspace: opts.Client.WorkspaceConfig(), - ConfigureDockerCredentials: credConfig.docker, - ConfigureGitCredentials: credConfig.git, - ConfigureGitSSHSignatureHelper: credConfig.gitSSHSignature, - GitSSHSigningKey: opts.GitSSHSigningKey, - }, - ) + return tunnel.RunServicesOptions{ + DevsyConfig: opts.DevsyConfig, + ContainerClient: opts.SSHClient, + User: opts.User, + ForwardPorts: opts.ForwardPorts, + ExtraPorts: opts.ExtraPorts, + PlatformOptions: nil, + Workspace: opts.Client.WorkspaceConfig(), + ConfigureDockerCredentials: credConfig.docker, + ConfigureGitCredentials: credConfig.git, + ConfigureGitHubCredentials: credConfig.github, + ConfigureGitSSHSignatureHelper: credConfig.gitSSHSignature, + GitSSHSigningKey: opts.GitSSHSigningKey, + }, nil } func getWorkspace( @@ -70,6 +115,9 @@ func getCredentialConfig( workspace *managementv1.DevsyWorkspaceInstance, ) credentialConfig { cfg := credentialConfig{ + github: devsyConfig.ContextOption( + config.ContextOptionGitHubCLIForwarding, + ) == config.BoolTrue, docker: devsyConfig.ContextOption( config.ContextOptionSSHInjectDockerCredentials, ) == config.BoolTrue, @@ -96,6 +144,7 @@ func getCredentialConfig( } if instanceCredentialForwarding.Git != nil { cfg.git = !instanceCredentialForwarding.Git.Disabled + cfg.github = cfg.github && !instanceCredentialForwarding.Git.Disabled cfg.gitSSHSignature = !instanceCredentialForwarding.Git.Disabled } diff --git a/pkg/client/clientimplementation/services_test.go b/pkg/client/clientimplementation/services_test.go new file mode 100644 index 0000000000..228164c518 --- /dev/null +++ b/pkg/client/clientimplementation/services_test.go @@ -0,0 +1,42 @@ +package clientimplementation + +import ( + "testing" + + managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" + storagev1 "github.com/devsy-org/api/pkg/apis/storage/v1" + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestDaemonGitHubPermissionRequiresOptInAndPreservesInstancePolicy(t *testing.T) { + for _, optedIn := range []bool{false, true} { + for _, gitDisabled := range []bool{false, true} { + value := config.BoolFalse + if optedIn { + value = config.BoolTrue + } + cfg := &config.Config{ + DefaultContext: "default", + Contexts: map[string]*config.ContextConfig{ + "default": {Options: map[string]config.OptionValue{ + config.ContextOptionGitHubCLIForwarding: {Value: value}, + config.ContextOptionSSHInjectGitCredentials: {Value: config.BoolTrue}, + config.ContextOptionSSHInjectDockerCredentials: {Value: config.BoolTrue}, + }}, + }, + } + require.Equal(t, optedIn, getCredentialConfig(cfg, nil).github) + workspace := &managementv1.DevsyWorkspaceInstance{} + workspace.Status.Instance = &storagev1.DevsyWorkspaceTemplateDefinition{ + CredentialForwarding: &storagev1.CredentialForwarding{ + Git: &storagev1.GitCredentialForwarding{Disabled: gitDisabled}, + }, + } + got := getCredentialConfig(cfg, workspace) + require.Equal(t, optedIn && !gitDisabled, got.github) + require.Equal(t, !gitDisabled, got.git) + require.True(t, got.docker) + } + } +} diff --git a/pkg/config/context.go b/pkg/config/context.go index d63ef53321..0584bf46db 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -12,6 +12,7 @@ const ( ContextOptionGitSSHSignatureForwarding = "GIT_SSH_SIGNATURE_FORWARDING" ContextOptionSSHInjectDockerCredentials = "SSH_INJECT_DOCKER_CREDENTIALS" ContextOptionSSHInjectGitCredentials = "SSH_INJECT_GIT_CREDENTIALS" + ContextOptionGitHubCLIForwarding = "SSH_INJECT_GITHUB_CREDENTIALS" ContextOptionExitAfterTimeout = "EXIT_AFTER_TIMEOUT" ContextOptionTelemetry = "TELEMETRY" ContextOptionAgentURL = "AGENT_URL" @@ -30,6 +31,12 @@ const ( ) var ContextOptions = []ContextOption{ + { + Name: ContextOptionGitHubCLIForwarding, + Description: "Specifies if Devsy should forward host GitHub CLI authentication into the workspace", + Default: BoolFalse, + Enum: []string{BoolTrue, BoolFalse}, + }, { Name: ContextOptionSSHAddPrivateKeys, Description: "Specifies if Devsy should automatically add ssh-keys to the ssh-agent", diff --git a/pkg/config/context_github_test.go b/pkg/config/context_github_test.go new file mode 100644 index 0000000000..25e0af1d5c --- /dev/null +++ b/pkg/config/context_github_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitHubForwardingDefaultsOffAndIsIndependent(t *testing.T) { + for _, value := range []string{"", BoolFalse, BoolTrue} { + c := &Config{ + Contexts: map[string]*ContextConfig{ + DefaultContext: { + Options: map[string]OptionValue{ + ContextOptionGitHubCLIForwarding: {Value: value}, + }, + }, + }, + DefaultContext: DefaultContext, + } + want := BoolFalse + if value == BoolTrue { + want = BoolTrue + } + require.Equal(t, want, c.ContextOption(ContextOptionGitHubCLIForwarding)) + require.Equal(t, BoolTrue, c.ContextOption(ContextOptionSSHInjectGitCredentials)) + require.Equal(t, BoolTrue, c.ContextOption(ContextOptionSSHInjectDockerCredentials)) + } +} diff --git a/pkg/credentials/github_test.go b/pkg/credentials/github_test.go new file mode 100644 index 0000000000..06178012f7 --- /dev/null +++ b/pkg/credentials/github_test.go @@ -0,0 +1,124 @@ +package credentials + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type githubCredentialsClient struct { + *mockCredentialsClient + calls atomic.Int32 + code codes.Code +} + +func (c *githubCredentialsClient) Ping( + context.Context, + *tunnel.Empty, + ...grpc.CallOption, +) (*tunnel.Empty, error) { + if c.code != codes.OK { + return nil, status.Error(c.code, "unavailable fixture transport") + } + return &tunnel.Empty{}, nil +} + +func TestReadinessRejectsDeadTransportWithoutCredentialLookup(t *testing.T) { + client := &githubCredentialsClient{code: codes.Unavailable} + h := newPreparedHandler(context.Background(), client, "vscode", readyFixture()) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost/ready", nil)) + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Zero(t, client.calls.Load()) +} + +func TestCredentialPostPinsVerifiedHostAndInstanceBeforeRPC(t *testing.T) { + client := &githubCredentialsClient{} + identity := readyFixture() + h := newPreparedHandler(context.Background(), client, identity.Owner, identity) + for _, pair := range [][2]string{{"", ""}, {"old-instance", identity.HostOwner}, {identity.Instance, "other-host"}} { + r := httptest.NewRequest(http.MethodPost, "http://localhost/github-credentials", nil) + r.Header.Set(HeaderCredentialsInstance, pair[0]) + r.Header.Set(HeaderCredentialsHostOwner, pair[1]) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + require.Equal(t, http.StatusConflict, w.Code) + require.JSONEq(t, `{"status":"relay_changed"}`, w.Body.String()) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + } + require.Zero(t, client.calls.Load()) + r := httptest.NewRequest(http.MethodPost, "http://localhost/github-credentials", nil) + r.Header.Set(HeaderCredentialsInstance, identity.Instance) + r.Header.Set(HeaderCredentialsHostOwner, identity.HostOwner) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + require.Equal(t, http.StatusOK, w.Code) + require.EqualValues(t, 1, client.calls.Load()) +} + +func (c *githubCredentialsClient) GitHubCredentials( + context.Context, + *tunnel.Empty, + ...grpc.CallOption, +) (*tunnel.Message, error) { + c.calls.Add(1) + if c.code != codes.OK { + return nil, status.Error(c.code, "must not expose raw RPC details") + } + return &tunnel.Message{Message: `{"status":"no_active_accounts"}`}, nil +} + +func TestGitHubHTTPBoundaryMethodsStatusesAndHeaders(t *testing.T) { + for _, tc := range []struct { + code codes.Code + want string + }{ + {codes.OK, "no_active_accounts"}, + {codes.Unimplemented, "rpc_unavailable"}, + {codes.PermissionDenied, "rpc_denied"}, + {codes.DeadlineExceeded, "rpc_timeout"}, + {codes.Canceled, "rpc_cancelled"}, + {codes.Internal, "rpc_error"}, + } { + t.Run(tc.want, func(t *testing.T) { + client := &githubCredentialsClient{code: tc.code} + h := newCredentialsHandler(context.Background(), client, "guest") + get := httptest.NewRecorder() + h.ServeHTTP( + get, + httptest.NewRequest(http.MethodGet, "http://localhost/github-credentials", nil), + ) + require.Equal(t, http.StatusMethodNotAllowed, get.Code) + require.Zero(t, client.calls.Load()) + post := httptest.NewRecorder() + h.ServeHTTP( + post, + httptest.NewRequest(http.MethodPost, "http://localhost/github-credentials", nil), + ) + require.Equal(t, http.StatusOK, post.Code) + require.Equal(t, "application/json", post.Header().Get("Content-Type")) + require.Equal(t, "no-store", post.Header().Get("Cache-Control")) + var body map[string]string + require.NoError(t, json.Unmarshal(post.Body.Bytes(), &body)) + require.Equal(t, tc.want, body["status"]) + require.EqualValues(t, 1, client.calls.Load()) + }) + } +} + +func TestGitHubHTTPMissingOptionalMethod(t *testing.T) { + h := newCredentialsHandler(context.Background(), &mockCredentialsClient{}, "guest") + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "http://localhost/github-credentials", nil)) + require.JSONEq(t, `{"status":"rpc_unavailable"}`, w.Body.String()) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) +} diff --git a/pkg/credentials/host_test.go b/pkg/credentials/host_test.go new file mode 100644 index 0000000000..dd3ab4609d --- /dev/null +++ b/pkg/credentials/host_test.go @@ -0,0 +1,73 @@ +package credentials + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type hostGuardClient struct { + githubCredentialsClient + pings atomic.Int32 +} + +func (c *hostGuardClient) Ping( + context.Context, + *tunnel.Empty, + ...grpc.CallOption, +) (*tunnel.Empty, error) { + c.pings.Add(1) + return &tunnel.Empty{}, nil +} + +func TestCredentialsRejectNonLoopbackHostBeforeDispatch(t *testing.T) { + for _, host := range []string{ + "attacker.invalid:12049", "localhost.attacker", "127.0.0.1.attacker", + "192.0.2.1", "[2001:db8::1]:12049", "0.0.0.0", "", + } { + t.Run(host, func(t *testing.T) { + client := &hostGuardClient{} + identity := readyFixture() + handler := newPreparedHandler(t.Context(), client, identity.Owner, identity) + for _, path := range []string{"/ready", ownerPath, "/github-credentials", "/git-credentials"} { + request := httptest.NewRequest(http.MethodPost, "http://localhost"+path, nil) + if path == "/ready" || path == ownerPath { + request.Method = http.MethodGet + } + request.Host = host + request.Header.Set(HeaderCredentialsInstance, identity.Instance) + request.Header.Set(HeaderCredentialsHostOwner, identity.HostOwner) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + require.Equal(t, http.StatusForbidden, response.Code, path) + } + require.Zero(t, client.pings.Load()) + require.Zero(t, client.calls.Load()) + }) + } +} + +func TestCredentialsAcceptLoopbackHosts(t *testing.T) { + for _, host := range []string{ + "localhost", "LOCALHOST:12049", "127.0.0.1", "127.0.0.1:12049", + "127.0.0.2:12049", "[::1]", "[::1]:12049", + } { + t.Run(host, func(t *testing.T) { + client := &hostGuardClient{} + handler := newPreparedHandler(t.Context(), client, "guest", readyFixture()) + request := httptest.NewRequest(http.MethodGet, "http://localhost/ready", nil) + request.Host = host + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + require.Equal(t, http.StatusOK, response.Code) + require.EqualValues(t, 1, client.pings.Load()) + require.Zero(t, client.calls.Load()) + }) + } +} diff --git a/pkg/credentials/readiness.go b/pkg/credentials/readiness.go new file mode 100644 index 0000000000..2770c67335 --- /dev/null +++ b/pkg/credentials/readiness.go @@ -0,0 +1,133 @@ +package credentials + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strconv" + "time" +) + +const ReadinessVersion = 1 + +const ( + // #nosec G101 -- Public HTTP header carrying a nonsecret relay identifier. + HeaderCredentialsInstance = "X-Devsy-Credentials-Instance" + // #nosec G101 -- Public HTTP header carrying a nonsecret host identifier. + HeaderCredentialsHostOwner = "X-Devsy-Credentials-Host-Owner" +) + +type Readiness struct { + Version int `json:"version"` + Owner string `json:"owner"` + HostOwner string `json:"host_owner"` + Instance string `json:"instance"` + GitHubAllowed bool `json:"github_allowed"` + GitHubState string `json:"github_state"` + Address string `json:"-"` +} + +func (r Readiness) Matches(expected Readiness) bool { + return r.validIdentity() && r.matchesIdentity(expected) && + r.GitHubAllowed == expected.GitHubAllowed && + r.terminal() +} + +func (r Readiness) validIdentity() bool { + return r.Version == ReadinessVersion && r.Owner != "" && r.HostOwner != "" && r.Instance != "" +} + +func (r Readiness) matchesIdentity(expected Readiness) bool { + return matchesString(r.Owner, expected.Owner) && + matchesString(r.HostOwner, expected.HostOwner) && + matchesString(r.Instance, expected.Instance) +} + +func matchesString(actual, expected string) bool { return expected == "" || actual == expected } + +func (r Readiness) terminal() bool { + switch r.GitHubState { + case "installed", "already_installed", "backend_missing", "unavailable", "disabled": + return true + default: + return false + } +} + +func LoopbackHTTPClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{Proxy: nil}, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } +} + +func ProbeReadiness(ctx context.Context, port int, expected Readiness) (Readiness, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + results := make(chan struct { + status Readiness + err error + }, 2) + for _, host := range []string{"127.0.0.1", "::1"} { + go func() { + address := net.JoinHostPort(host, strconv.Itoa(port)) + status, err := probeAddress(ctx, address, expected) + results <- struct { + status Readiness + err error + }{status, err} + }() + } + var result Readiness + var probeErr error + for range 2 { + r := <-results + if r.err == nil && result.Address == "" { + result = r.status + cancel() + } + probeErr = errors.Join(probeErr, r.err) + } + if result.Address != "" { + return result, nil + } + return Readiness{}, probeErr +} + +func probeAddress(ctx context.Context, address string, expected Readiness) (Readiness, error) { + client := LoopbackHTTPClient() + defer client.CloseIdleConnections() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address+"/ready", nil) + if err != nil { + return Readiness{}, err + } + response, err := client.Do(req) + if err != nil { + return Readiness{}, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return Readiness{}, errors.New("credentials readiness unavailable") + } + b, err := io.ReadAll(io.LimitReader(response.Body, 4097)) + if err != nil { + return Readiness{}, err + } + status, err := decodeReadiness(b, expected) + if err != nil { + return Readiness{}, err + } + status.Address = address + return status, nil +} + +func decodeReadiness(b []byte, expected Readiness) (Readiness, error) { + var status Readiness + if len(b) > 4096 || json.Unmarshal(b, &status) != nil || !status.Matches(expected) { + return Readiness{}, errors.New("invalid readiness response") + } + return status, nil +} diff --git a/pkg/credentials/readiness_test.go b/pkg/credentials/readiness_test.go new file mode 100644 index 0000000000..2822072fbd --- /dev/null +++ b/pkg/credentials/readiness_test.go @@ -0,0 +1,137 @@ +package credentials + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func readyFixture() Readiness { + return Readiness{ + Version: ReadinessVersion, + Owner: "vscode", + HostOwner: "host-one", + Instance: "instance-one", + GitHubAllowed: true, + GitHubState: "installed", + } +} + +func readinessServer(t *testing.T, network, address string, h http.Handler) *httptest.Server { + t.Helper() + ln, err := net.Listen(network, address) + if err != nil && network == "tcp6" { + t.Skipf("OS IPv6 loopback unavailable: %v", err) + } + require.NoError(t, err) + s := httptest.NewUnstartedServer(h) + s.Listener = ln + s.Start() + t.Cleanup(s.Close) + return s +} + +func TestReadinessLoopbackFamiliesPinIdentityWithoutProxy(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://invalid.invalid:1") + t.Setenv("HTTPS_PROXY", "http://invalid.invalid:1") + t.Setenv("NO_PROXY", "") + for _, family := range []struct{ network, address string }{{"tcp4", "127.0.0.1:0"}, {"tcp6", "[::1]:0"}} { + t.Run(family.network, func(t *testing.T) { + s := readinessServer( + t, + family.network, + family.address, + newPreparedHandler(context.Background(), nil, "vscode", readyFixture()), + ) + port := s.Listener.Addr().(*net.TCPAddr).Port + got, err := ProbeReadiness(context.Background(), port, readyFixture()) + require.NoError(t, err) + require.Equal(t, s.Listener.Addr().String(), got.Address) + require.True(t, got.Matches(readyFixture())) + }) + } +} + +func TestReadinessRejectsOldOccupiedMismatchedAndUnboundedListeners(t *testing.T) { + good, _ := json.Marshal(readyFixture()) + for _, body := range []string{"{}", "not-json", string(good) + " trailing", string(good) + strings.Repeat(" ", 4096)} { + s := readinessServer( + t, + "tcp4", + "127.0.0.1:0", + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }, + ), + ) + _, err := ProbeReadiness( + context.Background(), + s.Listener.Addr().(*net.TCPAddr).Port, + readyFixture(), + ) + require.Error(t, err) + } + for _, change := range []func(*Readiness){ + func(r *Readiness) { r.Version-- }, + func(r *Readiness) { r.HostOwner = "host-two" }, + func(r *Readiness) { r.GitHubAllowed = false }, + func(r *Readiness) { r.Instance = "instance-two" }, + func(r *Readiness) { r.GitHubState = "installing" }, + } { + other := readyFixture() + change(&other) + s := readinessServer( + t, + "tcp4", + "127.0.0.1:0", + newPreparedHandler(context.Background(), nil, "vscode", other), + ) + _, err := ProbeReadiness( + context.Background(), + s.Listener.Addr().(*net.TCPAddr).Port, + readyFixture(), + ) + require.Error(t, err) + } +} + +func TestReadinessRejectsRedirectAndSharesDeadline(t *testing.T) { + target := httptest.NewServer( + http.HandlerFunc( + func(http.ResponseWriter, *http.Request) { t.Error("readiness followed redirect") }, + ), + ) + defer target.Close() + s := readinessServer( + t, + "tcp4", + "127.0.0.1:0", + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }, + ), + ) + _, err := ProbeReadiness( + context.Background(), + s.Listener.Addr().(*net.TCPAddr).Port, + readyFixture(), + ) + require.Error(t, err) + stalled := readinessServer( + t, + "tcp4", + "127.0.0.1:0", + http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { <-r.Context().Done() }), + ) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + started := time.Now() + _, err = ProbeReadiness(ctx, stalled.Listener.Addr().(*net.TCPAddr).Port, readyFixture()) + require.Error(t, err) + require.Less(t, time.Since(started), time.Second) +} diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index 46ea8d39fc..da70f61eff 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -17,10 +17,14 @@ import ( "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" ) const DefaultPort = "12049" +const statusField = "status" + type CredentialsClient interface { GitCredentials( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, @@ -63,9 +67,27 @@ func RunCredentialsServerWithListener( ln net.Listener, client CredentialsClient, owner string, +) error { + return runCredentialsServer(ctx, ln, client, Readiness{Owner: owner}) +} + +func RunPreparedCredentialsServer( + ctx context.Context, + ln net.Listener, + client CredentialsClient, + status Readiness, +) error { + return runCredentialsServer(ctx, ln, client, status) +} + +func runCredentialsServer( + ctx context.Context, + ln net.Listener, + client CredentialsClient, + status Readiness, ) error { srv := &http.Server{ - Handler: newCredentialsHandler(ctx, client, owner), + Handler: newPreparedHandler(ctx, client, status.Owner, status), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, @@ -107,8 +129,30 @@ func newCredentialsHandler( ctx context.Context, client CredentialsClient, owner string, +) http.Handler { + return newPreparedHandler(ctx, client, owner, Readiness{}) +} + +func newPreparedHandler( + ctx context.Context, + client CredentialsClient, + owner string, + status Readiness, ) http.Handler { routes := map[string]credentialsHandlerFunc{ + "/ready": func(_ context.Context, w http.ResponseWriter, r *http.Request, client CredentialsClient) error { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return nil + } + if err := probeTransport(r.Context(), client); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return nil + } + return json.NewEncoder(w).Encode(status) + }, "/": func(_ context.Context, writer http.ResponseWriter, _ *http.Request, _ CredentialsClient) error { writer.WriteHeader(http.StatusOK) return nil @@ -120,6 +164,7 @@ func newCredentialsHandler( return err }, "/git-credentials": handleGitCredentialsRequest, + "/github-credentials": pinnedGitHubHandler(status), "/docker-credentials": handleDockerCredentialsRequest, "/git-ssh-signature": handleGitSSHSignatureRequest, "/devsy-platform-credentials": handleDevsyPlatformCredentialsRequest, @@ -131,6 +176,10 @@ func newCredentialsHandler( } return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if !loopbackHost(request.Host) { + http.Error(writer, "loopback Host required", http.StatusForbidden) + return + } log.Debugf("incoming client connection: path=%s", request.URL.Path) handler, ok := routes[request.URL.Path] if !ok { @@ -143,6 +192,89 @@ func newCredentialsHandler( }) } +func loopbackHost(authority string) bool { + host, _, err := net.SplitHostPort(authority) + if err != nil { + host = authority + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")) + return ip != nil && ip.IsLoopback() +} + +func pinnedGitHubHandler(status Readiness) credentialsHandlerFunc { + return func(ctx context.Context, w http.ResponseWriter, r *http.Request, client CredentialsClient) error { + if r.Method == http.MethodPost && status.validIdentity() && + (r.Header.Get(HeaderCredentialsInstance) != status.Instance || + r.Header.Get(HeaderCredentialsHostOwner) != status.HostOwner) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusConflict) + return json.NewEncoder(w).Encode(map[string]string{statusField: "relay_changed"}) + } + return handleGitHubCredentialsRequest(ctx, w, r, client) + } +} + +func probeTransport(ctx context.Context, client CredentialsClient) error { + if client == nil { + return nil + } + ping, ok := client.(interface { + Ping(context.Context, *tunnel.Empty, ...grpc.CallOption) (*tunnel.Empty, error) + }) + if !ok { + return fmt.Errorf("credentials readiness transport unavailable") + } + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + _, err := ping.Ping(ctx, &tunnel.Empty{}) + return err +} + +func handleGitHubCredentialsRequest( + _ context.Context, + writer http.ResponseWriter, + request *http.Request, + client CredentialsClient, +) error { + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Cache-Control", "no-store") + if request.Method != http.MethodPost { + writer.WriteHeader(http.StatusMethodNotAllowed) + return nil + } + githubClient, ok := client.(interface { + GitHubCredentials(context.Context, *tunnel.Empty, ...grpc.CallOption) (*tunnel.Message, error) + }) + if !ok { + return json.NewEncoder(writer).Encode(map[string]string{statusField: "rpc_unavailable"}) + } + ctx, cancel := context.WithTimeout(request.Context(), 4*time.Second) + defer cancel() + response, err := githubClient.GitHubCredentials(ctx, &tunnel.Empty{}) + if err != nil { + kind := "rpc_error" + switch grpcstatus.Code(err) { + case codes.Unimplemented: + kind = "rpc_unavailable" + case codes.PermissionDenied: + kind = "rpc_denied" + case codes.DeadlineExceeded: + kind = "rpc_timeout" + case codes.Canceled: + kind = "rpc_cancelled" + } + return json.NewEncoder(writer).Encode(map[string]string{statusField: kind}) + } + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Cache-Control", "no-store") + _, err = io.WriteString(writer, response.Message) + return err +} + const fetchOwnerTimeout = 2 * time.Second // maxOwnerResponseSize bounds how much of the /owner response FetchOwner diff --git a/pkg/credentials/server_test.go b/pkg/credentials/server_test.go index 10d56dac4a..e45ac6ecab 100644 --- a/pkg/credentials/server_test.go +++ b/pkg/credentials/server_test.go @@ -107,7 +107,7 @@ func TestOwnerEndpoint_ReturnsConfiguredOwner(t *testing.T) { mock := &mockCredentialsClient{} handler := newCredentialsHandler(context.Background(), mock, "alice") - req := httptest.NewRequest(http.MethodGet, ownerPath, nil) + req := httptest.NewRequest(http.MethodGet, "http://localhost"+ownerPath, nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) diff --git a/pkg/ghcredentials/auth_merge.go b/pkg/ghcredentials/auth_merge.go new file mode 100644 index 0000000000..a3e85fa280 --- /dev/null +++ b/pkg/ghcredentials/auth_merge.go @@ -0,0 +1,176 @@ +package ghcredentials + +import ( + "errors" + "sort" + + "gopkg.in/yaml.v3" +) + +type authenticationConflictError struct{} + +func (*authenticationConflictError) Error() string { + return "GitHub CLI authentication changed concurrently; newer identity preserved" +} + +var errAuthConflict = &authenticationConflictError{} + +func cloneNode(n *yaml.Node) *yaml.Node { + if n == nil { + return nil + } + out := *n + out.Content = make([]*yaml.Node, len(n.Content)) + for i, child := range n.Content { + out.Content[i] = cloneNode(child) + } + return &out +} + +func authentication(h *yaml.Node) *yaml.Node { + if h == nil { + return nil + } + a := mapping() + for _, key := range []string{activeUserKey, oauthTokenKey} { + put(a, key, cloneNode(get(h, key))) + } + if users := get(h, "users"); users != nil { + registrations := mapping() + for i := 0; i+1 < len(users.Content); i += 2 { + u := mapping() + put(u, oauthTokenKey, cloneNode(get(users.Content[i+1], oauthTokenKey))) + put(registrations, users.Content[i].Value, u) + } + if len(registrations.Content) > 0 { + put(a, "users", registrations) + } + } + if len(a.Content) == 0 { + return nil + } + return a +} + +func preferences(h *yaml.Node) *yaml.Node { + if h == nil { + return nil + } + p := cloneNode(h) + put(p, activeUserKey, nil) + put(p, oauthTokenKey, nil) + if users := get(p, "users"); users != nil { + for _, key := range sortedKeys(users) { + u := get(users, key) + put(u, oauthTokenKey, nil) + if u == nil || len(u.Content) == 0 { + put(users, key, nil) + } + } + if len(users.Content) == 0 { + put(p, "users", nil) + } + } + if len(p.Content) == 0 { + return nil + } + return p +} + +func sortedKeys(nodes ...*yaml.Node) []string { + keys := mappingKeys(nodes...) + result := make([]string, 0, len(keys)) + for key := range keys { + result = append(result, key) + } + sort.Strings(result) + return result +} + +func mergeHosts(before, desired, latest []byte) ([]byte, error) { + b, err := decodeHosts(before) + if err != nil { + return nil, err + } + d, err := decodeHosts(desired) + if err != nil { + return nil, err + } + l, err := decodeHosts(latest) + if err != nil { + return nil, err + } + if equal(b, d) { + return latest, nil + } + result := mapping() + var conflict error + for _, host := range sortedKeys(b, d, l) { + node, err := mergeHost(get(b, host), get(d, host), get(l, host)) + conflict = errors.Join(conflict, err) + put(result, host, node) + } + if equal(result, l) { + return latest, conflict + } + out, err := yaml.Marshal(result) + return out, errors.Join(conflict, err) +} + +func mergeHost(base, want, current *yaml.Node) (*yaml.Node, error) { + if equal(base, want) { + return cloneNode(current), nil + } + chosen, err := selectAuthentication( + authentication(base), + authentication(want), + authentication(current), + ) + p := merge(preferences(base), preferences(want), preferences(current)) + if p == nil { + p = mapping() + } + applyAuthentication(p, chosen) + if len(p.Content) == 0 { + return nil, err + } + return p, err +} + +func selectAuthentication(base, want, current *yaml.Node) (*yaml.Node, error) { + switch { + case equal(base, want), equal(current, want): + return current, nil + case equal(current, base): + return want, nil + default: + return current, errAuthConflict + } +} + +func applyAuthentication(p, chosen *yaml.Node) { + removeUnregisteredPreferences(p, get(chosen, "users")) + for _, key := range []string{activeUserKey, oauthTokenKey} { + put(p, key, cloneNode(get(chosen, key))) + } + users := get(chosen, "users") + for _, user := range sortedKeys(users) { + u := ensure(ensure(p, "users"), user) + put(u, oauthTokenKey, cloneNode(get(get(users, user), oauthTokenKey))) + } +} + +func removeUnregisteredPreferences(p, registrations *yaml.Node) { + users := get(p, "users") + if users == nil { + return + } + for _, user := range sortedKeys(users) { + if get(registrations, user) == nil { + put(users, user, nil) + } + } + if len(users.Content) == 0 { + put(p, "users", nil) + } +} diff --git a/pkg/ghcredentials/auth_merge_test.go b/pkg/ghcredentials/auth_merge_test.go new file mode 100644 index 0000000000..19dca8fddd --- /dev/null +++ b/pkg/ghcredentials/auth_merge_test.go @@ -0,0 +1,84 @@ +package ghcredentials + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func authFixture(user, token string) string { + if user == "" { + return "" + } + return "github.example:\n git_protocol: https\n user: " + user + "\n" + + " oauth_token: " + token + "\n users:\n " + user + ":\n oauth_token: " + token + "\n" +} + +func TestHostAuthenticationMergeNeverSplicesOrResurrects(t *testing.T) { + alice := authFixture("alice", "dummy-alice") + refresh := authFixture("alice", "dummy-refreshed-alice") + bob := authFixture("bob", "dummy-bob") + for _, tc := range []struct { + name, base, desired, latest, expected string + conflict bool + }{ + {"unchanged", alice, alice, bob, bob, false}, + {"uncontested refresh", alice, refresh, alice, refresh, false}, + {"idempotent refresh", alice, refresh, refresh, refresh, false}, + {"refresh after switch", alice, refresh, bob, bob, true}, + {"switch after refresh", alice, bob, refresh, refresh, true}, + {"refresh after logout", alice, refresh, "", "", true}, + {"logout after refresh", alice, "", refresh, refresh, true}, + {"login after logout", alice, bob, "", "", true}, + {"logout after login", alice, "", bob, bob, true}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := mergeHosts([]byte(tc.base), []byte(tc.desired), []byte(tc.latest)) + if tc.conflict { + require.ErrorIs(t, err, errAuthConflict) + } else { + require.NoError(t, err) + } + n, err := decode(out) + require.NoError(t, err) + expected, err := decode([]byte(tc.expected)) + require.NoError(t, err) + require.True( + t, + equal(authentication(get(n, testHost)), authentication(get(expected, testHost))), + "authentication identity was spliced or resurrected", + ) + }) + } +} + +func TestHostAuthenticationMergeKeepsPreferencesAndOtherHostsIndependent(t *testing.T) { + base := authFixture( + "alice", + "dummy-alice", + ) + "other.example:\n user: other\n oauth_token: dummy-other\n" + desired := authFixture( + "alice", + "dummy-refreshed-alice", + ) + "other.example:\n user: other\n oauth_token: dummy-other\n git_protocol: ssh\n" + latest := authFixture( + "bob", + "dummy-bob", + ) + "other.example:\n user: other\n oauth_token: dummy-other-new\n" + out, err := mergeHosts([]byte(base), []byte(desired), []byte(latest)) + require.ErrorIs(t, err, errAuthConflict) + n, err := decode(out) + require.NoError(t, err) + require.Equal(t, "bob", get(get(n, testHost), "user").Value) + require.True( + t, + get(get(n, testHost), "oauth_token").Value == "dummy-bob", + "wrong active credential", + ) + require.True( + t, + get(get(n, "other.example"), "oauth_token").Value == "dummy-other-new", + "independent identity changed", + ) + require.Equal(t, "ssh", get(get(n, "other.example"), "git_protocol").Value) +} diff --git a/pkg/ghcredentials/config.go b/pkg/ghcredentials/config.go new file mode 100644 index 0000000000..ee79252094 --- /dev/null +++ b/pkg/ghcredentials/config.go @@ -0,0 +1,197 @@ +package ghcredentials + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "runtime" + + "gopkg.in/yaml.v3" +) + +const ( + hostsFile = "hosts.yml" + // #nosec G101 -- YAML field name, not a credential. + oauthTokenKey = "oauth_token" + activeUserKey = "user" + configFile = "config.yml" +) + +func ConfigDir() (string, error) { + if dir := os.Getenv("GH_CONFIG_DIR"); dir != "" { + return filepath.Abs(dir) + } + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Abs(filepath.Join(dir, "gh")) + } + if dir := os.Getenv("APPDATA"); runtime.GOOS == "windows" && dir != "" { + return filepath.Abs(filepath.Join(dir, "GitHub CLI")) + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "gh"), nil +} + +func resolvedFile(path string) (*os.File, error) { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + return os.OpenInRoot(filepath.Dir(resolved), filepath.Base(resolved)) +} + +func readFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > 16<<20 { + return nil, errInvalidConfig + } + f, err := resolvedFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(io.LimitReader(f, 16<<20+1)) + if len(data) > 16<<20 { + return nil, errors.New("GitHub CLI configuration file too large") + } + return data, err +} + +func decode(data []byte) (*yaml.Node, error) { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var doc yaml.Node + if err := decoder.Decode(&doc); err != nil && !errors.Is(err, io.EOF) { + // YAML errors may include credential values. + return nil, errInvalidConfig + } + var trailing yaml.Node + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, errInvalidConfig + } + if len(doc.Content) == 0 { + return mapping(), nil + } + n := doc.Content[0] + if n.Kind != yaml.MappingNode { + return nil, errInvalidConfig + } + return n, nil +} + +func get(n *yaml.Node, key string) *yaml.Node { + if n == nil || n.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(n.Content); i += 2 { + if n.Content[i].Value == key { + return n.Content[i+1] + } + } + return nil +} + +func put(n *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(n.Content); i += 2 { + if n.Content[i].Value == key { + if value == nil { + n.Content = append(n.Content[:i], n.Content[i+2:]...) + } else { + n.Content[i+1] = value + } + return + } + } + if value != nil { + n.Content = append(n.Content, scalar(key), value) + } +} + +func scalar( + s string, +) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: s} +} + +func equal(a, b *yaml.Node) bool { + if a == nil || b == nil { + return a == b + } + var av, bv any + if a.Decode(&av) != nil || b.Decode(&bv) != nil { + return false + } + return reflect.DeepEqual(av, bv) +} + +func merge(before, after, latest *yaml.Node) *yaml.Node { + if equal(before, after) { + return latest + } + if mappingCompatible(before, after) { + if before == nil { + before = mapping() + } + if latest == nil || latest.Kind != yaml.MappingNode { + latest = mapping() + } + for key := range mappingKeys(before, after) { + put(latest, key, merge(get(before, key), get(after, key), get(latest, key))) + } + return latest + } + return after +} + +func mergeYAML(before, after, latest []byte) ([]byte, error) { + b, err := decode(before) + if err != nil { + return nil, err + } + a, err := decode(after) + if err != nil { + return nil, err + } + l, err := decode(latest) + if err != nil { + return nil, err + } + result := merge(b, a, l) + if equal(result, l) && equal(b, a) { + return latest, nil + } + return yaml.Marshal(result) +} + +func mappingKeys(nodes ...*yaml.Node) map[string]bool { + keys := map[string]bool{} + for _, n := range nodes { + if n == nil { + continue + } + for i := 0; i+1 < len(n.Content); i += 2 { + keys[n.Content[i].Value] = true + } + } + return keys +} + +func mappingCompatible(before, after *yaml.Node) bool { + if after == nil || after.Kind != yaml.MappingNode { + return false + } + return before == nil || before.Kind == yaml.MappingNode +} diff --git a/pkg/ghcredentials/config_test.go b/pkg/ghcredentials/config_test.go new file mode 100644 index 0000000000..b4b1657ae3 --- /dev/null +++ b/pkg/ghcredentials/config_test.go @@ -0,0 +1,63 @@ +package ghcredentials + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNativeConfigDirectoryPrecedenceAndEmptyValues(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("GH_CONFIG_DIR", "") + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("APPDATA", "") + got, err := ConfigDir() + require.NoError(t, err) + require.Equal(t, filepath.Join(home, ".config", "gh"), got) + app := filepath.Join(home, "app data") + t.Setenv("APPDATA", app) + got, err = ConfigDir() + require.NoError(t, err) + if runtime.GOOS == "windows" { + require.Equal(t, filepath.Join(app, "GitHub CLI"), got) + } + xdg := filepath.Join(home, "xdg space") + t.Setenv("XDG_CONFIG_HOME", xdg) + got, err = ConfigDir() + require.NoError(t, err) + require.Equal(t, filepath.Join(xdg, "gh"), got) + t.Setenv("GH_CONFIG_DIR", "relative space") + got, err = ConfigDir() + require.NoError(t, err) + want, err := filepath.Abs("relative space") + require.NoError(t, err) + require.Equal(t, want, got) + t.Setenv("GH_CONFIG_DIR", filepath.Join(home, "override space")) + got, err = ConfigDir() + require.NoError(t, err) + require.Equal(t, os.Getenv("GH_CONFIG_DIR"), got) +} + +func TestNativeConfigRejectsTrailingDocumentsAndMalformedContent(t *testing.T) { + for _, data := range []string{ + "{}\ninvalid.test:\n users: [alice, bob]\n", + "{}\n---\neditor: newer\n", + "{}\n---\n", + "{}\n...\n[malformed", + } { + _, err := decode([]byte(data)) + require.ErrorIs(t, err, errInvalidConfig) + } +} + +func TestNativeConfigAcceptsCompleteSingleDocuments(t *testing.T) { + for _, data := range []string{"", "# comment\n", "{}\n", "---\neditor: vi\n...\n# tail\n", "custom: [one, two]\n"} { + _, err := decode([]byte(data)) + require.NoError(t, err) + } +} diff --git a/pkg/ghcredentials/host.go b/pkg/ghcredentials/host.go new file mode 100644 index 0000000000..2c5fbc33ce --- /dev/null +++ b/pkg/ghcredentials/host.go @@ -0,0 +1,290 @@ +package ghcredentials + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/pkg/log" + "gopkg.in/yaml.v3" +) + +const hostNoActiveAccounts = "no_active_accounts" + +type Account struct { + Host string `json:"host"` + User string `json:"user"` + Token string `json:"token"` +} + +func HostAccounts(ctx context.Context) HostResult { + ctx, cancel := context.WithTimeout(ctx, hostCollectionTimeout) + defer cancel() + binary, err := exec.LookPath("gh") + if err != nil { + return HostResult{Status: "gh_not_found"} + } + dir, err := ConfigDir() + if err != nil { + return HostResult{Status: "config_path_failed"} + } + probe, err := os.MkdirTemp("", "devsy-gh-host-") + if err != nil { + return HostResult{Status: "config_snapshot_" + errorKind(err)} + } + defer func() { _ = os.RemoveAll(probe) }() + if err := copyConfigFiles(dir, probe); err != nil { + return HostResult{Status: "config_snapshot_" + errorKind(err)} + } + b, err := readFile(filepath.Join(probe, hostsFile)) + if err != nil { + return HostResult{Status: "config_read_" + errorKind(err)} + } + hosts, err := decode(b) + if err != nil { + return HostResult{Status: "config_decode_failed"} + } + return collectHostAccounts( + ctx, + binary, + hosts, + replaceEnv(hostLookupEnv(os.Environ()), "GH_CONFIG_DIR", probe), + ) +} + +func collectHostAccounts( + ctx context.Context, + binary string, + hosts *yaml.Node, + env []string, +) HostResult { + ctx, cancel := context.WithTimeout(ctx, hostCollectionTimeout) + defer cancel() + result := HostResult{Status: hostNoActiveAccounts} + accounts, truncated := registeredHostAccounts(hosts) + if truncated { + result.Status = "account_limit" + } + results := resolveHostAccounts(ctx, binary, accounts, env) + for _, r := range results { + if r.err != nil { + result.Status = "token_lookup_failed" + } else if r.account.valid() { + result.Accounts = append(result.Accounts, r.account) + } + } + if len(result.Accounts) > 0 && result.Status == hostNoActiveAccounts { + result.Status = stateAvailable + } + return result +} + +func registeredHostAccounts(hosts *yaml.Node) ([]Account, bool) { + var accounts []Account + for i := 0; i+1 < len(hosts.Content); i += 2 { + host := hosts.Content[i].Value + u := get(hosts.Content[i+1], "user") + if u == nil || u.Value == "" { + log.Debugw("gh relay: host account unavailable", "host", host) + continue + } + if len(accounts) == maxHostAccounts { + return accounts, true + } + accounts = append(accounts, Account{Host: host, User: u.Value}) + } + return accounts, false +} + +type accountLookup struct { + account Account + err error +} + +func resolveHostAccounts( + ctx context.Context, + binary string, + accounts []Account, + env []string, +) []accountLookup { + results := make([]accountLookup, len(accounts)) + jobs := make(chan int) + var workers sync.WaitGroup + for range min(4, len(accounts)) { + workers.Go(func() { + for i := range jobs { + results[i].account, results[i].err = lookupHostAccount( + ctx, + binary, + accounts[i], + env, + ) + } + }) + } + for i := range accounts { + jobs <- i + } + close(jobs) + workers.Wait() + return results +} + +const ( + hostCollectionTimeout = 3500 * time.Millisecond + tokenLookupTimeout = 1500 * time.Millisecond + maxHostAccounts = 32 + maxTokenOutput = 64 << 10 +) + +func lookupHostAccount( + ctx context.Context, + binary string, + a Account, + env []string, +) (Account, error) { + if err := ctx.Err(); err != nil { + return Account{}, err + } + dir, err := os.MkdirTemp("", "devsy-gh-account-") + if err != nil { + return Account{}, err + } + defer func() { _ = os.RemoveAll(dir) }() + if err := copyConfigFiles(envValue(env, "GH_CONFIG_DIR"), dir); err != nil { + return Account{}, err + } + return a.resolveHostToken(ctx, binary, replaceEnv(hostLookupEnv(env), "GH_CONFIG_DIR", dir)) +} + +func hostLookupEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, entry := range env { + name, _, _ := strings.Cut(entry, "=") + if !authEnvironment[strings.ToUpper(name)] { + out = append(out, entry) + } + } + return out +} + +func envValue(env []string, key string) string { + for _, entry := range env { + name, value, _ := strings.Cut(entry, "=") + if strings.EqualFold(name, key) { + return value + } + } + return "" +} + +func (a Account) resolveHostToken( + ctx context.Context, + binary string, + env []string, +) (Account, error) { + token, err := resolveToken(ctx, binary, a.Host, env) + if err != nil { + if noTokenResult(err) { + log.Debugw("gh relay: host account unavailable", "host", a.Host) + return Account{}, nil + } + log.Warnw( + "gh relay: host token lookup failed", + "host", + a.Host, + "error_type", + errorKind(err), + ) + return Account{}, err + } + a.Token = token + return a, nil +} + +func resolveToken(ctx context.Context, binary, host string, env []string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, tokenLookupTimeout) + defer cancel() + //nolint:gosec // binary is resolved from the user's PATH; host is one argument. + cmd := exec.CommandContext(ctx, binary, "auth", "token", "--hostname", host) + // Scope this to the lookup child: nested wrappers must not return forwarded + // credentials as evidence of local authentication. + cmd.Env = replaceEnv(env, authProbeEnv, "1") + cmd.WaitDelay = 250 * time.Millisecond + out := &tokenOutput{} + err := runTokenProbe(cmd, out) + if ctx.Err() != nil { + return "", ctx.Err() + } + if out.overflow { + return "", errTokenOutputLimit + } + if err != nil { + return "", err + } + return strings.TrimSpace(string(out.data)), nil +} + +func runTokenProbe(cmd *exec.Cmd, out io.Writer) error { + stdout, stdoutWriter, err := os.Pipe() + if err != nil { + return err + } + defer func() { _ = stdout.Close(); _ = stdoutWriter.Close() }() + stderr, stderrWriter, err := os.Pipe() + if err != nil { + return err + } + defer func() { _ = stderr.Close(); _ = stderrWriter.Close() }() + cmd.Stdout, cmd.Stderr = stdoutWriter, stderrWriter + if err := cmd.Start(); err != nil { + return err + } + _ = stdoutWriter.Close() + _ = stderrWriter.Close() + done := make(chan error, 2) + go func() { _, err := io.Copy(out, stdout); done <- err }() + go func() { _, err := io.Copy(io.Discard, stderr); done <- err }() + processErr := cmd.Wait() + timer := time.NewTimer(cmd.WaitDelay) + defer timer.Stop() + var readErr error + for received := 0; received < 2; received++ { + select { + case err := <-done: + readErr = errors.Join(readErr, err) + case <-timer.C: + _ = stdout.Close() + _ = stderr.Close() + for ; received < 2; received++ { + <-done + } + return exec.ErrWaitDelay + } + } + return errors.Join(processErr, readErr) +} + +var errTokenOutputLimit = errors.New("GitHub CLI token output limit exceeded") + +type tokenOutput struct { + data []byte + overflow bool +} + +func (w *tokenOutput) Write(p []byte) (int, error) { + n := len(p) + remaining := maxTokenOutput - len(w.data) + if n > remaining { + w.overflow = true + p = p[:remaining] + } + w.data = append(w.data, p...) + return n, nil +} diff --git a/pkg/ghcredentials/host_keyring_darwin_test.go b/pkg/ghcredentials/host_keyring_darwin_test.go new file mode 100644 index 0000000000..292d8249b6 --- /dev/null +++ b/pkg/ghcredentials/host_keyring_darwin_test.go @@ -0,0 +1,91 @@ +//go:build darwin + +package ghcredentials + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNativeMacKeyringStoredActiveAccount(t *testing.T) { + if os.Getenv("DEVSY_TEST_NATIVE_KEYRING") != "1" { + t.Skip("native keyring integration is opt-in and required by the macOS CI job") + } + requireRealGH(t) + random := make([]byte, 12) + _, err := rand.Read(random) + require.NoError(t, err) + host := "devsy-test-" + hex.EncodeToString(random) + ".invalid" + token := "dummy-native-keyring-token" + addDummyKeyring(t, host, token) + dir := t.TempDir() + require.NoError( + t, + os.WriteFile( + filepath.Join(dir, hostsFile), + []byte(host+":\n user: fixture-user\n users:\n fixture-user:\n"), + 0o600, + ), + ) + t.Setenv("GH_CONFIG_DIR", dir) + result := HostAccounts(context.Background()) + require.Equal(t, stateAvailable, result.Status) + require.Len(t, result.Accounts, 1) + require.True( + t, + result.Accounts[0] == (Account{Host: host, User: "fixture-user", Token: token}), + "stored active account must resolve through the native keyring", + ) + data, err := readFile(filepath.Join(dir, hostsFile)) + require.NoError(t, err) + require.False( + t, + strings.Contains(string(data), token), + "host discovery must not publish keyring credentials to YAML", + ) +} + +func addDummyKeyring(t *testing.T, host, token string) { + t.Helper() + service := "gh:" + host + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + add := exec.CommandContext(ctx, "/usr/bin/security", "-i") + add.Stdin = strings.NewReader( + fmt.Sprintf("add-generic-password -s %s -a fixture-user -w %s\n", service, token), + ) + add.WaitDelay = 250 * time.Millisecond + created := false + t.Cleanup(func() { + cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + //nolint:gosec // The service is the random test-owned keyring item, never an existing account. + remove := exec.CommandContext( + cleanup, + "/usr/bin/security", + "delete-generic-password", + "-s", + service, + "-a", + "fixture-user", + ) + remove.WaitDelay = 250 * time.Millisecond + removeErr := remove.Run() + if created { + require.NoError(t, removeErr, "could not remove the test-owned keyring item") + } + }) + addErr := add.Run() + created = addErr == nil + require.NoError(t, addErr, "could not create the uniquely named dummy keyring item") +} diff --git a/pkg/ghcredentials/host_real_test.go b/pkg/ghcredentials/host_real_test.go new file mode 100644 index 0000000000..2a3f6afd63 --- /dev/null +++ b/pkg/ghcredentials/host_real_test.go @@ -0,0 +1,84 @@ +package ghcredentials + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func requireRealGH(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("gh"); err != nil { + if os.Getenv("DEVSY_TEST_REAL_GH") == "1" { + require.NoError(t, err) + } + t.Skip("real gh is not installed; CI requires this native test") + } +} + +func TestNativeRealCLIStoredAccountsIgnoreHostTokenEnvironment(t *testing.T) { + requireRealGH(t) + dir := filepath.Join(t.TempDir(), "config with spaces") + require.NoError(t, os.Mkdir(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, hostsFile), []byte(`github.com: + user: stored-user + oauth_token: dummy-stored-token + users: + stored-user: + oauth_token: dummy-stored-token +enterprise.example.invalid: + user: enterprise-user + oauth_token: dummy-enterprise-token +`), 0o600)) + t.Setenv("GH_CONFIG_DIR", dir) + for _, name := range []string{"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"} { + t.Setenv(name, "dummy-unrelated-environment-token") + } + result := HostAccounts(context.Background()) + require.Equal(t, stateAvailable, result.Status) + require.Len(t, result.Accounts, 2) + // Boolean assertions keep even the generated tokens out of failure output. + require.True( + t, + result.Accounts[0] == (Account{Host: "github.com", User: "stored-user", Token: "dummy-stored-token"}), + ) + require.True( + t, + result.Accounts[1] == (Account{ + Host: "enterprise.example.invalid", User: "enterprise-user", Token: "dummy-enterprise-token", + }), + ) + require.Equal(t, "dummy-unrelated-environment-token", os.Getenv("GH_TOKEN")) +} + +func TestNativeRealCLIScalarUsernamesAcceptedByRelay(t *testing.T) { + requireRealGH(t) + dir := t.TempDir() + t.Setenv("GH_CONFIG_DIR", dir) + binary, err := exec.LookPath("gh") + require.NoError(t, err) + const host = "scalar-user-fixture.invalid" + for _, user := range []string{"12345", testScalarBooleanUser} { + data := host + ":\n user: " + user + "\n users:\n " + user + + ":\n oauth_token: dummy-scalar-user-token\n" + _, err := decodeHosts([]byte(data)) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, hostsFile), []byte(data), 0o600)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + //nolint:gosec // Installed CLI, private dummy configuration, and a reserved invalid hostname. + cmd := exec.CommandContext(ctx, binary, "auth", "token", "--hostname", host, "--user", user) + cmd.Env = hostLookupEnv(os.Environ()) + cmd.WaitDelay = 250 * time.Millisecond + out := &tokenOutput{} + err = runTokenProbe(cmd, out) + cancel() + require.NoError(t, err) + require.True(t, strings.TrimSpace(string(out.data)) == "dummy-scalar-user-token") + } +} diff --git a/pkg/ghcredentials/host_test.go b/pkg/ghcredentials/host_test.go new file mode 100644 index 0000000000..d685734b6b --- /dev/null +++ b/pkg/ghcredentials/host_test.go @@ -0,0 +1,295 @@ +package ghcredentials + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + testPathEnv = "PATH=/usr/bin:/bin" + testForwardedToken = "dummy-forwarded-value" + testHost = "github.example" + testUser = "relay-user" +) + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func TestMain(m *testing.M) { + if mode := os.Getenv("DEVSY_TEST_GH_HELPER"); mode != "" { + os.Exit(runTokenHelper(mode)) + } + os.Exit(m.Run()) +} + +const ( + helperPipes = "pipes" + helperPipesError = "pipes-error" + helperStall = "stall" + helperLarge = "large" + helperExitOne = "exit1" + helperCancel = "cancel" + allStalled = "all-stalled" + fastHost = "fast.one" +) + +const ( + secondFastHost = "fast.two" + cancelledCollection = "cancelled" +) + +const firstSlowHost = "slow.one" + +func runTokenHelper(mode string) int { + switch mode { + case "hold", helperStall: + time.Sleep(10 * time.Second) + return 0 + case helperPipes, helperPipesError: + return runPipeHelper(mode) + case helperLarge: + _, _ = os.Stdout.WriteString(strings.Repeat("x", maxTokenOutput+1)) + return 0 + case helperExitOne: + return 1 + case "collection": + return runCollectionHelper() + case "env": + return runEnvironmentHelper() + default: + return 47 + } +} + +func runPipeHelper(mode string) int { + //nolint:gosec // Re-execute the owned native test binary as a pipe-holding child. + cmd := exec.Command(os.Args[0]) + cmd.Env = replaceEnv(os.Environ(), "DEVSY_TEST_GH_HELPER", "hold") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if cmd.Start() != nil { + return 42 + } + if path := os.Getenv("DEVSY_TEST_CHILD_PID"); path != "" { + _ = writeHelperFile(path, []byte(strconv.Itoa(cmd.Process.Pid)), 0o600) + } + _, _ = os.Stdout.WriteString("partial-dummy") + if mode == helperPipesError { + return 1 + } + return 0 +} + +func runCollectionHelper() int { + host := os.Args[len(os.Args)-1] + if strings.HasPrefix(host, "slow") { + time.Sleep(10 * time.Second) + return 0 + } + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if authEnvironment[strings.ToUpper(name)] { + return 43 + } + } + path := filepath.Join(os.Getenv("GH_CONFIG_DIR"), hostsFile) + b, err := readHelperFile(path) + if err != nil || !strings.Contains(string(b), "baseline") { + return 44 + } + if writeHelperFile(path, []byte("migrated\n"), 0o600) != nil { + return 45 + } + _, _ = os.Stdout.WriteString("dummy-" + host) + return 0 +} + +func runEnvironmentHelper() int { + if os.Getenv(authProbeEnv) != "1" { + return 46 + } + _, _ = os.Stdout.WriteString(os.Getenv("GH_TOKEN")) + return 0 +} + +func helperBinary(t *testing.T) string { + t.Helper() + t.Setenv("GORACE", "atexit_sleep_ms=0") + source, err := os.Executable() + require.NoError(t, err) + return source +} + +func TestNativeTokenLookupBoundsAndRetainedPipes(t *testing.T) { + binary := helperBinary(t) + for _, mode := range []string{helperPipes, helperPipesError, helperStall, helperLarge, helperExitOne, helperCancel} { + t.Run(mode, func(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "pid") + t.Cleanup(func() { cleanupPipeHelper(t, pidPath) }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + helperMode := mode + if mode == helperCancel { + helperMode = helperStall + cancel() + } + env := replaceEnv(os.Environ(), "DEVSY_TEST_GH_HELPER", helperMode) + env = replaceEnv(env, "DEVSY_TEST_CHILD_PID", pidPath) + started := time.Now() + token, err := resolveToken(ctx, binary, testHost, env) + require.Error(t, err) + require.True(t, token == "", "failed lookup returned partial token") + require.Less(t, time.Since(started), 2*time.Second) + if mode == helperPipes || mode == helperPipesError { + require.ErrorIs(t, err, exec.ErrWaitDelay) + } + if mode == helperCancel { + require.ErrorIs(t, err, context.Canceled) + } + if mode == helperLarge { + require.ErrorIs(t, err, errTokenOutputLimit) + } + if mode != helperExitOne { + require.False(t, noTokenResult(err)) + } + }) + } +} + +func TestNativeHostCollectionIsolatesIdentityAndMigration(t *testing.T) { + for _, order := range []string{"fast-first", "slow-first", allStalled, cancelledCollection} { + t.Run(order, func(t *testing.T) { + binary := helperBinary(t) + baseline := t.TempDir() + writeTestFile(t, filepath.Join(baseline, hostsFile), "baseline: {}\n") + hosts := mapping() + list := collectionHosts(order) + for _, host := range list { + put(ensure(hosts, host), "user", scalar("stored-alice")) + } + env := replaceEnv(os.Environ(), "GH_CONFIG_DIR", baseline) + env = replaceEnv(env, "DEVSY_TEST_GH_HELPER", "collection") + for key := range authEnvironment { + env = replaceEnv(env, key, "dummy-bob-env") + } + parent := strings.Join(env, "\n") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if order == cancelledCollection { + cancel() + } + started := time.Now() + result := collectHostAccounts(ctx, binary, hosts, env) + require.Less(t, time.Since(started), 4*time.Second) + require.Equal(t, "token_lookup_failed", result.Status) + if order == allStalled || order == cancelledCollection { + require.Empty(t, result.Accounts) + } else { + require.Len(t, result.Accounts, 2) + require.Equal( + t, + []string{fastHost, secondFastHost}, + []string{result.Accounts[0].Host, result.Accounts[1].Host}, + ) + for _, a := range result.Accounts { + require.Equal(t, "stored-alice", a.User) + require.True(t, a.Token == "dummy-"+a.Host, "wrong host identity") + } + } + require.True(t, strings.Join(env, "\n") == parent, "parent environment changed") + b, err := readHelperFile(filepath.Join(baseline, hostsFile)) + require.NoError(t, err) + require.True(t, string(b) == "baseline: {}\n", "baseline migrated") + }) + } +} + +func TestHostLookupEnvironmentAndGuestEnvironmentAreIndependent(t *testing.T) { + env := []string{ + "GH_TOKEN=dummy-bob", + "github_token=dummy-bob", + "Gh_Enterprise_Token=dummy-bob", + "GITHUB_ENTERPRISE_TOKEN=dummy-bob", + "OTHER=keep", + "GORACE=atexit_sleep_ms=0", + } + require.Equal(t, []string{"OTHER=keep", "GORACE=atexit_sleep_ms=0"}, hostLookupEnv(env)) + binary := helperBinary(t) + env = replaceEnv(env, "DEVSY_TEST_GH_HELPER", "env") + token, err := resolveToken(context.Background(), binary, "github.com", env) + require.NoError(t, err) + require.True(t, token == "dummy-bob", "guest environment was stripped") + require.True(t, env[0] == "GH_TOKEN=dummy-bob", "parent environment was changed") +} + +func TestRegisteredGuestDeclinesAmbiguousKeyringResult(t *testing.T) { + binary := helperBinary(t) + env := replaceEnv(os.Environ(), "DEVSY_TEST_GH_HELPER", helperExitOne) + a := Account{Host: testHost, User: testUser, Token: testForwardedToken} + for _, body := range []string{"user: guest", "users: {guest: {}}", "oauth_token: ''\nuser: guest", "{}"} { + n, err := decode([]byte(body)) + require.NoError(t, err) + hosts := mapping() + put(hosts, testHost, n) + got := a.inject(context.Background(), binary, env, hosts) + if body == "{}" { + require.Equal(t, "forwarded", got) + } else { + require.Equal(t, "local_identity_unavailable", got) + require.False(t, hasToken(get(hosts, testHost))) + } + } +} + +func readHelperFile(path string) ([]byte, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + return root.ReadFile(filepath.Base(path)) +} + +func writeHelperFile(path string, data []byte, mode os.FileMode) error { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + return root.WriteFile(filepath.Base(path), data, mode) +} + +func cleanupPipeHelper(t *testing.T, pidPath string) { + t.Helper() + if b, readErr := readHelperFile(pidPath); readErr == nil { + pid, parseErr := strconv.Atoi(string(b)) + require.NoError(t, parseErr) + child, findErr := os.FindProcess(pid) + require.NoError(t, findErr) + _ = child.Kill() + _ = child.Release() + } +} + +func collectionHosts(order string) []string { + list := []string{fastHost, firstSlowHost, secondFastHost, secondSlowHost} + if order == "slow-first" { + list = []string{firstSlowHost, secondSlowHost, fastHost, secondFastHost} + } + if order == allStalled { + list = []string{firstSlowHost, secondSlowHost} + } + return list +} + +const secondSlowHost = "slow.two" diff --git a/pkg/ghcredentials/hosts_config.go b/pkg/ghcredentials/hosts_config.go new file mode 100644 index 0000000000..01909aa923 --- /dev/null +++ b/pkg/ghcredentials/hosts_config.go @@ -0,0 +1,66 @@ +package ghcredentials + +import "gopkg.in/yaml.v3" + +func decodeHosts(data []byte) (*yaml.Node, error) { + hosts, err := decode(data) + if err != nil { + return nil, err + } + if !validAuthMapping(hosts, func(_ string, host *yaml.Node) bool { return validHost(host) }) { + return nil, errInvalidConfig + } + return hosts, nil +} + +func validAuthMapping(n *yaml.Node, validValue func(string, *yaml.Node) bool) bool { + if nullable(n) { + return true + } + if n.Kind != yaml.MappingNode { + return false + } + seen := make(map[string]bool) + for i := 0; i+1 < len(n.Content); i += 2 { + key := n.Content[i] + if key.Kind != yaml.ScalarNode || seen[key.Value] || + !validValue(key.Value, n.Content[i+1]) { + return false + } + seen[key.Value] = true + } + return true +} + +func validHost(n *yaml.Node) bool { + return validAuthMapping(n, func(key string, value *yaml.Node) bool { + switch key { + case activeUserKey, oauthTokenKey: + return validAuthScalar(value) + case "users": + return validAuthMapping( + value, + func(_ string, user *yaml.Node) bool { return validUser(user) }, + ) + default: + return true + } + }) +} + +func validUser(n *yaml.Node) bool { + if n != nil && n.Kind == yaml.ScalarNode && n.Value == "" { + return true + } + return validAuthMapping(n, func(key string, value *yaml.Node) bool { + return key != oauthTokenKey || validAuthScalar(value) + }) +} + +func validAuthScalar(n *yaml.Node) bool { + return n == nil || n.Kind == yaml.ScalarNode +} + +func nullable(n *yaml.Node) bool { + return n == nil || n.Kind == yaml.ScalarNode && n.Tag == "!!null" +} diff --git a/pkg/ghcredentials/hosts_config_test.go b/pkg/ghcredentials/hosts_config_test.go new file mode 100644 index 0000000000..01867e977a --- /dev/null +++ b/pkg/ghcredentials/hosts_config_test.go @@ -0,0 +1,66 @@ +package ghcredentials + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHostsRejectMalformedAuthenticationAtEveryMergeBoundary(t *testing.T) { + for _, malformed := range []string{ + "github.com: [a, b]\n", + "github.com:\n users: [a, b]\n", + "github.com:\n users:\n alice: [a, b]\n", + "github.com:\n user: [alice, bob]\n", + "github.com:\n oauth_token: {secret: value}\n", + "github.com:\n users:\n alice:\n oauth_token: [a, b]\n", + "github.com:\n users: {}\n users: [a, b]\n", + "github.com:\n users:\n alice: {}\n alice: [a, b]\n", + } { + for index := range 3 { + inputs := [3][]byte{ + []byte(authFixture("alice", "dummy-a")), + []byte(authFixture("alice", "dummy-b")), + []byte(authFixture("alice", "dummy-a")), + } + inputs[index] = []byte(malformed) + _, err := mergeHosts(inputs[0], inputs[1], inputs[2]) + require.ErrorIs(t, err, errInvalidConfig) + } + } +} + +func TestHostsAcceptNullRegistrationsAndArbitraryPreferences(t *testing.T) { + data := []byte(`github.com: + user: alice + oauth_token: + users: + alice: + bob: {} + dave: "" + carol: + oauth_token: "" + custom: [one, two] + custom: [one, two] +`) + _, err := decodeHosts(data) + require.NoError(t, err) + _, err = mergeHosts(nil, data, nil) + require.NoError(t, err) + _, err = mergeYAML(nil, []byte("custom: [one, two]\n"), nil) + require.NoError(t, err) +} + +func TestHostsPreserveScalarUsernamesAcceptedByRealCLI(t *testing.T) { + for _, user := range []string{"12345", testScalarBooleanUser, "1.5"} { + data := []byte("github.com:\n user: " + user + "\n users:\n " + user + ": {}\n") + n, err := decodeHosts(data) + require.NoError(t, err) + require.True(t, registeredAccount(get(n, "github.com"))) + merged, err := mergeHosts(data, data, data) + require.NoError(t, err) + require.Equal(t, data, merged) + } +} + +const testScalarBooleanUser = "true" diff --git a/pkg/ghcredentials/install.go b/pkg/ghcredentials/install.go new file mode 100644 index 0000000000..421a39e55c --- /dev/null +++ b/pkg/ghcredentials/install.go @@ -0,0 +1,266 @@ +package ghcredentials + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strconv" + "time" + + "al.essio.dev/pkg/shellescape" +) + +const ( + wrapperPath = "/usr/local/bin/gh" + backupSuffix = ".devsy-original" + wrapperHeader = "#!/bin/sh\n# devsy gh relay\n" +) + +type InstallResult struct { + Status string `json:"status"` +} + +func Install(ctx context.Context, port int, targetUser string) (InstallResult, error) { + if runtime.GOOS != "linux" { + return InstallResult{Status: stateUnavailable}, nil + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + binary, err := os.Executable() + if err != nil { + return InstallResult{Status: stateUnavailable}, boundary("install.binary", err) + } + backend, err := discoverForUser(ctx, binary, targetUser) + if err != nil { + return InstallResult{Status: stateUnavailable}, boundary("install.discovery", err) + } + if backend.Status != stateAvailable { + return backend, nil + } + owned, _ := isWrapper(wrapperPath) + if os.Geteuid() != 0 { + err = installWithSudo(ctx, binary, port) + } else { + err = installPrepared(ctx, binary, port) + } + if err != nil { + return InstallResult{Status: stateUnavailable}, err + } + if owned { + return InstallResult{Status: "already_installed"}, nil + } + return InstallResult{Status: "installed"}, nil +} + +func DiscoverBackend() InstallResult { + if _, err := discoverBackend(wrapperPath); err != nil { + return InstallResult{Status: "backend_missing"} + } + return InstallResult{Status: stateAvailable} +} + +func discoverBackend(wrapper string) (string, error) { + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + path := filepath.Join(dir, "gh") + if _, err := executableInfo(path); err != nil { + continue + } + own, err := isWrapper(path) + if err != nil { + continue + } + if !own { + return filepath.Abs(path) + } + if backup := wrapperBackup(path, nil); backup != "" { + return filepath.Abs(backup) + } + } + if backup := wrapperBackup(wrapper, nil); backup != "" { + return filepath.Abs(backup) + } + return "", exec.ErrNotFound +} + +func discoverForUser(ctx context.Context, binary, targetUser string) (InstallResult, error) { + u, err := user.Lookup(targetUser) + if err != nil { + return InstallResult{}, err + } + if u.Uid == strconv.Itoa(os.Geteuid()) { + return DiscoverBackend(), nil + } + if os.Geteuid() != 0 { + return InstallResult{}, errors.New("GitHub CLI target user differs from service user") + } + return discoverWithLoginShell(ctx, binary, targetUser) +} + +func discoverWithLoginShell(ctx context.Context, binary, targetUser string) (InstallResult, error) { + // Separate the response even when the login profile prints without a newline. + //nolint:gosec // su drops to the requested guest user before reading their login environment. + cmd := exec.CommandContext( + ctx, + "su", + "-", + targetUser, + "-c", + "printf '\\n'; exec "+shellescape.Quote(binary)+" internal gh-backend", + ) + cmd.WaitDelay = 250 * time.Millisecond + out := &tokenOutput{} + if err := runTokenProbe(cmd, out); err != nil { + if ctx.Err() != nil { + return InstallResult{}, ctx.Err() + } + return InstallResult{}, err + } + response := bytes.TrimSpace(out.data) + response = response[bytes.LastIndexByte(response, '\n')+1:] + var result InstallResult + if out.overflow || json.Unmarshal(response, &result) != nil { + return InstallResult{}, errInvalidConfig + } + return result, nil +} + +func InstallPrepared(ctx context.Context, port int) error { + binary, err := os.Executable() + if err != nil { + return err + } + return installPrepared(ctx, binary, port) +} + +func installPrepared(ctx context.Context, binary string, port int) error { + return boundary("install.wrapper", installWrapperContext(ctx, wrapperPath, binary, port)) +} + +func installWithSudo(ctx context.Context, binary string, port int) error { + //nolint:gosec // Execute only the installed agent helper; never execute a user-selected gh as root. + cmd := exec.CommandContext( + ctx, + "sudo", + "-n", + binary, + "internal", + "install-gh-wrapper", + strconv.Itoa(port), + ) + cmd.WaitDelay = 250 * time.Millisecond + err := runTokenProbe(cmd, io.Discard) + if ctx.Err() != nil { + err = ctx.Err() + } + return boundary("install.sudo", err) +} + +func installWrapper(path, binary string, port int) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return installWrapperContext(ctx, path, binary, port) +} + +func installWrapperContext(ctx context.Context, path, binary string, port int) error { + if err := ctx.Err(); err != nil { + return err + } + dir := filepath.Dir(path) + // #nosec G301 -- Public executable directory without authentication data. + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + l, err := lock(ctx, filepath.Join(dir, ".devsy-gh-install.lock")) + if err != nil { + return err + } + defer func() { _ = l.Close() }() + + staged, err := stageWrapper(dir, binary, port) + if err != nil { + return err + } + defer func() { _ = os.Remove(staged) }() + moved, err := backupOriginal(path) + if err != nil { + return err + } + if err := os.Rename(staged, path); err != nil { + if moved { + return errors.Join(err, os.Rename(path+backupSuffix, path)) + } + return err + } + return nil +} + +func stageWrapper(dir, binary string, port int) (name string, err error) { + script := fmt.Sprintf( + wrapperHeader+"export DEVSY_CREDENTIALS_SERVER_PORT=%d\nexec %s internal gh \"$0\" \"$@\"\n", + port, + shellescape.Quote(binary), + ) + f, err := os.CreateTemp(dir, ".devsy-gh-wrapper-*") + if err != nil { + return "", err + } + name = f.Name() + defer func() { + _ = f.Close() + if err != nil { + _ = os.Remove(name) + } + }() + if _, err = f.WriteString(script); err != nil { + return name, err + } + if err = f.Chmod(0o755); err != nil { + return name, err + } + err = f.Close() + return name, err +} + +func backupOriginal(path string) (bool, error) { + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return false, nil + } else if err != nil { + return false, err + } + own, err := isWrapper(path) + if err != nil || own { + return false, err + } + if _, err := executableInfo(path); err != nil { + return false, err + } + if err := os.Rename(path, path+backupSuffix); err != nil { + return false, err + } + return true, nil +} + +func isWrapper(path string) (bool, error) { + f, err := resolvedFile(path) + if err != nil { + return false, err + } + defer func() { _ = f.Close() }() + header := make([]byte, len(wrapperHeader)) + _, err = io.ReadFull(f, header) + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return false, nil + } + if err != nil { + return false, err + } + return string(header) == wrapperHeader, nil +} diff --git a/pkg/ghcredentials/install_test.go b/pkg/ghcredentials/install_test.go new file mode 100644 index 0000000000..86a468ae24 --- /dev/null +++ b/pkg/ghcredentials/install_test.go @@ -0,0 +1,341 @@ +//go:build !windows + +package ghcredentials + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBackendDiscoveryToleratesLoginShellOutput(t *testing.T) { + const availableResult = `{"status":"available"}` + const missingResult = `{"status":"backend_missing"}` + for _, tc := range []struct { + name, profile, output, exit, want string + invalid bool + }{ + {name: "plain response", output: availableResult, want: stateAvailable}, + {name: "login banner", profile: "Welcome to the workspace\n", output: availableResult, want: stateAvailable}, + { + name: "banner without newline", profile: "Activating user environment", + output: availableResult, want: stateAvailable, + }, + { + name: "backend missing", profile: "User profile loaded\n", + output: missingResult + "\n\n", want: "backend_missing", + }, + {name: "JSON in profile", profile: availableResult + "\n", output: missingResult, want: "backend_missing"}, + {name: "malformed response", profile: "Welcome\n", output: "not JSON", invalid: true}, + {name: "empty response", profile: "Welcome\n", invalid: true}, + { + name: "malformed response after JSON profile", profile: availableResult + "\n", + output: "invalid", invalid: true, + }, + {name: "failed command", output: availableResult, exit: "17", invalid: true}, + { + name: "oversized banner", profile: strings.Repeat("x", maxTokenOutput+1), + output: availableResult, invalid: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + binary, record := loginShellFixture(t) + t.Setenv("DEVSY_TEST_PROFILE_OUTPUT", tc.profile) + t.Setenv("DEVSY_TEST_BACKEND_OUTPUT", tc.output) + t.Setenv("DEVSY_TEST_BACKEND_EXIT", tc.exit) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + result, err := discoverWithLoginShell(ctx, binary, "guest-user") + if tc.invalid { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, result.Status) + } + args, err := readHelperFile(record) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(args), "-\nguest-user\n-c\n")) + }) + } +} + +func loginShellFixture(t *testing.T) (binary, record string) { + t.Helper() + dir := t.TempDir() + record = filepath.Join(dir, "su-args") + writeTestExecutable(t, filepath.Join(dir, "su"), []byte(`#!/bin/sh +printf '%s\n' "$@" > "$DEVSY_TEST_SU_ARGS" +printf '%s' "$DEVSY_TEST_PROFILE_OUTPUT" +exec /bin/sh -c "$4" +`)) + binary = filepath.Join(dir, "agent's binary") + writeTestExecutable(t, binary, []byte(`#!/bin/sh +test "$#" = 2 && test "$1" = internal && test "$2" = gh-backend || exit 42 +printf '%s\n' "$DEVSY_TEST_BACKEND_OUTPUT" +exit "${DEVSY_TEST_BACKEND_EXIT:-0}" +`)) + t.Setenv("PATH", dir) + t.Setenv("DEVSY_TEST_SU_ARGS", record) + return binary, record +} + +func TestBackendDiscoveryLoginShellCancellationIsBounded(t *testing.T) { + dir := t.TempDir() + writeTestExecutable(t, filepath.Join(dir, "su"), []byte("#!/bin/sh\nexec /bin/sleep 10\n")) + t.Setenv("PATH", dir) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + started := time.Now() + _, err := discoverWithLoginShell(ctx, "/unused-agent", "guest-user") + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Less(t, time.Since(started), time.Second) +} + +func TestInstallWithSudoPreservesArgumentsAndFailure(t *testing.T) { + dir := t.TempDir() + record := filepath.Join(dir, "args") + writeTestExecutable(t, filepath.Join(dir, "sudo"), []byte( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DEVSY_TEST_SUDO_ARGS\"\n"+ + "exit \"$DEVSY_TEST_SUDO_EXIT\"\n", + )) + t.Setenv("PATH", dir) + t.Setenv("DEVSY_TEST_SUDO_ARGS", record) + t.Setenv("DEVSY_TEST_SUDO_EXIT", "0") + require.NoError(t, installWithSudo(context.Background(), "/absolute path/devsy", 12049)) + root, err := os.OpenRoot(dir) + require.NoError(t, err) + defer func() { _ = root.Close() }() + args, err := root.ReadFile("args") + require.NoError(t, err) + require.Equal( + t, + "-n\n/absolute path/devsy\ninternal\ninstall-gh-wrapper\n12049\n", + string(args), + ) + t.Setenv("DEVSY_TEST_SUDO_EXIT", "1") + var exit *exec.ExitError + require.ErrorAs(t, installWithSudo(context.Background(), "/absolute path/devsy", 12049), &exit) + require.Equal(t, 1, exit.ExitCode()) +} + +func TestBackendDiscoveryPreservesMissingCLIBackupsAndUserShims(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gh") + t.Setenv("PATH", dir) + _, err := discoverBackend(path) + require.ErrorIs(t, err, exec.ErrNotFound) + require.NoFileExists(t, path) + writeTestExecutable(t, path+backupSuffix, []byte("#!/bin/sh\nprintf old")) + backend, err := discoverBackend(path) + require.NoError(t, err) + require.Equal(t, path+backupSuffix, backend) + writeTestExecutable(t, path, []byte("#!/bin/sh\nprintf fresh")) + backend, err = discoverBackend(path) + require.NoError(t, err) + require.Equal(t, path, backend) + //nolint:gosec // Execute only the dummy backend just written into this test-owned directory. + out, err := exec.Command(backend).Output() + require.NoError(t, err) + require.Equal(t, "fresh", string(out)) + marker := filepath.Join(dir, "shim-ran") + writeTestExecutable(t, path, []byte("#!/bin/sh\ntouch '"+marker+"'\nprintf shim")) + _, err = discoverBackend(path) + require.NoError(t, err) + require.NoFileExists(t, marker, "discovery ran a user shim") +} + +func TestInstallWithSudoCancellationDoesNotWaitForPasswordOrProcess(t *testing.T) { + dir := t.TempDir() + writeTestExecutable(t, filepath.Join(dir, "sudo"), []byte("#!/bin/sh\nexec /bin/sleep 10\n")) + t.Setenv("PATH", dir) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + started := time.Now() + require.ErrorIs(t, installWithSudo(ctx, "/unused-agent", 12049), context.DeadlineExceeded) + require.Less(t, time.Since(started), time.Second) +} + +const ( + testOriginalName = "real-gh" + testExitScript = "#!/bin/sh\nexit 0" + testRegularFile = "file" + testRelativeLink = "relative symlink" + testInstallBinary = "/devsy" +) + +func writeTestExecutable(t *testing.T, path string, data []byte) { + t.Helper() + dir, err := os.OpenRoot(filepath.Dir(path)) + require.NoError(t, err) + defer func() { _ = dir.Close() }() + require.NoError(t, dir.WriteFile(filepath.Base(path), data, 0o700)) +} + +func TestInstallWrapperPreservesOriginalAndUpdates(t *testing.T) { + for _, kind := range []string{testRegularFile, "absolute symlink", testRelativeLink} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gh") + target := filepath.Join(dir, testOriginalName) + first := []byte("#!/bin/sh\nprintf first") + writeTestExecutable(t, target, first) + switch kind { + case testRegularFile: + writeTestExecutable(t, path, first) + case "absolute symlink": + require.NoError(t, os.Symlink(target, path)) + case testRelativeLink: + require.NoError(t, os.Symlink(testOriginalName, path)) + } + require.NoError(t, installWrapper(path, "/devsy first", 12049)) + backup := path + backupSuffix + original, err := readFile(backup) + require.NoError(t, err) + require.Equal(t, first, original) + if kind != testRegularFile { + link, err := os.Readlink(backup) + require.NoError(t, err) + if kind == testRelativeLink { + require.Equal(t, testOriginalName, link) + } else { + require.Equal(t, target, link) + } + } + require.NoError(t, installWrapper(path, "/devsy second", 12050)) + original, err = readFile(backup) + require.NoError(t, err) + require.Equal(t, first, original) + script, err := readFile(path) + require.NoError(t, err) + require.Contains(t, string(script), "12050") + require.Contains(t, string(script), "'/devsy second'") + require.NoError(t, os.Remove(path)) + second := []byte("#!/bin/sh\nprintf second") + writeTestExecutable(t, path, second) + require.NoError(t, installWrapper(path, testInstallBinary, 12049)) + original, err = readFile(backup) + require.NoError(t, err) + require.Equal(t, second, original) + }) + } +} + +func TestInstallWrapperWithoutOriginalAndInterruptedMove(t *testing.T) { + path := filepath.Join(t.TempDir(), "gh") + require.NoError(t, installWrapper(path, testInstallBinary, 12049)) + _, err := os.Lstat(path + backupSuffix) + require.ErrorIs(t, err, os.ErrNotExist) + require.NoError(t, os.Remove(path)) + original := []byte("#!/bin/sh\nprintf preserved") + writeTestExecutable(t, path+backupSuffix, original) + require.NoError(t, installWrapper(path, testInstallBinary, 12049)) + got, err := readFile(path + backupSuffix) + require.NoError(t, err) + require.Equal(t, original, got) +} + +func TestInstallWrapperConcurrent(t *testing.T) { + path := filepath.Join(t.TempDir(), "gh") + original := []byte("#!/bin/sh\nprintf original") + writeTestExecutable(t, path, original) + var wg sync.WaitGroup + errs := make(chan error, 8) + for range 8 { + wg.Go(func() { errs <- installWrapper(path, testInstallBinary, 12049) }) + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + got, err := readFile(path + backupSuffix) + require.NoError(t, err) + require.Equal(t, original, got) +} + +func TestInstallWrapperFailurePreservesOriginal(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gh") + original := []byte("#!/bin/sh\nprintf original") + writeTestExecutable(t, path, original) + require.NoError(t, os.Mkdir(path+backupSuffix, 0o700)) + require.Error(t, installWrapper(path, testInstallBinary, 12049)) + got, err := readFile(path) + require.NoError(t, err) + require.Equal(t, original, got) + entries, err := filepath.Glob(filepath.Join(dir, ".devsy-gh-wrapper-*")) + require.NoError(t, err) + require.Empty(t, entries) +} + +func TestRealBinaryRespectsPATHAndBackup(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + early := filepath.Join(root, "early") + standard := filepath.Join(root, "standard") + late := filepath.Join(root, "late") + for _, dir := range []string{early, standard, late} { + require.NoError(t, os.Mkdir(dir, 0o700)) + } + wrapper := filepath.Join(standard, "gh") + require.NoError(t, installWrapper(wrapper, testInstallBinary, 12049)) + later := filepath.Join(late, "gh") + writeTestExecutable(t, later, []byte(testExitScript)) + t.Setenv("PATH", standard+string(os.PathListSeparator)+late) + got, err := realBinary(wrapper) + require.NoError(t, err) + require.Equal(t, later, got) + backup := wrapper + backupSuffix + writeTestExecutable(t, backup, []byte(testExitScript)) + got, err = realBinary(wrapper) + require.NoError(t, err) + require.Equal(t, backup, got) + selected := filepath.Join(early, "gh") + writeTestExecutable(t, selected, []byte(testExitScript)) + t.Setenv("PATH", early+string(os.PathListSeparator)+standard+string(os.PathListSeparator)+late) + got, err = realBinary(wrapper) + require.NoError(t, err) + require.Equal(t, selected, got) + t.Setenv("PATH", late) + got, err = realBinary(wrapper) + require.NoError(t, err) + require.Equal(t, later, got) + require.NoError(t, os.Remove(selected)) + require.NoError(t, os.Remove(backup)) + require.NoError(t, os.Link(wrapper, backup)) + got, err = realBinary(wrapper) + require.NoError(t, err) + require.Equal(t, later, got) +} + +func TestRealBinaryUsesBackupOutsidePATH(t *testing.T) { + dir := t.TempDir() + wrapper := filepath.Join(dir, "gh") + require.NoError(t, installWrapper(wrapper, testInstallBinary, 12049)) + t.Setenv("PATH", t.TempDir()) + _, err := realBinary(wrapper) + require.ErrorIs(t, err, exec.ErrNotFound) + backup := wrapper + backupSuffix + writeTestExecutable(t, backup, []byte(testExitScript)) + got, err := realBinary(wrapper) + require.NoError(t, err) + want, err := filepath.EvalSymlinks(backup) + require.NoError(t, err) + require.Equal(t, want, got) + alias := filepath.Join(t.TempDir(), "gh") + require.NoError(t, os.Symlink(wrapper, alias)) + got, err = realBinary(alias) + require.NoError(t, err) + require.Equal(t, want, got) + require.NoError(t, os.Remove(backup)) + require.NoError(t, os.Link(wrapper, backup)) + _, err = realBinary(wrapper) + require.ErrorIs(t, err, exec.ErrNotFound) +} diff --git a/pkg/ghcredentials/journal.go b/pkg/ghcredentials/journal.go new file mode 100644 index 0000000000..25e97a2d11 --- /dev/null +++ b/pkg/ghcredentials/journal.go @@ -0,0 +1,297 @@ +package ghcredentials + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "os" + "path/filepath" +) + +type overlayAccount struct { + Host string `json:"host"` + User string `json:"user"` + Digest string `json:"digest"` +} + +func fingerprint(a Account) overlayAccount { + digest := sha256.Sum256([]byte(a.Host + "\x00" + a.User + "\x00" + a.Token)) + return overlayAccount{Host: a.Host, User: a.User, Digest: hex.EncodeToString(digest[:])} +} + +func (a overlayAccount) matches(token string) bool { + return fingerprint(Account{Host: a.Host, User: a.User, Token: token}).Digest == a.Digest +} + +type publication struct { + Name string `json:"name"` + Target string `json:"target"` + Preimage string `json:"preimage"` + Postimage string `json:"postimage"` + Result []byte `json:"result"` + Mode fs.FileMode `json:"mode"` + Applied bool `json:"applied"` + Publishing bool `json:"publishing,omitempty"` + Conflict bool `json:"conflict"` +} + +func imageDigest(data []byte) string { + prefix := byte(1) + if data == nil { + prefix = 0 + } + h := sha256.New() + _, _ = h.Write([]byte{prefix}) + _, _ = h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} + +func (s *session) sanitize() error { + if s.Sanitized { + return nil + } + err := s.sanitizeWork() + if err != nil && !errors.Is(err, errLostIntent) { + return err + } + s.Accounts = nil + s.Sanitized = true + s.Version = 2 + if saveErr := s.save(); saveErr != nil { + return errors.Join( + boundary("session.sanitized_checkpoint", saveErr), + boundary("session.metadata_cleanup", os.Remove(filepath.Join(s.dir, "session.json"))), + ) + } + if err != nil { + return boundary("session.lost_intent", err) + } + return s.checkpointAt("sanitized") +} + +var errLostIntent = errors.New("unreadable GitHub CLI working configuration discarded") + +func (s *session) sanitizedHosts() ([]byte, error) { + before, err := readFile(filepath.Join(s.dir, "before", hostsFile)) + if err != nil { + return nil, err + } + data, err := readFile(filepath.Join(s.work(), hostsFile)) + if err != nil || len(data) == 0 { + return data, err + } + return s.strip(before, data) +} + +func (s *session) sanitizeWork() error { + data, err := s.sanitizedHosts() + path := filepath.Join(s.work(), hostsFile) + if err == nil && data != nil { + err = writeOriginal(path, data, 0o600) + } + if err == nil { + return nil + } + if cleanupErr := removeFile(path); cleanupErr != nil { + return boundary("session.scrub", cleanupErr) + } + s.LostHosts = true + return errLostIntent +} + +func removeFile(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func (s *session) checkpointAt(stage string) error { + if s.checkpoint != nil { + return s.checkpoint(stage) + } + return nil +} + +func (s *session) planPublication(name string, latest, result []byte, conflict bool) error { + target, err := s.originalPath(name) + if err != nil { + return err + } + mode := fs.FileMode(0o600) + if info, statErr := os.Stat( + filepath.Join(s.work(), name), + ); statErr == nil && + info.Mode()&0o111 != 0 { + mode = 0o700 + } + s.Publications = append( + s.Publications, + publication{ + Name: name, + Target: target, + Preimage: imageDigest(latest), + Postimage: imageDigest(result), + Result: result, + Mode: mode, + Conflict: conflict, + }, + ) + if err := s.save(); err != nil { + s.Publications = s.Publications[:len(s.Publications)-1] + return boundary("config.plan_checkpoint", err) + } + if err := s.checkpointAt("planned:" + name); err != nil { + return err + } + return s.publish(&s.Publications[len(s.Publications)-1]) +} + +func (s *session) publish(p *publication) error { + if p.Applied { + if p.Conflict { + return errAuthConflict + } + return nil + } + if !validPublication(p) { + return errInvalidConfig + } + if err := s.applyPublication(p); err != nil { + return err + } + return s.markApplied(p) +} + +func (s *session) applyPublication(p *publication) error { + target, err := s.originalPath(p.Name) + if err != nil { + return err + } + current, err := readFile(target) + if err != nil { + return boundary("config.latest_read", err) + } + switch { + case target != p.Target: + p.Conflict = true + case imageDigest(current) == p.Postimage: + case imageDigest(current) != p.Preimage: + p.Conflict = true + case p.Publishing: + p.Conflict = true + default: + return s.writePublication(p) + } + return nil +} + +func (s *session) writePublication(p *publication) error { + p.Publishing = true + if err := s.save(); err != nil { + p.Publishing = false + return boundary("config.publishing_checkpoint", err) + } + if err := s.checkpointAt("publishing:" + p.Name); err != nil { + return err + } + needsWrite, err := s.publicationNeedsWrite(p) + if err != nil || !needsWrite { + return err + } + if err := writePublication(p); err != nil { + p.Publishing = false + if saveErr := s.save(); saveErr != nil { + return errors.Join( + boundary("config.write", err), + boundary("config.retry_checkpoint", saveErr), + ) + } + return boundary("config.write", err) + } + if err := s.checkpointAt("published:" + p.Name); err != nil { + return err + } + return nil +} + +func (s *session) publicationNeedsWrite(p *publication) (bool, error) { + target, err := s.originalPath(p.Name) + if err != nil { + return false, err + } + if target != p.Target { + return false, errAuthConflict + } + current, err := readFile(target) + if err != nil { + return false, boundary("config.latest_read", err) + } + switch imageDigest(current) { + case p.Postimage: + return false, nil + case p.Preimage: + return true, nil + default: + p.Conflict = true + return false, nil + } +} + +func (s *session) markApplied(p *publication) error { + p.Applied = true + if err := s.save(); err != nil { + p.Applied = false + return boundary("config.applied_checkpoint", err) + } + if err := s.checkpointAt("applied:" + p.Name); err != nil { + return err + } + if p.Conflict { + return errAuthConflict + } + return nil +} + +func writePublication(p *publication) error { + if p.Result == nil { + return removeFile(p.Target) + } + return writeResolvedOriginal(p.Target, p.Result, p.Mode) +} + +func validPublication(p *publication) bool { + return p.Name != "" && !filepath.IsAbs(p.Name) && filepath.IsLocal(p.Name) && + filepath.IsAbs(p.Target) && + len(p.Preimage) == 64 && + p.Postimage == imageDigest(p.Result) && + (p.Mode == 0o600 || p.Mode == 0o700) +} + +func (s *session) completed() bool { + for _, p := range s.Publications { + if !p.Applied { + return false + } + } + return true +} + +func onlyAuthConflict(err error) bool { + if err == errAuthConflict { + return true + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + for _, child := range joined.Unwrap() { + if !onlyAuthConflict(child) { + return false + } + } + return true + } + return false +} + +func (a overlayAccount) valid() bool { return a.Host != "" && a.User != "" && len(a.Digest) == 64 } diff --git a/pkg/ghcredentials/journal_test.go b/pkg/ghcredentials/journal_test.go new file mode 100644 index 0000000000..59ac9dbe5a --- /dev/null +++ b/pkg/ghcredentials/journal_test.go @@ -0,0 +1,379 @@ +//go:build !windows + +package ghcredentials + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func journalSession(t *testing.T) *session { + t.Helper() + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + original := filepath.Join(root, "original") + writeTestFile(t, filepath.Join(original, configFile), "editor: vi\n") + s, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = s.lock.Close() }) + a := Account{Host: testHost, User: testUser, Token: testForwardedToken} + s.Accounts = []Account{a} + s.Overlays = []overlayAccount{fingerprint(a)} + require.NoError(t, s.save()) + hosts := mapping() + a.overlay(hosts) + b, err := yaml.Marshal(hosts) + require.NoError(t, err) + writeTestFile(t, filepath.Join(s.work(), hostsFile), string(b)) + return s +} + +func assertNoForwardedArtifacts(t *testing.T, dir string) { + t.Helper() + err := filepath.WalkDir(dir, func(path string, e fs.DirEntry, err error) error { + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if e.IsDir() { + return nil + } + data, err := readHelperFile(path) + if err == nil { + require.False( + t, + bytes.Contains(data, []byte(testForwardedToken)), + "forwarded value retained in session artifact", + ) + } + return err + }) + require.NoError(t, err) +} + +func TestJournalSanitizesBeforeOriginalReadAndLegacyRecovery(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(map[bool]string{false: "current", true: "legacy"}[legacy], func(t *testing.T) { + s := journalSession(t) + writeTestFile(t, filepath.Join(s.work(), configFile), "editor: nano\n") + writeTestFile(t, filepath.Join(s.Original, configFile), "[invalid yaml") + if legacy { + b, err := json.Marshal( + map[string]any{"original": s.Original, "accounts": s.Accounts}, + ) + require.NoError(t, err) + writeTestFile(t, filepath.Join(s.dir, "session.json"), string(b)) + require.NoError(t, s.lock.Close()) + require.Error(t, recoverSession(context.Background(), s.dir)) + } else { + require.Error(t, s.finish(context.Background())) + } + assertNoForwardedArtifacts(t, s.dir) + writeTestFile(t, filepath.Join(s.Original, configFile), "editor: vi\n") + require.NoError(t, recoverSession(context.Background(), s.dir)) + assertNoForwardedArtifacts(t, filepath.Dir(s.dir)) + }) + } +} + +func TestJournalCheckpointRecoveryNeverReplaysLaterLogout(t *testing.T) { + for _, stage := range []string{ + "sanitized", "planned:config.yml", "published:config.yml", "applied:config.yml", + "planned:hosts.yml", "published:hosts.yml", "applied:hosts.yml", + } { + t.Run(stage, func(t *testing.T) { + s := journalSession(t) + writeTestFile(t, filepath.Join(s.work(), configFile), "editor: nano\n") + writeTestFile( + t, + filepath.Join(s.work(), hostsFile), + authFixture("local-user", "dummy-local-login"), + ) + s.checkpoint = func(at string) error { + if at == stage { + return errors.New("injected checkpoint interruption") + } + return nil + } + require.Error(t, s.finish(context.Background())) + assertNoForwardedArtifacts(t, s.dir) + // A new user operation after any partial publication must win recovery. + writeTestFile( + t, + filepath.Join(s.Original, hostsFile), + authFixture("new-user", "dummy-new-login"), + ) + err := recoverSession(context.Background(), s.dir) + if err != nil { + require.ErrorIs(t, err, errAuthConflict) + } + b, err := readFile(filepath.Join(s.Original, hostsFile)) + require.NoError(t, err) + n, err := decode(b) + require.NoError(t, err) + require.Equal(t, "new-user", get(get(n, testHost), "user").Value) + require.True( + t, + get(get(n, testHost), "oauth_token").Value == "dummy-new-login", + "recovery replayed old login", + ) + require.NoError(t, recoverSession(context.Background(), s.dir)) + }) + } +} + +func TestJournalMalformedWorkIsDiscardedWithoutRetainingForwardedToken(t *testing.T) { + s := journalSession(t) + writeTestFile(t, filepath.Join(s.work(), hostsFile), "[malformed "+testForwardedToken) + require.Error(t, s.finish(context.Background())) + assertNoForwardedArtifacts(t, s.dir) + require.NoError(t, recoverSession(context.Background(), s.dir)) + require.NoFileExists(t, filepath.Join(s.Original, hostsFile)) +} + +func TestJournalPreservesEditsDuringPublishingCheckpoint(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + }{ + {name: "login", data: []byte(authFixture("new-user", "dummy-new-login"))}, + {name: "logout"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := journalSession(t) + baseline := authFixture("before-user", "dummy-before-login") + writeTestFile(t, filepath.Join(s.dir, "before", hostsFile), baseline) + writeTestFile(t, filepath.Join(s.Original, hostsFile), baseline) + writeTestFile( + t, + filepath.Join(s.work(), hostsFile), + authFixture("local-user", "dummy-local-login"), + ) + path := filepath.Join(s.Original, hostsFile) + s.checkpoint = func(stage string) error { + if stage == "publishing:"+hostsFile { + if tc.data == nil { + return removeFile(path) + } + return os.WriteFile(path, tc.data, 0o600) + } + return nil + } + require.ErrorIs(t, s.finish(context.Background()), errAuthConflict) + got, err := readFile(path) + require.NoError(t, err) + require.True(t, bytes.Equal(got, tc.data), "concurrent authentication edit overwritten") + require.NoError(t, recoverSession(context.Background(), s.dir)) + got, err = readFile(path) + require.NoError(t, err) + require.True(t, bytes.Equal(got, tc.data), "recovery replayed earlier authentication") + assertNoForwardedArtifacts(t, s.dir) + }) + } +} + +const duringPublishingStage = "during-publishing" + +func TestJournalBindsPublicationToSymlinkTarget(t *testing.T) { + for _, stage := range []string{"planned:", "publishing:", duringPublishingStage} { + t.Run(stage, func(t *testing.T) { + testJournalBindsPublicationToSymlinkTarget(t, stage) + }) + } +} + +func testJournalBindsPublicationToSymlinkTarget(t *testing.T, stagePrefix string) { + s := journalSession(t) + one, two := filepath.Join(s.Original, "one"), filepath.Join(s.Original, "two") + writeTestFile(t, one, "editor: vi\n") + writeTestFile(t, two, "editor: other\n") + path := filepath.Join(s.Original, configFile) + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Symlink(one, path)) + writeTestFile(t, filepath.Join(s.work(), configFile), "editor: nano\n") + retarget := func() { + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Symlink(two, path)) + } + s.checkpoint = func(stage string) error { + if stagePrefix == duringPublishingStage && stage == "publishing:"+configFile { + retarget() + return nil + } + if stage == stagePrefix+configFile { + return errors.New("injected") + } + return nil + } + require.Error(t, s.finish(context.Background())) + if stagePrefix != duringPublishingStage { + retarget() + } + require.ErrorIs(t, recoverSession(context.Background(), s.dir), errAuthConflict) + b, err := readFile(two) + require.NoError(t, err) + require.True(t, string(b) == "editor: other\n", "different symlink target overwritten") +} + +func TestRecoveryLeavesLiveChildSessionIntact(t *testing.T) { + s := journalSession(t) + require.NoError(t, recoverSession(context.Background(), s.dir)) + b, err := readFile(filepath.Join(s.work(), hostsFile)) + require.NoError(t, err) + require.True(t, bytes.Contains(b, []byte(testForwardedToken)), "live session was sanitized") + require.NoError(t, s.finish(context.Background())) +} + +func TestRecoveryDoesNotResurrectLogoutReturningToPreimage(t *testing.T) { + for _, stage := range []string{"published:hosts.yml", "applied:hosts.yml"} { + t.Run(stage, func(t *testing.T) { + s := journalSession(t) + writeTestFile( + t, + filepath.Join(s.work(), hostsFile), + authFixture("local-user", "dummy-local-login"), + ) + s.checkpoint = func(at string) error { + if at == stage { + return errors.New("injected") + } + return nil + } + require.Error(t, s.finish(context.Background())) + require.NoError(t, os.Remove(filepath.Join(s.Original, hostsFile))) + err := recoverSession(context.Background(), s.dir) + if err != nil { + require.ErrorIs(t, err, errAuthConflict) + } + require.NoFileExists(t, filepath.Join(s.Original, hostsFile)) + }) + } +} + +func TestMalformedWorkingAuthenticationDoesNotDeleteLocalLogin(t *testing.T) { + s := journalSession(t) + local := authFixture("local-user", "dummy-local-login") + writeTestFile(t, filepath.Join(s.Original, hostsFile), local) + writeTestFile(t, filepath.Join(s.dir, "before", hostsFile), local) + writeTestFile(t, filepath.Join(s.work(), hostsFile), "[bad "+testForwardedToken) + require.Error(t, s.finish(context.Background())) + assertNoForwardedArtifacts(t, s.dir) + require.NoError(t, recoverSession(context.Background(), s.dir)) + b, err := readFile(filepath.Join(s.Original, hostsFile)) + require.NoError(t, err) + require.True(t, string(b) == local, "discarded work deleted original authentication") +} + +func TestJournalMalformedAuthenticationIsScrubbedBeforeRecovery(t *testing.T) { + for _, body := range []string{"users: [a, b]", "users:\n alice: [a, b]", "oauth_token: [a, b]"} { + t.Run(body, func(t *testing.T) { + s := journalSession(t) + local := authFixture("local-user", "dummy-local-login") + writeTestFile(t, filepath.Join(s.Original, hostsFile), local) + writeTestFile(t, filepath.Join(s.dir, "before", hostsFile), local) + writeTestFile( + t, + filepath.Join(s.work(), hostsFile), + "github.com:\n "+body+"\n extra: "+testForwardedToken+"\n", + ) + require.ErrorIs(t, s.finish(context.Background()), errLostIntent) + assertNoForwardedArtifacts(t, s.dir) + require.NoError(t, recoverSession(context.Background(), s.dir)) + saved, err := readFile(filepath.Join(s.Original, hostsFile)) + require.NoError(t, err) + require.True( + t, + string(saved) == local, + "malformed work changed original authentication", + ) + }) + } +} + +func TestJournalMalformedLatestAuthenticationRemainsPendingUntilRepaired(t *testing.T) { + for _, tc := range []struct{ name, data string }{ + {"structure", "github.com:\n users: [a, b]\n"}, + {"trailing content", "{}\nignored.test:\n user: newer-user\n"}, + {"second document", "{}\n---\nignored.test:\n user: newer-user\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + malformed := tc.data + s := journalSession(t) + local := authFixture("local-user", "dummy-local-login") + writeTestFile(t, filepath.Join(s.work(), hostsFile), local) + path := filepath.Join(s.Original, hostsFile) + writeTestFile(t, path, malformed) + require.ErrorIs(t, s.finish(context.Background()), errInvalidConfig) + for range 2 { + assertNoForwardedArtifacts(t, s.dir) + require.ErrorIs(t, recoverSession(context.Background(), s.dir), errInvalidConfig) + saved, err := readFile(path) + require.NoError(t, err) + require.Equal(t, malformed, string(saved)) + } + require.NoError(t, os.Remove(path)) + require.NoError(t, recoverSession(context.Background(), s.dir)) + require.NoError(t, recoverSession(context.Background(), s.dir)) + saved, err := readFile(path) + require.NoError(t, err) + want, err := decodeHosts([]byte(local)) + require.NoError(t, err) + got, err := decodeHosts(saved) + require.NoError(t, err) + require.True(t, equal(want, got), "recovery changed deliberate local authentication") + assertNoForwardedArtifacts(t, filepath.Dir(s.dir)) + }) + } +} + +func TestJournalPreservesSecureLoginEmptyStringRegistration(t *testing.T) { + s := journalSession(t) + working := testHost + ":\n user: local-user\n users:\n " + testUser + + ":\n oauth_token: " + testForwardedToken + "\n local-user: \"\"\n" + writeTestFile(t, filepath.Join(s.work(), hostsFile), working) + require.NoError(t, s.finish(context.Background())) + assertNoForwardedArtifacts(t, filepath.Dir(s.dir)) + saved, err := readFile(filepath.Join(s.Original, hostsFile)) + require.NoError(t, err) + n, err := decodeHosts(saved) + require.NoError(t, err) + host := get(n, testHost) + require.Equal(t, "local-user", get(host, "user").Value) + require.NotNil(t, get(get(host, "users"), "local-user")) + a := Account{Host: testHost, User: testUser, Token: testForwardedToken} + next, err := startSession(context.Background(), s.Original, []Account{a}) + require.NoError(t, err) + t.Cleanup(func() { _ = next.lock.Close() }) + require.NoError(t, next.inject(context.Background(), "/usr/bin/false", nil)) + require.Empty(t, next.Accounts, "unavailable keyring must preserve the registered guest") + require.NoError(t, next.finish(context.Background())) +} + +func TestJournalTrailingWorkingAuthenticationIsScrubbedWithLostIntent(t *testing.T) { + for _, prefix := range []string{"{}\n", "{}\n---\n"} { + t.Run(prefix, func(t *testing.T) { + s := journalSession(t) + writeTestFile( + t, + filepath.Join(s.work(), hostsFile), + prefix+authFixture(testUser, testForwardedToken), + ) + require.ErrorIs(t, s.finish(context.Background()), errLostIntent) + assertNoForwardedArtifacts(t, s.dir) + require.NoError(t, recoverSession(context.Background(), s.dir)) + require.NoFileExists(t, filepath.Join(s.Original, hostsFile)) + }) + } +} diff --git a/pkg/ghcredentials/owner_linux.go b/pkg/ghcredentials/owner_linux.go new file mode 100644 index 0000000000..11cc85456a --- /dev/null +++ b/pkg/ghcredentials/owner_linux.go @@ -0,0 +1,34 @@ +package ghcredentials + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +type ownerLock struct{ file *os.File } + +// Kernel ownership follows the inherited descriptor, independently of the +// cache filesystem's lock semantics. The real gh keeps this session alive +// even if its wrapper is killed; no socket accepts or transmits credentials. +func tryOwner(path string) (*ownerLock, bool, error) { + key := sha256.Sum256([]byte(fmt.Sprintf("%d:%s", os.Getuid(), path))) + fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + return nil, false, err + } + err = unix.Bind(fd, &unix.SockaddrUnix{Name: fmt.Sprintf("@devsy-gh-%x", key[:20])}) + if err == nil { + return &ownerLock{file: os.NewFile(uintptr(fd), "gh-session-owner")}, true, nil + } + _ = unix.Close(fd) + if errors.Is(err, unix.EADDRINUSE) { + return nil, false, nil + } + return nil, false, err +} + +func (l *ownerLock) Close() error { return l.file.Close() } diff --git a/pkg/ghcredentials/owner_unix.go b/pkg/ghcredentials/owner_unix.go new file mode 100644 index 0000000000..c3b8ca4042 --- /dev/null +++ b/pkg/ghcredentials/owner_unix.go @@ -0,0 +1,39 @@ +//go:build !linux && !windows + +package ghcredentials + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +type ownerLock struct{ file *os.File } + +func tryOwner(path string) (*ownerLock, bool, error) { + f, err := ownerFile(path) + if err != nil { + return nil, false, err + } + err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + return &ownerLock{file: f}, true, nil + } + _ = f.Close() + if err == unix.EWOULDBLOCK { + return nil, false, nil + } + return nil, false, err +} + +func (l *ownerLock) Close() error { return l.file.Close() } + +func ownerFile(path string) (*os.File, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + return root.OpenFile(filepath.Base(path), os.O_CREATE|os.O_RDWR, 0o600) +} diff --git a/pkg/ghcredentials/process_unix.go b/pkg/ghcredentials/process_unix.go new file mode 100644 index 0000000000..a369413168 --- /dev/null +++ b/pkg/ghcredentials/process_unix.go @@ -0,0 +1,49 @@ +//go:build !windows + +package ghcredentials + +import ( + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/devsy-org/devsy/pkg/log" +) + +func runGH(binary string, args, env []string, owner *os.File) int { + //nolint:gosec // The user chose this executable and these unchanged arguments. + cmd := exec.Command(binary, args...) + cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, os.Stdin, os.Stdout, os.Stderr + if owner != nil { + cmd.ExtraFiles = []*os.File{owner} + } + signals := make(chan os.Signal, 8) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(signals) + if err := cmd.Start(); err != nil { + log.Errorw("gh relay: executable unavailable", "error_type", errorKind(err)) + return 126 + } + done := make(chan struct{}) + defer close(done) + go forwardSignals(cmd.Process, signals, done) + if err := cmd.Wait(); err != nil { + if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + return cmd.ProcessState.ExitCode() + } + return 0 +} + +func forwardSignals(process *os.Process, signals <-chan os.Signal, done <-chan struct{}) { + for { + select { + case sig := <-signals: + _ = process.Signal(sig) + case <-done: + return + } + } +} diff --git a/pkg/ghcredentials/process_windows.go b/pkg/ghcredentials/process_windows.go new file mode 100644 index 0000000000..6c6b076340 --- /dev/null +++ b/pkg/ghcredentials/process_windows.go @@ -0,0 +1,40 @@ +package ghcredentials + +import ( + "os" + "os/exec" + + "github.com/devsy-org/devsy/pkg/log" + "github.com/gofrs/flock" +) + +type ownerLock struct { + file *os.File + lock *flock.Flock +} + +func tryOwner(path string) (*ownerLock, bool, error) { + l := flock.New(path) + ok, err := l.TryLock() + if err != nil || !ok { + _ = l.Close() + return nil, ok, err + } + return &ownerLock{lock: l}, true, nil +} + +func (l *ownerLock) Close() error { return l.lock.Close() } + +func runGH(binary string, args, env []string, _ *os.File) int { + //nolint:gosec // The user chose this executable and these unchanged arguments. + cmd := exec.Command(binary, args...) + cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + if cmd.ProcessState != nil { + return cmd.ProcessState.ExitCode() + } + log.Errorw("gh relay: executable unavailable", "error_type", errorKind(err)) + return 126 + } + return 0 +} diff --git a/pkg/ghcredentials/relay_test.go b/pkg/ghcredentials/relay_test.go new file mode 100644 index 0000000000..f008a5cc11 --- /dev/null +++ b/pkg/ghcredentials/relay_test.go @@ -0,0 +1,106 @@ +package ghcredentials + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/stretchr/testify/require" +) + +func relayFixtureStatus(owner, instance string) credentials.Readiness { + return credentials.Readiness{ + Version: credentials.ReadinessVersion, + Owner: "vscode", + HostOwner: owner, + Instance: instance, + GitHubAllowed: true, + GitHubState: "installed", + } +} + +func relayFixtureServer( + t *testing.T, + ln net.Listener, + status credentials.Readiness, + body string, +) *atomic.Int32 { + t.Helper() + calls := &atomic.Int32{} + s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ready" { + require.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(status) + return + } + require.Equal(t, "/github-credentials", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, status.Instance, r.Header.Get(credentials.HeaderCredentialsInstance)) + require.Equal(t, status.HostOwner, r.Header.Get(credentials.HeaderCredentialsHostOwner)) + calls.Add(1) + _, _ = w.Write([]byte(body)) + })) + s.Listener = ln + s.Start() + t.Cleanup(s.Close) + return calls +} + +func TestRelayFetchPinsSelectedFamilyAndOwner(t *testing.T) { + v4, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + port := v4.Addr().(*net.TCPAddr).Port + v6, err := net.Listen("tcp6", net.JoinHostPort("::1", strconv.Itoa(port))) + if err != nil { + _ = v4.Close() + t.Skipf("OS IPv6 loopback unavailable: %v", err) + } + first := relayFixtureServer( + t, + v4, + relayFixtureStatus("host-one", "one"), + `{"status":"available","accounts":[{"host":"github.com","user":"alice","token":"dummy-one"}]}`, + ) + second := relayFixtureServer( + t, + v6, + relayFixtureStatus("host-two", "two"), + `{"status":"available","accounts":[{"host":"github.com","user":"bob","token":"dummy-two"}]}`, + ) + t.Setenv("DEVSY_CREDENTIALS_SERVER_PORT", strconv.Itoa(port)) + t.Setenv("DEVSY_GH_HOST_OWNER", "host-two") + t.Setenv("DEVSY_GH_INSTANCE", "two") + t.Setenv("HTTP_PROXY", "http://invalid.invalid:1") + t.Setenv("NO_PROXY", "") + result, err := fetch(context.Background()) + require.NoError(t, err) + require.Len(t, result.Accounts, 1) + require.Equal(t, "bob", result.Accounts[0].User) + require.True(t, result.Accounts[0].Token == "dummy-two", "credentials crossed service families") + require.Zero(t, first.Load()) + require.EqualValues(t, 1, second.Load()) +} + +func TestRelayFetchRejectsMalformedTrailingAndOversizedResponses(t *testing.T) { + for _, body := range []string{ + "{", `{"status":"available"} trailing`, + `{"status":"available"}` + strings.Repeat(" ", 1<<20), + } { + ln, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + calls := relayFixtureServer(t, ln, relayFixtureStatus("host-one", "one"), body) + t.Setenv("DEVSY_CREDENTIALS_SERVER_PORT", strconv.Itoa(ln.Addr().(*net.TCPAddr).Port)) + t.Setenv("DEVSY_GH_HOST_OWNER", "host-one") + t.Setenv("DEVSY_GH_INSTANCE", "one") + _, err = fetch(context.Background()) + require.Error(t, err) + require.EqualValues(t, 1, calls.Load()) + } +} diff --git a/pkg/ghcredentials/result.go b/pkg/ghcredentials/result.go new file mode 100644 index 0000000000..566cd84677 --- /dev/null +++ b/pkg/ghcredentials/result.go @@ -0,0 +1,91 @@ +package ghcredentials + +import ( + "context" + "errors" + "os" + "os/exec" + "syscall" + + "github.com/devsy-org/devsy/pkg/log" +) + +type HostResult struct { + Accounts []Account `json:"accounts"` + Status string `json:"status"` +} + +type boundaryError struct { + operation, kind string + cause error +} + +func (e *boundaryError) Error() string { return e.operation + ": " + e.kind } +func (e *boundaryError) Unwrap() error { return e.cause } + +func boundary(operation string, err error) error { + if err == nil { + return nil + } + return &boundaryError{operation: operation, kind: errorKind(err), cause: err} +} + +func errorKind(err error) string { + classes := []struct { + cause error + kind string + }{ + {context.DeadlineExceeded, "timeout"}, + {context.Canceled, "cancelled"}, + {exec.ErrWaitDelay, "pipe_timeout"}, + {errTokenOutputLimit, "output_limit"}, + {errAuthConflict, "authentication_conflict"}, + {os.ErrPermission, "permission_denied"}, + {os.ErrNotExist, "not_found"}, + {syscall.ECONNREFUSED, "connection_refused"}, + {errInvalidConfig, "schema_invalid"}, + } + for _, class := range classes { + if errors.Is(err, class.cause) { + return class.kind + } + } + if _, ok := errors.AsType[*exec.ExitError](err); ok { + return "process_exit" + } + return "io_error" +} + +var errInvalidConfig = errors.New("invalid GitHub CLI configuration") + +func lockOwner(path string) (*ownerLock, error) { + l, ok, err := tryOwner(path) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("GitHub CLI session is active") + } + return l, nil +} + +func describeError(name string, err error) (string, string) { + if boundary, ok := errors.AsType[*boundaryError](err); ok { + return boundary.operation, boundary.kind + } + return name, errorKind(err) +} + +func logFailure(message, operation string, err error, saving bool) { + operation, kind := describeError(operation, err) + if saving { + log.Errorw(message, "operation", operation, "error_type", kind) + } else { + log.Warnw(message, "operation", operation, "error_type", kind) + } +} + +const ( + stateAvailable = "available" + stateUnavailable = "unavailable" +) diff --git a/pkg/ghcredentials/session.go b/pkg/ghcredentials/session.go new file mode 100644 index 0000000000..67e51d4680 --- /dev/null +++ b/pkg/ghcredentials/session.go @@ -0,0 +1,837 @@ +package ghcredentials + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/devsy-org/devsy/pkg/log" + "github.com/gofrs/flock" + "gopkg.in/yaml.v3" +) + +type session struct { + Version int `json:"version"` + Original string `json:"original"` + OriginalTarget string `json:"original_target,omitempty"` + Overlays []overlayAccount `json:"overlays,omitempty"` + Sanitized bool `json:"sanitized"` + LostHosts bool `json:"lost_hosts,omitempty"` + Publications []publication `json:"publications,omitempty"` + Accounts []Account `json:"-"` + dir string + lock *ownerLock + checkpoint func(string) error +} + +func sessionRoot() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + dir = filepath.Join(dir, "devsy", "gh-relay") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} + +func lock(ctx context.Context, path string) (*flock.Flock, error) { + l := flock.New(path) + ok, err := l.TryLockContext(ctx, 20*time.Millisecond) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("GitHub CLI configuration lock unavailable") + } + return l, nil +} + +func startSession(ctx context.Context, original string, accounts []Account) (*session, error) { + root, err := sessionRoot() + if err != nil { + return nil, err + } + l, err := lock(ctx, filepath.Join(root, "sessions.lock")) + if err != nil { + return nil, boundary("session.catalog_lock", err) + } + defer func() { _ = l.Close() }() + recoverSessions(ctx, root) + return newSession(ctx, root, original, accounts) +} + +func newSession(ctx context.Context, root, original string, accounts []Account) (*session, error) { + target, err := resolveOriginal(original) + if err != nil { + return nil, boundary("snapshot.target", err) + } + dir, err := os.MkdirTemp(root, "session-") + if err != nil { + return nil, err + } + owner, err := lockOwner(filepath.Join(dir, "owner.lock")) + if err != nil { + _ = os.RemoveAll(dir) + return nil, boundary("session.owner_lock", err) + } + s := &session{ + Version: 2, + Original: original, + OriginalTarget: target, + Accounts: accounts, + dir: dir, + lock: owner, + } + ready := false + defer func() { + if !ready { + _ = owner.Close() + _ = os.RemoveAll(dir) + } + }() + if err := copyTree(ctx, target, filepath.Join(dir, "before"), 0); err != nil { + return nil, boundary("snapshot.read", err) + } + if err := copyTree(ctx, filepath.Join(dir, "before"), s.work(), 0); err != nil { + return nil, boundary("snapshot.copy", err) + } + initial := *s + initial.Accounts = nil // No forwarded values exist until inject completes. + if err := initial.save(); err != nil { + return nil, boundary("session.metadata", err) + } + ready = true + return s, nil +} + +func (s *session) work() string { return filepath.Join(s.dir, "work") } + +type treeSnapshot struct { + ctx context.Context + bytes int64 + entries int +} + +func copyTree(ctx context.Context, src, dst string, depth int) error { + return (&treeSnapshot{ctx: ctx, bytes: 64 << 20, entries: 4096}).copyTree(src, dst, depth) +} + +func (s *treeSnapshot) copyTree(src, dst string, depth int) error { + if err := s.ctx.Err(); err != nil { + return err + } + if depth > 32 { + return errors.New("GitHub CLI configuration symlink cycle") + } + if err := os.MkdirAll(dst, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(src) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + for _, e := range entries { + if err := s.copyEntry( + filepath.Join(src, e.Name()), + filepath.Join(dst, e.Name()), + depth, + ); err != nil { + return err + } + } + return nil +} + +func (s *treeSnapshot) copyEntry(src, dst string, depth int) error { + s.entries-- + if s.entries < 0 { + return errors.New("GitHub CLI configuration has too many entries") + } + info, err := os.Stat(src) // Follow symlinks into a private copy, never a link to the original. + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.IsDir() { + return s.copyTree(src, dst, depth+1) + } + if !info.Mode().IsRegular() { + return nil + } + return s.copyFile(src, dst, info) +} + +func (s *treeSnapshot) copyFile(src, dst string, info fs.FileInfo) error { + if err := s.reserveBytes(info.Size()); err != nil { + return err + } + data, err := readFile(src) + if err != nil { + return err + } + mode := fs.FileMode(0o600) + if info.Mode()&0o111 != 0 { + mode = 0o700 + } + return os.WriteFile(dst, data, mode) +} + +func (s *treeSnapshot) reserveBytes(size int64) error { + if size > 16<<20 { + return errors.New("GitHub CLI configuration file too large") + } + s.bytes -= size + if s.bytes < 0 { + return errors.New("GitHub CLI configuration snapshot too large") + } + + return nil +} + +func recoverSessions(ctx context.Context, root string) { + entries, err := os.ReadDir(root) + if err != nil { + logFailure("gh relay: recovery pending", "recovery.list", err, true) + return + } + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "session-") { + dir := filepath.Join(root, e.Name()) + if err := recoverSession(ctx, dir); err != nil { + logFailure("gh relay: recovery pending", "recovery.writeback", err, true) + } + } + } +} + +func recoverSession(ctx context.Context, dir string) error { + if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) { + return nil + } + owner, ok, err := tryOwner(filepath.Join(dir, "owner.lock")) + if err != nil { + return boundary("recovery.owner_lock", err) + } + if !ok { + return nil + } + defer func() { _ = owner.Close() }() + old, err := loadSession(dir) + if err != nil { + return errors.Join( + boundary("recovery.lost_intent", err), + boundary("recovery.cleanup", os.RemoveAll(dir)), + ) + } + if err := recoverLoadedSession(ctx, old); err != nil { + return err + } + if err := os.RemoveAll(dir); err != nil { + return boundary("recovery.cleanup", err) + } + log.Debug("gh relay: configuration recovered") + return nil +} + +func recoverLoadedSession(ctx context.Context, old *session) error { + if old == nil { + return nil + } + err := old.sync(ctx) + if old.completed() && onlyAuthConflict(err) { + return errors.Join(err, boundary("recovery.cleanup", os.RemoveAll(old.dir))) + } + return err +} + +func loadSession(dir string) (*session, error) { + b, err := readFile(filepath.Join(dir, "session.json")) + if err != nil { + return nil, err + } + if len(b) == 0 { + return nil, nil + } + var old session + if json.Unmarshal(b, &old) != nil { + return nil, errInvalidConfig + } + if err := old.upgrade(b); err != nil { + return nil, err + } + if err := old.validate(); err != nil { + return nil, err + } + old.dir = dir + return &old, nil +} + +func (s *session) upgrade(b []byte) error { + if s.Version == 0 { + var legacy struct { + Accounts []Account `json:"accounts"` + } + if json.Unmarshal(b, &legacy) != nil { + return errInvalidConfig + } + for _, a := range legacy.Accounts { + s.Overlays = append(s.Overlays, fingerprint(a)) + } + s.Version = 2 + } + return nil +} + +func (s *session) validate() error { + if !s.validLocation() || s.Version != 2 { + return errInvalidConfig + } + seen := map[string]bool{} + for _, p := range s.Publications { + if !validPublication(&p) || seen[p.Name] { + return errInvalidConfig + } + seen[p.Name] = true + } + for _, a := range s.Overlays { + if !a.valid() { + return errInvalidConfig + } + } + return nil +} + +func (s *session) validLocation() bool { + return filepath.IsAbs(s.Original) && + (s.OriginalTarget == "" || filepath.IsAbs(s.OriginalTarget)) +} + +func (s *session) save() error { + b, err := json.Marshal(s) + if err != nil { + return err + } + return writeOriginal(filepath.Join(s.dir, "session.json"), b, 0o600) +} + +func localAuthSource(n *yaml.Node) string { + if value := get(n, "oauth_token"); value != nil && value.Value != "" { + return "local_config" + } + users := get(n, "users") + if users != nil { + for i := 1; i < len(users.Content); i += 2 { + if token := get(users.Content[i], "oauth_token"); token != nil && token.Value != "" { + return "local_config" + } + } + } + return "" +} + +func mapping() *yaml.Node { return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} } + +func ensure(n *yaml.Node, key string) *yaml.Node { + v := get(n, key) + if v == nil || v.Kind != yaml.MappingNode { + v = mapping() + put(n, key, v) + } + return v +} + +func (s *session) inject(ctx context.Context, binary string, env []string) error { + path := filepath.Join(s.work(), hostsFile) + b, err := readFile(path) + if err != nil { + return boundary("config.read", err) + } + hosts, err := decodeHosts(b) + if err != nil { + return boundary("config.decode", err) + } + if err := s.overlay(ctx, binary, env, hosts); err != nil { + return err + } + if err := s.save(); err != nil { + return err + } + if len(s.Accounts) == 0 { + return nil + } + b, err = yaml.Marshal(hosts) + if err != nil { + return err + } + return os.WriteFile(path, b, 0o600) +} + +// Even gh auth token can migrate configuration. Never point the lookup at +// original settings or the immutable writeback baseline. Each host gets a fresh +// copy because a migration may change registrations for other hosts as well. +func (s *session) overlay( + ctx context.Context, + binary string, + env []string, + hosts *yaml.Node, +) error { + var injected []Account + for _, a := range s.Accounts { + probe, err := s.probeConfig() + if err != nil { + return boundary("config.probe_snapshot", err) + } + source := a.inject(ctx, binary, replaceEnv(env, "GH_CONFIG_DIR", probe), hosts) + _ = os.RemoveAll(probe) + log.Debugw("gh relay: authentication source", "host", a.Host, "source", source) + if source == "forwarded" { + injected = append(injected, a) + } + } + s.Accounts = injected + s.Overlays = nil + for _, a := range injected { + s.Overlays = append(s.Overlays, fingerprint(a)) + } + return nil +} + +func (s *session) probeConfig() (string, error) { + dir, err := os.MkdirTemp(s.dir, "probe-") + if err != nil { + return "", err + } + if err := copyConfigFiles(filepath.Join(s.dir, "before"), dir); err != nil { + _ = os.RemoveAll(dir) + return "", err + } + return dir, nil +} + +func copyConfigFiles(src, dst string) error { + for _, name := range []string{hostsFile, configFile} { + b, err := readFile(filepath.Join(src, name)) + if err != nil { + return err + } + if b != nil { + if err := os.WriteFile(filepath.Join(dst, name), b, 0o600); err != nil { + return err + } + } + } + return nil +} + +func (s *session) strip(before, data []byte) ([]byte, error) { + original, err := decodeHosts(before) + if err != nil { + return nil, err + } + hosts, err := decodeHosts(data) + if err != nil { + return nil, err + } + accounts := s.Overlays + if len(accounts) == 0 { + for _, a := range s.Accounts { + accounts = append(accounts, fingerprint(a)) + } + } + for _, a := range accounts { + stripOverlay(hosts, get(original, a.Host), a) + } + return yaml.Marshal(hosts) +} + +func files(dir string) (map[string]bool, error) { + result := map[string]bool{} + err := filepath.WalkDir(dir, func(path string, e fs.DirEntry, err error) error { + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !e.IsDir() { + rel, _ := filepath.Rel(dir, path) + result[rel] = true + } + return nil + }) + return result, err +} + +func (s *session) sync(ctx context.Context) error { + if err := s.sanitize(); err != nil { + return err + } + root := filepath.Dir(s.dir) + target, err := s.bindOriginal() + if err != nil { + return err + } + key := sha256.Sum256([]byte(target)) + l, err := lock(ctx, filepath.Join(root, fmt.Sprintf("config-%x.lock", key[:16]))) + if err != nil { + return boundary("config.lock", err) + } + defer func() { _ = l.Close() }() + if _, err := s.originalPath(""); err != nil { + return err + } + ordered, err := s.changedNames() + if err != nil { + return err + } + var syncErr error + for _, name := range ordered { + if err := ctx.Err(); err != nil { + return errors.Join(syncErr, err) + } + syncErr = errors.Join(syncErr, s.syncFile(name)) + } + return syncErr +} + +func (s *session) bindOriginal() (string, error) { + target, err := resolveOriginal(s.Original) + if err != nil { + return "", boundary("config.target", err) + } + if s.OriginalTarget == "" { + s.OriginalTarget = target + if err := s.save(); err != nil { + s.OriginalTarget = "" + return "", boundary("config.target_checkpoint", err) + } + } + if target != s.OriginalTarget { + return "", errors.New("GitHub CLI configuration directory changed") + } + return target, nil +} + +func (s *session) originalPath(name string) (string, error) { + target, err := s.bindOriginal() + if err != nil { + return "", err + } + path, err := resolveOriginal(filepath.Join(target, name)) + return path, boundary("config.target", err) +} + +func (s *session) readOriginal(name string) ([]byte, error) { + path, err := s.originalPath(name) + if err != nil { + return nil, err + } + data, err := readFile(path) + return data, boundary("config.latest_read", err) +} + +func (s *session) changedNames() ([]string, error) { + beforeDir := filepath.Join(s.dir, "before") + names, err := files(beforeDir) + if err != nil { + return nil, err + } + afterNames, err := files(s.work()) + if err != nil { + return nil, err + } + for name := range afterNames { + names[name] = true + } + ordered := make([]string, 0, len(names)) + for name := range names { + ordered = append(ordered, name) + } + slices.Sort(ordered) + return ordered, nil +} + +func writeOriginal(path string, data []byte, mode fs.FileMode) error { + var err error + path, err = resolveOriginal(path) + if err != nil { + return err + } + return writeResolvedOriginal(path, data, mode) +} + +func writeResolvedOriginal(path string, data []byte, mode fs.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".devsy-gh-") + if err != nil { + return err + } + name := f.Name() + defer func() { _ = os.Remove(name) }() + if err := f.Chmod(mode); err != nil { + _ = f.Close() + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(name, path) +} + +func (s *session) finish(ctx context.Context) error { + err := s.sync(ctx) + if err == nil || (onlyAuthConflict(err) && s.completed()) { + err = errors.Join(err, boundary("session.cleanup", os.RemoveAll(s.dir))) + } + _ = s.lock.Close() + return err +} + +func (a Account) inject( + ctx context.Context, + binary string, + env []string, + hosts *yaml.Node, +) string { + h := get(hosts, a.Host) + if !a.valid() { + return "invalid_account" + } + if source := localAuthSource(h); source != "" { + return source + } + token, err := resolveToken(ctx, binary, a.Host, env) + if err != nil && !noTokenResult(err) { + log.Warnw( + "gh relay: forwarding unavailable", + "operation", + "local.token_lookup", + "host", + a.Host, + "error_type", + errorKind(err), + ) + return "local_lookup_failed" + } + if token != "" { + return localTokenSource(token, env) + } + if registeredAccount(h) { + return "local_identity_unavailable" + } + a.overlay(hosts) + return "forwarded" +} + +func registeredAccount(h *yaml.Node) bool { + if user := get(h, "user"); user != nil && user.Value != "" { + return true + } + users := get(h, "users") + return users != nil && len(users.Content) != 0 +} + +// gh auth token reports an absent token as exit 1. It also collapses keyring +// backend errors into that result; the CLI cannot distinguish those cases. +// Execution failures and timeouts are not evidence of absent authentication. +func noTokenResult(err error) bool { + exit, ok := errors.AsType[*exec.ExitError](err) + return ok && exit.ExitCode() == 1 +} + +func (a Account) valid() bool { + return a.Host != "" && a.User != "" && a.Token != "" +} + +func (a Account) overlay(hosts *yaml.Node) { + h := ensure(hosts, a.Host) + put(h, "user", scalar(a.User)) + put(h, "oauth_token", scalar(a.Token)) + // Tokenless registrations are settings, not usable accounts. Hide them + // only in the overlay so gh auth status does not report them as failures. + users := mapping() + put(users, a.User, get(get(h, "users"), a.User)) + put(ensure(users, a.User), "oauth_token", scalar(a.Token)) + put(h, "users", users) +} + +func stripOverlay(hosts, original *yaml.Node, a overlayAccount) { + h := get(hosts, a.Host) + if h == nil { + put(hosts, a.Host, original) + return + } + u := get(h, "users") + account := get(u, a.User) + activeToken := get(h, "oauth_token") + userToken := get(account, "oauth_token") + // Successful keyring login removes both file tokens. Keep its account + // registration; the keyring itself remains entirely under gh's control. + keyringLogin := activeToken == nil && userToken == nil + removeOverlayToken(h, original, a) + removeOverlayToken(account, get(get(original, "users"), a.User), a) + if !keyringLogin { + stripRegistration(h, get(original, "user"), a.User) + } + // Restore hidden dotfiles registrations while retaining new local logins. + // No real original credential can be hidden: inject excludes those hosts. + users := get(h, "users") + if users == nil { + users = mapping() + } + put(h, "users", merge(mapping(), users, get(original, "users"))) + if len(h.Content) == 0 { + put(hosts, a.Host, nil) + } +} + +func removeOverlayToken(n, original *yaml.Node, a overlayAccount) { + if t := get(n, "oauth_token"); t != nil && a.matches(t.Value) { + put(n, "oauth_token", get(original, "oauth_token")) + } +} + +func stripRegistration(host, originalUser *yaml.Node, user string) { + users := get(host, "users") + account := get(users, user) + if forwardedRegistration(host, account, user) { + put(host, "user", originalUser) + } + if account != nil && len(account.Content) == 0 { + put(users, user, nil) + } + if users != nil && len(users.Content) == 0 { + put(host, "users", nil) + } +} + +func hasToken(n *yaml.Node) bool { + t := get(n, "oauth_token") + return t != nil && t.Value != "" +} + +func forwardedRegistration(host, account *yaml.Node, user string) bool { + t := get(host, "user") + return t != nil && t.Value == user && !hasToken(account) && !hasToken(host) +} + +func (s *session) changedFile(name string) ([]byte, []byte, error) { + before, err := readFile(filepath.Join(s.dir, "before", name)) + if err != nil { + return nil, nil, boundary("config.snapshot_read", err) + } + after, err := readFile(filepath.Join(s.work(), name)) + if err != nil { + return nil, nil, boundary("config.changed_read", err) + } + return before, after, nil +} + +func (s *session) syncFile(name string) error { + if s.skipFile(name) { + return nil + } + if p := s.publication(name); p != nil { + return s.publish(p) + } + before, after, err := s.changedFile(name) + if err != nil { + return err + } + + if bytes.Equal(before, after) { + return nil + } + latest, err := s.readOriginal(name) + if err != nil { + return err + } + after, err = s.mergeFile(name, before, after, latest) + if err != nil && !errors.Is(err, errAuthConflict) { + return boundary("config.merge", err) + } + return s.planPublication(name, latest, after, errors.Is(err, errAuthConflict)) +} + +func (s *session) mergeFile(name string, before, after, latest []byte) ([]byte, error) { + if name == hostsFile { + return mergeHosts(before, after, latest) + } + if name == configFile { + return mergeYAML(before, after, latest) + } + return after, nil +} + +func localTokenSource(token string, env []string) string { + for _, entry := range env { + name, value, _ := strings.Cut(entry, "=") + if authEnvironment[name] && value == token { + return "environment" + } + } + return "keyring" +} + +var authEnvironment = map[string]bool{ + "GH_TOKEN": true, + "GITHUB_TOKEN": true, + "GH_ENTERPRISE_TOKEN": true, + "GITHUB_ENTERPRISE_TOKEN": true, +} + +func resolveOriginal(path string) (string, error) { + resolved, err := filepath.EvalSymlinks(path) + if err == nil { + return resolved, nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + if _, linkErr := os.Lstat(path); linkErr == nil { + return "", errors.New("GitHub CLI configuration target unavailable") + } + parent := filepath.Dir(path) + if parent == path { + return path, nil + } + resolved, err = resolveOriginal(parent) + if err != nil { + return "", err + } + return filepath.Join(resolved, filepath.Base(path)), nil +} + +func (s *session) skipFile(name string) bool { return name == hostsFile && s.LostHosts } + +func (s *session) publication(name string) *publication { + for i := range s.Publications { + if s.Publications[i].Name == name { + return &s.Publications[i] + } + } + return nil +} diff --git a/pkg/ghcredentials/session_test.go b/pkg/ghcredentials/session_test.go new file mode 100644 index 0000000000..dfe2d8d466 --- /dev/null +++ b/pkg/ghcredentials/session_test.go @@ -0,0 +1,433 @@ +//go:build !windows + +package ghcredentials + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSessionMergePreservesDotfilesAndConcurrentChanges(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + t.Setenv("HOME", root) + original := filepath.Join(root, "gh") + target := filepath.Join(root, "dotfiles", configFile) + writeTestFile(t, target, "editor: vi\npager: less\n") + require.NoError(t, os.MkdirAll(original, 0o700)) + require.NoError(t, os.Symlink(target, filepath.Join(original, configFile))) + s, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + writeTestFile(t, filepath.Join(s.work(), configFile), "editor: nano\npager: less\n") + writeTestFile(t, target, "editor: emacs\npager: cat\nprompt: disabled\n") + require.NoError(t, s.finish(context.Background())) + link, err := os.Readlink(filepath.Join(original, configFile)) + require.NoError(t, err) + require.Equal(t, target, link) + got, err := readFile(target) + require.NoError(t, err) + n, err := decode(got) + require.NoError(t, err) + require.Equal(t, "nano", get(n, "editor").Value) + require.Equal(t, "cat", get(n, "pager").Value) + require.Equal(t, "disabled", get(n, "prompt").Value) +} + +func TestSessionSnapshotIgnoresDanglingSymlinks(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + original := filepath.Join(root, "gh") + writeTestFile(t, filepath.Join(original, configFile), "editor: vi\n") + for _, name := range []string{"unused.yml", filepath.Join("extensions", "unused")} { + path := filepath.Join(original, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.Symlink("missing-target", path)) + } + s, err := startSession(context.Background(), original, []Account{ + {Host: testHost, User: testUser, Token: testForwardedToken}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = s.lock.Close() }) + env := []string{"HOME=" + root, "GH_CONFIG_DIR=" + original, testPathEnv} + require.NoError(t, s.inject(context.Background(), "/usr/bin/false", env)) + data, err := readFile(filepath.Join(s.work(), hostsFile)) + require.NoError(t, err) + require.True(t, bytes.Contains(data, []byte(testForwardedToken)), "forwarding was not prepared") + writeTestFile(t, filepath.Join(s.work(), configFile), "editor: nano\n") + require.NoError(t, s.finish(context.Background())) + data, err = readFile(filepath.Join(original, configFile)) + require.NoError(t, err) + require.Equal(t, "editor: nano\n", string(data)) + for _, name := range []string{"unused.yml", filepath.Join("extensions", "unused")} { + target, err := os.Readlink(filepath.Join(original, name)) + require.NoError(t, err) + require.Equal(t, "missing-target", target) + } + data, err = readFile(filepath.Join(original, hostsFile)) + require.NoError(t, err) + require.False( + t, + bytes.Contains(data, []byte(testForwardedToken)), + "forwarded credentials were published", + ) + assertNoForwardedArtifacts(t, filepath.Join(root, "cache")) +} + +func TestSessionSnapshotStillRejectsSymlinkCycles(t *testing.T) { + root := t.TempDir() + link := filepath.Join(root, "cycle") + require.NoError(t, os.Symlink("cycle", link)) + require.Error(t, copyTree(context.Background(), root, t.TempDir(), 0)) +} + +func TestSessionConcurrentInvocationsAndRecovery(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + t.Setenv("HOME", root) + original := filepath.Join(root, "gh") + writeTestFile(t, filepath.Join(original, configFile), "editor: vi\npager: less\n") + a, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + b, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + writeTestFile(t, filepath.Join(a.work(), configFile), "editor: nano\npager: less\n") + writeTestFile(t, filepath.Join(b.work(), configFile), "editor: vi\npager: cat\n") + require.NoError(t, a.finish(context.Background())) + require.NoError(t, b.lock.Close()) + c, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + got, err := os.ReadFile(filepath.Join(c.work(), configFile)) + require.NoError(t, err) + n, err := decode(got) + require.NoError(t, err) + require.Equal(t, "nano", get(n, "editor").Value) + require.Equal(t, "cat", get(n, "pager").Value) + require.NoError(t, c.finish(context.Background())) +} + +func TestSessionDirectoryAliasesShareWritebackLock(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + t.Setenv("HOME", root) + original, alias := filepath.Join(root, "gh"), filepath.Join(root, "alias") + writeTestFile(t, filepath.Join(original, configFile), "editor: vi\npager: less\n") + require.NoError(t, os.Symlink(original, alias)) + a, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = a.lock.Close() }) + b, err := startSession(context.Background(), alias, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = b.lock.Close() }) + writeTestFile(t, filepath.Join(a.work(), configFile), "editor: nano\npager: less\n") + writeTestFile(t, filepath.Join(b.work(), configFile), "editor: vi\npager: cat\n") + planned, release := make(chan struct{}), make(chan struct{}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + a.checkpoint = func(stage string) error { + if stage == "planned:"+configFile { + close(planned) + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + } + // The short-lived context makes B prove it contends before A publishes. + aDone := make(chan error, 1) + go func() { aDone <- a.sync(ctx) }() + select { + case <-planned: + case <-ctx.Done(): + t.Fatal("first writeback did not reach its publication barrier") + } + bCtx, bCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer bCancel() + err = b.sync(bCtx) + close(release) + require.ErrorIs(t, err, context.DeadlineExceeded, "alias bypassed the configuration lock") + require.NoError(t, <-aDone) + require.NoError(t, b.sync(context.Background())) + assertMergedPreferences(t, original) +} + +func assertMergedPreferences(t *testing.T, original string) { + t.Helper() + data, err := readFile(filepath.Join(original, configFile)) + require.NoError(t, err) + n, err := decode(data) + require.NoError(t, err) + require.Equal(t, "nano", get(n, "editor").Value) + require.Equal(t, "cat", get(n, "pager").Value) +} + +func TestSessionRejectsRetargetedConfigurationDirectory(t *testing.T) { + for _, stage := range []string{"before writeback", "planned:" + configFile} { + t.Run(stage, func(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + one := filepath.Join(root, "one") + two := filepath.Join(root, "two") + alias := filepath.Join(root, "alias") + writeTestFile(t, filepath.Join(one, configFile), "editor: vi\n") + writeTestFile(t, filepath.Join(two, configFile), "editor: vi\n") + require.NoError(t, os.Symlink(one, alias)) + s, err := startSession(context.Background(), alias, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = s.lock.Close() }) + writeTestFile(t, filepath.Join(s.work(), configFile), "editor: nano\n") + retarget := func() { + require.NoError(t, os.Remove(alias)) + require.NoError(t, os.Symlink(two, alias)) + } + if stage == "before writeback" { + retarget() + } else { + s.checkpoint = func(at string) error { + if at == stage { + retarget() + } + return nil + } + } + require.Error(t, s.finish(context.Background())) + assertNoForwardedArtifacts(t, s.dir) + for _, dir := range []string{one, two} { + data, err := readFile(filepath.Join(dir, configFile)) + require.NoError(t, err) + require.Equal(t, "editor: vi\n", string(data)) + } + require.NoError(t, os.Remove(alias)) + require.NoError(t, os.Symlink(one, alias)) + err = recoverSession(context.Background(), s.dir) + if err != nil { + require.ErrorIs(t, err, errAuthConflict) + } + assertNoForwardedArtifacts(t, filepath.Dir(s.dir)) + }) + } +} + +func TestStripForwardedCredentialsAndPreserveLocalLogin(t *testing.T) { + a := Account{Host: testHost, User: testUser, Token: testForwardedToken} + s := &session{Accounts: []Account{a}} + for _, tc := range []struct { + name, input string + local, keyring bool + }{ + {"unchanged", `github.example: + user: relay-user + oauth_token: dummy-forwarded-value + users: + relay-user: + oauth_token: dummy-forwarded-value +`, false, false}, + {"local login", `github.example: + user: relay-user + oauth_token: dummy-local-value + users: + relay-user: + oauth_token: dummy-local-value +`, true, false}, + {"keyring login", "github.example:\n user: relay-user\n users:\n relay-user: {}\n", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := s.strip(nil, []byte(tc.input)) + require.NoError(t, err) + // Do not include the produced configuration in assertion failures. + n, err := decode(got) + require.NoError(t, err) + h := get(n, a.Host) + require.Equal(t, tc.local, h != nil) + if tc.local { + require.NotNil(t, get(h, "user")) + } + if tc.keyring { + require.Nil(t, get(h, "oauth_token")) + } + }) + } +} + +func TestMergeAddedMappingsAndDeletions(t *testing.T) { + got, err := mergeYAML( + []byte("aliases: {}\neditor: vi\n"), + []byte("aliases:\n mine: repo view\n"), + []byte("aliases:\n other: issue list\neditor: vi\npager: cat\n"), + ) + require.NoError(t, err) + n, err := decode(got) + require.NoError(t, err) + require.Nil(t, get(n, "editor")) + require.Equal(t, "cat", get(n, "pager").Value) + require.NotNil(t, get(get(n, "aliases"), "mine")) + require.NotNil(t, get(get(n, "aliases"), "other")) +} + +func TestDanglingDotfilesSymlinkKeepsPendingChanges(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + original, target := filepath.Join(root, "gh"), filepath.Join(root, "dotfiles.yml") + writeTestFile(t, target, "editor: vi\n") + require.NoError(t, os.MkdirAll(original, 0o700)) + link := filepath.Join(original, configFile) + require.NoError(t, os.Symlink(target, link)) + s, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + writeTestFile(t, filepath.Join(s.work(), configFile), "editor: nano\n") + require.NoError(t, os.Remove(target)) + require.Error(t, s.finish(context.Background())) + actual, err := os.Readlink(link) + require.NoError(t, err) + require.Equal(t, target, actual) + writeTestFile(t, target, "editor: emacs\npager: cat\n") + c, err := startSession(context.Background(), original, nil) + require.NoError(t, err) + data, err := readFile(filepath.Join(c.work(), configFile)) + require.NoError(t, err) + n, err := decode(data) + require.NoError(t, err) + require.Equal(t, "nano", get(n, "editor").Value) + require.Equal(t, "cat", get(n, "pager").Value) + require.NoError(t, c.finish(context.Background())) +} + +func TestSessionPreservesTokenlessRegisteredIdentity(t *testing.T) { + const dotfilesUser = "dotfiles-user" + for _, tc := range []struct { + user, tokenField, userBody string + }{ + {testUser, "", "{}"}, + {dotfilesUser, "", ""}, + {dotfilesUser, "", "\"\""}, + {"12345", "", "{}"}, + {testScalarBooleanUser, "", "{}"}, + {testUser, " oauth_token: \"\"\n", "\n oauth_token: \"\""}, + {dotfilesUser, " oauth_token:\n", "\n oauth_token:"}, + } { + t.Run(tc.user+tc.tokenField, func(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + original := filepath.Join(root, "gh") + before := "github.example:\n git_protocol: https\n user: " + tc.user + "\n" + tc.tokenField + + " users:\n " + tc.user + ": " + tc.userBody + "\n other-user:\n" + writeTestFile(t, filepath.Join(original, hostsFile), before) + a := Account{Host: testHost, User: testUser, Token: testForwardedToken} + s, err := startSession(context.Background(), original, []Account{a}) + require.NoError(t, err) + t.Cleanup(func() { _ = s.lock.Close() }) + env := []string{"HOME=" + root, "GH_CONFIG_DIR=" + original, testPathEnv} + require.NoError(t, s.inject(context.Background(), "/usr/bin/false", env)) + data, err := readFile(filepath.Join(s.work(), hostsFile)) + require.NoError(t, err) + n, err := decode(data) + require.NoError(t, err) + require.Empty(t, s.Accounts) + originalNode, err := decode([]byte(before)) + require.NoError(t, err) + require.True(t, equal(n, originalNode), "registered guest identity was replaced") + require.NoError(t, s.finish(context.Background())) + got, err := readFile(filepath.Join(original, hostsFile)) + require.NoError(t, err) + // Never print credential-bearing configuration on assertion failure. + require.True(t, string(got) == before, "tokenless dotfiles registration changed") + }) + } +} + +func TestAuthenticationProbeCannotMigrateOriginalSettings(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CACHE_HOME", filepath.Join(root, "cache")) + original := filepath.Join(root, "gh") + before := "github.example:\n user: dotfiles-user\n users:\n dotfiles-user: {}\n" + writeTestFile(t, filepath.Join(original, hostsFile), before) + probe := filepath.Join(root, "auth") + writeTestFile(t, probe, `#!/bin/sh +printf '{}\n' > "$GH_CONFIG_DIR/hosts.yml" +printf 'version: "1"\n' > "$GH_CONFIG_DIR/config.yml" +exit 1 +`) + // resolveToken passes "auth" as the first argument, so sh reads this fixture. + t.Chdir(root) + s, err := startSession(context.Background(), original, []Account{ + {Host: testHost, User: testUser, Token: testForwardedToken}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = s.lock.Close() }) + env := []string{"HOME=" + root, "GH_CONFIG_DIR=" + original, testPathEnv} + require.NoError(t, s.inject(context.Background(), "/bin/sh", env)) + for _, path := range []string{filepath.Join(original, hostsFile), filepath.Join(s.dir, "before", hostsFile)} { + got, err := readFile(path) + require.NoError(t, err) + require.True(t, string(got) == before, "authentication probe modified original or baseline") + } + require.NoFileExists(t, filepath.Join(original, configFile)) + require.NoFileExists(t, filepath.Join(s.work(), configFile)) + require.NoError(t, s.finish(context.Background())) + got, err := readFile(filepath.Join(original, hostsFile)) + require.NoError(t, err) + require.True(t, string(got) == before, "probe migration was written back") +} + +func TestForwardedLogoutRestoresHiddenPreferences(t *testing.T) { + before := []byte( + "github.example:\n user: dotfiles-user\n git_protocol: ssh\n users:\n dotfiles-user: {}\n", + ) + s := &session{ + Accounts: []Account{ + {Host: testHost, User: testUser, Token: testForwardedToken}, + }, + } + after, err := s.strip(before, []byte("{}\n")) + require.NoError(t, err) + original, err := decode(before) + require.NoError(t, err) + result, err := decode(after) + require.NoError(t, err) + require.True(t, equal(original, result), "forwarded logout removed hidden preferences") +} + +func TestTokenLookupTimeoutDoesNotAllowForwarding(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + a := Account{Host: testHost, User: testUser, Token: testForwardedToken} + hosts := mapping() + decision := a.inject(ctx, "/usr/bin/false", []string{testPathEnv}, hosts) + require.Equal(t, "local_lookup_failed", decision) + require.Nil(t, get(hosts, a.Host)) +} + +func TestTokenLookupIsLocalOnlyAndChildScoped(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + t.Setenv("DEVSY_GH_AUTH_PROBE", "parent-value") + writeTestFile(t, filepath.Join(root, "auth"), `#!/bin/sh +test "$DEVSY_GH_AUTH_PROBE" = 1 || exit 42 +test "$DEVSY_GH_SESSION" = existing-session || exit 43 +printf '%s' "$GH_TOKEN" +`) + env := []string{ + testPathEnv, + "DEVSY_GH_AUTH_PROBE=0", + "DEVSY_GH_SESSION=existing-session", + "GH_TOKEN=dummy-local-value", + } + token, err := resolveToken(context.Background(), "/bin/sh", testHost, env) + require.NoError(t, err) + require.True(t, "GH_TOKEN="+token == env[3], "local environment authentication changed") + require.Equal(t, "DEVSY_GH_AUTH_PROBE=0", env[1]) + require.Equal(t, "parent-value", os.Getenv("DEVSY_GH_AUTH_PROBE")) +} diff --git a/pkg/ghcredentials/wrapper.go b/pkg/ghcredentials/wrapper.go new file mode 100644 index 0000000000..311d4ce744 --- /dev/null +++ b/pkg/ghcredentials/wrapper.go @@ -0,0 +1,284 @@ +package ghcredentials + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/devsy-org/devsy/pkg/log" +) + +const ( + sessionEnv = "DEVSY_GH_SESSION" + authProbeEnv = "DEVSY_GH_AUTH_PROBE" +) + +func Run(args []string) int { + if len(args) == 0 { + log.Error("gh relay: wrapper path missing") + return 127 + } + binary, err := realBinary(args[0]) + if err != nil { + log.Errorw("gh relay: executable unavailable", "error_type", errorKind(err)) + return 127 + } + log.Debugw("gh relay: executable selected", "binary", binary) + env := os.Environ() + if _, explicit := os.LookupEnv("GH_PATH"); !explicit { + env = replaceEnv(env, "GH_PATH", wrapperPath) + } + // A local lookup may re-enter through another dispatcher. Keep forwarding + // disabled throughout that process tree, even if it changes GH_CONFIG_DIR. + if localOnlySession() { + return runGH(binary, args[1:], env, nil) + } + inv := &invocation{binary: binary, args: args[1:], env: env} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + inv.recover(ctx) + if forwardingDisabled() { + return runGH(binary, inv.args, env, nil) + } + session := inv.prepare(ctx) + if session == nil { + return runGH(binary, inv.args, env, nil) + } + return inv.withSession(session) +} + +type invocation struct { + binary string + args, env []string +} + +func (inv *invocation) recover(ctx context.Context) { + root, err := sessionRoot() + if err != nil { + logFailure("gh relay: recovery unavailable", "session.recover", err, false) + return + } + l, err := lock(ctx, filepath.Join(root, "sessions.lock")) + if err != nil { + logFailure("gh relay: recovery unavailable", "session.recover_lock", err, false) + return + } + defer func() { _ = l.Close() }() + recoverSessions(ctx, root) +} + +func (inv *invocation) prepare(ctx context.Context) *session { + result, err := fetch(ctx) + if err != nil { + logFailure("gh relay: forwarding unavailable", "relay.fetch", err, false) + return nil + } + log.Debugw("gh relay: host authentication", "status", result.Status) + switch result.Status { + case stateAvailable, "gh_not_found", hostNoActiveAccounts, "rpc_denied": + default: + log.Warnw( + "gh relay: forwarding unavailable", + "operation", + "host.resolve", + "status", + result.Status, + ) + } + if len(result.Accounts) == 0 { + return nil + } + original, err := ConfigDir() + if err != nil { + logFailure("gh relay: forwarding unavailable", "config.path", err, false) + return nil + } + s, err := startSession(ctx, original, result.Accounts) + if err != nil { + logFailure("gh relay: forwarding unavailable", "session.snapshot", err, false) + return nil + } + if err := s.inject(ctx, inv.binary, inv.env); err != nil { + logFailure("gh relay: forwarding unavailable", "config.overlay", err, false) + _ = os.RemoveAll(s.dir) + _ = s.lock.Close() + return nil + } + return s +} + +func (inv *invocation) withSession(s *session) int { + env := replaceEnv(inv.env, "GH_CONFIG_DIR", s.work()) + env = replaceEnv(env, sessionEnv, s.work()) + code := runGH(inv.binary, inv.args, env, s.lock.file) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.finish(ctx); err != nil { + logFailure("gh relay: configuration writeback pending", "config.writeback", err, true) + } + return code +} + +func realBinary(wrapper string) (string, error) { + self, _ := os.Stat(wrapper) + backup := wrapperBackup(wrapper, self) + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + path := filepath.Join(dir, "gh") + info, err := executableInfo(path) + if err != nil { + continue + } + if self != nil && os.SameFile(self, info) { + if backup != "" { + return filepath.Abs(backup) + } + continue + } + return filepath.Abs(path) + } + if backup != "" { + return filepath.Abs(backup) + } + return "", exec.ErrNotFound +} + +func wrapperBackup(wrapper string, self os.FileInfo) string { + installed, err := filepath.EvalSymlinks(wrapper) + if err != nil { + if !os.IsNotExist(err) { + return "" + } + installed = wrapper + } + backup := installed + backupSuffix + original, err := executableInfo(backup) + if err != nil || (self != nil && os.SameFile(self, original)) { + return "" + } + if own, err := isWrapper(backup); err != nil || own { + return "" + } + return backup +} + +func replaceEnv(env []string, name, value string) []string { + out := make([]string, 0, len(env)+1) + for _, e := range env { + if !strings.HasPrefix(e, name+"=") { + out = append(out, e) + } + } + return append(out, name+"="+value) +} + +func fetch(ctx context.Context) (HostResult, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + number, err := relayPort() + if err != nil { + return HostResult{}, err + } + ready, err := credentials.ProbeReadiness( + ctx, + number, + credentials.Readiness{ + HostOwner: os.Getenv("DEVSY_GH_HOST_OWNER"), + Instance: os.Getenv("DEVSY_GH_INSTANCE"), + GitHubAllowed: true, + }, + ) + if err != nil { + return HostResult{}, boundary("relay.readiness", err) + } + client := credentials.LoopbackHTTPClient() + defer client.CloseIdleConnections() + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + "http://"+ready.Address+"/github-credentials", + nil, + ) + if err != nil { + return HostResult{}, boundary("relay.http", err) + } + req.Header.Set(credentials.HeaderCredentialsInstance, ready.Instance) + req.Header.Set(credentials.HeaderCredentialsHostOwner, ready.HostOwner) + resp, err := client.Do(req) + if err != nil { + return HostResult{}, boundary("relay.http", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return HostResult{}, &boundaryError{ + operation: "relay.http", + kind: "http_" + strconv.Itoa(resp.StatusCode), + } + } + return decodeHostResult(resp.Body) +} + +func decodeHostResult(body io.Reader) (HostResult, error) { + b, err := io.ReadAll(io.LimitReader(body, (1<<20)+1)) + if err != nil { + return HostResult{}, boundary("relay.read", err) + } + var result HostResult + if len(b) > 1<<20 { + return HostResult{}, &boundaryError{operation: "relay.decode", kind: "response_limit"} + } + err = json.Unmarshal(b, &result) + if err != nil { + return HostResult{}, &boundaryError{ + operation: "relay.decode", + kind: "schema_invalid", + cause: err, + } + } + return result, nil +} + +func executableInfo(path string) (os.FileInfo, error) { + f, err := resolvedFile(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return nil, exec.ErrNotFound + } + return info, nil +} + +func relayPort() (int, error) { + port := os.Getenv("DEVSY_CREDENTIALS_SERVER_PORT") + if port == "" { + port = "12049" + } + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return 0, &boundaryError{operation: "relay.port", kind: "config_invalid"} + } + return number, nil +} + +func forwardingDisabled() bool { + value := os.Getenv("DEVSY_GH_FORWARDING") + return value == "disabled" || value == stateUnavailable +} + +func localOnlySession() bool { + active := os.Getenv(sessionEnv) + return os.Getenv(authProbeEnv) == "1" || (active != "" && active == os.Getenv("GH_CONFIG_DIR")) +} diff --git a/pkg/ide/opener/browser_preparation_test.go b/pkg/ide/opener/browser_preparation_test.go new file mode 100644 index 0000000000..f656052c5a --- /dev/null +++ b/pkg/ide/opener/browser_preparation_test.go @@ -0,0 +1,100 @@ +package opener + +import ( + "context" + "os" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/log" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +func TestBrowserReuseWaitsForPreparedHelperIdentity(t *testing.T) { + setupTempHome(t) + created, err := helperCreateTime(os.Getpid()) + require.NoError(t, err) + expected := TunnelState{PID: os.Getpid(), CreateTime: created, PreparationVersion: 1} + require.NoError(t, WriteTunnelState("ctx", "ws", expected)) + marker, err := browserPreparation("ctx", "ws", &expected) + require.NoError(t, err) + defer marker.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + unpreparedCtx, stopUnprepared := context.WithTimeout(ctx, 20*time.Millisecond) + defer stopUnprepared() + require.False(t, waitBrowserPreparation(unpreparedCtx, "ctx", "ws", &expected)) + require.NoError(t, marker.Mark()) + require.True(t, waitBrowserPreparation(ctx, "ctx", "ws", &expected)) + other := expected + other.CreateTime++ + require.NoError(t, WriteTunnelState("ctx", "ws", other)) + require.False(t, waitBrowserPreparation(ctx, "ctx", "ws", &expected)) +} + +func TestBrowserReuseRejectsLegacyAndCanceledPreparation(t *testing.T) { + setupTempHome(t) + expected := TunnelState{PID: 123, CreateTime: 456} + require.NoError(t, WriteTunnelState("ctx", "ws", expected)) + require.False(t, waitBrowserPreparation(context.Background(), "ctx", "ws", &expected)) + expected.PreparationVersion = 1 + ctx, cancel := context.WithCancel(context.Background()) + cancel() + require.NoError(t, WriteTunnelState("ctx", "ws", expected)) + marker, err := browserPreparation("ctx", "ws", &expected) + require.NoError(t, err) + defer marker.Close() + require.NoError(t, marker.Mark()) + require.False(t, waitBrowserPreparation(ctx, "ctx", "ws", &expected)) +} + +func TestBrowserReuseReleasesLockWhilePreparationIsPending(t *testing.T) { + setupTempHome(t) + logs := log.InitTestObserved(t, zapcore.InfoLevel) + created, err := helperCreateTime(os.Getpid()) + require.NoError(t, err) + existing := TunnelState{PID: os.Getpid(), CreateTime: created, PreparationVersion: 1} + require.NoError(t, WriteTunnelState("ctx", "ws", existing)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = runDaemonBrowserTunnel(ctx, daemonTunnelArgs{ + ContextName: "ctx", WorkspaceID: "ws", + Inv: browserIDEInvocation{OpenBrowser: true, RequirePreparation: true}, + }) + }() + require.Eventually(t, func() bool { + return logs.FilterMessageSnippet("browser tunnel already running").Len() == 1 + }, time.Second, time.Millisecond) + unlocked := make(chan error, 1) + go func() { + unlock, err := acquireTunnelLock("ctx", "ws") + if err == nil { + unlock() + } + unlocked <- err + }() + select { + case err := <-unlocked: + require.NoError(t, err) + case <-time.After(2 * time.Second): + cancel() + <-done + <-unlocked + t.Fatal("browser preparation held the shared tunnel lock") + } + select { + case <-done: + t.Fatal("browser reuse returned before pending preparation completed") + default: + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("browser reuse did not observe cancellation") + } +} diff --git a/pkg/ide/opener/browser_tunnel.go b/pkg/ide/opener/browser_tunnel.go index f88ad07e04..17ffc69f24 100644 --- a/pkg/ide/opener/browser_tunnel.go +++ b/pkg/ide/opener/browser_tunnel.go @@ -9,10 +9,12 @@ import ( "os" "os/exec" "path/filepath" + "sync" "syscall" "time" "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/flags/names" pkglog "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/open" @@ -71,6 +73,9 @@ func KillBrowserTunnel(contextName, workspaceID string) { } terminateTunnelProcess(state.PID) + if preparation, err := browserPreparation(contextName, workspaceID, state); err == nil { + preparation.Close() + } _ = os.Remove(statePath) } @@ -157,8 +162,9 @@ type inheritedListenerSetup struct { // browserIDEInvocation bundles the per-call IDE-specific knobs that aren't // part of the lower-level tunnel parameters. type browserIDEInvocation struct { - Label string // e.g. "vscode", "jupyter", "rstudio" - used in user-facing log lines - OpenBrowser bool // whether to launch a host browser pointing at TargetURL + Label string // e.g. "vscode", "jupyter", "rstudio" - used in user-facing log lines + OpenBrowser bool // whether to launch a host browser pointing at TargetURL + RequirePreparation bool } // startDetachedBrowserTunnel spawns `devsy internal browser-tunnel ...` as a @@ -182,6 +188,9 @@ func startDetachedBrowserTunnel( openBrowser := inv.OpenBrowser contextName := params.Client.Context() workspaceID := params.Client.Workspace() + inv.RequirePreparation = params.DevsyConfig.ContextOption( + config.ContextOptionGitHubCLIForwarding, + ) == config.BoolTrue if _, ok := params.Client.(client.DaemonClient); ok { return runDaemonBrowserTunnel(ctx, daemonTunnelArgs{ @@ -202,11 +211,14 @@ func startDetachedBrowserTunnel( err, ) } + unlock = sync.OnceFunc(unlock) defer unlock() // Reuse an existing live tunnel if present; clear stale state otherwise. - if reusedURL, ok := tryReuseExistingTunnel(ctx, contextName, workspaceID, inv); ok { - return reusedURL, nil + if existing, ok := tryReuseExistingTunnel(contextName, workspaceID, inv); ok { + unlock() + inv.openReusedBrowser(ctx, contextName, workspaceID, existing) + return existing.TargetURL, nil } pid, logLocation, err := spawnTunnelHelper(spawnHelperOpts{ @@ -337,10 +349,11 @@ func recordHelperState(proc *os.Process, pid int, opts recordHelperStateOpts) er return fmt.Errorf("identify helper pid %d: %w", pid, err) } state := TunnelState{ - PID: pid, - CreateTime: createTime, - TargetURL: opts.TargetURL, - Label: opts.Label, + PID: pid, + CreateTime: createTime, + TargetURL: opts.TargetURL, + Label: opts.Label, + PreparationVersion: 1, } if err := WriteTunnelState(opts.ContextName, opts.WorkspaceID, state); err != nil { _ = proc.Kill() @@ -453,31 +466,45 @@ func openBrowserAsync(ctx context.Context, url string) { // workspace, if any. On reuse it returns the recorded TargetURL so the // caller can surface the live URL (not its own freshly-computed one). // If state exists but the recorded PID is dead, the stale state file is -// removed and ("", false) is returned so the caller can proceed with a +// removed and (nil, false) is returned so the caller can proceed with a // fresh spawn. func tryReuseExistingTunnel( - ctx context.Context, contextName, workspaceID string, inv browserIDEInvocation, -) (string, bool) { +) (*TunnelState, bool) { existing, _ := ReadTunnelState(contextName, workspaceID) if existing == nil { - return "", false + return nil, false } if helperMatchesState(existing) { pkglog.Infof( "%s browser tunnel already running (PID %d). Reusing existing tunnel at %s", inv.Label, existing.PID, existing.TargetURL, ) - if inv.OpenBrowser { - go openBrowserAsync(ctx, existing.TargetURL) - } - return existing.TargetURL, true + return existing, true } if statePath, err := TunnelStateFilePath(contextName, workspaceID); err == nil { _ = os.Remove(statePath) } - return "", false + return nil, false +} + +func (inv browserIDEInvocation) openReusedBrowser( + ctx context.Context, + contextName, workspaceID string, + existing *TunnelState, +) { + if !inv.OpenBrowser { + return + } + if existing.PreparationVersion == 0 && !inv.RequirePreparation || + waitBrowserPreparation(ctx, contextName, workspaceID, existing) { + go openBrowserAsync(ctx, existing.TargetURL) + } else if ctx.Err() == nil { + pkglog.Warn( + "Browser tunnel preparation unavailable; restart the workspace connection before opening its URL", + ) + } } // daemonTunnelArgs bundles the parameters for runDaemonBrowserTunnel to keep @@ -501,10 +528,13 @@ func runDaemonBrowserTunnel(ctx context.Context, a daemonTunnelArgs) (string, er err, ) } + unlock = sync.OnceFunc(unlock) defer unlock() - if reusedURL, ok := tryReuseExistingTunnel(ctx, a.ContextName, a.WorkspaceID, a.Inv); ok { - return reusedURL, nil + if existing, ok := tryReuseExistingTunnel(a.ContextName, a.WorkspaceID, a.Inv); ok { + unlock() + a.Inv.openReusedBrowser(ctx, a.ContextName, a.WorkspaceID, existing) + return existing.TargetURL, nil } // Derive a cancellable context for the probe goroutine. When @@ -515,8 +545,10 @@ func runDaemonBrowserTunnel(ctx context.Context, a daemonTunnelArgs) (string, er probeCtx, cancelProbe := context.WithCancel(ctx) defer cancelProbe() - if a.OpenBrowser { - go openBrowserWhenReachable(probeCtx, a.TunnelParams.TargetURL) + a.TunnelParams.OnPrepared = func(context.Context) { + if a.OpenBrowser { + go openBrowserWhenReachable(probeCtx, a.TunnelParams.TargetURL) + } } return a.TunnelParams.TargetURL, tunnel.StartBrowserTunnel(ctx, a.TunnelParams) } diff --git a/pkg/ide/opener/browser_tunnel_state.go b/pkg/ide/opener/browser_tunnel_state.go index 55c5086343..4f7d31ad30 100644 --- a/pkg/ide/opener/browser_tunnel_state.go +++ b/pkg/ide/opener/browser_tunnel_state.go @@ -1,12 +1,14 @@ package opener import ( + "context" "encoding/json" "errors" "fmt" "math" "os" "path/filepath" + "time" pkglog "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" @@ -45,10 +47,91 @@ const LabelVSCodeWeb = "vscode-web" // has a different CreateTime than the recorded value, the helper is gone // and a foreign process now occupies that PID. type TunnelState struct { - PID int `json:"pid"` - CreateTime int64 `json:"createTime"` - TargetURL string `json:"targetUrl"` - Label string `json:"label,omitempty"` + PID int `json:"pid"` + CreateTime int64 `json:"createTime"` + TargetURL string `json:"targetUrl"` + Label string `json:"label,omitempty"` + PreparationVersion int `json:"preparationVersion,omitempty"` +} + +type BrowserPreparation struct{ path string } + +func NewBrowserPreparation(contextName, workspaceID string) (*BrowserPreparation, error) { + created, err := helperCreateTime(os.Getpid()) + if err != nil { + return nil, err + } + return browserPreparation( + contextName, + workspaceID, + &TunnelState{PID: os.Getpid(), CreateTime: created}, + ) +} + +func browserPreparation( + contextName, workspaceID string, + state *TunnelState, +) (*BrowserPreparation, error) { + path, err := TunnelStateFilePath(contextName, workspaceID) + if err != nil { + return nil, err + } + return &BrowserPreparation{ + path: filepath.Join( + filepath.Dir(path), + fmt.Sprintf("tunnel-prepared-%d-%d", state.PID, state.CreateTime), + ), + }, nil +} + +func (p *BrowserPreparation) Mark() error { return os.WriteFile(p.path, nil, 0o600) } + +func (p *BrowserPreparation) Close() { _ = os.Remove(p.path) } + +func waitBrowserPreparation( + ctx context.Context, + contextName, workspaceID string, + expected *TunnelState, +) bool { + ctx, cancel := context.WithTimeout(ctx, browserProbeBudget) + defer cancel() + marker, err := browserPreparation(contextName, workspaceID, expected) + if err != nil || expected.PreparationVersion != 1 { + return false + } + ticker := time.NewTicker(browserProbeInterval) + defer ticker.Stop() + for ctx.Err() == nil { + prepared, changed := marker.status(contextName, workspaceID, expected) + if changed { + return false + } + if prepared { + return true + } + select { + case <-ctx.Done(): + return false + case <-ticker.C: + } + } + return false +} + +func (p *BrowserPreparation) status( + contextName, workspaceID string, + expected *TunnelState, +) (bool, bool) { + current, err := ReadTunnelState(contextName, workspaceID) + if err != nil { + return false, false + } + if current == nil || current.PID != expected.PID || current.CreateTime != expected.CreateTime || + current.PreparationVersion != expected.PreparationVersion { + return false, true + } + _, err = os.Stat(p.path) + return err == nil && helperMatchesState(current), false } // TunnelStateFilePath returns the path used to store the browser tunnel state diff --git a/pkg/ide/opener/browser_tunnel_state_unix_test.go b/pkg/ide/opener/browser_tunnel_state_unix_test.go index 6cd374a64a..5b4902f7b9 100644 --- a/pkg/ide/opener/browser_tunnel_state_unix_test.go +++ b/pkg/ide/opener/browser_tunnel_state_unix_test.go @@ -228,17 +228,15 @@ func TestTryReuseExistingTunnel_ReturnsRecordedURL(t *testing.T) { t.Fatalf("WriteTunnelState: %v", err) } - // OpenBrowser=false so the test doesn't spawn an actual browser process. - gotURL, ok := tryReuseExistingTunnel( - t.Context(), + gotState, ok := tryReuseExistingTunnel( "ctx", "ws", browserIDEInvocation{Label: LabelVSCodeBrowser, OpenBrowser: false}, ) if !ok { t.Fatal("expected tryReuseExistingTunnel to reuse live helper") } - if gotURL != recordedURL { - t.Errorf("reused URL = %q, want %q", gotURL, recordedURL) + if gotState.TargetURL != recordedURL { + t.Errorf("reused URL = %q, want %q", gotState.TargetURL, recordedURL) } } @@ -261,13 +259,12 @@ func TestTryReuseExistingTunnel_DeadHelperReturnsFalse(t *testing.T) { t.Fatalf("TunnelStateFilePath: %v", err) } - gotURL, ok := tryReuseExistingTunnel( - t.Context(), + gotState, ok := tryReuseExistingTunnel( "ctx", "ws", browserIDEInvocation{Label: LabelVSCodeBrowser, OpenBrowser: false}, ) if ok { - t.Errorf("expected reuse=false for non-matching helper, got url=%q", gotURL) + t.Errorf("expected reuse=false for non-matching helper, got state=%v", gotState) } if _, err := os.Stat(statePath); !os.IsNotExist(err) { t.Errorf("expected stale state file removed; stat err=%v", err) diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go index 415f903173..38a5966e1b 100644 --- a/pkg/ide/opener/opener.go +++ b/pkg/ide/opener/opener.go @@ -13,6 +13,7 @@ import ( "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/credentials" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/ide/codeserver" @@ -272,20 +273,20 @@ func makeDaemonStartFunc( params IDEParams, forwardPorts bool, extraPorts []string, -) func(ctx context.Context) error { +) func(ctx context.Context, prepared func()) error { daemonClient, ok := params.Client.(client2.DaemonClient) if !ok { return nil } - return func(ctx context.Context) error { + return func(ctx context.Context, prepared func()) error { toolClient, _, err := daemonClient.SSHClients(ctx, params.User) if err != nil { return err } defer func() { _ = toolClient.Close() }() - err = clientimplementation.StartServicesDaemon( + return clientimplementation.RunPreparedServicesDaemon( ctx, clientimplementation.StartServicesDaemonOptions{ DevsyConfig: params.DevsyConfig, @@ -296,13 +297,11 @@ func makeDaemonStartFunc( ExtraPorts: extraPorts, GitSSHSigningKey: params.GitSSHSigningKey, }, + func(status credentials.Readiness) { + pkglog.Debugw("browser credentials prepared", "status", status.GitHubState) + prepared() + }, ) - if err != nil { - return err - } - <-ctx.Done() - - return nil } } diff --git a/pkg/tunnel/browser.go b/pkg/tunnel/browser.go index 80a71fcfb2..2337aa6848 100644 --- a/pkg/tunnel/browser.go +++ b/pkg/tunnel/browser.go @@ -3,7 +3,6 @@ package tunnel import ( "context" "errors" - "fmt" "io" "net" "os" @@ -12,6 +11,7 @@ import ( client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/credentials" "github.com/devsy-org/devsy/pkg/exitcode" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -39,7 +39,8 @@ type BrowserTunnelParams struct { // DaemonStartFunc is called when the client is a DaemonClient. // If nil, the SSH tunnel path is always used. - DaemonStartFunc func(ctx context.Context) error + DaemonStartFunc func(ctx context.Context, prepared func()) error + OnPrepared func(ctx context.Context) // DisableIdleTimeout opts forwarded ports out of the // EXIT_AFTER_TIMEOUT idle shutdown. Set by callers whose lifecycle @@ -59,7 +60,7 @@ func StartBrowserTunnel(ctx context.Context, p BrowserTunnelParams) error { } if p.DaemonStartFunc != nil { - return p.DaemonStartFunc(ctx) + return p.DaemonStartFunc(ctx, func() { notifyBrowserPrepared(ctx, p) }) } return startBrowserTunnelSSH(ctx, p) @@ -96,37 +97,56 @@ func runBrowserTunnelServices( p BrowserTunnelParams, containerClient *ssh.Client, ) error { - log.Infow("browser tunnel ready", "url", p.TargetURL, "done", "true") + return runPreparedBrowserServices(ctx, p, func(prepared func(credentials.Readiness)) error { + return RunPreparedServices( + ctx, + RunServicesOptions{ + DevsyConfig: p.DevsyConfig, + ContainerClient: containerClient, + User: p.User, + ForwardPorts: p.ForwardPorts, + ExtraPorts: p.ExtraPorts, + Workspace: p.Client.WorkspaceConfig(), + ConfigureDockerCredentials: p.DevsyConfig.ContextOption( + config.ContextOptionSSHInjectDockerCredentials, + ) == config.BoolTrue, + ConfigureGitHubCredentials: p.DevsyConfig.ContextOption( + config.ContextOptionGitHubCLIForwarding, + ) == config.BoolTrue, + ConfigureGitCredentials: p.DevsyConfig.ContextOption( + config.ContextOptionSSHInjectGitCredentials, + ) == config.BoolTrue, + ConfigureGitSSHSignatureHelper: p.DevsyConfig.ContextOption( + config.ContextOptionGitSSHSignatureForwarding, + ) == config.BoolTrue, + GitSSHSigningKey: p.GitSSHSigningKey, + ExtraListeners: p.ExtraListeners, + DisableIdleTimeout: p.DisableIdleTimeout, + }, + prepared, + ) + }) +} - err := RunServices( - ctx, - RunServicesOptions{ - DevsyConfig: p.DevsyConfig, - ContainerClient: containerClient, - User: p.User, - ForwardPorts: p.ForwardPorts, - ExtraPorts: p.ExtraPorts, - Workspace: p.Client.WorkspaceConfig(), - ConfigureDockerCredentials: p.DevsyConfig.ContextOption( - config.ContextOptionSSHInjectDockerCredentials, - ) == config.BoolTrue, - ConfigureGitCredentials: p.DevsyConfig.ContextOption( - config.ContextOptionSSHInjectGitCredentials, - ) == config.BoolTrue, - ConfigureGitSSHSignatureHelper: p.DevsyConfig.ContextOption( - config.ContextOptionGitSSHSignatureForwarding, - ) == config.BoolTrue, - GitSSHSigningKey: p.GitSSHSigningKey, - ExtraListeners: p.ExtraListeners, - DisableIdleTimeout: p.DisableIdleTimeout, - }, - ) - if err != nil { - return fmt.Errorf("run credentials server in browser tunnel: %w", err) - } +func runPreparedBrowserServices( + ctx context.Context, + p BrowserTunnelParams, + run func(func(credentials.Readiness)) error, +) error { + return run(func(status credentials.Readiness) { + log.Debugw("browser credentials prepared", "status", status.GitHubState) + notifyBrowserPrepared(ctx, p) + }) +} - <-ctx.Done() - return nil +func notifyBrowserPrepared(ctx context.Context, p BrowserTunnelParams) { + if ctx.Err() != nil { + return + } + log.Infow("browser tunnel ready", "url", p.TargetURL, "done", "true") + if p.OnPrepared != nil { + p.OnPrepared(ctx) + } } // SetupBackhaul sets up a long-running SSH connection for backhaul. diff --git a/pkg/tunnel/browser_preparation_test.go b/pkg/tunnel/browser_preparation_test.go new file mode 100644 index 0000000000..00fff2af3e --- /dev/null +++ b/pkg/tunnel/browser_preparation_test.go @@ -0,0 +1,262 @@ +package tunnel + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/devsy-org/devsy/pkg/transport" + "github.com/stretchr/testify/require" +) + +func TestBrowserAutoOpenWaitsForInstallationAndServing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + install, serving := make(chan struct{}), make(chan struct{}) + probeStarted, installed := make(chan struct{}), make(chan struct{}) + opened, exited := make(chan struct{}), make(chan error, 1) + serviceStopped := make(chan struct{}) + runner := servicePreparation{ + run: func() error { defer close(serviceStopped); <-ctx.Done(); return nil }, + probe: func(ctx context.Context) credentials.Readiness { + close(probeStarted) + if !waitPreparationSignal(ctx, install) { + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + close(installed) + if !waitPreparationSignal(ctx, serving) { + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + return credentials.Readiness{GitHubState: installedPreparation} + }, + } + go func() { + exited <- runPreparedBrowserServices(ctx, BrowserTunnelParams{ + OnPrepared: func(context.Context) { close(opened) }, + }, func(prepared func(credentials.Readiness)) error { return runner.runPrepared(ctx, true, prepared) }) + }() + require.True(t, waitPreparationSignal(ctx, probeStarted)) + assertPreparationPending(t, opened) + close(install) + require.True(t, waitPreparationSignal(ctx, installed)) + assertPreparationPending(t, opened) + close(serving) + require.True(t, waitPreparationSignal(ctx, opened)) + assertPreparationPending(t, exited) + cancel() + require.True(t, waitPreparationSignal(context.Background(), serviceStopped)) + select { + case err := <-exited: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("browser service lifetime did not stop") + } +} + +func TestDaemonBrowserAutoOpenReceivesPreparationAndKeepsLifetime(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + started, ready, opened := make(chan struct{}), make(chan struct{}), make(chan struct{}) + exited := make(chan error, 1) + go func() { + exited <- StartBrowserTunnel(ctx, BrowserTunnelParams{ + DaemonStartFunc: func(ctx context.Context, prepared func()) error { + close(started) + if !waitPreparationSignal(ctx, ready) { + return ctx.Err() + } + prepared() + <-ctx.Done() + return nil + }, + OnPrepared: func(context.Context) { close(opened) }, + }) + }() + require.True(t, waitPreparationSignal(ctx, started)) + assertPreparationPending(t, opened) + close(ready) + require.True(t, waitPreparationSignal(ctx, opened)) + assertPreparationPending(t, exited) + cancel() + select { + case err := <-exited: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("daemon browser lifetime did not stop") + } +} + +func TestBrowserAutoOpenDoesNotRunAfterFailureOrCancellation(t *testing.T) { + opened := false + ctx, cancel := context.WithCancel(context.Background()) + cancel() + p := BrowserTunnelParams{OnPrepared: func(context.Context) { opened = true }} + require.NoError( + t, + runPreparedBrowserServices(ctx, p, func(prepared func(credentials.Readiness)) error { + prepared(credentials.Readiness{GitHubState: credentialsUnavailable}) + return nil + }), + ) + require.False(t, opened) + want := errors.New("daemon transport unavailable") + p.DaemonStartFunc = func(context.Context, func()) error { return want } + require.ErrorIs(t, StartBrowserTunnel(context.Background(), p), want) + require.False(t, opened) +} + +func TestBrowserServiceCompletionEndsHealthyTunnel(t *testing.T) { + for _, tc := range []struct { + name string + enabled, startup bool + }{ + {name: "startup failure", enabled: true, startup: true}, + {name: "failure after readiness", enabled: true}, + {name: "failure with forwarding disabled"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + finish, opened := make(chan struct{}), make(chan struct{}, 1) + runner := browserCompletionRunner(ctx, finish, tc.startup) + if tc.startup { + close(finish) + } + exited := make(chan error, 1) + go func() { + exited <- runPreparedBrowserServices(ctx, BrowserTunnelParams{ + OnPrepared: func(context.Context) { opened <- struct{}{} }, + }, func(prepared func(credentials.Readiness)) error { return runner.runPrepared(ctx, tc.enabled, prepared) }) + }() + if !tc.startup { + require.True(t, waitPreparationSignal(ctx, opened)) + assertPreparationPending(t, exited) + close(finish) + } + select { + case err := <-exited: + require.Error(t, err) + require.NotContains(t, err.Error(), "dummy-secret") + case <-time.After(time.Second): + t.Fatal("completed services left the browser tunnel alive") + } + require.NoError(t, ctx.Err()) + if tc.startup { + assertPreparationPending(t, opened) + } + }) + } +} + +func TestBrowserPreservesCleanServiceReuseLifetime(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + completed, opened := make(chan struct{}), make(chan struct{}) + runner := servicePreparation{ + run: func() error { close(completed); return nil }, + probe: func(context.Context) credentials.Readiness { + return credentials.Readiness{GitHubState: installedPreparation} + }, + } + exited := make(chan error, 1) + go func() { + exited <- runPreparedBrowserServices(ctx, BrowserTunnelParams{ + OnPrepared: func(context.Context) { close(opened) }, + }, func(prepared func(credentials.Readiness)) error { return runner.runPrepared(ctx, true, prepared) }) + }() + require.True(t, waitPreparationSignal(ctx, completed)) + require.True(t, waitPreparationSignal(ctx, opened)) + select { + case <-exited: + t.Fatal("benign service reuse ended browser port forwarding") + case <-time.After(100 * time.Millisecond): + } + cancel() + select { + case err := <-exited: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("reused browser service lifetime did not stop") + } +} + +func browserCompletionRunner( + ctx context.Context, + finish <-chan struct{}, + startup bool, +) servicePreparation { + return servicePreparation{ + run: func() error { + select { + case <-finish: + return errors.New("dummy-secret-service-output") + case <-ctx.Done(): + return ctx.Err() + } + }, + probe: func(ctx context.Context) credentials.Readiness { + if startup { + <-ctx.Done() + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + return credentials.Readiness{GitHubState: installedPreparation} + }, + } +} + +func TestBrowserServiceFailureClosesManagedTransport(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + transportStopped := make(chan struct{}) + conn, err := transport.OpenCallbackConn( + ctx, + func(ctx context.Context, _ io.Reader, _ io.Writer) error { + defer close(transportStopped) + <-ctx.Done() + return ctx.Err() + }, + transport.CallbackConnOptions{}, + ) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + opened, fail := make(chan struct{}), make(chan struct{}) + exited := make(chan error, 1) + go func() { + exited <- transport.RunManaged(transport.RunManagedOptions{ + Parent: ctx, + Conn: conn, + TransportSide: transport.SideProvider, + Handler: func(ctx context.Context) error { + runner := servicePreparation{ + run: func() error { + select { + case <-fail: + return errors.New("dummy-secret-service-output") + case <-ctx.Done(): + return ctx.Err() + } + }, + probe: func(context.Context) credentials.Readiness { + return credentials.Readiness{GitHubState: installedPreparation} + }, + } + return runPreparedBrowserServices(ctx, BrowserTunnelParams{ + OnPrepared: func(context.Context) { close(opened) }, + }, func(prepared func(credentials.Readiness)) error { return runner.runPrepared(ctx, true, prepared) }) + }, + }) + }() + require.True(t, waitPreparationSignal(ctx, opened)) + close(fail) + select { + case err := <-exited: + require.EqualError(t, err, "credential services stopped unexpectedly") + case <-ctx.Done(): + t.Fatal("failed services did not tear down the managed connection") + } + require.NoError(t, ctx.Err()) + require.True(t, waitPreparationSignal(ctx, transportStopped)) +} diff --git a/pkg/tunnel/services.go b/pkg/tunnel/services.go index f611815304..5922f0d2bc 100644 --- a/pkg/tunnel/services.go +++ b/pkg/tunnel/services.go @@ -5,7 +5,9 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" + "io" "net" "os" "strconv" @@ -50,8 +52,10 @@ type RunServicesOptions struct { Workspace *provider.Workspace ConfigureDockerCredentials bool ConfigureGitCredentials bool + ConfigureGitHubCredentials bool ConfigureGitSSHSignatureHelper bool GitSSHSigningKey string + HostOwner string // ExtraListeners holds pre-bound listeners keyed by "host:port" so the // helper can skip net.Listen and avoid a probe-to-listen TOCTOU race. ExtraListeners map[string]net.Listener @@ -112,6 +116,7 @@ func runTunnelServer(ctx context.Context, cancel context.CancelFunc, p tunnelSer p.forwarder, p.opts.Workspace, tunnelserver.WithPlatformOptions(p.opts.PlatformOptions), + tunnelserver.WithAllowGitHubCredentials(p.opts.ConfigureGitHubCredentials), ) if err != nil { p.errChan <- fmt.Errorf("run tunnel server: %w", err) @@ -156,6 +161,10 @@ func buildCredentialsCommand(ctx context.Context, opts RunServicesOptions) strin if opts.ConfigureGitCredentials { command += " --configure-git-helper" } + if opts.ConfigureGitHubCredentials { + command += " --host-owner " + shellescape.Quote(opts.HostOwner) + command += " --configure-github-helper" + } if opts.ConfigureGitSSHSignatureHelper { workingDir := "" if opts.Workspace != nil { @@ -216,13 +225,14 @@ func runServicesIteration( }() command := buildCredentialsCommand(ctx, opts) + diagnostic := &boundedPreparationOutput{} err = devssh.Run(cancelCtx, devssh.RunOptions{ Client: opts.ContainerClient, Command: command, Stdin: stdinReader, Stdout: stdoutWriter, - Stderr: writer, + Stderr: io.MultiWriter(writer, diagnostic), }) if err != nil { // Drain errChan to allow goroutine to exit cleanly @@ -230,7 +240,7 @@ func runServicesIteration( case <-errChan: default: } - return err + return classifyServicesStartupError(err, diagnostic) } return <-errChan } @@ -239,6 +249,9 @@ func runServicesIteration( // to run the credentials server remotely and the services server locally to // communicate with the container. func RunServices(ctx context.Context, opts RunServicesOptions) error { + if opts.HostOwner == "" { + opts.HostOwner = hostCredentialOwner() + } exitAfterTimeout := getExitAfterTimeout(opts.DevsyConfig, opts.DisableIdleTimeout) fp := portForwardParams{ @@ -264,10 +277,56 @@ func RunServices(ctx context.Context, opts RunServicesOptions) error { }, func(err error) bool { return ctx.Err() == nil }, func() error { - return runServicesIteration(ctx, opts, forwardedPorts, resolver) + return runOptionalGitHubServices(ctx, opts, func(current RunServicesOptions) error { + return runServicesIteration(ctx, current, forwardedPorts, resolver) + }) }) } +func runOptionalGitHubServices( + ctx context.Context, + opts RunServicesOptions, + run func(RunServicesOptions) error, +) error { + err := run(opts) + if !errors.Is(err, errUnsupportedGitHubServiceOptions) || + !opts.ConfigureGitHubCredentials || ctx.Err() != nil { + return err + } + log.Debug("GitHub credential service unavailable; retrying other credential services") + opts.ConfigureGitHubCredentials = false + opts.HostOwner = "" + return run(opts) +} + +var errUnsupportedGitHubServiceOptions = errors.New( + "agent does not support GitHub credential service options", +) + +func classifyServicesStartupError(err error, diagnostic *boundedPreparationOutput) error { + var exit *devssh.ExitError + if !errors.As(err, &exit) || exit.ExitCode == 0 || diagnostic.overflow { + return err + } + decoder := json.NewDecoder(bytes.NewReader(diagnostic.data)) + for { + var record struct { + Kind string `json:"kind"` + Message string `json:"message"` + } + if decoder.Decode(&record) != nil { + return err + } + if record.Kind != "error" { + continue + } + switch record.Message { + case "unknown flag: --host-owner", "unknown flag: --configure-github-helper": + return fmt.Errorf("%w: %w", errUnsupportedGitHubServiceOptions, err) + } + } +} + // buildPortAttributeResolver loads port attributes from the container result. func buildPortAttributeResolver(result *config2.Result) PortAttributeResolver { if result == nil || result.MergedConfig == nil { diff --git a/pkg/tunnel/services_preparation.go b/pkg/tunnel/services_preparation.go new file mode 100644 index 0000000000..b969f50f21 --- /dev/null +++ b/pkg/tunnel/services_preparation.go @@ -0,0 +1,201 @@ +package tunnel + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "time" + + "al.essio.dev/pkg/shellescape" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/devsy-org/devsy/pkg/log" + devssh "github.com/devsy-org/devsy/pkg/ssh" +) + +func hostCredentialOwner() string { + host, _ := os.Hostname() + home, _ := os.UserHomeDir() + digest := sha256.Sum256([]byte(host + "\x00" + home)) + return hex.EncodeToString(digest[:16]) +} + +func StartPreparedServices(ctx context.Context, opts RunServicesOptions) credentials.Readiness { + return prepareServices(ctx, opts).start(ctx, opts.ConfigureGitHubCredentials) +} + +func RunPreparedServices( + ctx context.Context, + opts RunServicesOptions, + prepared func(credentials.Readiness), +) error { + return prepareServices(ctx, opts).runPrepared(ctx, opts.ConfigureGitHubCredentials, prepared) +} + +func prepareServices(ctx context.Context, opts RunServicesOptions) servicePreparation { + opts.HostOwner = hostCredentialOwner() + return servicePreparation{ + run: func() error { return RunServices(ctx, opts) }, + probe: func(ctx context.Context) credentials.Readiness { return probePreparedServices(ctx, opts) }, + } +} + +type servicePreparation struct { + run func() error + probe func(context.Context) credentials.Readiness +} + +func (p servicePreparation) start(ctx context.Context, enabled bool) credentials.Readiness { + status, _ := p.startWithLifetime(ctx, enabled) + return status +} + +type serviceCompletion struct { + done chan struct{} + err error +} + +func (p servicePreparation) startWithLifetime( + ctx context.Context, + enabled bool, +) (credentials.Readiness, *serviceCompletion) { + completion := &serviceCompletion{done: make(chan struct{})} + go func() { + completion.err = p.run() + if completion.err != nil && ctx.Err() == nil { + log.Warn("credential services stopped unexpectedly") + } + close(completion.done) + }() + if !enabled { + return credentials.Readiness{GitHubState: "disabled"}, completion + } + probeCtx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + ready := make(chan credentials.Readiness, 1) + go func() { ready <- p.probe(probeCtx) }() + return p.await(probeCtx, completion, ready), completion +} + +func (p servicePreparation) runPrepared( + ctx context.Context, + enabled bool, + prepared func(credentials.Readiness), +) error { + status, completion := p.startWithLifetime(ctx, enabled) + select { + case <-completion.done: + if completion.err != nil { + return completion.result(ctx) + } + default: + } + if ctx.Err() == nil { + prepared(status) + } + select { + case <-completion.done: + if err := completion.result(ctx); err != nil { + return err + } + <-ctx.Done() + case <-ctx.Done(): + } + return nil +} + +func (c *serviceCompletion) result(ctx context.Context) error { + if c.err != nil && ctx.Err() == nil { + return errors.New("credential services stopped unexpectedly") + } + return nil +} + +func (p servicePreparation) await( + ctx context.Context, + completion *serviceCompletion, + ready <-chan credentials.Readiness, +) credentials.Readiness { + done := completion.done + for { + select { + case status := <-ready: + return status + case <-done: + if completion.err != nil { + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + done = nil + case <-ctx.Done(): + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + } +} + +func probePreparedServices(ctx context.Context, opts RunServicesOptions) credentials.Readiness { + command := shellescape.Quote( + config.ContainerDevsyHelperLocation, + ) + " internal agent container credentials-status --github-enabled --user " + shellescape.Quote( + opts.User, + ) + " --host-owner " + shellescape.Quote( + opts.HostOwner, + ) + out := &boundedPreparationOutput{} + err := devssh.Run( + ctx, + devssh.RunOptions{ + Client: opts.ContainerClient, + Command: command, + Stdout: out, + Stderr: io.Discard, + }, + ) + var status credentials.Readiness + if err != nil || out.overflow || json.Unmarshal(out.data, &status) != nil { + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + expected := credentials.Readiness{ + Owner: opts.User, + HostOwner: opts.HostOwner, + GitHubAllowed: true, + } + if !status.Matches(expected) { + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + return status +} + +type boundedPreparationOutput struct { + data []byte + overflow bool +} + +func (b *boundedPreparationOutput) Write(p []byte) (int, error) { + n := len(p) + if remaining := 4096 - len(b.data); n > remaining { + p = p[:remaining] + b.overflow = true + } + b.data = append(b.data, p...) + return n, nil +} + +func GitHubSessionEnvironment(status credentials.Readiness) map[string]string { + state := status.GitHubState + if state != "installed" && state != "already_installed" { + if state != "disabled" { + state = credentialsUnavailable + } + } + return map[string]string{ + "DEVSY_GH_FORWARDING": state, + "DEVSY_GH_HOST_OWNER": status.HostOwner, + "DEVSY_GH_INSTANCE": status.Instance, + } +} + +const credentialsUnavailable = "unavailable" diff --git a/pkg/tunnel/services_preparation_test.go b/pkg/tunnel/services_preparation_test.go new file mode 100644 index 0000000000..78b00f1c80 --- /dev/null +++ b/pkg/tunnel/services_preparation_test.go @@ -0,0 +1,194 @@ +package tunnel + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/credentials" + "github.com/devsy-org/devsy/pkg/log" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +func TestServicePreparationWaitsForInstallAndServingWithoutEndingLifetime(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + install, serve := make(chan struct{}), make(chan struct{}) + probeStarted := make(chan struct{}) + installDone := make(chan struct{}) + runDone := make(chan struct{}) + runner := servicePreparation{ + run: func() error { defer close(runDone); <-ctx.Done(); return nil }, + probe: func(ctx context.Context) credentials.Readiness { + close(probeStarted) + if !waitPreparationSignal(ctx, install) { + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + close(installDone) + if !waitPreparationSignal(ctx, serve) { + return credentials.Readiness{GitHubState: credentialsUnavailable} + } + return credentials.Readiness{GitHubState: installedPreparation} + }, + } + ready := make(chan credentials.Readiness, 1) + go func() { ready <- runner.start(ctx, true) }() + require.True(t, waitPreparationSignal(ctx, probeStarted)) + assertPreparationPending(t, ready) + close(install) + require.True(t, waitPreparationSignal(ctx, installDone)) + assertPreparationPending(t, ready) + close(serve) + require.Equal(t, installedPreparation, receivePreparation(t, ctx, ready).GitHubState) + assertPreparationPending(t, runDone) + cancel() + require.True(t, waitPreparationSignal(context.Background(), runDone)) +} + +func waitPreparationSignal(ctx context.Context, signal <-chan struct{}) bool { + select { + case <-signal: + return true + case <-ctx.Done(): + return false + case <-time.After(time.Second): + return false + } +} + +func assertPreparationPending[T any](t *testing.T, c <-chan T) { + t.Helper() + select { + case <-c: + t.Fatal("preparation crossed an unreleased barrier") + default: + } +} + +func receivePreparation( + t *testing.T, + ctx context.Context, + c <-chan credentials.Readiness, +) credentials.Readiness { + t.Helper() + select { + case status := <-c: + return status + case <-ctx.Done(): + t.Fatal("preparation timed out") + return credentials.Readiness{} + } +} + +func TestServicePreparationFailureCancellationAndDisabledHaveBoundedOutcomes(t *testing.T) { + for _, mode := range []string{ + startupFailedPreparation, cancelledPreparation, + disabledPreparation, missingCLIPreparation, + } { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + runner := servicePreparation{ + run: func() error { + defer close(done) + if mode == startupFailedPreparation { + return errors.New("startup failed") + } + <-ctx.Done() + return nil + }, + probe: func(ctx context.Context) credentials.Readiness { + if mode == missingCLIPreparation { + return credentials.Readiness{GitHubState: "backend_missing"} + } + <-ctx.Done() + return credentials.Readiness{GitHubState: "unavailable"} + }, + } + if mode == cancelledPreparation { + cancel() + } + status := runner.start(ctx, mode != disabledPreparation) + want := map[string]string{ + startupFailedPreparation: credentialsUnavailable, cancelledPreparation: credentialsUnavailable, + disabledPreparation: disabledPreparation, missingCLIPreparation: "backend_missing", + }[mode] + require.Equal(t, want, status.GitHubState) + env := GitHubSessionEnvironment(status) + if mode != disabledPreparation { + require.Equal(t, "unavailable", env["DEVSY_GH_FORWARDING"]) + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("service goroutine did not stop") + } + }) + } +} + +const cancelledPreparation = "cancelled" + +const disabledPreparation = "disabled" + +const missingCLIPreparation = "missing-cli" + +const startupFailedPreparation = "startup-failed" + +func TestServicePreparationReportsFailureThroughoutServiceLifetime(t *testing.T) { + for _, tc := range []struct { + name string + enabled, ready, cancelled bool + }{ + {name: disabledPreparation}, + {name: "startup", enabled: true}, + {name: "after-ready", enabled: true, ready: true}, + {name: cancelledPreparation, enabled: true, cancelled: true}, + } { + t.Run(tc.name, func(t *testing.T) { + logs := log.InitTestObserved(t, zapcore.WarnLevel) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + fail := make(chan struct{}) + runner := servicePreparation{ + run: func() error { <-fail; return errors.New("dummy-secret-must-not-be-logged") }, + probe: func(ctx context.Context) credentials.Readiness { + if tc.ready { + return credentials.Readiness{GitHubState: installedPreparation} + } + <-ctx.Done() + return credentials.Readiness{GitHubState: credentialsUnavailable} + }, + } + if tc.cancelled { + cancel() + } + if !tc.ready { + close(fail) + } + status := runner.start(ctx, tc.enabled) + if tc.ready { + require.Equal(t, installedPreparation, status.GitHubState) + close(fail) + } + if tc.cancelled { + require.Empty(t, logs.All()) + return + } + require.Eventually( + t, + func() bool { return logs.FilterMessage("credential services stopped unexpectedly").Len() == 1 }, + time.Second, + time.Millisecond, + ) + require.NotContains(t, fmt.Sprint(logs.All()), "dummy-secret") + }) + } +} + +const installedPreparation = "installed" diff --git a/pkg/tunnel/services_ssh_test.go b/pkg/tunnel/services_ssh_test.go new file mode 100644 index 0000000000..ec5cf4b295 --- /dev/null +++ b/pkg/tunnel/services_ssh_test.go @@ -0,0 +1,117 @@ +package tunnel + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "io" + "net" + "testing" + "time" + + devssh "github.com/devsy-org/devsy/pkg/ssh" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestOptionalGitHubServiceFallbackAcrossSSH(t *testing.T) { + for _, tc := range []struct { + name, message string + fallback bool + }{ + {"legacy-agent", "unknown flag: --host-owner", true}, + {"runtime-failure", "credential transport stopped", false}, + } { + t.Run(tc.name, func(t *testing.T) { + client := failingServicesSSH(t, `{"kind":"error","message":"`+tc.message+`"}`) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + opts := RunServicesOptions{ + ContainerClient: client, + User: "vscode", + HostOwner: "fixture-owner", + ConfigureGitHubCredentials: true, + ConfigureGitCredentials: true, + } + calls := 0 + err := runOptionalGitHubServices(ctx, opts, func(current RunServicesOptions) error { + calls++ + if calls == 1 { + return runServicesIteration(ctx, current, nil, nil) + } + require.True(t, tc.fallback) + require.False(t, current.ConfigureGitHubCredentials) + require.True(t, current.ConfigureGitCredentials) + return nil + }) + if tc.fallback { + require.NoError(t, err) + require.Equal(t, 2, calls) + } else { + var exit *devssh.ExitError + require.ErrorAs(t, err, &exit) + require.Equal(t, 1, calls) + } + }) + } +} + +func failingServicesSSH(t *testing.T, diagnostic string) *ssh.Client { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + signer, err := ssh.NewSignerFromKey(key) + require.NoError(t, err) + serverConfig := &ssh.ServerConfig{NoClientAuth: true} + serverConfig.AddHostKey(signer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + serveServicesFailure(conn, serverConfig, diagnostic) + }() + t.Cleanup(func() { _ = listener.Close(); <-done }) + conn, err := net.DialTimeout("tcp", listener.Addr().String(), 5*time.Second) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + client, err := devssh.ClientFromConn(conn, "vscode", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func serveServicesFailure(conn net.Conn, serverConfig *ssh.ServerConfig, diagnostic string) { + defer func() { _ = conn.Close() }() + _, channels, requests, err := ssh.NewServerConn(conn, serverConfig) + if err != nil { + return + } + go ssh.DiscardRequests(requests) + for incoming := range channels { + channel, requests, err := incoming.Accept() + if err != nil { + return + } + for request := range requests { + if request.Type != "exec" { + _ = request.Reply(false, nil) + continue + } + _ = request.Reply(true, nil) + _, _ = io.WriteString(channel.Stderr(), diagnostic+"\n") + _, _ = channel.SendRequest( + "exit-status", + false, + ssh.Marshal(struct{ Status uint32 }{1}), + ) + _ = channel.Close() + break + } + } +} diff --git a/pkg/tunnel/services_startup_test.go b/pkg/tunnel/services_startup_test.go new file mode 100644 index 0000000000..1dc8d6c0f7 --- /dev/null +++ b/pkg/tunnel/services_startup_test.go @@ -0,0 +1,41 @@ +package tunnel + +import ( + "errors" + "strings" + "testing" + + devssh "github.com/devsy-org/devsy/pkg/ssh" + "github.com/stretchr/testify/require" +) + +func TestServicesStartupClassifiesOnlyUnsupportedGitHubFlags(t *testing.T) { + const unknownOwner = `{"kind":"error","message":"unknown flag: --host-owner"}` + exit := &devssh.ExitError{ExitCode: 1} + for _, tc := range []struct { + name, output string + err error + unsupported bool + }{ + {"legacy-owner", unknownOwner, exit, true}, + {"legacy-helper", `{"kind":"error","message":"unknown flag: --configure-github-helper"}`, exit, true}, + {"preceding-log", "{\"kind\":\"info\",\"message\":\"starting\"}\n" + unknownOwner, exit, true}, + {"unrelated-flag", `{"kind":"error","message":"unknown flag: --configure-git-helper"}`, exit, false}, + {"runtime-error", `{"kind":"error","message":"connection reset"}`, exit, false}, + {"not-error-record", `{"kind":"info","message":"unknown flag: --host-owner"}`, exit, false}, + {"partial-output", `{"kind":"error","message":"unknown flag: --host-owner"`, exit, false}, + {"unstructured", "unknown flag: --host-owner", exit, false}, + {"transport-error", unknownOwner, errors.New("SSH disconnected"), false}, + {"successful-command", unknownOwner, nil, false}, + {"overflow", unknownOwner + strings.Repeat(" ", 4096), exit, false}, + } { + t.Run(tc.name, func(t *testing.T) { + output := &boundedPreparationOutput{} + _, err := output.Write([]byte(tc.output)) + require.NoError(t, err) + got := classifyServicesStartupError(tc.err, output) + require.Equal(t, tc.unsupported, errors.Is(got, errUnsupportedGitHubServiceOptions)) + require.ErrorIs(t, got, tc.err) + }) + } +} diff --git a/pkg/tunnel/services_test.go b/pkg/tunnel/services_test.go index 6c2bd317e5..7891a2f19d 100644 --- a/pkg/tunnel/services_test.go +++ b/pkg/tunnel/services_test.go @@ -3,6 +3,7 @@ package tunnel import ( "context" "encoding/base64" + "errors" "testing" "time" @@ -12,6 +13,8 @@ import ( const testCtxName = "default" +const testServiceOwner = "fixture-owner" + // newConfigWithExitAfterTimeout returns a *config.Config whose default // context sets EXIT_AFTER_TIMEOUT to the given value. func newConfigWithExitAfterTimeout(value string) *config.Config { @@ -111,3 +114,106 @@ func TestBuildCredentialsCommand_NoSigningKey(t *testing.T) { assert.NotContains(t, command, "--git-user-signing-key") } + +func TestDisabledGitHubServicePreservesLegacyAgentCommand(t *testing.T) { + opts := RunServicesOptions{ + User: "root", + HostOwner: testServiceOwner, + ConfigureGitCredentials: true, + ConfigureDockerCredentials: true, + } + command := buildCredentialsCommand(context.Background(), opts) + assert.NotContains(t, command, "--host-owner") + assert.NotContains(t, command, "--configure-github-helper") + assert.Contains(t, command, "--configure-git-helper") + assert.Contains(t, command, "--configure-docker-helper") + opts.ConfigureGitHubCredentials = true + command = buildCredentialsCommand(context.Background(), opts) + assert.Contains(t, command, "--host-owner "+testServiceOwner) + assert.Contains(t, command, "--configure-github-helper") +} + +func TestOptionalGitHubServiceFailurePreservesUnrelatedServices(t *testing.T) { + opts := RunServicesOptions{ + User: "vscode", HostOwner: testServiceOwner, ConfigureGitHubCredentials: true, + ConfigureGitCredentials: true, ConfigureDockerCredentials: true, + ConfigureGitSSHSignatureHelper: true, ForwardPorts: true, + } + calls := 0 + err := runOptionalGitHubServices( + context.Background(), + opts, + func(current RunServicesOptions) error { + calls++ + if current.ConfigureGitHubCredentials { + return errUnsupportedGitHubServiceOptions + } + expected := opts + expected.ConfigureGitHubCredentials = false + expected.HostOwner = "" + assert.Equal(t, expected, current) + assert.NotContains( + t, + buildCredentialsCommand(context.Background(), current), + "--host-owner", + ) + return nil + }, + ) + assert.NoError(t, err) + assert.Equal(t, 2, calls) +} + +func TestOptionalGitHubServiceDoesNotRetrySuccessDisabledOrCanceled(t *testing.T) { + for _, mode := range []string{"success", disabledPreparation, "canceled"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if mode == "canceled" { + cancel() + } + calls := 0 + want := errors.New("startup unavailable") + if mode == "success" { + want = nil + } + err := runOptionalGitHubServices( + ctx, + RunServicesOptions{ConfigureGitHubCredentials: mode != disabledPreparation}, + func(RunServicesOptions) error { + calls++ + return want + }, + ) + assert.ErrorIs(t, err, want) + assert.Equal(t, 1, calls) + }) + } +} + +func TestOptionalGitHubServicePreservesPolicyAfterUnrelatedFailure(t *testing.T) { + for _, message := range []string{"SSH transport closed", "relay stopped after readiness", "unrelated helper failed"} { + t.Run(message, func(t *testing.T) { + want := errors.New(message) + calls := 0 + opts := RunServicesOptions{ + ConfigureGitHubCredentials: true, + HostOwner: testServiceOwner, + } + err := runOptionalGitHubServices( + t.Context(), + opts, + func(current RunServicesOptions) error { + calls++ + assert.Equal(t, opts, current) + if !current.ConfigureGitHubCredentials { + return nil + } + return want + }, + ) + assert.ErrorIs(t, err, want) + assert.Equal(t, 1, calls) + }) + } +} diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/credentials.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/credentials.mdx index d6a8472b42..70e6ad30cd 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/credentials.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/credentials.mdx @@ -3,7 +3,7 @@ title: Reuse local credentials sidebar_label: Reuse local credentials --- -Devsy makes some of your local credentials available inside the dev container, so you do not configure them in each workspace. It supports git credentials, Docker registry credentials, and GPG keys. +Devsy makes some of your local credentials available inside the dev container, so you do not configure them in each workspace. It supports Git credentials, opt-in GitHub CLI authentication, Docker registry credentials, and GPG keys. Code that runs in the container, including lifecycle commands and features from `devcontainer.json`, can use these credentials. Do not enable them for repositories or `devcontainer.json` files you have not reviewed. @@ -27,6 +27,48 @@ devsy context set default -o SSH_AGENT_FORWARDING=false Disabling HTTPS credential injection does not disable SSH agent forwarding. +## GitHub CLI + +GitHub CLI credential forwarding is **off by default**. Enable it for a trusted context: + +```sh +devsy context set -o SSH_INJECT_GITHUB_CREDENTIALS=true +``` + +To disable it: + +```sh +devsy context set -o SSH_INJECT_GITHUB_CREDENTIALS=false +``` + +Desktop exposes the same option as **GitHub CLI Credentials** in the context's credential forwarding settings. This option is independent of Git HTTPS credentials, Docker credentials, SSH agent forwarding, and signing. A platform workspace's policy disabling Git credential forwarding also disables GitHub CLI forwarding. + +### Scope and prerequisites + +Install and sign in to `gh` on your macOS, Linux, or Windows host. Install `gh` (or configure an executable version-manager shim) for the intended user in the **Linux guest**. Devsy does not install GitHub CLI or sign you in. A missing guest CLI leaves `command -v gh` unchanged; install it and reconnect to retry setup. + +An enabled relay makes the host's **stored active account on every configured GitHub host** available to workspace code. This includes GitHub.com and each configured GitHub Enterprise host, with each token's existing API permissions. It is broader than access to the workspace repository: code can call GitHub APIs directly using those tokens. Only enable it for code you trust with that authority. + +The host must have an active `user` registered in its GitHub CLI configuration, with a file token or an accessible keyring credential. Host `GH_TOKEN`, `GITHUB_TOKEN`, `GH_ENTERPRISE_TOKEN`, and `GITHUB_ENTERPRISE_TOKEN` values are excluded from account lookup; environment-only host authentication is not forwarded. Devsy does not call an API to invent an account identity. GitHub CLI itself selects the target host for commands, aliases, and extensions. + +### Guest authentication and saved changes + +Nonempty explicit guest authentication environment variables and existing local file or keyring tokens take precedence, even if an API rejects them. Devsy does not retry an authentication failure with the host account. A registered guest account also prevents forwarding if its token cannot be retrieved: GitHub CLI cannot reliably distinguish an absent keyring entry from a locked or unavailable keyring. Repair or deliberately remove that guest registration to use forwarding. + +Forwarded invocations use private temporary configuration. Forwarded values are stripped before user changes are written back. Deliberate local logins and keyring changes stay under GitHub CLI's control. Saving the exact forwarded token back to a file may remain treated as forwarding; disable forwarding before a login that must be independent of the host. Devsy serializes writeback from its wrappers and preserves a newer complete local identity when a conflict is detected, instead of combining accounts or resurrecting a logout. + +An unwrapped `gh` process or another editor does not share Devsy's writeback lock. Changes detected before the final file replacement are preserved, including changes made while the journal checkpoint is saved. There is no atomic comparison-and-replacement guarantee against unrelated writers: an edit between the final comparison and replacement can still be overwritten. Avoid overlapping authentication changes through the wrapper and an unwrapped CLI or editor. Independent keyring changes are not rolled back through configuration writeback. + +Normal completion scrubs forwarded credentials before attempting configuration writeback. An interrupted process can leave private temporary credentials until a later invocation recovers the session; cleanup failures are reported on stderr. This is not secure erasure. Host configuration and keyring accounts are not changed by guest commands. + +### Connections and troubleshooting + +Close existing service connections and reconnect after changing the option. Restart a browser IDE when needed to replace its existing services connection. After opting in, an older browser tunnel must be restarted before Devsy can verify preparation and open it automatically. Changing the context option does not revoke tokens already obtained by running code. + +The guest's shared credential port has one live services owner. SSH sessions carry their expected host and service identity; a mismatched or older service falls back to local authentication. Browser terminals and external SSH clients that do not carry this session environment use the first live services owner. Two hosts using the same guest user must end that first connection before changing the owner; the guest username alone does not identify a host. Do not rely on a second browser connection to change or revoke the first owner's authority. + +If forwarding is unavailable, the wrapper runs the real CLI with local configuration. Check `gh --version` and `gh auth status` on both the host and guest, confirm the context option, and reconnect. Verify the guest user's PATH and noninteractive `sudo` access for wrapper setup. Locked keyrings, a missing session bus, old agents, failed setup, and occupied credential ports can prevent forwarding. `DEVSY_LOG_LEVEL=debug gh ...` explains the selected authentication source and sanitized failure category on stderr without changing GitHub CLI's stdout or exit status. + ## Docker Devsy provides registry credentials through a [Docker credential helper](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers), so you can pull and push private images from the container. To turn it off for all workspaces: