diff --git a/README.md b/README.md index c63a264..3c3a45a 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ Commands with JSON output support: - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` + - `--chrome-policy ` - Custom Chrome enterprise policy as a JSON object. Kernel-managed policies (extensions, proxy, automation) are rejected server-side. + - `--chrome-policy-file ` - Read the Chrome enterprise policy from a file (use `-` for stdin). Mutually exclusive with `--chrome-policy`. - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser by ID or name @@ -258,11 +260,12 @@ Commands with JSON output support: - `--timeout ` - Idle timeout for browsers acquired from the pool - `--stealth`, `--headless`, `--kiosk` - Default pool configuration - `--profile-id`, `--profile-name`, `--save-changes`, `--proxy-id`, `--start-url`, `--extension`, `--viewport` - Same semantics as `kernel browsers create` + - `--chrome-policy ` / `--chrome-policy-file ` - Custom Chrome enterprise policy applied to every browser in the pool, as a JSON object or from a file (`-` for stdin). Same semantics as `kernel browsers create`. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools get ` - Get pool details - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools update ` - Update pool configuration - - Same flags as create plus `--clear-start-url` (remove the pool's start URL) and `--discard-all-idle` (discard all idle browsers and refill) + - Same flags as create plus `--clear-start-url` (remove the pool's start URL) and `--discard-all-idle` (discard all idle browsers and refill). An empty `--chrome-policy '{}'` is ignored and does not clear an existing policy; recreate the pool to remove one. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools delete ` - Delete a pool - `--force` - Force delete even if browsers are leased @@ -657,6 +660,10 @@ kernel browsers create --kiosk # Create a browser with a profile for session state kernel browsers create --profile-name my-profile +# Create a browser with a custom Chrome enterprise policy +kernel browsers create --chrome-policy '{"BookmarkBarEnabled": false}' +kernel browsers create --chrome-policy-file policy.json + # Delete a browser kernel browsers delete browser123 diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 094920a..5afe8bc 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -105,6 +105,8 @@ type BrowserPoolsCreateInput struct { StartURL string Extensions []string Viewport string + ChromePolicy string + ChromePolicyFile string Output string } @@ -166,6 +168,14 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) params.Viewport = *viewport } + chromePolicy, err := parseChromePolicy(in.ChromePolicy, in.ChromePolicyFile) + if err != nil { + return err + } + if len(chromePolicy) > 0 { + params.ChromePolicy = chromePolicy + } + pool, err := c.client.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -245,6 +255,8 @@ type BrowserPoolsUpdateInput struct { ClearStartURL bool Extensions []string Viewport string + ChromePolicy string + ChromePolicyFile string DiscardAllIdle BoolFlag Output string } @@ -316,6 +328,19 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.Viewport = *viewport } + chromePolicy, err := parseChromePolicy(in.ChromePolicy, in.ChromePolicyFile) + if err != nil { + return err + } + if len(chromePolicy) > 0 { + params.ChromePolicy = chromePolicy + } else if (in.ChromePolicy != "" || in.ChromePolicyFile != "") && in.Output != "json" { + // An empty policy ({}) cannot clear an existing one: omitzero drops it before it + // reaches the server. Warn instead of silently doing nothing, but stay quiet on the + // json path so stdout remains valid JSON. + pterm.Warning.Println("An empty chrome policy is ignored and does not clear the pool's existing policy; recreate the pool to remove a policy.") + } + pool, err := c.client.Update(ctx, in.IDOrName, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -541,6 +566,9 @@ func init() { browserPoolsCreateCmd.Flags().String("start-url", "", "Initial page to open for new browsers") browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") browserPoolsCreateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") + browserPoolsCreateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") + browserPoolsCreateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") + browserPoolsCreateCmd.MarkFlagsMutuallyExclusive("chrome-policy", "chrome-policy-file") addJSONOutputFlag(browserPoolsGetCmd) @@ -559,6 +587,9 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("clear-start-url", false, "Clear the pool start URL") browserPoolsUpdateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") + browserPoolsUpdateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") + browserPoolsUpdateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") + browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("chrome-policy", "chrome-policy-file") browserPoolsUpdateCmd.Flags().Bool("discard-all-idle", false, "Discard all idle browsers") addJSONOutputFlag(browserPoolsUpdateCmd) @@ -615,6 +646,8 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") + chromePolicy, _ := cmd.Flags().GetString("chrome-policy") + chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") output, _ := cmd.Flags().GetString("output") in := BrowserPoolsCreateInput{ @@ -632,6 +665,8 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { StartURL: startURL, Extensions: extensions, Viewport: viewport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, Output: output, } @@ -664,6 +699,8 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { clearStartURL, _ := cmd.Flags().GetBool("clear-start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") + chromePolicy, _ := cmd.Flags().GetString("chrome-policy") + chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") discardIdle, _ := cmd.Flags().GetBool("discard-all-idle") output, _ := cmd.Flags().GetString("output") @@ -684,6 +721,8 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { ClearStartURL: clearStartURL, Extensions: extensions, Viewport: viewport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, Output: output, } diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index 9b7ea2a..60ed449 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -14,6 +14,8 @@ import ( type FakeBrowserPoolsService struct { AcquireFunc func(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) ListFunc func(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error) + NewFunc func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) + UpdateFunc func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) } func (f *FakeBrowserPoolsService) List(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error) { @@ -24,6 +26,9 @@ func (f *FakeBrowserPoolsService) List(ctx context.Context, query kernel.Browser } func (f *FakeBrowserPoolsService) New(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + if f.NewFunc != nil { + return f.NewFunc(ctx, body, opts...) + } return &kernel.BrowserPool{}, nil } @@ -32,6 +37,9 @@ func (f *FakeBrowserPoolsService) Get(ctx context.Context, id string, opts ...op } func (f *FakeBrowserPoolsService) Update(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, id, body, opts...) + } return &kernel.BrowserPool{}, nil } @@ -128,3 +136,101 @@ func TestBuildAcquireParams(t *testing.T) { assert.Len(t, empty.Tags, 0) assert.False(t, empty.AcquireTimeoutSeconds.Valid()) } + +func TestBrowserPoolsCreate_WithChromePolicy(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolNewParams + fake := &FakeBrowserPoolsService{ + NewFunc: func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + captured = body + return &kernel.BrowserPool{ID: "pool-cp"}, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.Create(context.Background(), BrowserPoolsCreateInput{ + Size: 1, + ChromePolicy: `{"BookmarkBarEnabled": false}`, + }) + assert.NoError(t, err) + assert.Equal(t, map[string]any{"BookmarkBarEnabled": false}, captured.ChromePolicy) +} + +func TestBrowserPoolsCreate_ChromePolicyEmptyObjectOmitted(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolNewParams + fake := &FakeBrowserPoolsService{ + NewFunc: func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + captured = body + return &kernel.BrowserPool{ID: "pool-cp"}, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.Create(context.Background(), BrowserPoolsCreateInput{Size: 1, ChromePolicy: "{}"}) + assert.NoError(t, err) + assert.Nil(t, captured.ChromePolicy) +} + +func TestBrowserPoolsUpdate_WithChromePolicy(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolUpdateParams + fake := &FakeBrowserPoolsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + captured = body + return &kernel.BrowserPool{ID: id}, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.Update(context.Background(), BrowserPoolsUpdateInput{ + IDOrName: "pool-1", + ChromePolicy: `{"BookmarkBarEnabled": false}`, + }) + assert.NoError(t, err) + assert.Equal(t, map[string]any{"BookmarkBarEnabled": false}, captured.ChromePolicy) +} + +func TestBrowserPoolsUpdate_EmptyChromePolicyWarnsAndDoesNotClear(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolUpdateParams + fake := &FakeBrowserPoolsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + captured = body + return &kernel.BrowserPool{ID: id}, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.Update(context.Background(), BrowserPoolsUpdateInput{ + IDOrName: "pool-1", + ChromePolicy: "{}", + }) + assert.NoError(t, err) + assert.Nil(t, captured.ChromePolicy) + assert.Contains(t, outBuf.String(), "does not clear") +} + +func TestBrowserPoolsUpdate_EmptyChromePolicyQuietInJSONMode(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeBrowserPoolsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + return &kernel.BrowserPool{ID: id}, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.Update(context.Background(), BrowserPoolsUpdateInput{ + IDOrName: "pool-1", + ChromePolicy: "{}", + Output: "json", + }) + assert.NoError(t, err) + // The warning must not leak onto stdout in json mode, where it would corrupt the payload. + assert.NotContains(t, outBuf.String(), "does not clear") +} diff --git a/cmd/browsers.go b/cmd/browsers.go index de8aa01..a9cc2e7 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -161,6 +161,43 @@ func parseViewport(viewport string) (width, height, refreshRate int64, err error return w, h, refreshRate, nil } +// parseChromePolicy resolves the --chrome-policy / --chrome-policy-file inputs into a +// custom Chrome enterprise policy object. The two inputs are mutually exclusive (enforced +// by cobra); a file path of "-" reads stdin. It returns a nil map when neither input is +// set or the content is empty. An explicit empty object ("{}") yields a non-nil empty map, +// so callers must guard the SDK assignment with len>0: chrome_policy uses omitzero, which +// drops only a nil map, not an empty one. +func parseChromePolicy(inline, file string) (map[string]any, error) { + data := strings.TrimSpace(inline) + if file != "" { + var b []byte + var err error + if file == "-" { + b, err = io.ReadAll(os.Stdin) + } else { + b, err = os.ReadFile(file) + } + if err != nil { + return nil, fmt.Errorf("failed to read chrome policy file: %w", err) + } + data = strings.TrimSpace(string(b)) + } + + if data == "" { + return nil, nil + } + + policy := map[string]any{} + if err := json.Unmarshal([]byte(data), &policy); err != nil { + return nil, fmt.Errorf("invalid JSON in chrome policy (must be a JSON object): %w", err) + } + if policy == nil { + // json.Unmarshal of the literal `null` succeeds but nils the map. + return nil, fmt.Errorf("chrome policy must be a JSON object, not null") + } + return policy, nil +} + // parseKeyValueSpecs parses repeated KEY=VALUE flag values into a map. It // returns the parsed pairs along with any specs that were malformed (missing // "=" or an empty key), mirroring the kernel hypeman CLI convention. @@ -229,6 +266,8 @@ type BrowsersCreateInput struct { Extensions []string Viewport string Telemetry string + ChromePolicy string + ChromePolicyFile string Name string Tags map[string]string Output string @@ -478,6 +517,14 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { params.Telemetry = t } + chromePolicy, err := parseChromePolicy(in.ChromePolicy, in.ChromePolicyFile) + if err != nil { + return err + } + if len(chromePolicy) > 0 { + params.ChromePolicy = chromePolicy + } + if in.Name != "" { params.Name = kernel.Opt(in.Name) } @@ -2600,6 +2647,9 @@ func init() { browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; set at creation only)") browsersCreateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the session (repeatable; up to 50 pairs)") + browsersCreateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") + browsersCreateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") + browsersCreateCmd.MarkFlagsMutuallyExclusive("chrome-policy", "chrome-policy-file") // curl curlCmd := &cobra.Command{ @@ -2660,6 +2710,25 @@ func runBrowsersList(cmd *cobra.Command, args []string) error { }) } +// poolLeaseAllowedFlags returns the `browsers create` flags that are compatible with +// acquiring a session from a pool (--pool-id/--pool-name). When a pool flag is set, the +// pool determines browser configuration, so any other browser-config flag set alongside it +// triggers a conflict warning. Session-only flags like --chrome-policy must stay out of +// this set so they correctly surface that warning rather than being silently ignored. +func poolLeaseAllowedFlags() map[string]bool { + return map[string]bool{ + "pool-id": true, + "pool-name": true, + "timeout": true, + "name": true, + "tag": true, + "output": true, + // Global persistent flags that don't configure browsers + "no-color": true, + "log-level": true, + } +} + func runBrowsersCreate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) @@ -2683,6 +2752,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { telemetry, _ := cmd.Flags().GetString("telemetry") name, _ := cmd.Flags().GetString("name") tags := tagsFromFlag(cmd, "tag") + chromePolicy, _ := cmd.Flags().GetString("chrome-policy") + chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") output, _ := cmd.Flags().GetString("output") if poolID != "" && poolName != "" { @@ -2693,17 +2764,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if poolID != "" || poolName != "" { // When using a pool, configuration comes from the pool itself, but // name and tags apply per-lease to the acquired session. - allowedFlags := map[string]bool{ - "pool-id": true, - "pool-name": true, - "timeout": true, - "name": true, - "tag": true, - "output": true, - // Global persistent flags that don't configure browsers - "no-color": true, - "log-level": true, - } + allowedFlags := poolLeaseAllowedFlags() // Check if any browser configuration flags were set (which would conflict). var conflicts []string @@ -2797,6 +2858,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { Extensions: extensions, Viewport: viewport, Telemetry: telemetry, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, Name: name, Tags: tags, Output: output, diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index f864c67..f283130 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -486,6 +486,159 @@ func TestBrowsersCreate_WithNameAndTags(t *testing.T) { assert.Contains(t, out, "env=staging, team=backend") } +func TestBrowsersCreate_WithChromePolicy(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-cp", CdpWsURL: "ws://cdp-cp"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + err := b.Create(context.Background(), BrowsersCreateInput{ + ChromePolicy: `{"BookmarkBarEnabled": false}`, + }) + assert.NoError(t, err) + assert.Equal(t, map[string]any{"BookmarkBarEnabled": false}, captured.ChromePolicy) + + // The policy reaches the wire. + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), "chrome_policy") + assert.Contains(t, string(raw), "BookmarkBarEnabled") +} + +func TestBrowsersCreate_ChromePolicyEmptyObjectOmitted(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-cp"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + // An empty object must not be sent: omitzero only drops a nil map, so the len>0 guard + // at the call site is what keeps "chrome_policy":{} off the wire. + err := b.Create(context.Background(), BrowsersCreateInput{ChromePolicy: "{}"}) + assert.NoError(t, err) + assert.Nil(t, captured.ChromePolicy) + + // Verify the actual serialized contract, not just the Go field: no chrome_policy key. + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.NotContains(t, string(raw), "chrome_policy") +} + +func TestBrowsersCreate_ChromePolicyInvalidJSON(t *testing.T) { + setupStdoutCapture(t) + + called := false + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + called = true + return &kernel.BrowserNewResponse{}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + err := b.Create(context.Background(), BrowsersCreateInput{ChromePolicy: "not json"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JSON") + assert.False(t, called, "request should not be sent when the policy is invalid") +} + +func TestParseChromePolicy(t *testing.T) { + t.Run("inline object", func(t *testing.T) { + got, err := parseChromePolicy(`{"BookmarkBarEnabled": false}`, "") + require.NoError(t, err) + assert.Equal(t, map[string]any{"BookmarkBarEnabled": false}, got) + }) + + t.Run("from file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "policy.json") + require.NoError(t, os.WriteFile(path, []byte(`{"BookmarkBarEnabled": true}`), 0o600)) + got, err := parseChromePolicy("", path) + require.NoError(t, err) + assert.Equal(t, map[string]any{"BookmarkBarEnabled": true}, got) + }) + + t.Run("both empty returns nil", func(t *testing.T) { + got, err := parseChromePolicy("", "") + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("whitespace-only returns nil", func(t *testing.T) { + got, err := parseChromePolicy(" \n\t ", "") + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("empty object parses to a non-nil empty map", func(t *testing.T) { + got, err := parseChromePolicy("{}", "") + require.NoError(t, err) + assert.NotNil(t, got) + assert.Len(t, got, 0) + }) + + t.Run("invalid JSON errors", func(t *testing.T) { + _, err := parseChromePolicy("not json", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JSON") + }) + + t.Run("top-level array is rejected", func(t *testing.T) { + _, err := parseChromePolicy("[1, 2, 3]", "") + require.Error(t, err) + }) + + t.Run("null literal is rejected", func(t *testing.T) { + _, err := parseChromePolicy("null", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a JSON object") + }) + + t.Run("from stdin via -", func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = w.WriteString(`{"BookmarkBarEnabled": true}`) + require.NoError(t, err) + require.NoError(t, w.Close()) + + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = orig }) + + got, err := parseChromePolicy("", "-") + require.NoError(t, err) + assert.Equal(t, map[string]any{"BookmarkBarEnabled": true}, got) + }) + + t.Run("missing file errors", func(t *testing.T) { + _, err := parseChromePolicy("", filepath.Join(t.TempDir(), "nope.json")) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read") + }) +} + +func TestPoolLeaseAllowedFlags_ExcludesChromePolicy(t *testing.T) { + allowed := poolLeaseAllowedFlags() + // Chrome policy is session-only config; it must NOT be allowed on the pool-lease path so + // that `browsers create --pool-id ... --chrome-policy ...` trips the conflict warning. + assert.False(t, allowed["chrome-policy"]) + assert.False(t, allowed["chrome-policy-file"]) + // The flags that genuinely apply per-lease stay allowed. + assert.True(t, allowed["pool-id"]) + assert.True(t, allowed["name"]) + assert.True(t, allowed["tag"]) +} + func TestBrowsersList_WithTags_PassesParamAndShowsName(t *testing.T) { setupStdoutCapture(t)