From e33fb46f051016a7077b763fb454b7797fc94b50 Mon Sep 17 00:00:00 2001 From: Eric Posen <563881+goeric@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:06:06 -0700 Subject: [PATCH 1/2] Add --extension flag to load Chrome extensions Extensions previously only worked with "rodney start --show". The cause was that Chrome's old headless mode cannot run extensions at all, and rod's Headless() sets a bare --headless, which selects old headless on the Chromium build rodney downloads. Passing --extension now switches to the new headless mode, where extensions work fully, service workers included. rodney start --extension ./my-extension rodney start --extension ./packed.crx --extension ./other.zip Chrome only accepts unpacked extensions on the command line, so .crx and .zip archives are unpacked into the session directory first (CRX2 and CRX3 headers are both handled). Chrome 137+ also ignores --load-extension unless DisableLoadExtensionCommandLineSwitch is disabled, so that is appended to the existing --disable-features list. The new "rodney extensions" command lists the loaded extensions along with the ID Chrome assigned to each, which is needed to reach chrome-extension:// URLs. IDs are derived the same way Chrome derives them for unpacked extensions, from the symlink-resolved path. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 39 ++++ extensions.go | 295 +++++++++++++++++++++++ extensions_test.go | 569 +++++++++++++++++++++++++++++++++++++++++++++ help.txt | 6 +- main.go | 94 +++++++- main_test.go | 30 +-- 6 files changed, 1008 insertions(+), 25 deletions(-) create mode 100644 extensions.go create mode 100644 extensions_test.go diff --git a/README.md b/README.md index 3cb2e26..9f02432 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,45 @@ rodney status # Show browser info and active page rodney stop # Shut down Chrome ``` +### Browser extensions + +`--extension` loads a Chrome extension at startup. It works in headless mode as +well as with `--show`: + +```bash +# An unpacked extension directory +rodney start --extension ./my-extension + +# A packed .crx or .zip, which is unpacked into the session directory +rodney start --extension ./my-extension.crx + +# Repeat the flag to load more than one +rodney start --extension ./one --extension ./two +``` + +`rodney extensions` lists what was loaded, including the ID Chrome assigned to +each one, which you need to reach pages served by the extension: + +```bash +rodney extensions +# ldmakemplfmadpiihagnajidjbhnjlcm My Extension 1.0.0 /path/to/my-extension + +rodney open chrome-extension://ldmakemplfmadpiihagnajidjbhnjlcm/popup.html +rodney screenshot popup.png +``` + +Notes: + +- Extensions run in Chrome's [new headless + mode](https://developer.chrome.com/docs/chromium/new-headless), which rodney + switches to automatically when `--extension` is used — the old headless mode + cannot run extensions at all. +- Chrome only loads *unpacked* extensions from the command line, so a `.crx` is + unpacked before being loaded. It therefore gets an ID derived from its path on + disk rather than the ID it would have when installed from the Web Store. +- Only the extensions passed to `--extension` are enabled, and they stay loaded + for the lifetime of the session. + ### Navigate ```bash diff --git a/extensions.go b/extensions.go new file mode 100644 index 0000000..4b4d99c --- /dev/null +++ b/extensions.go @@ -0,0 +1,295 @@ +package main + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "unicode/utf16" +) + +// extensionList collects repeatable --extension flag values. +type extensionList []string + +func (e *extensionList) String() string { return strings.Join(*e, ",") } + +func (e *extensionList) Set(v string) error { + if v == "" { + return fmt.Errorf("--extension requires a path") + } + *e = append(*e, v) + return nil +} + +// manifest is the subset of manifest.json we care about. +type manifest struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// extensionInfo describes one extension that was handed to Chrome. +type extensionInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Dir string `json:"dir"` +} + +// extensionID computes the ID Chrome assigns to an unpacked extension, which is +// derived from the absolute path of its directory: the first 16 bytes of the +// SHA-256 of the path, with each hex digit mapped from 0-f onto a-p. +func extensionID(dir string) string { + sum := sha256.Sum256(extensionIDPathBytes(dir)) + id := make([]byte, 32) + for i, b := range sum[:16] { + id[i*2] = 'a' + (b >> 4) + id[i*2+1] = 'a' + (b & 0x0f) + } + return string(id) +} + +// extensionIDPathBytes reproduces the bytes Chrome hashes for a path. Chrome +// hashes the raw bytes of its native path representation, which is UTF-16 on +// Windows (where it also upper-cases the drive letter) and UTF-8 elsewhere. +// See crx_file::id_util::GenerateIdForPath in the Chromium source. +func extensionIDPathBytes(dir string) []byte { + return extensionIDPathBytesFor(dir, runtime.GOOS) +} + +// extensionIDPathBytesFor is extensionIDPathBytes with the OS passed in, so the +// Windows encoding can be tested from any platform. +func extensionIDPathBytesFor(dir, goos string) []byte { + if goos != "windows" { + return []byte(dir) + } + if len(dir) >= 2 && dir[1] == ':' { + dir = strings.ToUpper(dir[:1]) + dir[1:] + } + var buf bytes.Buffer + for _, unit := range utf16.Encode([]rune(dir)) { + binary.Write(&buf, binary.LittleEndian, unit) + } + return buf.Bytes() +} + +// readManifest loads name and version from an extension directory. +func readManifest(dir string) (manifest, error) { + var m manifest + data, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + return m, fmt.Errorf("no manifest.json in %s", dir) + } + if err := json.Unmarshal(data, &m); err != nil { + return m, fmt.Errorf("invalid manifest.json in %s: %v", dir, err) + } + return m, nil +} + +// resolveExtension turns a user-supplied path into a directory Chrome can load. +// Directories are used as-is; .crx and .zip archives are unpacked underneath +// unpackRoot. The returned path is absolute and contains a manifest.json. +func resolveExtension(path, unpackRoot string) (extensionInfo, error) { + var info extensionInfo + + abs, err := filepath.Abs(path) + if err != nil { + return info, fmt.Errorf("%s: %v", path, err) + } + st, err := os.Stat(abs) + if err != nil { + return info, fmt.Errorf("%s: no such file or directory", path) + } + + dir := abs + if !st.IsDir() { + switch strings.ToLower(filepath.Ext(abs)) { + case ".crx", ".zip": + dir = filepath.Join(unpackRoot, unpackDirName(abs)) + if err := unpackExtension(abs, dir); err != nil { + return info, fmt.Errorf("%s: %v", path, err) + } + default: + return info, fmt.Errorf("%s: expected a directory or a .crx/.zip archive", path) + } + } + + // Chrome resolves symlinks before deriving the extension ID from the path + // (on macOS /tmp/x is really /private/tmp/x), so resolve them here too or + // the id we report back would not be the one Chrome uses. + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + // Chrome separates --load-extension paths with commas, so a path containing + // one would silently be split into two bogus paths. + if strings.Contains(dir, ",") { + return info, fmt.Errorf("%s: extension paths cannot contain commas", path) + } + + m, err := readManifest(dir) + if err != nil { + return info, err + } + return extensionInfo{ID: extensionID(dir), Name: m.Name, Version: m.Version, Dir: dir}, nil +} + +// unpackDirName picks the directory an archive is unpacked into. The archive's +// own name is only a readable prefix: names like "...zip" or ".zip" reduce to +// "..", "." or "", which would escape the unpack root and get deleted, so the +// full path is hashed to guarantee a distinct, well-formed directory name. +func unpackDirName(archivePath string) string { + sum := sha256.Sum256([]byte(archivePath)) + suffix := hex.EncodeToString(sum[:4]) + + name := strings.TrimSuffix(filepath.Base(archivePath), filepath.Ext(archivePath)) + name = strings.Map(func(r rune) rune { + if r == '.' || r == '-' || r == '_' || + (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + return r + } + return '-' + }, name) + name = strings.Trim(name, ".-") + if name == "" { + return suffix + } + return name + "-" + suffix +} + +// unpackExtension extracts a .crx or .zip archive into dest, which is replaced +// if it already exists. If the archive wraps everything in a single top-level +// directory, dest is rewritten to point at that directory instead. +func unpackExtension(src, dest string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + + size, err := f.Seek(0, io.SeekEnd) + if err != nil { + return err + } + offset, err := crxZipOffset(f, size) + if err != nil { + return err + } + zr, err := zip.NewReader(io.NewSectionReader(f, offset, size-offset), size-offset) + if err != nil { + return fmt.Errorf("not a readable crx/zip archive: %v", err) + } + + if err := os.RemoveAll(dest); err != nil { + return err + } + for _, zf := range zr.File { + if err := extractZipEntry(zf, dest); err != nil { + return err + } + } + + if _, err := os.Stat(filepath.Join(dest, "manifest.json")); err == nil { + return nil + } + // Some archives nest the extension one level down. + entries, err := os.ReadDir(dest) + if err != nil { + return err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + nested := filepath.Join(dest, e.Name()) + if _, err := os.Stat(filepath.Join(nested, "manifest.json")); err == nil { + return moveDir(nested, dest) + } + } + return fmt.Errorf("archive does not contain a manifest.json") +} + +// crxZipOffset returns the offset of the zip payload within a crx file of the +// given size. Plain zip files (no "Cr24" magic) start at 0. The declared +// lengths are attacker-controlled, so they are widened to uint64 before being +// added and the result is checked against the file size. +func crxZipOffset(r io.ReaderAt, size int64) (int64, error) { + header := make([]byte, 16) + if _, err := r.ReadAt(header, 0); err != nil { + return 0, fmt.Errorf("file is too small to be an extension archive") + } + if string(header[:4]) != "Cr24" { + return 0, nil // plain zip + } + + var offset uint64 + switch version := binary.LittleEndian.Uint32(header[4:8]); version { + case 2: + // magic + version + key length + signature length, then key and signature + keyLen := uint64(binary.LittleEndian.Uint32(header[8:12])) + sigLen := uint64(binary.LittleEndian.Uint32(header[12:16])) + offset = 16 + keyLen + sigLen + case 3: + // magic + version + header length, then the protobuf header + offset = 12 + uint64(binary.LittleEndian.Uint32(header[8:12])) + default: + return 0, fmt.Errorf("unsupported crx version %d", version) + } + + if offset > uint64(size) { + return 0, fmt.Errorf("crx header extends past the end of the file") + } + return int64(offset), nil +} + +// extractZipEntry writes a single zip entry underneath dest, rejecting paths +// that would escape it. +func extractZipEntry(zf *zip.File, dest string) error { + target := filepath.Join(dest, filepath.FromSlash(zf.Name)) + if target == filepath.Clean(dest) { + return nil // an entry for the archive root itself, nothing to write + } + if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("archive entry escapes the destination directory: %s", zf.Name) + } + if zf.FileInfo().IsDir() { + return os.MkdirAll(target, 0755) + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + rc, err := zf.Open() + if err != nil { + return err + } + defer rc.Close() + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, rc) + return err +} + +// moveDir replaces dest with the contents of the nested directory src. +func moveDir(src, dest string) error { + tmp := dest + ".unwrap" + if err := os.RemoveAll(tmp); err != nil { + return err + } + if err := os.Rename(src, tmp); err != nil { + return err + } + if err := os.RemoveAll(dest); err != nil { + return err + } + return os.Rename(tmp, dest) +} diff --git a/extensions_test.go b/extensions_test.go new file mode 100644 index 0000000..834329f --- /dev/null +++ b/extensions_test.go @@ -0,0 +1,569 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" +) + +// --- fixtures --- + +const testExtensionManifest = `{ + "manifest_version": 3, + "name": "Rodney Test Extension", + "version": "1.2.3", + "content_scripts": [{"matches": [""], "js": ["content.js"]}] +}` + +// The content script renames the page so a test can prove it ran. +const testExtensionContentScript = `document.title = "extension-was-here";` + +// writeTestExtension creates an unpacked extension directory and returns its path. +func writeTestExtension(t *testing.T, dir string) string { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0644); err != nil { + t.Fatal(err) + } + } + write("manifest.json", testExtensionManifest) + write("content.js", testExtensionContentScript) + return dir +} + +// zipBytes builds an in-memory zip archive from name -> contents. +func zipBytes(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, body := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// crx3Bytes wraps zip payload in a CRX3 container with a dummy header. +func crx3Bytes(t *testing.T, payload []byte) []byte { + t.Helper() + header := []byte("dummy-crx3-header") + var buf bytes.Buffer + buf.WriteString("Cr24") + binary.Write(&buf, binary.LittleEndian, uint32(3)) + binary.Write(&buf, binary.LittleEndian, uint32(len(header))) + buf.Write(header) + buf.Write(payload) + return buf.Bytes() +} + +// crx2Bytes wraps zip payload in a CRX2 container with dummy key and signature. +func crx2Bytes(t *testing.T, payload []byte) []byte { + t.Helper() + key, sig := []byte("dummy-key"), []byte("dummy-signature") + var buf bytes.Buffer + buf.WriteString("Cr24") + binary.Write(&buf, binary.LittleEndian, uint32(2)) + binary.Write(&buf, binary.LittleEndian, uint32(len(key))) + binary.Write(&buf, binary.LittleEndian, uint32(len(sig))) + buf.Write(key) + buf.Write(sig) + buf.Write(payload) + return buf.Bytes() +} + +// --- extensionID --- + +func TestExtensionID_MatchesChromeAlgorithm(t *testing.T) { + // Chrome derives an unpacked extension's ID from its absolute path. This + // expectation was captured by loading an extension from this exact path in + // a real Chrome session and reading back the id it assigned. + got := extensionID("/private/tmp/rodney-test-extension") + want := "kpcblmbemcppaagejgmknhdmdmmnhcml" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestExtensionID_UsesLettersAToP(t *testing.T) { + id := extensionID("/tmp/some-extension") + if len(id) != 32 { + t.Fatalf("expected a 32 character id, got %d: %q", len(id), id) + } + for _, c := range id { + if c < 'a' || c > 'p' { + t.Errorf("id contains out-of-range character %q: %s", c, id) + } + } +} + +// TestExtensionIDPathBytes_Windows pins the Windows encoding, which differs +// from every other platform: Chrome hashes the raw bytes of its native path +// type, which on Windows is UTF-16, and it upper-cases the drive letter first. +func TestExtensionIDPathBytes_Windows(t *testing.T) { + got := extensionIDPathBytesFor(`c:\ext`, "windows") + want := []byte{'C', 0, ':', 0, '\\', 0, 'e', 0, 'x', 0, 't', 0} + if !bytes.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestExtensionIDPathBytes_Posix(t *testing.T) { + got := extensionIDPathBytesFor("/tmp/ext", "linux") + if !bytes.Equal(got, []byte("/tmp/ext")) { + t.Errorf("got %v, want the plain path bytes", got) + } +} + +func TestExtensionID_DiffersByPath(t *testing.T) { + if extensionID("/tmp/a") == extensionID("/tmp/b") { + t.Error("different paths should produce different ids") + } +} + +// --- parseStartArgs --- + +func TestParseStartArgs_NoExtensions(t *testing.T) { + opts, err := parseStartArgs([]string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.extensions) != 0 { + t.Errorf("expected no extensions, got %v", opts.extensions) + } +} + +func TestParseStartArgs_SingleExtension(t *testing.T) { + opts, err := parseStartArgs([]string{"--extension", "/tmp/ext"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.extensions) != 1 || opts.extensions[0] != "/tmp/ext" { + t.Errorf("got %v, want [/tmp/ext]", opts.extensions) + } +} + +func TestParseStartArgs_RepeatedExtensions(t *testing.T) { + opts, err := parseStartArgs([]string{"--extension", "/tmp/one", "--extension", "/tmp/two"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.extensions) != 2 || opts.extensions[0] != "/tmp/one" || opts.extensions[1] != "/tmp/two" { + t.Errorf("got %v, want [/tmp/one /tmp/two]", opts.extensions) + } +} + +func TestParseStartArgs_ExtensionWithOtherFlags(t *testing.T) { + opts, err := parseStartArgs([]string{"--show", "--extension", "/tmp/ext", "-k"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.headless { + t.Error("expected headless=false when --show is passed") + } + if !opts.ignoreCertErrors { + t.Error("expected insecure=true when -k is passed") + } + if len(opts.extensions) != 1 { + t.Errorf("got %v, want one extension", opts.extensions) + } +} + +// --- resolveExtension --- + +func TestResolveExtension_Directory(t *testing.T) { + dir := writeTestExtension(t, filepath.Join(t.TempDir(), "ext")) + dir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(dir, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Name != "Rodney Test Extension" { + t.Errorf("got name %q", info.Name) + } + if info.Version != "1.2.3" { + t.Errorf("got version %q", info.Version) + } + if info.Dir != dir { + t.Errorf("got dir %q, want %q", info.Dir, dir) + } + if info.ID != extensionID(dir) { + t.Errorf("got id %q, want %q", info.ID, extensionID(dir)) + } +} + +func TestResolveExtension_RelativeDirectoryBecomesAbsolute(t *testing.T) { + tmp := t.TempDir() + writeTestExtension(t, filepath.Join(tmp, "ext")) + t.Chdir(tmp) + + info, err := resolveExtension("ext", t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !filepath.IsAbs(info.Dir) { + t.Errorf("expected an absolute path, got %q", info.Dir) + } +} + +func TestResolveExtension_ResolvesSymlinks(t *testing.T) { + // Chrome derives the extension ID from the symlink-free path, so rodney has + // to resolve links or it would report an ID Chrome never uses. + real := writeTestExtension(t, filepath.Join(t.TempDir(), "real")) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + info, err := resolveExtension(link, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatal(err) + } + if info.Dir != want { + t.Errorf("got dir %q, want %q", info.Dir, want) + } +} + +func TestResolveExtension_MissingPath(t *testing.T) { + _, err := resolveExtension(filepath.Join(t.TempDir(), "nope"), t.TempDir()) + if err == nil { + t.Fatal("expected an error for a missing path") + } + if !strings.Contains(err.Error(), "no such file") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveExtension_DirectoryWithoutManifest(t *testing.T) { + _, err := resolveExtension(t.TempDir(), t.TempDir()) + if err == nil { + t.Fatal("expected an error for a directory with no manifest.json") + } + if !strings.Contains(err.Error(), "manifest.json") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveExtension_UnsupportedFileType(t *testing.T) { + path := filepath.Join(t.TempDir(), "ext.tar.gz") + if err := os.WriteFile(path, []byte("nope"), 0644); err != nil { + t.Fatal(err) + } + _, err := resolveExtension(path, t.TempDir()) + if err == nil { + t.Fatal("expected an error for an unsupported archive type") + } + if !strings.Contains(err.Error(), ".crx/.zip") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveExtension_Zip(t *testing.T) { + path := filepath.Join(t.TempDir(), "packed.zip") + payload := zipBytes(t, map[string]string{ + "manifest.json": testExtensionManifest, + "content.js": testExtensionContentScript, + }) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(path, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Name != "Rodney Test Extension" { + t.Errorf("got name %q", info.Name) + } + if _, err := os.Stat(filepath.Join(info.Dir, "content.js")); err != nil { + t.Errorf("content.js was not extracted: %v", err) + } +} + +func TestResolveExtension_CRX3(t *testing.T) { + path := filepath.Join(t.TempDir(), "packed.crx") + payload := crx3Bytes(t, zipBytes(t, map[string]string{ + "manifest.json": testExtensionManifest, + "content.js": testExtensionContentScript, + })) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(path, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Version != "1.2.3" { + t.Errorf("got version %q", info.Version) + } +} + +func TestResolveExtension_CRX2(t *testing.T) { + path := filepath.Join(t.TempDir(), "packed.crx") + payload := crx2Bytes(t, zipBytes(t, map[string]string{ + "manifest.json": testExtensionManifest, + })) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + if _, err := resolveExtension(path, t.TempDir()); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveExtension_ArchiveWithNestedDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested.zip") + payload := zipBytes(t, map[string]string{ + "my-extension/manifest.json": testExtensionManifest, + "my-extension/content.js": testExtensionContentScript, + }) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + info, err := resolveExtension(path, t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(info.Dir, "manifest.json")); err != nil { + t.Errorf("manifest.json should sit at the root of the resolved dir: %v", err) + } +} + +func TestResolveExtension_ArchiveWithoutManifest(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.zip") + if err := os.WriteFile(path, zipBytes(t, map[string]string{"readme.txt": "hi"}), 0644); err != nil { + t.Fatal(err) + } + _, err := resolveExtension(path, t.TempDir()) + if err == nil { + t.Fatal("expected an error for an archive with no manifest.json") + } +} + +func TestResolveExtension_UnsupportedCRXVersion(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("Cr24") + binary.Write(&buf, binary.LittleEndian, uint32(99)) + buf.Write(make([]byte, 8)) + path := filepath.Join(t.TempDir(), "future.crx") + if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + _, err := resolveExtension(path, t.TempDir()) + if err == nil { + t.Fatal("expected an error for an unknown crx version") + } + if !strings.Contains(err.Error(), "crx version 99") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestUnpackDirName_StaysInsideUnpackRoot guards a nasty case: an archive named +// "...zip" has extension ".zip" and stem "..", which naively joined onto the +// unpack root escapes it and points at the session directory, which unpacking +// then deletes. +func TestUnpackDirName_StaysInsideUnpackRoot(t *testing.T) { + root := "/state/extensions" + for _, name := range []string{"...zip", "..zip", ".zip", ".crx", "..crx", "a/b/../../evil.zip", "ext.zip"} { + dir := filepath.Join(root, unpackDirName(name)) + if filepath.Dir(dir) != root { + t.Errorf("archive %q unpacks to %q, which is outside %q", name, dir, root) + } + } +} + +func TestUnpackDirName_DistinctForSameBasename(t *testing.T) { + if unpackDirName("/one/ext.zip") == unpackDirName("/two/ext.zip") { + t.Error("archives with the same basename must not share an unpack directory") + } +} + +func TestUnpackDirName_StableForSamePath(t *testing.T) { + if unpackDirName("/one/ext.zip") != unpackDirName("/one/ext.zip") { + t.Error("the same archive path must map to the same unpack directory") + } +} + +// TestResolveExtension_ArchiveDoesNotEscapeUnpackRoot is the end-to-end version +// of the check above: it proves a hostile archive name cannot delete the +// session directory that contains the unpack root. +func TestResolveExtension_ArchiveDoesNotEscapeUnpackRoot(t *testing.T) { + stateDir := t.TempDir() + unpackRoot := filepath.Join(stateDir, "extensions") + if err := os.MkdirAll(unpackRoot, 0755); err != nil { + t.Fatal(err) + } + canary := filepath.Join(stateDir, "state.json") + if err := os.WriteFile(canary, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + archive := filepath.Join(t.TempDir(), "...zip") + payload := zipBytes(t, map[string]string{"manifest.json": testExtensionManifest}) + if err := os.WriteFile(archive, payload, 0644); err != nil { + t.Fatal(err) + } + + if _, err := resolveExtension(archive, unpackRoot); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(canary); err != nil { + t.Errorf("unpacking deleted the session directory: %v", err) + } +} + +func TestResolveExtension_SameBasenameArchivesStaySeparate(t *testing.T) { + unpackRoot := t.TempDir() + newArchive := func(dir, name, version string) string { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "ext.zip") + manifest := `{"manifest_version": 3, "name": "` + name + `", "version": "` + version + `"}` + if err := os.WriteFile(path, zipBytes(t, map[string]string{"manifest.json": manifest}), 0644); err != nil { + t.Fatal(err) + } + return path + } + + tmp := t.TempDir() + first, err := resolveExtension(newArchive(filepath.Join(tmp, "a"), "First", "1.0.0"), unpackRoot) + if err != nil { + t.Fatal(err) + } + second, err := resolveExtension(newArchive(filepath.Join(tmp, "b"), "Second", "2.0.0"), unpackRoot) + if err != nil { + t.Fatal(err) + } + + if first.Dir == second.Dir { + t.Fatalf("both archives unpacked to %q", first.Dir) + } + if first.ID == second.ID { + t.Error("both extensions were given the same id") + } + // The first extension must survive unpacking the second. + if got, err := readManifest(first.Dir); err != nil || got.Name != "First" { + t.Errorf("first extension was clobbered: name=%q err=%v", got.Name, err) + } +} + +func TestCrxZipOffset_RejectsOverflowingLengths(t *testing.T) { + // A CRX2 header claiming a key length near uint32 max used to wrap around + // to a small offset instead of being rejected. + var crx2 bytes.Buffer + crx2.WriteString("Cr24") + binary.Write(&crx2, binary.LittleEndian, uint32(2)) + binary.Write(&crx2, binary.LittleEndian, uint32(0xFFFFFFF0)) + binary.Write(&crx2, binary.LittleEndian, uint32(0xFFFFFFF0)) + crx2.Write(make([]byte, 32)) + + var crx3 bytes.Buffer + crx3.WriteString("Cr24") + binary.Write(&crx3, binary.LittleEndian, uint32(3)) + binary.Write(&crx3, binary.LittleEndian, uint32(0xFFFFFFFC)) + crx3.Write(make([]byte, 32)) + + for name, payload := range map[string][]byte{"crx2": crx2.Bytes(), "crx3": crx3.Bytes()} { + r := bytes.NewReader(payload) + if _, err := crxZipOffset(r, int64(len(payload))); err == nil { + t.Errorf("%s: expected an error for a header longer than the file", name) + } + } +} + +func TestUnpackExtension_AcceptsArchiveRootEntry(t *testing.T) { + // Some zip writers emit a "./" entry, which must not be mistaken for an + // attempt to escape the destination directory. + path := filepath.Join(t.TempDir(), "rooted.zip") + payload := zipBytes(t, map[string]string{ + "./": "", + "manifest.json": testExtensionManifest, + }) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + if _, err := resolveExtension(path, t.TempDir()); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUnpackExtension_RejectsPathTraversal(t *testing.T) { + path := filepath.Join(t.TempDir(), "evil.zip") + payload := zipBytes(t, map[string]string{"../escaped.txt": "pwned"}) + if err := os.WriteFile(path, payload, 0644); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "unpacked") + err := unpackExtension(path, dest) + if err == nil { + t.Fatal("expected an error for an archive entry escaping the destination") + } + if !strings.Contains(err.Error(), "escapes") { + t.Errorf("unexpected error: %v", err) + } +} + +// --- end to end --- + +// TestExtension_LoadsInHeadlessChrome is the test that matters: it launches a +// headless browser the same way "rodney start --extension" does and checks the +// extension's content script actually ran on a page. +func TestExtension_LoadsInHeadlessChrome(t *testing.T) { + dir := writeTestExtension(t, filepath.Join(t.TempDir(), "ext")) + + l := launcher.New(). + Set("no-sandbox"). + Set("disable-gpu"). + Set("single-process"). + Leakless(false). + HeadlessNew(true). + Set("load-extension", dir). + Set("disable-extensions-except", dir). + Append("disable-features", "DisableLoadExtensionCommandLineSwitch") + + if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { + l = l.Bin(bin) + } + + browser := rod.New().ControlURL(l.MustLaunch()).MustConnect() + defer browser.MustClose() + + page := browser.MustPage(env.server.URL + "/") + page.MustWaitLoad() + + if got := page.MustInfo().Title; got != "extension-was-here" { + t.Errorf("content script did not run: page title is %q, want %q", got, "extension-was-here") + } +} diff --git a/help.txt b/help.txt index 79bac7f..62ec986 100644 --- a/help.txt +++ b/help.txt @@ -1,10 +1,14 @@ rodney - Chrome automation from the command line Browser lifecycle: - rodney start [--show] [--insecure | -k] Launch Chrome (headless by default, --show for visible) + rodney start [--show] [--insecure | -k] [--extension PATH] + Launch Chrome (headless by default, --show for visible) + --extension loads an unpacked directory or a + .crx/.zip archive; repeat for several rodney connect Connect to existing Chrome on remote debug port rodney stop Shut down Chrome rodney status Show browser status + rodney extensions List extensions loaded into this session Navigation: rodney open Navigate to URL diff --git a/main.go b/main.go index a5188db..69c1123 100644 --- a/main.go +++ b/main.go @@ -85,6 +85,8 @@ type State struct { DataDir string `json:"data_dir"` ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy + + Extensions []extensionInfo `json:"extensions,omitempty"` // extensions passed to --load-extension } func stateDir() string { @@ -215,6 +217,8 @@ func main() { cmdStop(args) case "status": cmdStatus(args) + case "extensions": + cmdExtensions(args) case "open": cmdOpen(args) case "back": @@ -336,30 +340,44 @@ func withPage() (*State, *rod.Browser, *rod.Page) { // --- Commands --- +const startUsage = "usage: rodney start [--show] [--insecure] [--extension PATH]" + +// startOptions holds the parsed flags for the "start" command. +type startOptions struct { + ignoreCertErrors bool + headless bool + extensions []string +} + // parseStartArgs parses the flags for the "start" command. -// Returns ignoreCertErrors, headless, and an error for unknown flags. -func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, err error) { +func parseStartArgs(args []string) (startOptions, error) { + opts := startOptions{headless: true} + var extensions extensionList + fs := flag.NewFlagSet("start", flag.ContinueOnError) fs.SetOutput(io.Discard) - fs.BoolVar(&ignoreCertErrors, "insecure", false, "") - fs.BoolVar(&ignoreCertErrors, "k", false, "") + fs.BoolVar(&opts.ignoreCertErrors, "insecure", false, "") + fs.BoolVar(&opts.ignoreCertErrors, "k", false, "") + fs.Var(&extensions, "extension", "") show := fs.Bool("show", false, "") if parseErr := fs.Parse(args); parseErr != nil { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", findUnknownFlag(args, fs)) + return startOptions{headless: true}, fmt.Errorf("unknown flag: %s\n%s", findUnknownFlag(args, fs), startUsage) } if fs.NArg() > 0 { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", fs.Arg(0)) + return startOptions{headless: true}, fmt.Errorf("unknown flag: %s\n%s", fs.Arg(0), startUsage) } - headless = !*show - return ignoreCertErrors, headless, nil + opts.headless = !*show + opts.extensions = extensions + return opts, nil } func cmdStart(args []string) { - ignoreCertErrors, headless, err := parseStartArgs(args) + opts, err := parseStartArgs(args) if err != nil { fatal("%s", err) } + ignoreCertErrors, headless := opts.ignoreCertErrors, opts.headless // Check if already running if s, err := loadState(); err == nil { @@ -374,6 +392,8 @@ func cmdStart(args []string) { dataDir := filepath.Join(stateDir(), "chrome-data") os.MkdirAll(dataDir, 0755) + extensions := loadExtensions(opts.extensions) + l := launcher.New(). Set("no-sandbox"). Set("disable-gpu"). @@ -388,6 +408,21 @@ func cmdStart(args []string) { l = l.Delete("no-startup-window") } + if len(extensions) > 0 { + dirs := make([]string, len(extensions)) + for i, ext := range extensions { + dirs[i] = ext.Dir + } + // Old headless Chrome cannot run extensions at all, so extensions force + // the new headless mode: https://developer.chrome.com/docs/chromium/new-headless + l = l.HeadlessNew(headless). + Set("load-extension", strings.Join(dirs, ",")). + Set("disable-extensions-except", strings.Join(dirs, ",")) + // Chrome 137+ ignores --load-extension unless this feature is turned off. + // Append rather than Set: rod already disables some features by default. + l = l.Append("disable-features", "DisableLoadExtensionCommandLineSwitch") + } + if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { l = l.Bin(bin) } @@ -441,6 +476,7 @@ func cmdStart(args []string) { DataDir: dataDir, ProxyPID: proxyPID, ProxyPort: proxyPort, + Extensions: extensions, } if err := saveState(state); err != nil { @@ -449,6 +485,43 @@ func cmdStart(args []string) { fmt.Printf("Chrome started (PID %d)\n", pid) fmt.Printf("Debug URL: %s\n", debugURL) + for _, ext := range extensions { + fmt.Printf("Extension loaded: %s (%s)\n", ext.Name, ext.ID) + } +} + +// loadExtensions resolves --extension paths into directories Chrome can load, +// unpacking .crx/.zip archives into the session directory as needed. +func loadExtensions(paths []string) []extensionInfo { + if len(paths) == 0 { + return nil + } + unpackRoot := filepath.Join(stateDir(), "extensions") + if err := os.MkdirAll(unpackRoot, 0755); err != nil { + fatal("failed to create extension directory: %v", err) + } + extensions := make([]extensionInfo, 0, len(paths)) + for _, path := range paths { + info, err := resolveExtension(path, unpackRoot) + if err != nil { + fatal("%v", err) + } + extensions = append(extensions, info) + } + return extensions +} + +func cmdExtensions(args []string) { + if len(args) > 0 { + fatal("usage: rodney extensions") + } + s, err := loadState() + if err != nil { + fatal("%v", err) + } + for _, ext := range s.Extensions { + fmt.Printf("%s %s %s %s\n", ext.ID, ext.Name, ext.Version, ext.Dir) + } } func cmdConnect(args []string) { @@ -542,6 +615,9 @@ func cmdStatus(args []string) { fmt.Printf("Debug URL: %s\n", s.DebugURL) fmt.Printf("Pages: %d\n", len(pages)) fmt.Printf("Active page: %d\n", s.ActivePage) + for _, ext := range s.Extensions { + fmt.Printf("Extension: %s (%s)\n", ext.Name, ext.ID) + } if page, err := getActivePage(browser, s); err == nil { info, _ := page.Info() if info != nil { diff --git a/main_test.go b/main_test.go index 1753da9..e55bd2d 100644 --- a/main_test.go +++ b/main_test.go @@ -1079,69 +1079,69 @@ func TestFormatAssertFail_EqualityWithMessage(t *testing.T) { // ===================== func TestParseStartArgs_NoFlags(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{}) + opts, err := parseStartArgs([]string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if insecure { + if opts.ignoreCertErrors { t.Error("expected insecure=false with no flags") } - if !headless { + if !opts.headless { t.Error("expected headless=true with no flags") } } func TestParseStartArgs_ShowFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show"}) + opts, err := parseStartArgs([]string{"--show"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if insecure { + if opts.ignoreCertErrors { t.Error("expected insecure=false") } - if headless { + if opts.headless { t.Error("expected headless=false when --show is passed") } } func TestParseStartArgs_InsecureFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--insecure"}) + opts, err := parseStartArgs([]string{"--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !opts.ignoreCertErrors { t.Error("expected insecure=true when --insecure is passed") } - if !headless { + if !opts.headless { t.Error("expected headless=true when only --insecure is passed") } } func TestParseStartArgs_InsecureShortFlag(t *testing.T) { - insecure, _, err := parseStartArgs([]string{"-k"}) + opts, err := parseStartArgs([]string{"-k"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !opts.ignoreCertErrors { t.Error("expected insecure=true when -k is passed") } } func TestParseStartArgs_ShowAndInsecure(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show", "--insecure"}) + opts, err := parseStartArgs([]string{"--show", "--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !opts.ignoreCertErrors { t.Error("expected insecure=true") } - if headless { + if opts.headless { t.Error("expected headless=false when --show is passed") } } func TestParseStartArgs_UnknownFlag(t *testing.T) { - _, _, err := parseStartArgs([]string{"--bogus"}) + _, err := parseStartArgs([]string{"--bogus"}) if err == nil { t.Fatal("expected error for unknown flag --bogus") } From 920b808ae9fe028f064e7d1702e3333dc4f1228e Mon Sep 17 00:00:00 2001 From: Eric Posen <563881+goeric@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:33:53 -0700 Subject: [PATCH 2/2] Don't run extensions under --single-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rodney sets --single-process unconditionally, for screenshots in gVisor/container environments. That is survivable under old headless, which is a stripped-down shell, but --extension switches Chrome to new headless, where the full browser stack comes up: renderer, GPU, viz compositor, media, and the extension's own service worker. Collapsing all of that into one OS process removes every fault boundary, so a CHECK failure or bad access anywhere takes down the whole browser instead of one renderer. Measured with an MV3 extension loaded: --headless=new + --single-process 0 child processes, 205 threads --headless=new 10 child processes, 59 threads Local Chromium crash reports match the first row: a single process holding Chrome_InProcGpuThread, Chrome_InProcRendererThread, Chrome_InProcUtilityThread, a ServiceWorker thread and 23 DedicatedWorker threads, aborting on a CHECK on the in-process utility thread. Drop --single-process only on the extension path; launches without --extension keep it and are unchanged. Also extract configureExtensions() so cmdStart and the end-to-end test build the launcher the same way — the test previously duplicated the flags inline, so it could not have caught this. Co-Authored-By: Claude Opus 5 (1M context) --- extensions.go | 32 +++++++++++++ extensions_test.go | 110 +++++++++++++++++++++++++++++++++++++++++---- main.go | 15 +------ 3 files changed, 134 insertions(+), 23 deletions(-) diff --git a/extensions.go b/extensions.go index 4b4d99c..1cd50cb 100644 --- a/extensions.go +++ b/extensions.go @@ -14,6 +14,8 @@ import ( "runtime" "strings" "unicode/utf16" + + "github.com/go-rod/rod/lib/launcher" ) // extensionList collects repeatable --extension flag values. @@ -29,6 +31,36 @@ func (e *extensionList) Set(v string) error { return nil } +// configureExtensions points Chrome at the given extensions, returning l +// unchanged when there are none. +// +// Old headless Chrome cannot run extensions at all, so extensions switch to the +// new headless mode: https://developer.chrome.com/docs/chromium/new-headless +// +// New headless is the full browser stack — renderer, GPU, utility services and +// the extension's own service worker — where rodney's --single-process becomes +// dangerous: it collapses all of those into one OS process (measured: 0 child +// processes and 205 threads, versus 10 and 59 without it), so any CHECK failure +// or bad access anywhere takes down the entire browser rather than one renderer. +// It is dropped here only; launches without --extension keep it, preserving the +// screenshot behaviour it was added for in gVisor/container environments. +func configureExtensions(l *launcher.Launcher, headless bool, extensions []extensionInfo) *launcher.Launcher { + if len(extensions) == 0 { + return l + } + dirs := make([]string, len(extensions)) + for i, ext := range extensions { + dirs[i] = ext.Dir + } + l = l.HeadlessNew(headless). + Delete("single-process"). + Set("load-extension", strings.Join(dirs, ",")). + Set("disable-extensions-except", strings.Join(dirs, ",")) + // Chrome 137+ ignores --load-extension unless this feature is turned off. + // Append rather than Set: rod already disables some features by default. + return l.Append("disable-features", "DisableLoadExtensionCommandLineSwitch") +} + // manifest is the subset of manifest.json we care about. type manifest struct { Name string `json:"name"` diff --git a/extensions_test.go b/extensions_test.go index 834329f..6582e0c 100644 --- a/extensions_test.go +++ b/extensions_test.go @@ -540,18 +540,110 @@ func TestUnpackExtension_RejectsPathTraversal(t *testing.T) { // TestExtension_LoadsInHeadlessChrome is the test that matters: it launches a // headless browser the same way "rodney start --extension" does and checks the // extension's content script actually ran on a page. -func TestExtension_LoadsInHeadlessChrome(t *testing.T) { - dir := writeTestExtension(t, filepath.Join(t.TempDir(), "ext")) - - l := launcher.New(). +// baseLauncher mirrors the flags cmdStart sets before configureExtensions runs. +func baseLauncher() *launcher.Launcher { + return launcher.New(). Set("no-sandbox"). Set("disable-gpu"). Set("single-process"). - Leakless(false). - HeadlessNew(true). - Set("load-extension", dir). - Set("disable-extensions-except", dir). - Append("disable-features", "DisableLoadExtensionCommandLineSwitch") + Leakless(false) +} + +// headlessMode reports the --headless value: "" for old headless (the flag is +// present but valueless), "new" for new headless, "off" when it is absent. +// launcher.Get panics on a valueless flag, so read the values directly. +func headlessMode(l *launcher.Launcher) string { + values, ok := l.GetFlags("headless") + if !ok { + return "off" + } + if len(values) == 0 { + return "" + } + return values[0] +} + +func TestConfigureExtensions_NoExtensionsLeavesLauncherAlone(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, nil) + + if !l.Has("single-process") { + t.Error("--single-process was dropped for a launch with no extensions") + } + if got := headlessMode(l); got != "" { + t.Errorf("headless = %q, want %q (old headless) when no extensions are loaded", got, "") + } + if l.Has("load-extension") { + t.Error("--load-extension set with no extensions") + } +} + +// New headless brings up the full browser stack (renderer, GPU, utility services, +// the extension service worker). --single-process collapses all of that into one +// OS process where a single CHECK failure kills the whole browser, so extensions +// must drop it. +func TestConfigureExtensions_DropsSingleProcess(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: "/tmp/ext"}}) + + if l.Has("single-process") { + t.Error("--single-process survived; new headless + extensions must not run single-process") + } +} + +func TestConfigureExtensions_SelectsNewHeadless(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: "/tmp/ext"}}) + + if got := headlessMode(l); got != "new" { + t.Errorf("headless = %q, want %q; old headless cannot run extensions", got, "new") + } +} + +// --show must stay a real visible window, not new headless. +func TestConfigureExtensions_HeadedStaysHeaded(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(false), false, + []extensionInfo{{Dir: "/tmp/ext"}}) + + if got := headlessMode(l); got != "off" { + t.Errorf("headless = %q, want it absent, for a --show launch", got) + } + if l.Has("single-process") { + t.Error("--single-process survived; extensions must not run single-process") + } +} + +func TestConfigureExtensions_JoinsDirsWithCommas(t *testing.T) { + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: "/tmp/one"}, {Dir: "/tmp/two"}}) + + const want = "/tmp/one,/tmp/two" + if got := l.Get("load-extension"); got != want { + t.Errorf("load-extension = %q, want %q", got, want) + } + if got := l.Get("disable-extensions-except"); got != want { + t.Errorf("disable-extensions-except = %q, want %q", got, want) + } +} + +// rod disables some features by default; the extension switch must be appended +// to those rather than replacing them. +func TestConfigureExtensions_AppendsToExistingDisableFeatures(t *testing.T) { + base := baseLauncher().Headless(true).Set("disable-features", "TranslateUI") + l := configureExtensions(base, true, []extensionInfo{{Dir: "/tmp/ext"}}) + + flags, _ := l.GetFlags("disable-features") + got := strings.Join(flags, ",") + const want = "TranslateUI,DisableLoadExtensionCommandLineSwitch" + if got != want { + t.Errorf("disable-features = %q, want %q", got, want) + } +} + +func TestExtension_LoadsInHeadlessChrome(t *testing.T) { + dir := writeTestExtension(t, filepath.Join(t.TempDir(), "ext")) + + l := configureExtensions(baseLauncher().Headless(true), true, + []extensionInfo{{Dir: dir}}) if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { l = l.Bin(bin) diff --git a/main.go b/main.go index 69c1123..c549596 100644 --- a/main.go +++ b/main.go @@ -408,20 +408,7 @@ func cmdStart(args []string) { l = l.Delete("no-startup-window") } - if len(extensions) > 0 { - dirs := make([]string, len(extensions)) - for i, ext := range extensions { - dirs[i] = ext.Dir - } - // Old headless Chrome cannot run extensions at all, so extensions force - // the new headless mode: https://developer.chrome.com/docs/chromium/new-headless - l = l.HeadlessNew(headless). - Set("load-extension", strings.Join(dirs, ",")). - Set("disable-extensions-except", strings.Join(dirs, ",")) - // Chrome 137+ ignores --load-extension unless this feature is turned off. - // Append rather than Set: rod already disables some features by default. - l = l.Append("disable-features", "DisableLoadExtensionCommandLineSwitch") - } + l = configureExtensions(l, headless, extensions) if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { l = l.Bin(bin)