diff --git a/.vscode/settings.json b/.vscode/settings.json
index d37c537ed..d186bb770 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,7 +13,8 @@
             "--proto_path=/usr/local/include/protoc"
         ]
     },
-    "go.buildTags": "test_with_docker,test_with_txtar"
+    "go.buildTags": "test_with_docker,test_with_txtar",
+    "makefile.configureOnOpen": false
     // Uncomment if you want to work on files in ./web.
     // "go.buildTags": "js,wasm",
     // Uncomment if you want to check compilation errors on Windows.
diff --git a/cmd/gqltool/main.go b/cmd/gqltool/main.go
index bcd1115ce..772fd7936 100644
--- a/cmd/gqltool/main.go
+++ b/cmd/gqltool/main.go
@@ -11,7 +11,6 @@ import (
 	"github.com/stateful/runme/v3/internal/client/graphql"
 )
 
-// var tokenDir = flag.String("token-dir", cmd.GetUserConfigHome(), "The directory with tokens")
 var apiURL = flag.String("api-url", "http://localhost:4000", "The API base address")
 
 func init() {
diff --git a/internal/cmd/beta/beta_cmd.go b/internal/cmd/beta/beta_cmd.go
index 5412819a4..65620a890 100644
--- a/internal/cmd/beta/beta_cmd.go
+++ b/internal/cmd/beta/beta_cmd.go
@@ -59,6 +59,7 @@ All commands use the runme.yaml configuration file.`,
 
 				return nil
 			})
+
 			// Print the error to stderr but don't return it because error modes
 			// are neither fully baked yet nor ready for users to consume.
 			if err != nil {
diff --git a/internal/cmd/beta/run_cmd.go b/internal/cmd/beta/run_cmd.go
index c5fd80f5f..4789faafe 100644
--- a/internal/cmd/beta/run_cmd.go
+++ b/internal/cmd/beta/run_cmd.go
@@ -2,7 +2,10 @@ package beta
 
 import (
 	"context"
+	"io"
+	"os"
 
+	"github.com/creack/pty"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
 	"go.uber.org/zap"
@@ -10,6 +13,7 @@ import (
 	"github.com/stateful/runme/v3/internal/command"
 	"github.com/stateful/runme/v3/internal/config/autoconfig"
 	rcontext "github.com/stateful/runme/v3/internal/runner/context"
+	"github.com/stateful/runme/v3/internal/runnerv2client"
 	"github.com/stateful/runme/v3/internal/session"
 	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
 	"github.com/stateful/runme/v3/pkg/document"
@@ -17,6 +21,8 @@ import (
 )
 
 func runCmd(*commonFlags) *cobra.Command {
+	var remote bool
+
 	cmd := cobra.Command{
 		Use:     "run [command1 command2 ...]",
 		Aliases: []string{"exec"},
@@ -36,6 +42,7 @@ Run all blocks from the "setup" and "teardown" tags:
 		RunE: func(cmd *cobra.Command, args []string) error {
 			return autoconfig.InvokeForCommand(
 				func(
+					clientFactory autoconfig.ClientFactory,
 					cmdFactory command.Factory,
 					filters []project.Filter,
 					logger *zap.Logger,
@@ -67,21 +74,57 @@ Run all blocks from the "setup" and "teardown" tags:
 						return errors.WithStack(err)
 					}
 
-					session, err := session.New(
-						session.WithOwl(false),
-						session.WithProject(proj),
-						session.WithSeedEnv(nil),
-					)
-					if err != nil {
-						return err
-					}
-					options := getCommandOptions(cmd, session)
+					ctx := cmd.Context()
+
+					if remote {
+						client, err := clientFactory()
+						if err != nil {
+							return err
+						}
+
+						sessionResp, err := client.CreateSession(
+							ctx,
+							&runnerv2.CreateSessionRequest{
+								Project: &runnerv2.Project{
+									Root:         proj.Root(),
+									EnvLoadOrder: proj.EnvFilesReadOrder(),
+								},
+							},
+						)
+						if err != nil {
+							return errors.WithMessage(err, "failed to create session")
+						}
 
-					for _, t := range tasks {
-						err := runCodeBlock(cmd.Context(), t.CodeBlock, cmdFactory, options)
+						for _, t := range tasks {
+							err := runCodeBlockWithClient(
+								ctx,
+								cmd,
+								client,
+								t.CodeBlock,
+								sessionResp.GetSession().GetId(),
+							)
+							if err != nil {
+								return err
+							}
+						}
+					} else {
+						session, err := session.New(
+							session.WithOwl(false),
+							session.WithProject(proj),
+							session.WithSeedEnv(nil),
+						)
 						if err != nil {
 							return err
 						}
+
+						options := createCommandOptions(cmd, session)
+
+						for _, t := range tasks {
+							err := runCodeBlock(ctx, t.CodeBlock, cmdFactory, options)
+							if err != nil {
+								return err
+							}
+						}
 					}
 
 					return nil
@@ -90,10 +133,12 @@ Run all blocks from the "setup" and "teardown" tags:
 		},
 	}
 
+	cmd.Flags().BoolVarP(&remote, "remote", "r", false, "Run commands on a remote server.")
+
 	return &cmd
 }
 
-func getCommandOptions(
+func createCommandOptions(
 	cmd *cobra.Command,
 	sess *session.Session,
 ) command.CommandOptions {
@@ -105,12 +150,7 @@ func getCommandOptions(
 	}
 }
 
-func runCodeBlock(
-	ctx context.Context,
-	block *document.CodeBlock,
-	factory command.Factory,
-	options command.CommandOptions,
-) error {
+func createProgramConfigFromCodeBlock(block *document.CodeBlock, opts ...command.ConfigBuilderOption) (*command.ProgramConfig, error) {
 	// TODO(adamb): [command.Config] is generated exclusively from the [document.CodeBlock].
 	// As we introduce some document- and block-related configs in runme.yaml (root but also nested),
 	// this [Command.Config] should be further extended.
@@ -121,7 +161,16 @@ func runCodeBlock(
 	// the last element of the returned config chain. Finally, [command.Config] should be updated.
 	// This algorithm should be likely encapsulated in the [internal/config] and [internal/command]
 	// packages.
-	cfg, err := command.NewProgramConfigFromCodeBlock(block)
+	return command.NewProgramConfigFromCodeBlock(block, opts...)
+}
+
+func runCodeBlock(
+	ctx context.Context,
+	block *document.CodeBlock,
+	factory command.Factory,
+	options command.CommandOptions,
+) error {
+	cfg, err := createProgramConfigFromCodeBlock(block)
 	if err != nil {
 		return err
 	}
@@ -138,9 +187,45 @@ func runCodeBlock(
 	if err != nil {
 		return err
 	}
-	err = cmd.Start(ctx)
-	if err != nil {
+	if err := cmd.Start(ctx); err != nil {
 		return err
 	}
 	return cmd.Wait(ctx)
 }
+
+func runCodeBlockWithClient(
+	ctx context.Context,
+	cobraCommand *cobra.Command,
+	client *runnerv2client.Client,
+	block *document.CodeBlock,
+	sessionID string,
+) error {
+	cfg, err := createProgramConfigFromCodeBlock(block, command.WithInteractiveLegacy())
+	if err != nil {
+		return err
+	}
+
+	opts := runnerv2client.ExecuteProgramOptions{
+		SessionID:        sessionID,
+		Stdin:            io.NopCloser(cobraCommand.InOrStdin()),
+		Stdout:           cobraCommand.OutOrStdout(),
+		Stderr:           cobraCommand.ErrOrStderr(),
+		StoreStdoutInEnv: true,
+	}
+
+	if stdin, ok := cobraCommand.InOrStdin().(*os.File); ok {
+		size, err := pty.GetsizeFull(stdin)
+		if err != nil {
+			return errors.WithMessage(err, "failed to get terminal size")
+		}
+
+		opts.Winsize = &runnerv2.Winsize{
+			Rows: uint32(size.Rows),
+			Cols: uint32(size.Cols),
+			X:    uint32(size.X),
+			Y:    uint32(size.Y),
+		}
+	}
+
+	return client.ExecuteProgram(ctx, cfg, opts)
+}
diff --git a/internal/command/command_terminal_test.go b/internal/command/command_terminal_test.go
index 178f4ccbe..80278581e 100644
--- a/internal/command/command_terminal_test.go
+++ b/internal/command/command_terminal_test.go
@@ -52,12 +52,12 @@ func TestTerminalCommand_EnvPropagation(t *testing.T) {
 
 	// Terminal command sets up a trap on EXIT.
 	// Wait for it before starting to send commands.
-	expectContainLines(t, stdout, []string{"trap -- \"__cleanup\" EXIT"})
+	expectContainLines(ctx, t, stdout, []string{"trap -- \"__cleanup\" EXIT"})
 
 	_, err = stdinW.Write([]byte("export TEST_ENV=1\n"))
 	require.NoError(t, err)
 	// Wait for the prompt before sending the next command.
-	expectContainLines(t, stdout, []string{"$"})
+	expectContainLines(ctx, t, stdout, []string{"$"})
 	_, err = stdinW.Write([]byte("exit\n"))
 	require.NoError(t, err)
 
@@ -96,15 +96,19 @@ func TestTerminalCommand_Intro(t *testing.T) {
 
 	require.NoError(t, cmd.Start(ctx))
 
-	expectContainLines(t, stdout, []string{envSourceCmd, introSecondLine})
+	expectContainLines(ctx, t, stdout, []string{envSourceCmd, introSecondLine})
 }
 
-func expectContainLines(t *testing.T, r io.Reader, expected []string) {
+func expectContainLines(ctx context.Context, t *testing.T, r io.Reader, expected []string) {
 	t.Helper()
-	var output strings.Builder
+
 	hits := make(map[string]bool, len(expected))
+	output := new(strings.Builder)
 
 	for {
+		buf := new(bytes.Buffer)
+		r := io.TeeReader(r, buf)
+
 		scanner := bufio.NewScanner(r)
 		for scanner.Scan() {
 			_, _ = output.WriteString(scanner.Text())
@@ -121,7 +125,11 @@ func expectContainLines(t *testing.T, r io.Reader, expected []string) {
 			return
 		}
 
-		time.Sleep(time.Millisecond * 400)
+		select {
+		case <-time.After(100 * time.Millisecond):
+		case <-ctx.Done():
+			t.Fatalf("error waiting for line %q, instead read %q: %s", expected, buf.Bytes(), ctx.Err())
+		}
 	}
 }
 
diff --git a/internal/command/command_virtual.go b/internal/command/command_virtual.go
index bc62333fb..5b726cfd7 100644
--- a/internal/command/command_virtual.go
+++ b/internal/command/command_virtual.go
@@ -5,7 +5,6 @@ import (
 	"io"
 	"os"
 	"os/exec"
-	"reflect"
 	"sync"
 	"syscall"
 
@@ -224,21 +223,6 @@ func SetWinsize(cmd Command, winsize *Winsize) (err error) {
 	return errors.WithStack(err)
 }
 
-func isNil(val any) bool {
-	if val == nil {
-		return true
-	}
-
-	v := reflect.ValueOf(val)
-
-	switch v.Type().Kind() {
-	case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer:
-		return v.IsNil()
-	default:
-		return false
-	}
-}
-
 // readCloser wraps [io.Reader] into [io.ReadCloser].
 //
 // When Close is called, the underlying read operation is ignored.
diff --git a/internal/command/command_windows.go b/internal/command/command_windows.go
index 59b748999..7a8ca640d 100644
--- a/internal/command/command_windows.go
+++ b/internal/command/command_windows.go
@@ -28,4 +28,4 @@ func disableEcho(uintptr) error {
 		"and join our Discord server at https://discord.gg/runme if you have further questions!")
 }
 
-func signalPgid(int, os.Signal) error { return errors.New("unsupported") }
+func signalPgid(int, os.Signal) error { return errors.New("signalPgid: unsupported") }
diff --git a/internal/command/config_code_block.go b/internal/command/config_code_block.go
index 44344ffb2..01040b222 100644
--- a/internal/command/config_code_block.go
+++ b/internal/command/config_code_block.go
@@ -9,12 +9,30 @@ import (
 	"github.com/stateful/runme/v3/pkg/document"
 )
 
-func NewProgramConfigFromCodeBlock(block *document.CodeBlock) (*ProgramConfig, error) {
-	return (&configBuilder{block: block}).Build()
+type ConfigBuilderOption func(*configBuilder) error
+
+func WithInteractiveLegacy() ConfigBuilderOption {
+	return func(b *configBuilder) error {
+		b.useInteractiveLegacy = true
+		return nil
+	}
+}
+
+func NewProgramConfigFromCodeBlock(block *document.CodeBlock, opts ...ConfigBuilderOption) (*ProgramConfig, error) {
+	b := &configBuilder{block: block}
+
+	for _, opt := range opts {
+		if err := opt(b); err != nil {
+			return nil, err
+		}
+	}
+
+	return b.Build()
 }
 
 type configBuilder struct {
-	block *document.CodeBlock
+	block                *document.CodeBlock
+	useInteractiveLegacy bool
 }
 
 func (b *configBuilder) Build() (*ProgramConfig, error) {
@@ -22,7 +40,12 @@ func (b *configBuilder) Build() (*ProgramConfig, error) {
 		ProgramName: b.programPath(),
 		LanguageId:  b.block.Language(),
 		Directory:   b.dir(),
-		Interactive: b.block.Interactive(),
+	}
+
+	if b.useInteractiveLegacy {
+		cfg.Interactive = b.block.InteractiveLegacy()
+	} else {
+		cfg.Interactive = b.block.Interactive()
 	}
 
 	if isShell(cfg) {
diff --git a/internal/command/factory.go b/internal/command/factory.go
index 1338c9761..8d996a281 100644
--- a/internal/command/factory.go
+++ b/internal/command/factory.go
@@ -2,6 +2,7 @@ package command
 
 import (
 	"io"
+	"reflect"
 
 	"go.uber.org/zap"
 
@@ -303,3 +304,18 @@ func (f *commandFactory) getLogger(name string) *zap.Logger {
 	id := ulid.GenerateID()
 	return f.logger.Named(name).With(zap.String("instanceID", id))
 }
+
+func isNil(val any) bool {
+	if val == nil {
+		return true
+	}
+
+	v := reflect.ValueOf(val)
+
+	switch v.Type().Kind() {
+	case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer:
+		return v.IsNil()
+	default:
+		return false
+	}
+}
diff --git a/internal/config/autoconfig/autoconfig.go b/internal/config/autoconfig/autoconfig.go
index 9ab399e81..7d9353e1e 100644
--- a/internal/config/autoconfig/autoconfig.go
+++ b/internal/config/autoconfig/autoconfig.go
@@ -98,6 +98,7 @@ func getClient(cfg *config.Config, logger *zap.Logger) (*runnerv2client.Client,
 
 	return runnerv2client.New(
 		cfg.Server.Address,
+		logger,
 		opts...,
 	)
 }
@@ -185,6 +186,10 @@ func getProject(c *config.Config, logger *zap.Logger) (*project.Project, error)
 		project.WithLogger(logger),
 	}
 
+	if env := c.Project.Env; env != nil {
+		opts = append(opts, project.WithEnvFilesReadOrder(env.Sources))
+	}
+
 	if c.Project.Filename != "" {
 		return project.NewFileProject(c.Project.Filename, opts...)
 	}
@@ -199,7 +204,6 @@ func getProject(c *config.Config, logger *zap.Logger) (*project.Project, error)
 		opts,
 		project.WithIgnoreFilePatterns(c.Project.Ignore...),
 		project.WithRespectGitignore(!c.Project.DisableGitignore),
-		project.WithEnvFilesReadOrder(c.Project.Env.Sources),
 	)
 
 	if c.Project.FindRepoUpward {
diff --git a/internal/project/projectservice/project_service.go b/internal/project/projectservice/project_service.go
index ae45c1d4a..af4e9b24b 100644
--- a/internal/project/projectservice/project_service.go
+++ b/internal/project/projectservice/project_service.go
@@ -6,6 +6,8 @@ import (
 	"sync"
 
 	"go.uber.org/zap"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
 
 	projectv1 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/project/v1"
 	"github.com/stateful/runme/v3/pkg/project"
@@ -36,34 +38,45 @@ func (s *projectServiceServer) Load(req *projectv1.LoadRequest, srv projectv1.Pr
 	go func() {
 		defer wg.Done()
 		defer close(errc)
-		for event := range eventc {
-			msg := &projectv1.LoadResponse{
-				Type: projectv1.LoadEventType(event.Type),
-			}
-
-			if err := setDataForLoadResponseFromLoadEvent(msg, event); err != nil {
-				errc <- err
-				goto errhandler
-			}
-
-			if err := srv.Send(msg); err != nil {
-				errc <- err
-				goto errhandler
-			}
 
-			continue
-
-		errhandler:
-			cancel()
-			// Project.Load() should be notified that it should exit early
-			// via cancel(). eventc will be closed, but it should be drained too
-			// in order to clean up any in-flight events.
-			// In theory, this is not necessary provided that all sends to eventc
-			// are wrapped in selects which observe ctx.Done().
-			//revive:disable:empty-block
-			for range eventc {
+		for {
+			select {
+			case <-ctx.Done():
+				errc <- status.Error(codes.Canceled, ctx.Err().Error())
+				return
+			case event, ok := <-eventc:
+				if !ok {
+					return
+				}
+
+				msg := &projectv1.LoadResponse{
+					Type: projectv1.LoadEventType(event.Type),
+				}
+
+				if err := setDataForLoadResponseFromLoadEvent(msg, event); err != nil {
+					errc <- err
+					goto errhandler
+				}
+
+				if err := srv.Send(msg); err != nil {
+					errc <- err
+					goto errhandler
+				}
+
+				continue
+
+			errhandler:
+				cancel()
+				// Project.Load() should be notified that it should exit early
+				// via cancel(). eventc will be closed, but it should be drained too
+				// in order to clean up any in-flight events.
+				// In theory, this is not necessary provided that all sends to eventc
+				// are wrapped in selects which observe ctx.Done().
+				//revive:disable:empty-block
+				for range eventc {
+				}
+				//revive:enable:empty-block
 			}
-			//revive:enable:empty-block
 		}
 	}()
 
diff --git a/internal/project/projectservice/project_service_test.go b/internal/project/projectservice/project_service_test.go
index 4675bac05..61df0bb36 100644
--- a/internal/project/projectservice/project_service_test.go
+++ b/internal/project/projectservice/project_service_test.go
@@ -9,30 +9,26 @@ import (
 	"github.com/pkg/errors"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	"go.uber.org/zap"
+	"go.uber.org/zap/zaptest"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
-	"google.golang.org/grpc/credentials/insecure"
-	"google.golang.org/grpc/resolver"
 	"google.golang.org/grpc/status"
 	"google.golang.org/grpc/test/bufconn"
 
 	"github.com/stateful/runme/v3/internal/project/projectservice"
+	"github.com/stateful/runme/v3/internal/testutils"
 	projectv1 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/project/v1"
 	"github.com/stateful/runme/v3/pkg/project/teststub"
-	"github.com/stateful/runme/v3/pkg/project/testutils"
+	projtestutils "github.com/stateful/runme/v3/pkg/project/testutils"
 )
 
-func init() {
-	resolver.SetDefaultScheme("passthrough")
-}
-
 func TestProjectServiceServer_Load(t *testing.T) {
 	t.Parallel()
 
 	lis, stop := testStartProjectServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateProjectServiceClient(t, lis)
+
+	_, client := testutils.NewTestGRPCClient(t, lis, projectv1.NewProjectServiceClient)
 
 	t.Run("GitProject", func(t *testing.T) {
 		t.Parallel()
@@ -45,7 +41,7 @@ func TestProjectServiceServer_Load(t *testing.T) {
 				Directory: &projectv1.DirectoryProjectOptions{
 					Path:                 testData.GitProjectPath(),
 					SkipGitignore:        false,
-					IgnoreFilePatterns:   testutils.IgnoreFilePatternsWithDefaults("ignored.md"),
+					IgnoreFilePatterns:   projtestutils.IgnoreFilePatternsWithDefaults("ignored.md"),
 					SkipRepoLookupUpward: false,
 				},
 			},
@@ -56,7 +52,7 @@ func TestProjectServiceServer_Load(t *testing.T) {
 
 		eventTypes, err := collectLoadEventTypes(loadClient)
 		require.NoError(t, err)
-		assert.Len(t, eventTypes, len(testutils.GitProjectLoadOnlyNotIgnoredFilesEvents))
+		assert.Len(t, eventTypes, len(projtestutils.GitProjectLoadOnlyNotIgnoredFilesEvents))
 	})
 
 	t.Run("FileProject", func(t *testing.T) {
@@ -78,7 +74,7 @@ func TestProjectServiceServer_Load(t *testing.T) {
 
 		eventTypes, err := collectLoadEventTypes(loadClient)
 		require.NoError(t, err)
-		assert.Len(t, eventTypes, len(testutils.FileProjectEvents))
+		assert.Len(t, eventTypes, len(projtestutils.FileProjectEvents))
 	})
 }
 
@@ -90,7 +86,8 @@ func TestProjectServiceServer_Load_ClientConnClosed(t *testing.T) {
 
 	lis, stop := testStartProjectServiceServer(t)
 	t.Cleanup(stop)
-	clientConn, client := testCreateProjectServiceClient(t, lis)
+
+	clientConn, client := testutils.NewTestGRPCClient(t, lis, projectv1.NewProjectServiceClient)
 
 	req := &projectv1.LoadRequest{
 		Kind: &projectv1.LoadRequest_File{
@@ -99,14 +96,11 @@ func TestProjectServiceServer_Load_ClientConnClosed(t *testing.T) {
 			},
 		},
 	}
-
 	loadClient, err := client.Load(context.Background(), req)
 	require.NoError(t, err)
 
-	errc := make(chan error, 1)
-	go func() {
-		errc <- clientConn.Close()
-	}()
+	err = clientConn.Close()
+	require.NoError(t, err)
 
 	for {
 		_, err := loadClient.Recv()
@@ -115,8 +109,6 @@ func TestProjectServiceServer_Load_ClientConnClosed(t *testing.T) {
 			break
 		}
 	}
-
-	require.NoError(t, <-errc)
 }
 
 func collectLoadEventTypes(client projectv1.ProjectService_LoadClient) ([]projectv1.LoadEventType, error) {
@@ -140,31 +132,14 @@ func testStartProjectServiceServer(t *testing.T) (
 	interface{ Dial() (net.Conn, error) },
 	func(),
 ) {
-	logger, err := zap.NewDevelopment()
-	require.NoError(t, err)
+	t.Helper()
 
 	server := grpc.NewServer()
-	service := projectservice.NewProjectServiceServer(logger)
+
+	service := projectservice.NewProjectServiceServer(zaptest.NewLogger(t))
 	projectv1.RegisterProjectServiceServer(server, service)
 
 	lis := bufconn.Listen(1024 << 10)
-
 	go server.Serve(lis)
-
 	return lis, server.Stop
 }
-
-func testCreateProjectServiceClient(
-	t *testing.T,
-	lis interface{ Dial() (net.Conn, error) },
-) (*grpc.ClientConn, projectv1.ProjectServiceClient) {
-	conn, err := grpc.NewClient(
-		"passthrough",
-		grpc.WithTransportCredentials(insecure.NewCredentials()),
-		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
-			return lis.Dial()
-		}),
-	)
-	require.NoError(t, err)
-	return conn, projectv1.NewProjectServiceClient(conn)
-}
diff --git a/internal/runner/service_test.go b/internal/runner/service_test.go
index 988fd8019..d63518777 100644
--- a/internal/runner/service_test.go
+++ b/internal/runner/service_test.go
@@ -20,26 +20,24 @@ import (
 	"go.uber.org/zap"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
-	"google.golang.org/grpc/credentials/insecure"
-	"google.golang.org/grpc/resolver"
 	"google.golang.org/grpc/status"
 	"google.golang.org/grpc/test/bufconn"
 	"google.golang.org/protobuf/proto"
 
+	"github.com/stateful/runme/v3/internal/testutils"
 	"github.com/stateful/runme/v3/internal/ulid"
 	runnerv1 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v1"
 )
 
+// TODO(adamb): remove global state. It prevents from running tests in parallel.
 var (
 	logger  *zap.Logger
 	logFile string
 )
 
-func init() {
-	resolver.SetDefaultScheme("passthrough")
-}
-
+// TODO(adamb): remove and use [zaptest.NewLogger] instead.
 func testCreateLogger(t *testing.T) *zap.Logger {
+	t.Helper()
 	logger, err := zap.NewDevelopment()
 	require.NoError(t, err)
 	t.Cleanup(func() { _ = logger.Sync() })
@@ -75,21 +73,6 @@ func testStartRunnerServiceServer(t *testing.T) (
 	return lis, server.Stop
 }
 
-func testCreateRunnerServiceClient(
-	t *testing.T,
-	lis interface{ Dial() (net.Conn, error) },
-) (*grpc.ClientConn, runnerv1.RunnerServiceClient) {
-	conn, err := grpc.NewClient(
-		"passthrough",
-		grpc.WithTransportCredentials(insecure.NewCredentials()),
-		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
-			return lis.Dial()
-		}),
-	)
-	require.NoError(t, err)
-	return conn, runnerv1.NewRunnerServiceClient(conn)
-}
-
 type executeResult struct {
 	Stdout   []byte
 	Stderr   []byte
@@ -129,7 +112,8 @@ func getExecuteResult(
 func Test_runnerService(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv1.NewRunnerServiceClient)
 
 	t.Run("Sessions", func(t *testing.T) {
 		t.Parallel()
diff --git a/internal/runnerv2client/client.go b/internal/runnerv2client/client.go
index d253c859c..d0e6305e3 100644
--- a/internal/runnerv2client/client.go
+++ b/internal/runnerv2client/client.go
@@ -1,22 +1,164 @@
 package runnerv2client
 
 import (
+	"context"
+	"io"
+	"reflect"
+
 	"github.com/pkg/errors"
-	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
+	"go.uber.org/zap"
 	"google.golang.org/grpc"
+
+	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
 )
 
 type Client struct {
 	runnerv2.RunnerServiceClient
+	conn   *grpc.ClientConn
+	logger *zap.Logger
 }
 
-func New(target string, opts ...grpc.DialOption) (*Client, error) {
+func New(target string, logger *zap.Logger, opts ...grpc.DialOption) (*Client, error) {
 	client, err := grpc.NewClient(target, opts...)
 	if err != nil {
 		return nil, errors.WithStack(err)
 	}
+	serviceClient := &Client{
+		RunnerServiceClient: runnerv2.NewRunnerServiceClient(client),
+		conn:                client,
+		logger:              logger,
+	}
+	return serviceClient, nil
+}
 
-	serviceClient := &Client{RunnerServiceClient: runnerv2.NewRunnerServiceClient(client)}
+func (c *Client) Close() error {
+	return c.conn.Close()
+}
 
-	return serviceClient, nil
+type ExecuteProgramOptions struct {
+	InputData        []byte
+	SessionID        string
+	Stdin            io.ReadCloser
+	Stdout           io.Writer
+	Stderr           io.Writer
+	StoreStdoutInEnv bool
+	Winsize          *runnerv2.Winsize
+}
+
+func (c *Client) ExecuteProgram(
+	ctx context.Context,
+	cfg *runnerv2.ProgramConfig,
+	opts ExecuteProgramOptions,
+) error {
+	return c.executeProgram(ctx, cfg, opts)
+}
+
+func (c *Client) executeProgram(
+	ctx context.Context,
+	cfg *runnerv2.ProgramConfig,
+	opts ExecuteProgramOptions,
+) error {
+	stream, err := c.Execute(ctx)
+	if err != nil {
+		return errors.WithMessage(err, "failed to call Execute()")
+	}
+
+	// Send the initial request.
+	req := &runnerv2.ExecuteRequest{
+		Config:           cfg,
+		InputData:        opts.InputData,
+		SessionId:        opts.SessionID,
+		StoreStdoutInEnv: opts.StoreStdoutInEnv,
+		Winsize:          opts.Winsize,
+	}
+	if err := stream.Send(req); err != nil {
+		return errors.WithMessage(err, "failed to send initial request")
+	}
+
+	if stdin := opts.Stdin; !isNil(stdin) {
+		// TODO(adamb): reimplement it. There should be a singleton
+		// handling and forwarding the stdin. The current implementation
+		// does not temrinate multiple stdin readers in the case of
+		// running multiple commands using "beta run command1 command2 ... commandN".
+		go func() {
+			defer func() {
+				c.logger.Info("finishing reading stdin")
+				err := stream.CloseSend()
+				if err != nil {
+					c.logger.Info("failed to close send", zap.Error(err))
+				}
+			}()
+
+			c.logger.Info("reading stdin")
+
+			buf := make([]byte, 2*1024*1024)
+
+			for {
+				n, err := stdin.Read(buf)
+				if err != nil {
+					c.logger.Info("failed to read stdin", zap.Error(err))
+					break
+				}
+
+				c.logger.Info("sending stdin", zap.Int("size", n))
+
+				err = stream.Send(&runnerv2.ExecuteRequest{
+					InputData: buf[:n],
+				})
+				if err != nil {
+					c.logger.Info("failed to send stdin", zap.Error(err))
+					break
+				}
+			}
+		}()
+	}
+
+	for {
+		resp, err := stream.Recv()
+		if err != nil {
+			if !errors.Is(err, io.EOF) {
+				c.logger.Info("failed to receive response", zap.Error(err))
+			}
+			break
+		}
+
+		if pid := resp.Pid; pid != nil {
+			c.logger.Info("server started a process with PID", zap.Uint32("pid", pid.GetValue()), zap.String("mime", resp.MimeType))
+		}
+
+		if stdout := opts.Stdout; !isNil(stdout) {
+			_, err = stdout.Write(resp.StdoutData)
+			if err != nil {
+				return errors.WithMessage(err, "failed to write stdout")
+			}
+		}
+
+		if stderr := opts.Stderr; !isNil(stderr) {
+			_, err = stderr.Write(resp.StderrData)
+			if err != nil {
+				return errors.WithMessage(err, "failed to write stderr")
+			}
+		}
+
+		if code := resp.GetExitCode(); code != nil && code.GetValue() != 0 {
+			return errors.WithMessagef(err, "exit with code %d", code.GetValue())
+		}
+	}
+
+	return nil
+}
+
+func isNil(val any) bool {
+	if val == nil {
+		return true
+	}
+
+	v := reflect.ValueOf(val)
+
+	switch v.Type().Kind() {
+	case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer:
+		return v.IsNil()
+	default:
+		return false
+	}
 }
diff --git a/internal/runnerv2client/client_test.go b/internal/runnerv2client/client_test.go
new file mode 100644
index 000000000..ad99a050b
--- /dev/null
+++ b/internal/runnerv2client/client_test.go
@@ -0,0 +1,137 @@
+package runnerv2client
+
+import (
+	"bytes"
+	"context"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap/zaptest"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+	"google.golang.org/grpc/test/bufconn"
+
+	"github.com/stateful/runme/v3/internal/command"
+	"github.com/stateful/runme/v3/internal/runnerv2service"
+	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
+)
+
+func init() {
+	command.SetEnvDumpCommand("env -0")
+}
+
+func TestClient_ExecuteProgram(t *testing.T) {
+	t.Parallel()
+
+	lis, stop := testStartRunnerServiceServer(t)
+	t.Cleanup(stop)
+
+	t.Run("OutputWithSession", func(t *testing.T) {
+		t.Parallel()
+
+		client := testCreateClient(t, lis)
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+		defer cancel()
+
+		sessionResp, err := client.CreateSession(
+			ctx,
+			&runnerv2.CreateSessionRequest{
+				Env: []string{"TEST=test-output-with-session-env"},
+			},
+		)
+		require.NoError(t, err)
+
+		cfg := &command.ProgramConfig{
+			ProgramName: "bash",
+			Source: &runnerv2.ProgramConfig_Commands{
+				Commands: &runnerv2.ProgramConfig_CommandList{
+					Items: []string{
+						"echo -n $TEST",
+						">&2 echo -n test-output-with-session-stderr",
+					},
+				},
+			},
+			Mode: runnerv2.CommandMode_COMMAND_MODE_INLINE,
+		}
+		stdout := new(bytes.Buffer)
+		stderr := new(bytes.Buffer)
+		err = client.ExecuteProgram(
+			ctx,
+			cfg,
+			ExecuteProgramOptions{
+				SessionID: sessionResp.GetSession().GetId(),
+				Stdout:    stdout,
+				Stderr:    stderr,
+			},
+		)
+		require.NoError(t, err)
+		require.Equal(t, "test-output-with-session-env", stdout.String())
+		require.Equal(t, "test-output-with-session-stderr", stderr.String())
+	})
+
+	t.Run("InputNonInteractive", func(t *testing.T) {
+		t.Parallel()
+
+		client := testCreateClient(t, lis)
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+		defer cancel()
+
+		cfg := &command.ProgramConfig{
+			ProgramName: "bash",
+			Source: &runnerv2.ProgramConfig_Commands{
+				Commands: &runnerv2.ProgramConfig_CommandList{
+					Items: []string{
+						"read -r name",
+						"echo $name",
+					},
+				},
+			},
+			Mode: runnerv2.CommandMode_COMMAND_MODE_INLINE,
+		}
+		stdout := new(bytes.Buffer)
+		err := client.ExecuteProgram(
+			ctx,
+			cfg,
+			ExecuteProgramOptions{
+				InputData: []byte("test-input-non-interactive\n"),
+				Stdout:    stdout,
+			},
+		)
+		require.NoError(t, err)
+		require.Equal(t, "test-input-non-interactive\n", stdout.String())
+	})
+}
+
+// TODO(adamb): it's copied from internal/runnerv2service.
+func testStartRunnerServiceServer(t *testing.T) (*bufconn.Listener, func()) {
+	t.Helper()
+
+	logger := zaptest.NewLogger(t)
+	factory := command.NewFactory(command.WithLogger(logger))
+
+	server := grpc.NewServer()
+
+	runnerService, err := runnerv2service.NewRunnerService(factory, logger)
+	require.NoError(t, err)
+	runnerv2.RegisterRunnerServiceServer(server, runnerService)
+
+	lis := bufconn.Listen(1024 << 10)
+	go server.Serve(lis)
+
+	return lis, server.Stop
+}
+
+func testCreateClient(t *testing.T, lis *bufconn.Listener) *Client {
+	client, err := New(
+		"passthrough://bufconn",
+		zaptest.NewLogger(t),
+		grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
+			return lis.Dial()
+		}),
+		grpc.WithTransportCredentials(insecure.NewCredentials()),
+	)
+	require.NoError(t, err)
+	return client
+}
diff --git a/internal/runnerv2client/client_unix_test.go b/internal/runnerv2client/client_unix_test.go
new file mode 100644
index 000000000..05cf77c44
--- /dev/null
+++ b/internal/runnerv2client/client_unix_test.go
@@ -0,0 +1,54 @@
+//go:build !windows
+// +build !windows
+
+package runnerv2client
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"testing"
+	"time"
+
+	"github.com/stateful/runme/v3/internal/command"
+	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
+	"github.com/stretchr/testify/require"
+)
+
+func TestClient_ExecuteProgram_InputInteractive(t *testing.T) {
+	t.Parallel()
+
+	lis, stop := testStartRunnerServiceServer(t)
+	t.Cleanup(stop)
+
+	client := testCreateClient(t, lis)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+	defer cancel()
+
+	cfg := &command.ProgramConfig{
+		ProgramName: "bash",
+		Source: &runnerv2.ProgramConfig_Commands{
+			Commands: &runnerv2.ProgramConfig_CommandList{
+				Items: []string{
+					"read -r name",
+					"echo $name",
+				},
+			},
+		},
+		Interactive: true,
+		Mode:        runnerv2.CommandMode_COMMAND_MODE_INLINE,
+	}
+	stdout := new(bytes.Buffer)
+	err := client.ExecuteProgram(
+		ctx,
+		cfg,
+		ExecuteProgramOptions{
+			Stdin:  io.NopCloser(bytes.NewBufferString("test-input-interactive\n")),
+			Stdout: stdout,
+		},
+	)
+	require.NoError(t, err)
+	// Using [require.Contains] because on Linux the input is repeated.
+	// Unclear why it passes fine on macOS.
+	require.Contains(t, stdout.String(), "test-input-interactive\r\n")
+}
diff --git a/internal/runnerv2service/execution.go b/internal/runnerv2service/execution.go
index 7328ce02f..d3100622b 100644
--- a/internal/runnerv2service/execution.go
+++ b/internal/runnerv2service/execution.go
@@ -29,7 +29,7 @@ const (
 	// small.
 	// In the future, it might be worth to implement
 	// variable-sized buffers.
-	msgBufferSize = 2048 << 10 // 2 MiB
+	msgBufferSize = 2 * 1024 * 1024 // 2 MiB
 )
 
 var opininatedEnvVarNamingRegexp = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{1}[A-Z0-9_]*[A-Z][A-Z0-9_]*$`)
