Skip to content

Commit 38df03e

Browse files
authored
CLI control of browser extensions (#24)
- Adds - `kernel extensions <list|delete|download|download-web-store>` for managing extensions and also downloading them as unpacked extensions from the chrome web store - `kernel browsers extensions upload` to do an ad-hoc upload to a running browser instance Along the way: - added an -o/--output json option to `browsers list` - added the ability to pass more than one session ID to `browsers delete` --- <!-- mesa-description-start --> ## TL;DR This PR introduces CLI commands to manage browser extensions, allowing users to list, delete, download from the Chrome Web Store, and upload extensions to browser instances. ## Why we made these changes This addresses a feature request for programmatic control over browser extensions. It enables users to automate the setup of browser profiles with necessary extensions, improving developer workflow and consistency across environments. ## What changed? - **New `extensions` command:** - Added a new top-level command `kernel extensions` for managing the extension registry with `list`, `delete`, `download`, and `download-web-store` subcommands. - **Enhanced `browsers` command:** - Added `browsers extensions upload` to install an extension in a running browser. - Added an `--extension` flag to `browsers create` to install extensions on creation. - Added a `-o/--output json` option to `browsers list`. - Updated `browsers delete` to accept multiple session IDs. - **Utilities & Dependencies:** - Added a new `Unzip` utility in `pkg/util/zip.go` to handle extension packages. - Updated `go.mod` to replace the `kernel-go-sdk` dependency. - **Documentation & Testing:** - Updated `README.md` to document the new commands. - Added comprehensive unit tests for the new functionality. ## Validation - [ ] `kernel extensions list` correctly displays registered extensions. - [ ] `kernel extensions delete` successfully removes an extension. - [ ] `kernel extensions download-web-store` downloads and unpacks an extension correctly. - [ ] `kernel browsers extensions upload` successfully installs an extension in a running browser. <sup>_Description generated by Mesa. [Update settings](https://app.mesa.dev/onkernel/settings/pull-requests)_</sup> <!-- mesa-description-end -->
1 parent 44e88b5 commit 38df03e

File tree

9 files changed

+896
-15
lines changed

9 files changed

+896
-15
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com).
106106
### App Deployment
107107

108108
- `kernel deploy <file>` - Deploy an app to Kernel
109+
109110
- `--version <version>` - Specify app version (default: latest)
110111
- `--force` - Allow overwriting existing version
111112
- `--env <KEY=VALUE>`, `-e` - Set environment variables (can be used multiple times)
112113
- `--env-file <file>` - Load environment variables from file (can be used multiple times)
113114

114115
- `kernel deploy logs <deployment_id>` - Stream logs for a deployment
116+
115117
- `--follow`, `-f` - Follow logs in real-time (stream continuously)
116118
- `--since`, `-s` - How far back to retrieve logs. Duration formats: ns, us, ms, s, m, h (e.g., 5m, 2h, 1h30m). Timestamps also supported: 2006-01-02, 2006-01-02T15:04, 2006-01-02T15:04:05, 2006-01-02T15:04:05.000
117119
- `--with-timestamps`, `-t` - Include timestamps in each log line
@@ -234,6 +236,23 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com).
234236
- `--mode <mode>` - File mode (octal string)
235237
- `--source <path>` - Local source file path (required)
236238

239+
### Browser Extensions
240+
241+
- `kernel browsers extensions upload <id or persistent id> <extension-path>...` - Ad-hoc upload of one or more unpacked extensions to a running browser instance.
242+
243+
### Extension Management
244+
245+
- `kernel extensions list` - List all uploaded extensions
246+
- `kernel extensions upload <directory>` - Upload an unpacked browser extension directory
247+
- `--name <name>` - Optional unique extension name
248+
- `kernel extensions download <id-or-name>` - Download an extension archive
249+
- `--to <directory>` - Output directory (required)
250+
- `kernel extensions download-web-store <url>` - Download an extension from the Chrome Web Store
251+
- `--to <directory>` - Output directory (required)
252+
- `--os <os>` - Target OS: mac, win, or linux (default: linux)
253+
- `kernel extensions delete <id-or-name>` - Delete an extension by ID or name
254+
- `-y, --yes` - Skip confirmation prompt
255+
237256
## Examples
238257

