diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a369c96..1571859b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: browser-actions/setup-chrome@v2 + id: setup-chrome + with: + chrome-version: stable + install-dependencies: true + - name: Set up Go uses: actions/setup-go@v6 with: @@ -137,5 +143,32 @@ jobs: - name: Check examples run: sh scripts/check-examples.sh - run: go test $(go list ./... | grep -v '/examples$') + env: + CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} - run: go -C internal/generator test ./... - run: go build $(go list ./... | grep -v '/examples$') + + go-windows: + name: Go (Windows process management) + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + working-directory: packages/sdk-go + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.x" + cache: false + + - name: Test Windows process management + run: >- + go test + -run 'TestConfigureChromeProcessUsesDedicatedProcessGroupWindows|TestTaskkillArgsPreserveGracefulThenForcefulShutdown|TestIgnoreFinishedProcessErrorWindows' + . diff --git a/justfile b/justfile index e65a15b86..aec074d27 100644 --- a/justfile +++ b/justfile @@ -10,6 +10,7 @@ install: generate: pnpm --filter ./packages/protocol build uv --directory {{python_dir}} run --locked python scripts/generate.py + pnpm --filter ./packages/server build go -C {{go_dir}} generate ./... check: check-go-examples @@ -23,6 +24,8 @@ check: check-go-examples uv --directory {{python_dir}} run --locked ruff check . uv --directory {{python_dir}} run --locked ty check go -C {{go_generator_dir}} run . --check + pnpm --filter ./packages/server build + go -C {{go_dir}} run ./internal/extensionpack --check test -z "$(find {{go_dir}} -name '*.go' -type f -exec gofmt -l {} +)" go -C {{go_dir}} vet $(go -C {{go_dir}} list ./... | grep -v '/examples$') go -C {{go_generator_dir}} vet ./... @@ -53,8 +56,9 @@ fmt: build: pnpm build uv --directory {{python_dir}} run --locked python scripts/build.py + go -C {{go_dir}} run ./internal/extensionpack --check go -C {{go_dir}} build $(go -C {{go_dir}} list ./... | grep -v '/examples$') - go -C {{go_generator_dir}} build ./... + go -C {{go_generator_dir}} test -run '^$' ./... changeset: pnpm exec changeset diff --git a/packages/sdk-go/chrome_launcher.go b/packages/sdk-go/chrome_launcher.go new file mode 100644 index 000000000..2b84225b0 --- /dev/null +++ b/packages/sdk-go/chrome_launcher.go @@ -0,0 +1,523 @@ +package stagehand + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultChromeWidth = 1280 + defaultChromeHeight = 800 + defaultChromeLaunchTimeout = 10 * time.Second + chromePollInterval = 100 * time.Millisecond +) + +// Copyright 2017 Google Inc. All Rights Reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// https://www.apache.org/licenses/LICENSE-2.0. +// +// defaultChromeFlags tracks chrome-launcher 1.2.1's DEFAULT_FLAGS, excluding +// --disable-extensions so Stagehand can load its unpacked extension. The +// TypeScript and Python SDKs use the same list. +var defaultChromeFlags = []string{ + "--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider," + + "CalculateNativeWinOcclusion,InterestFeedContentSuggestions," + + "CertificateTransparencyComponentUpdater,AutofillServerCommunication," + + "PrivacySandboxSettings4,RenderDocument", + "--disable-component-extensions-with-background-pages", + "--disable-background-networking", + "--disable-component-update", + "--disable-client-side-phishing-detection", + "--disable-sync", + "--metrics-recording-only", + "--disable-default-apps", + "--mute-audio", + "--no-default-browser-check", + "--no-first-run", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling", + "--disable-ipc-flooding-protection", + "--password-store=basic", + "--use-mock-keychain", + "--force-fieldtrials=*BackgroundTracing/default/", + "--disable-hang-monitor", + "--disable-prompt-on-repost", + "--disable-domain-reliability", + "--propagate-iph-for-testing", +} + +type launchedChrome struct { + cdpURL string + userDataDir string + process *chromeProcess + removeDir bool + + closeOnce sync.Once + closeErr error +} + +type chromeProcess struct { + command *exec.Cmd + done chan struct{} + + mu sync.Mutex + err error +} + +func launchLocalBrowser( + ctx context.Context, + options LocalBrowserSource, +) (resolvedBrowserSource, error) { + launched, err := launchChrome(ctx, options) + if err != nil { + return resolvedBrowserSource{}, err + } + return resolvedBrowserSource{ + cdpURL: launched.cdpURL, + keepAlive: options.KeepAlive, + close: launched.close, + }, nil +} + +func launchChrome( + ctx context.Context, + options LocalBrowserSource, +) (*launchedChrome, error) { + if ctx == nil { + return nil, errors.New("stagehand Chrome launch context is required") + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("launch Chrome: %w", err) + } + if err := validateLocalBrowserOptions(options); err != nil { + return nil, err + } + + chromePath, err := findChromePath(options.ExecutablePath) + if err != nil { + return nil, err + } + port := options.Port + if port == 0 { + port, err = availablePort() + if err != nil { + return nil, err + } + } + + userDataDir := options.UserDataDir + temporaryProfile := userDataDir == "" + if temporaryProfile { + userDataDir, err = os.MkdirTemp("", "stagehand-chrome-") + if err != nil { + return nil, fmt.Errorf("create Chrome profile: %w", err) + } + } else if err := os.MkdirAll(userDataDir, 0o700); err != nil { + return nil, fmt.Errorf("create Chrome profile %q: %w", userDataDir, err) + } + + removeDir := temporaryProfile && !options.PreserveUserDataDir + cleanupProfile := func() error { + if !removeDir { + return nil + } + if err := os.RemoveAll(userDataDir); err != nil { + return fmt.Errorf("remove Chrome profile: %w", err) + } + return nil + } + + command := exec.Command(chromePath, buildChromeArgs(options, port, userDataDir)...) + command.Stdin = nil + command.Stdout = io.Discard + command.Stderr = io.Discard + configureChromeProcess(command) + if err := ctx.Err(); err != nil { + return nil, errors.Join( + fmt.Errorf("launch Chrome: %w", err), + cleanupProfile(), + ) + } + if err := command.Start(); err != nil { + return nil, errors.Join( + fmt.Errorf("start Chrome: %w", err), + cleanupProfile(), + ) + } + + process := newChromeProcess(command) + launched := &launchedChrome{ + cdpURL: fmt.Sprintf("http://127.0.0.1:%d", port), + userDataDir: userDataDir, + process: process, + removeDir: removeDir, + } + if err := waitForChrome(ctx, launched.cdpURL, process, chromeLaunchTimeout(options)); err != nil { + return nil, errors.Join(err, launched.close(context.Background())) + } + return launched, nil +} + +func validateLocalBrowserOptions(options LocalBrowserSource) error { + if options.Port < 0 || options.Port > 65_535 { + return errors.New("stagehand Chrome port must be 0 or between 1 and 65535") + } + if options.ConnectTimeoutMs < 0 { + return errors.New("stagehand Chrome connect timeout cannot be negative") + } + if options.Viewport != nil && (options.Viewport.Width <= 0 || options.Viewport.Height <= 0) { + return errors.New("stagehand Chrome viewport dimensions must be positive") + } + if options.DeviceScaleFactor != nil && + (*options.DeviceScaleFactor <= 0 || + math.IsNaN(*options.DeviceScaleFactor) || + math.IsInf(*options.DeviceScaleFactor, 0)) { + return errors.New("stagehand Chrome device scale factor must be positive and finite") + } + if options.Proxy != nil { + if options.Proxy.Server == "" { + return errors.New("stagehand Chrome proxy server is required") + } + if options.Proxy.Username != "" || options.Proxy.Password != "" { + return errors.New("stagehand authenticated local browser proxies are not implemented") + } + } + if options.DownloadsPath != "" || options.AcceptDownloads != nil { + return errors.New("stagehand local browser download options require post-connect CDP setup") + } + return nil +} + +func buildChromeArgs(options LocalBrowserSource, port int, userDataDir string) []string { + args := selectedDefaultChromeFlags(options.IgnoreDefaultArgs) + + width, height := defaultChromeWidth, defaultChromeHeight + if options.Viewport != nil { + width, height = options.Viewport.Width, options.Viewport.Height + } + args = append(args, + "--enable-unsafe-extension-debugging", + "--remote-allow-origins=*", + fmt.Sprintf("--window-size=%d,%d", width, height), + fmt.Sprintf("--remote-debugging-port=%d", port), + "--user-data-dir="+userDataDir, + ) + args = append(args, options.Args...) + + if options.Headless { + args = append(args, "--headless") + } + if options.Devtools { + args = append(args, "--auto-open-devtools-for-tabs") + } + if os.Getenv("CI") != "" || + (options.ChromiumSandbox != nil && !*options.ChromiumSandbox) || + (runtime.GOOS == "linux" && runningAsRoot()) { + args = append(args, "--no-sandbox") + } + if options.Proxy != nil { + args = append(args, "--proxy-server="+options.Proxy.Server) + if options.Proxy.Bypass != "" { + args = append(args, "--proxy-bypass-list="+options.Proxy.Bypass) + } + } + if options.Locale != "" { + args = append(args, "--lang="+options.Locale) + } + if options.DeviceScaleFactor != nil { + args = append( + args, + "--force-device-scale-factor="+strconv.FormatFloat( + *options.DeviceScaleFactor, + 'f', + -1, + 64, + ), + ) + } + if options.HasTouch { + args = append(args, "--touch-events=enabled") + } + if options.IgnoreHTTPSErrors { + args = append(args, "--ignore-certificate-errors") + } + return append(args, "about:blank") +} + +func selectedDefaultChromeFlags(ignore *IgnoreDefaultArgs) []string { + if ignore != nil && ignore.All { + return nil + } + if ignore == nil || len(ignore.Args) == 0 { + return append([]string(nil), defaultChromeFlags...) + } + + ignored := make(map[string]struct{}, len(ignore.Args)) + for _, arg := range ignore.Args { + ignored[arg] = struct{}{} + } + result := make([]string, 0, len(defaultChromeFlags)) + for _, arg := range defaultChromeFlags { + if _, skip := ignored[arg]; !skip { + result = append(result, arg) + } + } + return result +} + +func findChromePath(explicitPath string) (string, error) { + if explicitPath != "" { + if isFile(explicitPath) { + return explicitPath, nil + } + return "", fmt.Errorf("Chrome executable %q does not exist", explicitPath) + } + return findChromePathForOS(runtime.GOOS, os.Getenv, exec.LookPath, isFile) +} + +func findChromePathForOS( + goos string, + getenv func(string) string, + lookPath func(string) (string, error), + fileExists func(string) bool, +) (string, error) { + if configured := getenv("CHROME_PATH"); configured != "" && fileExists(configured) { + return configured, nil + } + + var candidates []string + switch goos { + case "darwin": + candidates = []string{ + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + } + case "windows": + roots := []string{ + getenv("LOCALAPPDATA"), + getenv("PROGRAMFILES"), + getenv("PROGRAMFILES(X86)"), + } + suffixes := [][]string{ + {"Google", "Chrome SxS", "Application", "chrome.exe"}, + {"Google", "Chrome", "Application", "chrome.exe"}, + } + for _, root := range roots { + if root == "" { + continue + } + for _, suffix := range suffixes { + candidates = append(candidates, filepath.Join(append([]string{root}, suffix...)...)) + } + } + case "linux": + for _, name := range []string{ + "google-chrome-stable", + "google-chrome", + "chromium-browser", + "chromium", + } { + if path, err := lookPath(name); err == nil { + candidates = append(candidates, path) + } + } + default: + return "", fmt.Errorf("Chrome launching is not supported on %s", goos) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + } + return "", errors.New("Chrome installation not found; set CHROME_PATH") +} + +func isFile(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func availablePort() (int, error) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return 0, fmt.Errorf("select Chrome debugging port: %w", err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + return port, nil +} + +func chromeLaunchTimeout(options LocalBrowserSource) time.Duration { + if options.ConnectTimeoutMs > 0 { + return time.Duration(options.ConnectTimeoutMs) * time.Millisecond + } + return defaultChromeLaunchTimeout +} + +func waitForChrome( + ctx context.Context, + cdpURL string, + process *chromeProcess, + timeout time.Duration, +) error { + waitContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + client := &http.Client{Timeout: chromePollInterval} + ticker := time.NewTicker(chromePollInterval) + defer ticker.Stop() + + for { + if err := waitContext.Err(); err != nil { + return fmt.Errorf("wait for Chrome debugging port: %w", err) + } + select { + case <-process.done: + return chromeExitedBeforeReadyError(process) + default: + } + + if chromeDebuggingReady(waitContext, client, cdpURL) { + if err := waitContext.Err(); err != nil { + return fmt.Errorf("wait for Chrome debugging port: %w", err) + } + select { + case <-process.done: + return chromeExitedBeforeReadyError(process) + default: + } + return nil + } + + select { + case <-process.done: + return chromeExitedBeforeReadyError(process) + case <-waitContext.Done(): + return fmt.Errorf("wait for Chrome debugging port: %w", waitContext.Err()) + case <-ticker.C: + } + } +} + +func chromeDebuggingReady(ctx context.Context, client *http.Client, cdpURL string) bool { + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + strings.TrimRight(cdpURL, "/")+"/json/version", + nil, + ) + if err != nil { + return false + } + response, err := client.Do(request) + if err != nil { + return false + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return false + } + var version struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + return json.NewDecoder(response.Body).Decode(&version) == nil && + strings.TrimSpace(version.WebSocketDebuggerURL) != "" +} + +func chromeExitedBeforeReadyError(process *chromeProcess) error { + if processErr := process.waitError(); processErr != nil { + return fmt.Errorf("Chrome exited before its debugging port was ready: %w", processErr) + } + return errors.New("Chrome exited before its debugging port was ready") +} + +func newChromeProcess(command *exec.Cmd) *chromeProcess { + process := &chromeProcess{command: command, done: make(chan struct{})} + go func() { + err := command.Wait() + process.mu.Lock() + process.err = err + process.mu.Unlock() + close(process.done) + }() + return process +} + +func (process *chromeProcess) waitError() error { + process.mu.Lock() + defer process.mu.Unlock() + return process.err +} + +func (launched *launchedChrome) close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + launched.closeOnce.Do(func() { + launched.closeErr = launched.closeProcess(ctx) + if launched.removeDir { + launched.closeErr = errors.Join( + launched.closeErr, + removeChromeProfile(launched.userDataDir), + ) + } + }) + return launched.closeErr +} + +func (launched *launchedChrome) closeProcess(ctx context.Context) error { + select { + case <-launched.process.done: + return nil + default: + } + + terminateErr := terminateChromeProcess(launched.process.command) + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + select { + case <-launched.process.done: + return ignoreFinishedProcessError(terminateErr) + case <-ctx.Done(): + killErr := killChromeProcess(launched.process.command) + <-launched.process.done + return errors.Join(ignoreFinishedProcessError(terminateErr), ignoreFinishedProcessError(killErr)) + case <-timer.C: + killErr := killChromeProcess(launched.process.command) + <-launched.process.done + return errors.Join(ignoreFinishedProcessError(terminateErr), ignoreFinishedProcessError(killErr)) + } +} + +func removeChromeProfile(path string) error { + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove Chrome profile: %w", err) + } + return nil +} + +func ignoreFinishedProcessError(err error) error { + if errors.Is(err, os.ErrProcessDone) || isFinishedChromeProcessError(err) { + return nil + } + return err +} diff --git a/packages/sdk-go/chrome_launcher_test.go b/packages/sdk-go/chrome_launcher_test.go new file mode 100644 index 000000000..74cf076da --- /dev/null +++ b/packages/sdk-go/chrome_launcher_test.go @@ -0,0 +1,420 @@ +package stagehand + +import ( + "context" + "encoding/json" + "errors" + "math" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" +) + +func TestDefaultChromeFlagsMatchChromeLauncher(t *testing.T) { + want := []string{ + "--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider," + + "CalculateNativeWinOcclusion,InterestFeedContentSuggestions," + + "CertificateTransparencyComponentUpdater,AutofillServerCommunication," + + "PrivacySandboxSettings4,RenderDocument", + "--disable-component-extensions-with-background-pages", + "--disable-background-networking", + "--disable-component-update", + "--disable-client-side-phishing-detection", + "--disable-sync", + "--metrics-recording-only", + "--disable-default-apps", + "--mute-audio", + "--no-default-browser-check", + "--no-first-run", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling", + "--disable-ipc-flooding-protection", + "--password-store=basic", + "--use-mock-keychain", + "--force-fieldtrials=*BackgroundTracing/default/", + "--disable-hang-monitor", + "--disable-prompt-on-repost", + "--disable-domain-reliability", + "--propagate-iph-for-testing", + } + if !reflect.DeepEqual(defaultChromeFlags, want) { + t.Fatalf("defaultChromeFlags = %#v, want %#v", defaultChromeFlags, want) + } + if slices.Contains(defaultChromeFlags, "--disable-extensions") { + t.Fatal("defaultChromeFlags contains --disable-extensions") + } +} + +func TestBuildChromeArgsSupportsLocalBrowserOptions(t *testing.T) { + t.Setenv("CI", "") + sandbox := false + deviceScaleFactor := 2.0 + options := LocalBrowserSource{ + Args: []string{"--custom-flag=value"}, + Headless: true, + Devtools: true, + ChromiumSandbox: &sandbox, + Proxy: &LocalProxyConfig{Server: "http://proxy.test:8080", Bypass: "localhost"}, + Locale: "de-CH", + Viewport: &LocalViewport{Width: 1440, Height: 900}, + DeviceScaleFactor: &deviceScaleFactor, + HasTouch: true, + IgnoreHTTPSErrors: true, + } + + got := buildChromeArgs(options, 9_222, "/tmp/stagehand profile") + for _, want := range []string{ + "--enable-unsafe-extension-debugging", + "--remote-allow-origins=*", + "--window-size=1440,900", + "--remote-debugging-port=9222", + "--user-data-dir=/tmp/stagehand profile", + "--custom-flag=value", + "--headless", + "--auto-open-devtools-for-tabs", + "--no-sandbox", + "--proxy-server=http://proxy.test:8080", + "--proxy-bypass-list=localhost", + "--lang=de-CH", + "--force-device-scale-factor=2", + "--touch-events=enabled", + "--ignore-certificate-errors", + } { + if !slices.Contains(got, want) { + t.Errorf("buildChromeArgs() missing %q in %#v", want, got) + } + } + if got[len(got)-1] != "about:blank" { + t.Fatalf("buildChromeArgs() last argument = %q, want about:blank", got[len(got)-1]) + } +} + +func TestBuildChromeArgsCanIgnoreDefaultArgs(t *testing.T) { + t.Run("all", func(t *testing.T) { + got := buildChromeArgs( + LocalBrowserSource{IgnoreDefaultArgs: &IgnoreDefaultArgs{All: true}}, + 9_222, + "/tmp/profile", + ) + for _, defaultArg := range defaultChromeFlags { + if slices.Contains(got, defaultArg) { + t.Fatalf("buildChromeArgs() contains ignored default %q", defaultArg) + } + } + }) + + t.Run("selected", func(t *testing.T) { + ignored := defaultChromeFlags[1] + got := buildChromeArgs( + LocalBrowserSource{ + IgnoreDefaultArgs: &IgnoreDefaultArgs{Args: []string{ignored}}, + }, + 9_222, + "/tmp/profile", + ) + if slices.Contains(got, ignored) { + t.Fatalf("buildChromeArgs() contains ignored default %q", ignored) + } + if !slices.Contains(got, defaultChromeFlags[0]) { + t.Fatalf("buildChromeArgs() omitted non-ignored default %q", defaultChromeFlags[0]) + } + }) +} + +func TestFindChromePathForSupportedPlatforms(t *testing.T) { + t.Run("configured path takes precedence", func(t *testing.T) { + const configured = "/custom/chrome" + got, err := findChromePathForOS( + "darwin", + func(name string) string { + if name == "CHROME_PATH" { + return configured + } + return "" + }, + exec.LookPath, + func(path string) bool { return path == configured }, + ) + if err != nil { + t.Fatalf("findChromePathForOS() error = %v", err) + } + if got != configured { + t.Fatalf("findChromePathForOS() = %q, want %q", got, configured) + } + }) + + t.Run("macOS", func(t *testing.T) { + const stable = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + got, err := findChromePathForOS( + "darwin", + func(string) string { return "" }, + exec.LookPath, + func(path string) bool { return path == stable }, + ) + if err != nil { + t.Fatalf("findChromePathForOS() error = %v", err) + } + if got != stable { + t.Fatalf("findChromePathForOS() = %q, want %q", got, stable) + } + }) + + t.Run("Windows", func(t *testing.T) { + root := filepath.Join("C:", "Users", "stagehand", "AppData", "Local") + stable := filepath.Join(root, "Google", "Chrome", "Application", "chrome.exe") + got, err := findChromePathForOS( + "windows", + func(name string) string { + if name == "LOCALAPPDATA" { + return root + } + return "" + }, + exec.LookPath, + func(path string) bool { return path == stable }, + ) + if err != nil { + t.Fatalf("findChromePathForOS() error = %v", err) + } + if got != stable { + t.Fatalf("findChromePathForOS() = %q, want %q", got, stable) + } + }) + + t.Run("Linux", func(t *testing.T) { + const stable = "/usr/bin/google-chrome-stable" + got, err := findChromePathForOS( + "linux", + func(string) string { return "" }, + func(name string) (string, error) { + if name == "google-chrome-stable" { + return stable, nil + } + return "", errors.New("not found") + }, + func(path string) bool { return path == stable }, + ) + if err != nil { + t.Fatalf("findChromePathForOS() error = %v", err) + } + if got != stable { + t.Fatalf("findChromePathForOS() = %q, want %q", got, stable) + } + }) +} + +func TestValidateLocalBrowserOptions(t *testing.T) { + tests := []struct { + name string + options LocalBrowserSource + }{ + {name: "negative port", options: LocalBrowserSource{Port: -1}}, + {name: "large port", options: LocalBrowserSource{Port: 65_536}}, + {name: "negative timeout", options: LocalBrowserSource{ConnectTimeoutMs: -1}}, + {name: "empty viewport", options: LocalBrowserSource{Viewport: &LocalViewport{}}}, + { + name: "negative scale", + options: func() LocalBrowserSource { + scale := -1.0 + return LocalBrowserSource{DeviceScaleFactor: &scale} + }(), + }, + { + name: "NaN scale", + options: func() LocalBrowserSource { + scale := math.NaN() + return LocalBrowserSource{DeviceScaleFactor: &scale} + }(), + }, + { + name: "positive infinite scale", + options: func() LocalBrowserSource { + scale := math.Inf(1) + return LocalBrowserSource{DeviceScaleFactor: &scale} + }(), + }, + { + name: "negative infinite scale", + options: func() LocalBrowserSource { + scale := math.Inf(-1) + return LocalBrowserSource{DeviceScaleFactor: &scale} + }(), + }, + {name: "empty proxy", options: LocalBrowserSource{Proxy: &LocalProxyConfig{}}}, + { + name: "authenticated proxy", + options: LocalBrowserSource{ + Proxy: &LocalProxyConfig{Server: "http://proxy.test", Username: "user"}, + }, + }, + { + name: "download options", + options: func() LocalBrowserSource { + acceptDownloads := false + return LocalBrowserSource{AcceptDownloads: &acceptDownloads} + }(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := validateLocalBrowserOptions(test.options); err == nil { + t.Fatal("validateLocalBrowserOptions() error = nil") + } + }) + } +} + +func TestLaunchChromeRejectsCanceledContextBeforeStarting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := launchChrome(ctx, LocalBrowserSource{ExecutablePath: os.Args[0]}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("launchChrome() error = %v, want context.Canceled", err) + } +} + +func TestWaitForChromeRequiresCDPVersionEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + response http.ResponseWriter, + _ *http.Request, + ) { + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{}`)) + })) + defer server.Close() + + process := &chromeProcess{done: make(chan struct{})} + err := waitForChrome(context.Background(), server.URL, process, 250*time.Millisecond) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("waitForChrome() error = %v, want context deadline exceeded", err) + } +} + +func TestWaitForChromeAcceptsValidCDPVersionEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + response http.ResponseWriter, + request *http.Request, + ) { + if request.URL.Path != "/json/version" { + t.Errorf("request path = %q, want /json/version", request.URL.Path) + } + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte( + `{"webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser/test"}`, + )) + })) + defer server.Close() + + process := &chromeProcess{done: make(chan struct{})} + if err := waitForChrome(context.Background(), server.URL, process, time.Second); err != nil { + t.Fatalf("waitForChrome() error = %v", err) + } +} + +func TestWaitForChromePrioritizesCancellationAndProcessExit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + response http.ResponseWriter, + _ *http.Request, + ) { + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte( + `{"webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser/test"}`, + )) + })) + defer server.Close() + + t.Run("canceled context", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + process := &chromeProcess{done: make(chan struct{})} + err := waitForChrome(ctx, server.URL, process, time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("waitForChrome() error = %v, want context.Canceled", err) + } + }) + + t.Run("exited process", func(t *testing.T) { + process := &chromeProcess{done: make(chan struct{}), err: errors.New("exit status 1")} + close(process.done) + err := waitForChrome(context.Background(), server.URL, process, time.Second) + if err == nil || !strings.Contains(err.Error(), "exited before") { + t.Fatalf("waitForChrome() error = %v, want exited-before-ready error", err) + } + }) +} + +func TestLaunchLocalBrowserAgainstInstalledChrome(t *testing.T) { + chromePath, err := findChromePath("") + if err != nil { + t.Skipf("Chrome is not installed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + launched, err := launchChrome(ctx, LocalBrowserSource{ + ExecutablePath: chromePath, + Headless: true, + ConnectTimeoutMs: 15_000, + }) + if err != nil { + t.Fatalf("launchLocalBrowser() error = %v", err) + } + t.Cleanup(func() { + closeContext, closeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer closeCancel() + if err := launched.close(closeContext); err != nil { + t.Errorf("close launched Chrome: %v", err) + } + }) + + response, err := http.Get(launched.cdpURL + "/json/version") + if err != nil { + t.Fatalf("GET /json/version: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("GET /json/version status = %d, want 200", response.StatusCode) + } + var version struct { + Browser string `json:"Browser"` + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := json.NewDecoder(response.Body).Decode(&version); err != nil { + t.Fatalf("decode /json/version: %v", err) + } + if version.Browser == "" || version.WebSocketDebuggerURL == "" { + t.Fatalf("GET /json/version = %#v, want browser and WebSocket URL", version) + } + if err := launched.close(context.Background()); err != nil { + t.Fatalf("close launched Chrome: %v", err) + } + if _, err := os.Stat(launched.userDataDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("temporary Chrome profile still exists after close: %v", err) + } +} + +func TestFindChromePathRejectsMissingExplicitPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing-chrome") + if _, err := findChromePath(path); err == nil { + t.Fatal("findChromePath() error = nil") + } +} + +func TestAvailablePort(t *testing.T) { + port, err := availablePort() + if err != nil { + t.Fatalf("availablePort() error = %v", err) + } + if port < 1 || port > 65_535 { + t.Fatalf("availablePort() = %d, want valid TCP port", port) + } +} diff --git a/packages/sdk-go/chrome_process_unix.go b/packages/sdk-go/chrome_process_unix.go new file mode 100644 index 000000000..cdd785296 --- /dev/null +++ b/packages/sdk-go/chrome_process_unix.go @@ -0,0 +1,36 @@ +//go:build !windows + +package stagehand + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func configureChromeProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func terminateChromeProcess(command *exec.Cmd) error { + if command.Process == nil { + return nil + } + return syscall.Kill(-command.Process.Pid, syscall.SIGTERM) +} + +func killChromeProcess(command *exec.Cmd) error { + if command.Process == nil { + return nil + } + return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) +} + +func isFinishedChromeProcessError(err error) bool { + return errors.Is(err, syscall.ESRCH) +} + +func runningAsRoot() bool { + return os.Geteuid() == 0 +} diff --git a/packages/sdk-go/chrome_process_unix_test.go b/packages/sdk-go/chrome_process_unix_test.go new file mode 100644 index 000000000..e2f361d09 --- /dev/null +++ b/packages/sdk-go/chrome_process_unix_test.go @@ -0,0 +1,97 @@ +//go:build !windows + +package stagehand + +import ( + "errors" + "os/exec" + "syscall" + "testing" + "time" +) + +func TestConfigureChromeProcessUsesDedicatedProcessGroup(t *testing.T) { + command := exec.Command("sh", "-c", "exit 0") + configureChromeProcess(command) + if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid { + t.Fatalf("SysProcAttr = %#v, want Setpgid", command.SysProcAttr) + } +} + +func TestChromeProcessGroupTermination(t *testing.T) { + tests := []struct { + name string + signal func(*exec.Cmd) error + }{ + {name: "terminate", signal: terminateChromeProcess}, + {name: "kill", signal: killChromeProcess}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := exec.Command("sh", "-c", "trap 'exit 0' TERM; sleep 30 & wait") + configureChromeProcess(command) + if err := command.Start(); err != nil { + t.Fatalf("start process group: %v", err) + } + process := newChromeProcess(command) + t.Cleanup(func() { + select { + case <-process.done: + return + default: + } + _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + select { + case <-process.done: + case <-time.After(3 * time.Second): + t.Errorf("process group did not exit during cleanup") + } + }) + + processGroup, err := syscall.Getpgid(command.Process.Pid) + if err != nil { + t.Fatalf("get process group: %v", err) + } + if processGroup != command.Process.Pid { + t.Fatalf("process group = %d, want %d", processGroup, command.Process.Pid) + } + if err := test.signal(command); err != nil { + t.Fatalf("%s process group: %v", test.name, err) + } + select { + case <-process.done: + case <-time.After(3 * time.Second): + t.Fatalf("%s did not stop process group", test.name) + } + waitForProcessGroupRemoval(t, command.Process.Pid) + }) + } +} + +func TestIgnoreFinishedProcessErrorUnix(t *testing.T) { + if err := ignoreFinishedProcessError(syscall.ESRCH); err != nil { + t.Fatalf("ignoreFinishedProcessError(ESRCH) = %v, want nil", err) + } + sentinel := errors.New("permission denied") + if err := ignoreFinishedProcessError(sentinel); !errors.Is(err, sentinel) { + t.Fatalf("ignoreFinishedProcessError() = %v, want %v", err, sentinel) + } +} + +func waitForProcessGroupRemoval(t *testing.T, processGroup int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + err := syscall.Kill(-processGroup, 0) + if errors.Is(err, syscall.ESRCH) { + return + } + if err != nil { + t.Fatalf("probe process group: %v", err) + } + if time.Now().After(deadline) { + t.Fatalf("process group %d still exists", processGroup) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/packages/sdk-go/chrome_process_windows.go b/packages/sdk-go/chrome_process_windows.go new file mode 100644 index 000000000..0ddf34960 --- /dev/null +++ b/packages/sdk-go/chrome_process_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package stagehand + +import ( + "errors" + "os/exec" + "strconv" + "syscall" +) + +const createNewProcessGroup = 0x00000200 + +func configureChromeProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup} +} + +func terminateChromeProcess(command *exec.Cmd) error { + if command.Process == nil { + return nil + } + return exec.Command("taskkill", taskkillArgs(command.Process.Pid, false)...).Run() +} + +func killChromeProcess(command *exec.Cmd) error { + if command.Process == nil { + return nil + } + return exec.Command("taskkill", taskkillArgs(command.Process.Pid, true)...).Run() +} + +func taskkillArgs(pid int, force bool) []string { + args := []string{"/PID", strconv.Itoa(pid), "/T"} + if force { + args = append(args, "/F") + } + return args +} + +func isFinishedChromeProcessError(err error) bool { + var exitErr *exec.ExitError + return errors.As(err, &exitErr) && exitErr.ExitCode() == 128 +} + +func runningAsRoot() bool { + return false +} diff --git a/packages/sdk-go/chrome_process_windows_test.go b/packages/sdk-go/chrome_process_windows_test.go new file mode 100644 index 000000000..475de3a69 --- /dev/null +++ b/packages/sdk-go/chrome_process_windows_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package stagehand + +import ( + "errors" + "os/exec" + "reflect" + "testing" +) + +func TestConfigureChromeProcessUsesDedicatedProcessGroupWindows(t *testing.T) { + command := exec.Command("cmd", "/C", "exit 0") + configureChromeProcess(command) + if command.SysProcAttr == nil || + command.SysProcAttr.CreationFlags&createNewProcessGroup == 0 { + t.Fatalf( + "SysProcAttr = %#v, want CreationFlags to contain %#x", + command.SysProcAttr, + createNewProcessGroup, + ) + } +} + +func TestTaskkillArgsPreserveGracefulThenForcefulShutdown(t *testing.T) { + if got, want := taskkillArgs(42, false), []string{"/PID", "42", "/T"}; !reflect.DeepEqual(got, want) { + t.Fatalf("graceful taskkill args = %#v, want %#v", got, want) + } + if got, want := taskkillArgs(42, true), []string{"/PID", "42", "/T", "/F"}; !reflect.DeepEqual(got, want) { + t.Fatalf("forceful taskkill args = %#v, want %#v", got, want) + } +} + +func TestIgnoreFinishedProcessErrorWindows(t *testing.T) { + exitError := exec.Command("cmd", "/C", "exit /B 128").Run() + if exitError == nil { + t.Fatal("cmd exit 128 returned nil") + } + if err := ignoreFinishedProcessError(exitError); err != nil { + t.Fatalf("ignoreFinishedProcessError(exit 128) = %v, want nil", err) + } + + sentinel := errors.New("permission denied") + if err := ignoreFinishedProcessError(sentinel); !errors.Is(err, sentinel) { + t.Fatalf("ignoreFinishedProcessError() = %v, want %v", err, sentinel) + } +} diff --git a/packages/sdk-go/client_options.go b/packages/sdk-go/client_options.go index f41b8307d..69d45b39f 100644 --- a/packages/sdk-go/client_options.go +++ b/packages/sdk-go/client_options.go @@ -12,7 +12,6 @@ type BrowserSource interface { } // LocalBrowserSource configures a Chromium process launched by the SDK. -// Browser launching is the one intentionally deferred part of this client PR. type LocalBrowserSource struct { Args []string ExecutablePath string @@ -21,21 +20,29 @@ type LocalBrowserSource struct { PreserveUserDataDir bool Headless bool Devtools bool - ChromiumSandbox bool + ChromiumSandbox *bool + IgnoreDefaultArgs *IgnoreDefaultArgs Proxy *LocalProxyConfig Locale string Viewport *LocalViewport - DeviceScaleFactor float64 + DeviceScaleFactor *float64 HasTouch bool IgnoreHTTPSErrors bool ConnectTimeoutMs int DownloadsPath string - AcceptDownloads bool + AcceptDownloads *bool KeepAlive bool } func (LocalBrowserSource) isBrowserSource() {} +// IgnoreDefaultArgs selects which of Stagehand's default Chrome arguments to +// omit. All takes precedence over Args. +type IgnoreDefaultArgs struct { + All bool + Args []string +} + // LocalProxyConfig configures an upstream proxy for a local browser. type LocalProxyConfig struct { Server string diff --git a/packages/sdk-go/generate.go b/packages/sdk-go/generate.go index 9c5d1d5fa..7c6013045 100644 --- a/packages/sdk-go/generate.go +++ b/packages/sdk-go/generate.go @@ -1,3 +1,4 @@ //go:generate go -C internal/generator run . +//go:generate go run ./internal/extensionpack package stagehand diff --git a/packages/sdk-go/internal/extensionassets/assets.go b/packages/sdk-go/internal/extensionassets/assets.go new file mode 100644 index 000000000..1079e33ff --- /dev/null +++ b/packages/sdk-go/internal/extensionassets/assets.go @@ -0,0 +1,144 @@ +// Package extensionassets exposes the Stagehand extension bundled into the Go +// module. It is internal so the SDK can share one artifact between local Chrome +// and Browserbase without adding a public asset API. +package extensionassets + +import ( + "archive/zip" + "bytes" + _ "embed" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +//go:embed stagehand-extension.zip +var stagehandExtensionArchive []byte + +// Archive returns a copy of the ZIP uploaded when Browserbase needs the +// Stagehand extension. +func Archive() []byte { + return bytes.Clone(stagehandExtensionArchive) +} + +// Materialize extracts the bundled extension for a local Chrome process. The +// caller owns cleanup and should invoke it when the browser closes. +func Materialize() (directory string, cleanup func() error, err error) { + return materialize(stagehandExtensionArchive, "") +} + +func materialize(archive []byte, parentDirectory string) (string, func() error, error) { + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return "", nil, fmt.Errorf("open bundled Stagehand extension: %w", err) + } + + directory, err := os.MkdirTemp(parentDirectory, "stagehand-extension-") + if err != nil { + return "", nil, fmt.Errorf("create Stagehand extension directory: %w", err) + } + cleanup := func() error { + return os.RemoveAll(directory) + } + fail := func(err error) (string, func() error, error) { + return "", nil, errors.Join(err, cleanup()) + } + + seen := make(map[string]struct{}, len(reader.File)) + for _, file := range reader.File { + relativePath, err := safeArchivePath(file.Name) + if err != nil { + return fail(err) + } + if _, exists := seen[relativePath]; exists { + return fail(fmt.Errorf( + "bundled Stagehand extension contains duplicate path %q", + relativePath, + )) + } + seen[relativePath] = struct{}{} + + targetPath := filepath.Join(directory, filepath.FromSlash(relativePath)) + relativeTarget, err := filepath.Rel(directory, targetPath) + if err != nil || + relativeTarget == ".." || + strings.HasPrefix(relativeTarget, ".."+string(filepath.Separator)) { + return fail(fmt.Errorf( + "bundled Stagehand extension has unsafe path %q", + file.Name, + )) + } + mode := file.FileInfo().Mode() + switch { + case mode&os.ModeSymlink != 0: + return fail(fmt.Errorf( + "bundled Stagehand extension contains symbolic link %q", + relativePath, + )) + case file.FileInfo().IsDir(): + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return fail(fmt.Errorf( + "create Stagehand extension directory %q: %w", + relativePath, + err, + )) + } + case !mode.IsRegular(): + return fail(fmt.Errorf( + "bundled Stagehand extension contains unsupported file %q", + relativePath, + )) + default: + if err := extractFile(file, targetPath); err != nil { + return fail(err) + } + } + } + + if _, err := os.Stat(filepath.Join(directory, "manifest.json")); err != nil { + return fail(fmt.Errorf("bundled Stagehand extension has no manifest: %w", err)) + } + return directory, cleanup, nil +} + +func safeArchivePath(name string) (string, error) { + cleaned := path.Clean(name) + if cleaned == "." || + strings.Contains(name, `\`) || + path.IsAbs(cleaned) || + filepath.IsAbs(filepath.FromSlash(cleaned)) || + cleaned == ".." || + strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("bundled Stagehand extension has unsafe path %q", name) + } + return cleaned, nil +} + +func extractFile(file *zip.File, targetPath string) error { + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return fmt.Errorf("create Stagehand extension parent directory: %w", err) + } + + source, err := file.Open() + if err != nil { + return fmt.Errorf("open Stagehand extension file %q: %w", file.Name, err) + } + defer source.Close() + + target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("create Stagehand extension file %q: %w", file.Name, err) + } + if _, err := io.Copy(target, source); err != nil { + _ = target.Close() + return fmt.Errorf("extract Stagehand extension file %q: %w", file.Name, err) + } + if err := target.Close(); err != nil { + return fmt.Errorf("close Stagehand extension file %q: %w", file.Name, err) + } + return nil +} diff --git a/packages/sdk-go/internal/extensionassets/assets_test.go b/packages/sdk-go/internal/extensionassets/assets_test.go new file mode 100644 index 000000000..fb4e21845 --- /dev/null +++ b/packages/sdk-go/internal/extensionassets/assets_test.go @@ -0,0 +1,156 @@ +package extensionassets + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "slices" + "testing" +) + +func TestArchiveContainsLoadableStagehandExtension(t *testing.T) { + t.Parallel() + + archive := Archive() + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatalf("open Archive(): %v", err) + } + + var names []string + var manifest struct { + ManifestVersion int `json:"manifest_version"` + Name string `json:"name"` + Version string `json:"version"` + } + for _, file := range reader.File { + names = append(names, file.Name) + if file.Name != "manifest.json" { + continue + } + source, err := file.Open() + if err != nil { + t.Fatalf("open manifest: %v", err) + } + if err := json.NewDecoder(source).Decode(&manifest); err != nil { + _ = source.Close() + t.Fatalf("decode manifest: %v", err) + } + if err := source.Close(); err != nil { + t.Fatalf("close manifest: %v", err) + } + } + slices.Sort(names) + wantNames := []string{ + "blank.html", + "content-script.js", + "manifest.json", + "offscreen/service-worker-heartbeat.html", + "offscreen/service-worker-heartbeat.js", + "service-worker.js", + "wake-service-worker.html", + "wake-service-worker.js", + } + if !reflect.DeepEqual(names, wantNames) { + t.Fatalf("archive paths = %#v, want %#v", names, wantNames) + } + if manifest.ManifestVersion != 3 || + manifest.Name != "Stagehand Runtime" || + manifest.Version == "" { + t.Fatalf("manifest = %#v", manifest) + } +} + +func TestArchiveReturnsAnIndependentCopy(t *testing.T) { + t.Parallel() + + first := Archive() + first[0] ^= 0xff + if bytes.Equal(first, Archive()) { + t.Fatal("Archive() returned mutable embedded storage") + } +} + +func TestMaterializeMatchesArchiveAndCleansUp(t *testing.T) { + t.Parallel() + + parentDirectory := filepath.Join(t.TempDir(), "parent with spaces") + if err := os.Mkdir(parentDirectory, 0o755); err != nil { + t.Fatalf("create parent directory: %v", err) + } + directory, cleanup, err := materialize(Archive(), parentDirectory) + if err != nil { + t.Fatalf("materialize() error = %v", err) + } + + reader, err := zip.NewReader( + bytes.NewReader(stagehandExtensionArchive), + int64(len(stagehandExtensionArchive)), + ) + if err != nil { + t.Fatalf("open embedded archive: %v", err) + } + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + source, err := file.Open() + if err != nil { + t.Fatalf("open archive file %q: %v", file.Name, err) + } + want, err := io.ReadAll(source) + if err != nil { + _ = source.Close() + t.Fatalf("read archive file %q: %v", file.Name, err) + } + if err := source.Close(); err != nil { + t.Fatalf("close archive file %q: %v", file.Name, err) + } + got, err := os.ReadFile(filepath.Join(directory, filepath.FromSlash(file.Name))) + if err != nil { + t.Fatalf("read extracted file %q: %v", file.Name, err) + } + if !bytes.Equal(got, want) { + t.Errorf("extracted file %q does not match archive", file.Name) + } + } + + if err := cleanup(); err != nil { + t.Fatalf("cleanup() error = %v", err) + } + if err := cleanup(); err != nil { + t.Fatalf("second cleanup() error = %v", err) + } + if _, err := os.Stat(directory); !os.IsNotExist(err) { + t.Fatalf("materialized directory still exists: %v", err) + } +} + +func TestMaterializeRejectsUnsafePaths(t *testing.T) { + t.Parallel() + + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + file, err := writer.Create("../outside") + if err != nil { + t.Fatalf("create unsafe ZIP entry: %v", err) + } + if _, err := file.Write([]byte("unsafe")); err != nil { + t.Fatalf("write unsafe ZIP entry: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close unsafe ZIP: %v", err) + } + + parentDirectory := t.TempDir() + if _, _, err := materialize(archive.Bytes(), parentDirectory); err == nil { + t.Fatal("materialize() accepted an unsafe path") + } + if _, err := os.Stat(filepath.Join(parentDirectory, "outside")); !os.IsNotExist(err) { + t.Fatalf("unsafe file escaped the materialization directory: %v", err) + } +} diff --git a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip new file mode 100644 index 000000000..b1c3d8af2 Binary files /dev/null and b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip differ diff --git a/packages/sdk-go/internal/extensionpack/main.go b/packages/sdk-go/internal/extensionpack/main.go new file mode 100644 index 000000000..f3819032f --- /dev/null +++ b/packages/sdk-go/internal/extensionpack/main.go @@ -0,0 +1,162 @@ +// Command extensionpack synchronizes the deterministic extension built by the +// server into the Go module, where go:embed can include it for consumers. +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" +) + +func main() { + check := flag.Bool("check", false, "fail when the embedded extension differs") + flag.Parse() + + if err := run(*check); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(check bool) error { + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + return errors.New("locate Go extension packaging source") + } + sdkRoot := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..")) + return syncArchive( + filepath.Join( + sdkRoot, + "..", + "server", + "artifacts", + "stagehand-extension.zip", + ), + filepath.Join( + sdkRoot, + "internal", + "extensionassets", + "stagehand-extension.zip", + ), + filepath.Join(sdkRoot, "..", "server", "package.json"), + check, + ) +} + +func syncArchive(sourcePath, targetPath, packagePath string, check bool) error { + source, err := os.ReadFile(sourcePath) + if err != nil { + return fmt.Errorf( + "read built Stagehand extension (run the root `just build` command): %w", + err, + ) + } + if err := validateArchiveVersion(source, packagePath); err != nil { + return err + } + + current, err := os.ReadFile(targetPath) + if check { + switch { + case err != nil: + return fmt.Errorf( + "read embedded Stagehand extension (run `just generate`): %w", + err, + ) + case !bytes.Equal(current, source): + return errors.New("embedded Stagehand extension is stale; run `just generate`") + default: + return nil + } + } + if err == nil && bytes.Equal(current, source) { + return nil + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return fmt.Errorf("create Go extension asset directory: %w", err) + } + + temporary, err := os.CreateTemp(filepath.Dir(targetPath), ".stagehand-extension-*.tmp") + if err != nil { + return fmt.Errorf("create temporary Go extension asset: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o644); err != nil { + _ = temporary.Close() + return fmt.Errorf("set Go extension asset permissions: %w", err) + } + if _, err := temporary.Write(source); err != nil { + _ = temporary.Close() + return fmt.Errorf("write Go extension asset: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close Go extension asset: %w", err) + } + if err := os.Rename(temporaryPath, targetPath); err != nil { + return fmt.Errorf("replace Go extension asset: %w", err) + } + return nil +} + +func validateArchiveVersion(archive []byte, packagePath string) error { + var serverPackage struct { + Version string `json:"version"` + } + packageData, err := os.ReadFile(packagePath) + if err != nil { + return fmt.Errorf("read server package version: %w", err) + } + if err := json.Unmarshal(packageData, &serverPackage); err != nil { + return fmt.Errorf("decode server package version: %w", err) + } + expectedVersion := serverPackage.Version + if separator := strings.IndexAny(expectedVersion, "+-"); separator >= 0 { + expectedVersion = expectedVersion[:separator] + } + if expectedVersion == "" { + return errors.New("server package version is empty") + } + + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return fmt.Errorf("open built Stagehand extension: %w", err) + } + for _, file := range reader.File { + if file.Name != "manifest.json" { + continue + } + source, err := file.Open() + if err != nil { + return fmt.Errorf("open Stagehand extension manifest: %w", err) + } + var manifest struct { + Version string `json:"version"` + } + decodeErr := json.NewDecoder(io.LimitReader(source, 1<<20)).Decode(&manifest) + closeErr := source.Close() + if decodeErr != nil { + return fmt.Errorf("decode Stagehand extension manifest: %w", decodeErr) + } + if closeErr != nil { + return fmt.Errorf("close Stagehand extension manifest: %w", closeErr) + } + if manifest.Version != expectedVersion { + return fmt.Errorf( + "Stagehand extension version %q does not match server package version %q", + manifest.Version, + expectedVersion, + ) + } + return nil + } + return errors.New("built Stagehand extension has no manifest") +} diff --git a/packages/sdk-go/internal/extensionpack/main_test.go b/packages/sdk-go/internal/extensionpack/main_test.go new file mode 100644 index 000000000..33d169220 --- /dev/null +++ b/packages/sdk-go/internal/extensionpack/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestSyncArchiveRefreshesAndChecksCanonicalArtifact(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + sourcePath := filepath.Join(directory, "built.zip") + targetPath := filepath.Join(directory, "embedded.zip") + packagePath := filepath.Join(directory, "package.json") + archive := testArchive(t, "1.2.3") + if err := os.WriteFile(sourcePath, archive, 0o644); err != nil { + t.Fatalf("write source archive: %v", err) + } + if err := os.WriteFile(packagePath, []byte(`{"version":"1.2.3-beta.1"}`), 0o644); err != nil { + t.Fatalf("write package.json: %v", err) + } + + if err := syncArchive(sourcePath, targetPath, packagePath, false); err != nil { + t.Fatalf("syncArchive() error = %v", err) + } + if err := syncArchive(sourcePath, targetPath, packagePath, true); err != nil { + t.Fatalf("syncArchive(check) error = %v", err) + } + if err := os.WriteFile(targetPath, []byte("stale"), 0o644); err != nil { + t.Fatalf("write stale archive: %v", err) + } + if err := syncArchive(sourcePath, targetPath, packagePath, true); err == nil { + t.Fatal("syncArchive(check) accepted a stale archive") + } +} + +func TestValidateArchiveVersionRejectsDrift(t *testing.T) { + t.Parallel() + + packagePath := filepath.Join(t.TempDir(), "package.json") + if err := os.WriteFile(packagePath, []byte(`{"version":"2.0.0"}`), 0o644); err != nil { + t.Fatalf("write package.json: %v", err) + } + if err := validateArchiveVersion(testArchive(t, "1.0.0"), packagePath); err == nil { + t.Fatal("validateArchiveVersion() accepted a mismatched manifest") + } +} + +func testArchive(t *testing.T, version string) []byte { + t.Helper() + + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + manifest, err := writer.Create("manifest.json") + if err != nil { + t.Fatalf("create manifest entry: %v", err) + } + if _, err := manifest.Write([]byte(`{"version":"` + version + `"}`)); err != nil { + t.Fatalf("write manifest entry: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close archive: %v", err) + } + return archive.Bytes() +}