diff --git a/internal/runnerv2service/service_execute_test.go b/internal/runnerv2service/service_execute_test.go
index 3957b1f3a..20ad89a34 100644
--- a/internal/runnerv2service/service_execute_test.go
+++ b/internal/runnerv2service/service_execute_test.go
@@ -18,22 +18,19 @@ import (
 	"github.com/stretchr/testify/require"
 	"go.uber.org/zap/zaptest"
 	"google.golang.org/grpc"
-	"google.golang.org/grpc/credentials/insecure"
-	"google.golang.org/grpc/resolver"
 	"google.golang.org/grpc/test/bufconn"
 
 	"github.com/stateful/runme/v3/internal/command"
 	"github.com/stateful/runme/v3/internal/command/testdata"
 	"github.com/stateful/runme/v3/internal/config"
 	"github.com/stateful/runme/v3/internal/config/autoconfig"
+	"github.com/stateful/runme/v3/internal/testutils"
 	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
 )
 
 func init() {
 	command.SetEnvDumpCommand("env -0")
 
-	resolver.SetDefaultScheme("passthrough")
-
 	// Server uses autoconfig to get necessary dependencies.
 	// One of them, implicit, is [config.Config]. With the default
 	// [config.Loader] it won't be found during testing, so
@@ -89,7 +86,7 @@ func Test_conformsOpinionatedEnvVarNaming(t *testing.T) {
 func TestRunnerServiceServerExecute_Response(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	stream, err := client.Execute(context.Background())
 	require.NoError(t, err)
@@ -160,7 +157,7 @@ func TestRunnerServiceServerExecute_Response(t *testing.T) {
 func TestRunnerServiceServerExecute_MimeType(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	stream, err := client.Execute(context.Background())
 	require.NoError(t, err)
@@ -199,7 +196,7 @@ func TestRunnerServiceServerExecute_MimeType(t *testing.T) {
 func TestRunnerServiceServerExecute_StoreLastStdout(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	sessionResp, err := client.CreateSession(context.Background(), &runnerv2.CreateSessionRequest{})
 	require.NoError(t, err)
@@ -276,7 +273,7 @@ func TestRunnerServiceServerExecute_LastStdoutExceedsEnvLimit(t *testing.T) {
 
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	sessionResp, err := client.CreateSession(context.Background(), &runnerv2.CreateSessionRequest{})
 	require.NoError(t, err)
@@ -353,7 +350,7 @@ func TestRunnerServiceServerExecute_LargeOutput(t *testing.T) {
 
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	stream, err := client.Execute(context.Background())
 	require.NoError(t, err)
@@ -387,7 +384,7 @@ func TestRunnerServiceServerExecute_LargeOutput(t *testing.T) {
 func TestRunnerServiceServerExecute_StoreKnownName(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	sessionResp, err := client.CreateSession(context.Background(), &runnerv2.CreateSessionRequest{})
 	require.NoError(t, err)
@@ -460,7 +457,7 @@ func TestRunnerServiceServerExecute_StoreKnownName(t *testing.T) {
 func TestRunnerServiceServerExecute_Configs(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	testCases := []struct {
 		name           string
@@ -617,7 +614,7 @@ func TestRunnerServiceServerExecute_Configs(t *testing.T) {
 func TestRunnerServiceServerExecute_CommandMode_Terminal(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	sessResp, err := client.CreateSession(context.Background(), &runnerv2.CreateSessionRequest{})
 	require.NoError(t, err)
@@ -696,7 +693,7 @@ func TestRunnerServiceServerExecute_CommandMode_Terminal(t *testing.T) {
 func TestRunnerServiceServerExecute_PathEnvInSession(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	sessionResp, err := client.CreateSession(context.Background(), &runnerv2.CreateSessionRequest{})
 	require.NoError(t, err)
@@ -759,7 +756,7 @@ func TestRunnerServiceServerExecute_PathEnvInSession(t *testing.T) {
 func TestRunnerServiceServerExecute_WithInput(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	t.Run("ContinuousInput", func(t *testing.T) {
 		stream, err := client.Execute(context.Background())
@@ -876,7 +873,7 @@ func TestRunnerServiceServerExecute_WithInput(t *testing.T) {
 func TestRunnerServiceServerExecute_WithSession(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	t.Run("WithEnvAndMostRecentSessionStrategy", func(t *testing.T) {
 		{
@@ -942,7 +939,7 @@ func TestRunnerServiceServerExecute_WithSession(t *testing.T) {
 func TestRunnerServiceServerExecute_WithStop(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	stream, err := client.Execute(context.Background())
 	require.NoError(t, err)
@@ -1009,7 +1006,8 @@ func TestRunnerServiceServerExecute_WithStop(t *testing.T) {
 func TestRunnerServiceServerExecute_Winsize(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	t.Run("DefaultWinsize", func(t *testing.T) {
 		t.Parallel()
@@ -1104,24 +1102,6 @@ func testStartRunnerServiceServer(t *testing.T) (
 	return lis, server.Stop
 }
 
-func testCreateRunnerServiceClient(
-	t *testing.T,
-	lis interface{ Dial() (net.Conn, error) },
-) (*grpc.ClientConn, runnerv2.RunnerServiceClient) {
-	t.Helper()
-
-	conn, err := grpc.NewClient(
-		"passthrough",
-		grpc.WithTransportCredentials(insecure.NewCredentials()),
-		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
-			return lis.Dial()
-		}),
-	)
-	require.NoError(t, err)
-
-	return conn, runnerv2.NewRunnerServiceClient(conn)
-}
-
 type executeResult struct {
 	Stdout   []byte
 	Stderr   []byte
diff --git a/internal/runnerv2service/service_resolve_program_test.go b/internal/runnerv2service/service_resolve_program_test.go
index 8ab8dcb5e..1ce0a5c36 100644
--- a/internal/runnerv2service/service_resolve_program_test.go
+++ b/internal/runnerv2service/service_resolve_program_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"google.golang.org/protobuf/proto"
 
+	"github.com/stateful/runme/v3/internal/testutils"
 	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
 )
 
@@ -16,7 +17,7 @@ import (
 func TestRunnerServiceResolveProgram(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	testCases := []struct {
 		name    string
@@ -93,7 +94,7 @@ func TestRunnerResolveProgram_CommandsWithNewLines(t *testing.T) {
 
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	request := &runnerv2.ResolveProgramRequest{
 		Env:        []string{"FILE_NAME=my-file.txt"},
@@ -145,7 +146,7 @@ func TestRunnerResolveProgram_CommandsWithNewLines(t *testing.T) {
 func TestRunnerResolveProgram_OnlyShellLanguages(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	t.Run("Javascript passed as script", func(t *testing.T) {
 		script := "console.log('test');"
diff --git a/internal/runnerv2service/service_sessions_test.go b/internal/runnerv2service/service_sessions_test.go
index ff51e4e83..43d6727ae 100644
--- a/internal/runnerv2service/service_sessions_test.go
+++ b/internal/runnerv2service/service_sessions_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"google.golang.org/grpc/resolver"
 
+	"github.com/stateful/runme/v3/internal/testutils"
 	runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
 	"github.com/stateful/runme/v3/pkg/project/teststub"
 )
@@ -25,7 +26,8 @@ func TestRunnerServiceSessions(t *testing.T) {
 
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	EnvStoreSeedingNone := runnerv2.CreateSessionRequest_Config_SESSION_ENV_STORE_SEEDING_UNSPECIFIED.Enum()
 
@@ -117,7 +119,8 @@ func TestRunnerServiceSessions(t *testing.T) {
 func TestRunnerServiceSessions_StrategyMostRecent(t *testing.T) {
 	lis, stop := testStartRunnerServiceServer(t)
 	t.Cleanup(stop)
-	_, client := testCreateRunnerServiceClient(t, lis)
+
+	_, client := testutils.NewTestGRPCClient(t, lis, runnerv2.NewRunnerServiceClient)
 
 	// Create a session with env.
 	sessResp, err := client.CreateSession(
diff --git a/internal/testutils/grpc.go b/internal/testutils/grpc.go
new file mode 100644
index 000000000..266cdb359
--- /dev/null
+++ b/internal/testutils/grpc.go
@@ -0,0 +1,51 @@
+package testutils
+
+import (
+	"context"
+	"net"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+)
+
+func NewTestGRPCClient[T any](
+	t *testing.T,
+	lis interface{ Dial() (net.Conn, error) },
+	fn func(grpc.ClientConnInterface) T,
+) (*grpc.ClientConn, T) {
+	t.Helper()
+	conn, client, err := newGRPCClient(lis, fn)
+	require.NoError(t, err)
+	return conn, client
+}
+
+func NewGRPCClient[T any](
+	lis interface{ Dial() (net.Conn, error) },
+	fn func(grpc.ClientConnInterface) T,
+) (*grpc.ClientConn, T) {
+	conn, client, err := newGRPCClient(lis, fn)
+	if err != nil {
+		panic(err)
+	}
+	return conn, client
+}
+
+func newGRPCClient[T any](
+	lis interface{ Dial() (net.Conn, error) },
+	fn func(grpc.ClientConnInterface) T,
+) (*grpc.ClientConn, T, error) {
+	conn, err := grpc.NewClient(
+		"passthrough://bufconn",
+		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
+			return lis.Dial()
+		}),
+		grpc.WithTransportCredentials(insecure.NewCredentials()),
+	)
+	if err != nil {
+		var result T
+		return nil, result, err
+	}
+	return conn, fn(conn), nil
+}
diff --git a/internal/tls/tls.go b/internal/tls/tls.go
index 2e6cfbc28..aecded448 100644
--- a/internal/tls/tls.go
+++ b/internal/tls/tls.go
@@ -91,7 +91,8 @@ func LoadOrGenerateConfig(certFile, keyFile string, logger *zap.Logger) (*tls.Co
 	}
 
 	if config != nil {
-		if ttl, err := validateTLSConfig(config); err == nil {
+		ttl, err := validateTLSConfig(config)
+		if err == nil {
 			logger.Info("certificate is valid", zap.Duration("ttl", ttl), zap.String("certFile", certFile), zap.String("keyFile", keyFile))
 			return config, nil
 		}
diff --git a/pkg/document/block.go b/pkg/document/block.go
index 8e104f0fe..9cf982309 100644
--- a/pkg/document/block.go
+++ b/pkg/document/block.go
@@ -176,7 +176,8 @@ func (b *CodeBlock) Interactive() bool {
 }
 
 // InteractiveLegacy returns true as a default value.
-// Deprecated: use Interactive instead.
+// Deprecated: use Interactive instead, however, keep using
+// if you want to align with the VS Code extension.
 func (b *CodeBlock) InteractiveLegacy() bool {
 	val, err := strconv.ParseBool(b.Attributes()["interactive"])
 	if err != nil {
diff --git a/pkg/document/editor/editorservice/service_test.go b/pkg/document/editor/editorservice/service_test.go
index fadf883fd..cc72e1665 100644
--- a/pkg/document/editor/editorservice/service_test.go
+++ b/pkg/document/editor/editorservice/service_test.go
@@ -3,19 +3,17 @@ package editorservice
 import (
 	"context"
 	"fmt"
-	"net"
 	"os"
 	"strings"
 	"testing"
 
+	"github.com/stateful/runme/v3/internal/testutils"
 	"github.com/stateful/runme/v3/internal/ulid"
 	"github.com/stateful/runme/v3/internal/version"
 	parserv1 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/parser/v1"
 	"github.com/stretchr/testify/assert"
 	"go.uber.org/zap"
 	"google.golang.org/grpc"
-	"google.golang.org/grpc/credentials/insecure"
-	"google.golang.org/grpc/resolver"
 	"google.golang.org/grpc/test/bufconn"
 	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/wrapperspb"
@@ -56,27 +54,15 @@ var (
 
 func TestMain(m *testing.M) {
 	ulid.MockGenerator(testMockID)
-	resolver.SetDefaultScheme("passthrough")
 
 	lis := bufconn.Listen(2048)
 	server := grpc.NewServer()
 	parserv1.RegisterParserServiceServer(server, NewParserServiceServer(zap.NewNop()))
 	go server.Serve(lis)
 
-	conn, err := grpc.NewClient(
-		"passthrough",
-		grpc.WithTransportCredentials(insecure.NewCredentials()),
-		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
-			return lis.Dial()
-		}),
-	)
-	if err != nil {
-		panic(err)
-	}
+	_, client = testutils.NewGRPCClient(lis, parserv1.NewParserServiceClient)
 
-	client = parserv1.NewParserServiceClient(conn)
 	code := m.Run()
-
 	ulid.ResetGenerator()
 	os.Exit(code)
 }