239258
### Deploy with environment variables
@@ -309,6 +328,28 @@ kernel browsers fs upload my-browser --file "local.txt:remote.txt" --dest-dir "/
309328
kernel browsers fs list-files my-browser --path "/tmp"
310329
```
311330

331+
### Extension management
332+
333+
```bash
334+
# List all uploaded extensions
335+
kernel extensions list
336+
337+
# Upload an unpacked extension directory
338+
kernel extensions upload ./my-extension --name my-custom-extension
339+
340+
# Download an extension from Chrome Web Store
341+
kernel extensions download-web-store "https://chrome.google.com/webstore/detail/extension-id" --to ./downloaded-extension
342+
343+
# Download a previously uploaded extension
344+
kernel extensions download my-extension-id --to ./my-extension
345+
346+
# Delete an extension
347+
kernel extensions delete my-extension-name --yes
348+
349+
# Upload extensions to a running browser instance
350+
kernel browsers extensions upload my-browser ./extension1 ./extension2
351+
```
352+
312353
## Getting Help
313354

314355
- `kernel --help` - Show all available commands

cmd/browsers.go

Lines changed: 176 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ package cmd
22

33
import (
44
"context"
5+
"crypto/rand"
56
"encoding/base64"
7+
"encoding/json"
68
"fmt"
79
"io"
10+
"math/big"
811
"net/http"
912
"os"
1013
"path/filepath"
14+
"regexp"
1115
"strings"
1216

1317
"github.com/onkernel/cli/pkg/util"
@@ -26,6 +30,7 @@ type BrowsersService interface {
2630
New(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (res *kernel.BrowserNewResponse, err error)
2731
Delete(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) (err error)
2832
DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error)
33+
UploadExtensions(ctx context.Context, id string, body kernel.BrowserUploadExtensionsParams, opts ...option.RequestOption) (err error)
2934
}
3035

3136
// BrowserReplaysService defines the subset we use for browser replays.
@@ -73,6 +78,9 @@ type BoolFlag struct {
7378
Value bool
7479
}
7580

81+
// Regular expression to validate CUID2 identifiers (24 lowercase alphanumeric characters).
82+
var cuidRegex = regexp.MustCompile(`^[a-z0-9]{24}$`)
83+
7684
// Inputs for each command
7785
type BrowsersCreateInput struct {
7886
PersistenceID string
@@ -83,6 +91,7 @@ type BrowsersCreateInput struct {
8391
ProfileName string
8492
ProfileSaveChanges BoolFlag
8593
ProxyID string
94+
Extensions []string
8695
}
8796

8897
type BrowsersDeleteInput struct {
@@ -103,14 +112,34 @@ type BrowsersCmd struct {
103112
logs BrowserLogService
104113
}
105114

106-
func (b BrowsersCmd) List(ctx context.Context) error {
107-
pterm.Info.Println("Fetching browsers...")
115+
type BrowsersListInput struct {
116+
Output string
117+
}
118+
119+
func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error {
120+
if in.Output != "" && in.Output != "json" {
121+
pterm.Error.Println("unsupported --output value: use 'json'")
122+
return nil
123+
}
108124

109125
browsers, err := b.browsers.List(ctx)
110126
if err != nil {
111127
return util.CleanedUpSdkError{Err: err}
112128
}
113129

130+
if in.Output == "json" {
131+
if browsers == nil {
132+
fmt.Println("[]")
133+
return nil
134+
}
135+
bs, err := json.MarshalIndent(*browsers, "", " ")
136+
if err != nil {
137+
return err
138+
}
139+
fmt.Println(string(bs))
140+
return nil
141+
}
142+
114143
if browsers == nil || len(*browsers) == 0 {
115144
pterm.Info.Println("No running or persistent browsers found")
116145
return nil
@@ -184,6 +213,23 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
184213
params.ProxyID = kernel.Opt(in.ProxyID)
185214
}
186215

216+
// Map extensions (IDs or names) into params.Extensions
217+
if len(in.Extensions) > 0 {
218+
for _, ext := range in.Extensions {
219+
val := strings.TrimSpace(ext)
220+
if val == "" {
221+
continue
222+
}
223+
item := kernel.BrowserNewParamsExtension{}
224+
if cuidRegex.MatchString(val) {
225+
item.ID = kernel.Opt(val)
226+
} else {
227+
item.Name = kernel.Opt(val)
228+
}
229+
params.Extensions = append(params.Extensions, item)
230+
}
231+
}
232+
187233
browser, err := b.browsers.New(ctx, params)
188234
if err != nil {
189235
return util.CleanedUpSdkError{Err: err}
@@ -809,6 +855,11 @@ type BrowsersFSWriteFileInput struct {
809855
SourcePath string
810856
}
811857

858+
type BrowsersExtensionsUploadInput struct {
859+
Identifier string
860+
ExtensionPaths []string
861+
}
862+
812863
func (b BrowsersCmd) FSNewDirectory(ctx context.Context, in BrowsersFSNewDirInput) error {
813864
if b.fs == nil {
814865
pterm.Error.Println("fs service not available")
@@ -1169,6 +1220,99 @@ func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInpu
11691220
return nil
11701221
}
11711222

1223+
func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensionsUploadInput) error {
1224+
if b.browsers == nil {
1225+
pterm.Error.Println("browsers service not available")
1226+
return nil
1227+
}
1228+
br, err := b.resolveBrowserByIdentifier(ctx, in.Identifier)
1229+
if err != nil {
1230+
return util.CleanedUpSdkError{Err: err}
1231+
}
1232+
if br == nil {
1233+
pterm.Error.Printf("Browser '%s' not found\n", in.Identifier)
1234+
return nil
1235+
}
1236+
1237+
if len(in.ExtensionPaths) == 0 {
1238+
pterm.Error.Println("no extension paths provided")
1239+
return nil
1240+
}
1241+
1242+
var extensions []kernel.BrowserUploadExtensionsParamsExtension
1243+
var tempZipFiles []string
1244+
var openFiles []*os.File
1245+
1246+
defer func() {
1247+
for _, f := range openFiles {
1248+
_ = f.Close()
1249+
}
1250+
for _, zipPath := range tempZipFiles {
1251+
_ = os.Remove(zipPath)
1252+
}
1253+
}()
1254+
1255+
for _, extPath := range in.ExtensionPaths {
1256+
info, err := os.Stat(extPath)
1257+
if err != nil {
1258+
pterm.Error.Printf("Failed to stat %s: %v\n", extPath, err)
1259+
return nil
1260+
}
1261+
if !info.IsDir() {
1262+
pterm.Error.Printf("Path %s is not a directory\n", extPath)
1263+
return nil
1264+
}
1265+
1266+
extName := generateRandomExtensionName()
1267+
tempZipPath := filepath.Join(os.TempDir(), fmt.Sprintf("kernel-ext-%s.zip", extName))
1268+
1269+
pterm.Info.Printf("Zipping %s as %s...\n", extPath, extName)
1270+
if err := util.ZipDirectory(extPath, tempZipPath); err != nil {
1271+
pterm.Error.Printf("Failed to zip %s: %v\n", extPath, err)
1272+
return nil
1273+
}
1274+
tempZipFiles = append(tempZipFiles, tempZipPath)
1275+
1276+
zipFile, err := os.Open(tempZipPath)
1277+
if err != nil {
1278+
pterm.Error.Printf("Failed to open zip %s: %v\n", tempZipPath, err)
1279+
return nil
1280+
}
1281+
openFiles = append(openFiles, zipFile)
1282+
1283+
extensions = append(extensions, kernel.BrowserUploadExtensionsParamsExtension{
1284+
Name: extName,
1285+
ZipFile: zipFile,
1286+
})
1287+
}
1288+
1289+
pterm.Info.Printf("Uploading %d extension(s) to browser %s...\n", len(extensions), br.SessionID)
1290+
if err := b.browsers.UploadExtensions(ctx, br.SessionID, kernel.BrowserUploadExtensionsParams{
1291+
Extensions: extensions,
1292+
}); err != nil {
1293+
return util.CleanedUpSdkError{Err: err}
1294+
}
1295+
1296+
if len(extensions) == 1 {
1297+
pterm.Success.Println("Successfully uploaded 1 extension and restarted Chromium")
1298+
} else {
1299+
pterm.Success.Printf("Successfully uploaded %d extensions and restarted Chromium\n", len(extensions))
1300+
}
1301+
return nil
1302+
}
1303+
1304+
// generateRandomExtensionName generates a random name matching pattern ^[A-Za-z0-9._-]{1,64}$
1305+
func generateRandomExtensionName() string {
1306+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
1307+
const nameLen = 16
1308+
result := make([]byte, nameLen)
1309+
for i := range result {
1310+
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
1311+
result[i] = chars[n.Int64()]
1312+
}
1313+
return string(result)
1314+
}
1315+
11721316
var browsersCmd = &cobra.Command{
11731317
Use: "browsers",
11741318
Short: "Manage browsers",
@@ -1188,9 +1332,9 @@ var browsersCreateCmd = &cobra.Command{
11881332
}
11891333

11901334
var browsersDeleteCmd = &cobra.Command{
1191-
Use: "delete <id-or-persistent-id>",
1335+
Use: "delete <id-or-persistent-id> [ids...]",
11921336
Short: "Delete a browser",
1193-
Args: cobra.ExactArgs(1),
1337+
Args: cobra.MinimumNArgs(1),
11941338
RunE: runBrowsersDelete,
11951339
}
11961340

@@ -1202,6 +1346,9 @@ var browsersViewCmd = &cobra.Command{
12021346
}
12031347

12041348
func init() {
1349+
// list flags
1350+
browsersListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response")
1351+
12051352
browsersCmd.AddCommand(browsersListCmd)
12061353
browsersCmd.AddCommand(browsersCreateCmd)
12071354
browsersCmd.AddCommand(browsersDeleteCmd)
@@ -1319,6 +1466,12 @@ func init() {
13191466
fsRoot.AddCommand(fsNewDir, fsDelDir, fsDelFile, fsDownloadZip, fsFileInfo, fsListFiles, fsMove, fsReadFile, fsSetPerms, fsUpload, fsUploadZip, fsWriteFile)
13201467
browsersCmd.AddCommand(fsRoot)
13211468

1469+
// extensions
1470+
extensionsRoot := &cobra.Command{Use: "extensions", Short: "Add browser extensions to a running instance"}
1471+
extensionsUpload := &cobra.Command{Use: "upload <id|persistent-id> <extension-path>...", Short: "Upload one or more unpacked extensions and restart Chromium", Args: cobra.MinimumNArgs(2), RunE: runBrowsersExtensionsUpload}
1472+
extensionsRoot.AddCommand(extensionsUpload)
1473+
browsersCmd.AddCommand(extensionsRoot)
1474+
13221475
// Add flags for create command
13231476
browsersCreateCmd.Flags().StringP("persistent-id", "p", "", "Unique identifier for browser session persistence")
13241477
browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection")
@@ -1328,6 +1481,7 @@ func init() {
13281481
browsersCreateCmd.Flags().String("profile-name", "", "Profile name to load into the browser session (mutually exclusive with --profile-id)")
13291482
browsersCreateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends")
13301483
browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session")
1484+
browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)")
13311485

13321486
// Add flags for delete command
13331487
browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
@@ -1339,7 +1493,8 @@ func runBrowsersList(cmd *cobra.Command, args []string) error {
13391493
client := getKernelClient(cmd)
13401494
svc := client.Browsers
13411495
b := BrowsersCmd{browsers: &svc}
1342-
return b.List(cmd.Context())
1496+
out, _ := cmd.Flags().GetString("output")
1497+
return b.List(cmd.Context(), BrowsersListInput{Output: out})
13431498
}
13441499

13451500
func runBrowsersCreate(cmd *cobra.Command, args []string) error {
@@ -1354,6 +1509,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
13541509
profileName, _ := cmd.Flags().GetString("profile-name")
13551510
saveChanges, _ := cmd.Flags().GetBool("save-changes")
13561511
proxyID, _ := cmd.Flags().GetString("proxy-id")
1512+
extensions, _ := cmd.Flags().GetStringSlice("extension")
13571513

13581514
in := BrowsersCreateInput{
13591515
PersistenceID: persistenceID,
@@ -1364,6 +1520,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
13641520
ProfileName: profileName,
13651521
ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges},
13661522
ProxyID: proxyID,
1523+
Extensions: extensions,
13671524
}
13681525

13691526
svc := client.Browsers
@@ -1373,14 +1530,17 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
13731530

13741531
func runBrowsersDelete(cmd *cobra.Command, args []string) error {
13751532
client := getKernelClient(cmd)
1376-
1377-
identifier := args[0]
13781533
skipConfirm, _ := cmd.Flags().GetBool("yes")
13791534

1380-
in := BrowsersDeleteInput{Identifier: identifier, SkipConfirm: skipConfirm}
13811535
svc := client.Browsers
13821536
b := BrowsersCmd{browsers: &svc}
1383-
return b.Delete(cmd.Context(), in)
1537+
// Iterate all provided identifiers
1538+
for _, identifier := range args {
1539+
if err := b.Delete(cmd.Context(), BrowsersDeleteInput{Identifier: identifier, SkipConfirm: skipConfirm}); err != nil {
1540+
return err
1541+
}
1542+
}
1543+
return nil
13841544
}
13851545

13861546
func runBrowsersView(cmd *cobra.Command, args []string) error {
@@ -1633,6 +1793,13 @@ func runBrowsersFSWriteFile(cmd *cobra.Command, args []string) error {
16331793
return b.FSWriteFile(cmd.Context(), BrowsersFSWriteFileInput{Identifier: args[0], DestPath: path, Mode: mode, SourcePath: input})
16341794
}
16351795

1796+
func runBrowsersExtensionsUpload(cmd *cobra.Command, args []string) error {
1797+
client := getKernelClient(cmd)
1798+
svc := client.Browsers
1799+
b := BrowsersCmd{browsers: &svc}
1800+
return b.ExtensionsUpload(cmd.Context(), BrowsersExtensionsUploadInput{Identifier: args[0], ExtensionPaths: args[1:]})
1801+
}
1802+
16361803
func truncateURL(url string, maxLen int) string {
16371804
if len(url) <= maxLen {
16381805
return url

0 commit comments

Comments
 (0)