diff --git a/images/chromium-headful/run-docker.sh b/images/chromium-headful/run-docker.sh index 9e5af0ae..46b75354 100755 --- a/images/chromium-headful/run-docker.sh +++ b/images/chromium-headful/run-docker.sh @@ -14,7 +14,7 @@ mkdir -p "$HOST_RECORDINGS_DIR" RUN_AS_ROOT="${RUN_AS_ROOT:-false}" # Build Chromium flags file and mount -CHROMIUM_FLAGS_DEFAULT="--user-data-dir=/home/kernel/user-data --disable-dev-shm-usage --disable-gpu --start-maximized --disable-software-rasterizer --remote-allow-origins=*" +CHROMIUM_FLAGS_DEFAULT="--user-data-dir=/home/kernel/user-data --disable-dev-shm-usage --disable-gpu --start-maximized --disable-software-rasterizer --remote-allow-origins=* --enable-features=WebMCPTesting,DevToolsWebMCPSupport" if [[ "$RUN_AS_ROOT" == "true" ]]; then CHROMIUM_FLAGS_DEFAULT="$CHROMIUM_FLAGS_DEFAULT --no-sandbox --no-zygote" fi diff --git a/images/chromium-headful/run-unikernel.sh b/images/chromium-headful/run-unikernel.sh index 54feb23d..cd5504fd 100755 --- a/images/chromium-headful/run-unikernel.sh +++ b/images/chromium-headful/run-unikernel.sh @@ -20,7 +20,7 @@ volume_name="${NAME}-flags" # RUN_AS_ROOT defaults to true in unikernel (for now, until we figure it out) RUN_AS_ROOT="${RUN_AS_ROOT:-true}" -chromium_flags_default="--user-data-dir=/home/kernel/user-data --disable-dev-shm-usage --disable-gpu --start-maximized --disable-software-rasterizer --remote-allow-origins=*" +chromium_flags_default="--user-data-dir=/home/kernel/user-data --disable-dev-shm-usage --disable-gpu --start-maximized --disable-software-rasterizer --remote-allow-origins=* --enable-features=WebMCPTesting,DevToolsWebMCPSupport" if [[ "$RUN_AS_ROOT" == "true" ]]; then chromium_flags_default="$chromium_flags_default --no-sandbox --no-zygote" fi diff --git a/server/cmd/api/api/chromium.go b/server/cmd/api/api/chromium.go index e221d17b..b03ea082 100644 --- a/server/cmd/api/api/chromium.go +++ b/server/cmd/api/api/chromium.go @@ -598,6 +598,9 @@ func (s *ApiService) mergeAndWriteChromiumFlags(ctx context.Context, newTokens [ // Merge existing flags with new flags using token-aware API mergedTokens := chromiumflags.MergeFlags(existingTokens, newTokens) + // Fold kernel-namespaced disable tokens into the plain Chromium switch so + // /chromium/flags only ever holds switches Chromium understands. + mergedTokens = chromiumflags.TranslateKernelDisableFeatures(mergedTokens) if err := writeChromiumFlags(mergedTokens); err != nil { log.Error("failed to write flags", "error", err) diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index 7bce8506..d08d040b 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -81,6 +81,7 @@ func main() { os.Exit(1) } final := chromiumflags.MergeFlagsWithRuntimeTokens(baseFlags, runtimeTokens) + final = chromiumflags.TranslateKernelDisableFeatures(final) final = withDefaultPrivateNetworkBypass(final) // Diagnostics for parity with previous scripts diff --git a/server/cmd/wrapper/chromium.go b/server/cmd/wrapper/chromium.go index 511fce60..1448ea73 100644 --- a/server/cmd/wrapper/chromium.go +++ b/server/cmd/wrapper/chromium.go @@ -41,6 +41,11 @@ func applyHeadlessDefaultFlags() { "--disable-renderer-backgrounding", "--disable-search-engine-choice-screen", "--disable-software-rasterizer", + // WebMCP (navigator.modelContext + CDP WebMCP domain, Chromium 151+). + // DevToolsWebMCPSupport is default-on upstream; listing it keeps the + // CDP domain stable against finch flips. Sessions can opt out per + // instance via --disable-features=WebMCPTesting,DevToolsWebMCPSupport. + "--enable-features=WebMCPTesting,DevToolsWebMCPSupport", "--enable-use-zoom-for-dsf=false", "--export-tagged-pdf", "--force-color-profile=srgb", diff --git a/server/lib/chromiumflags/chromiumflags.go b/server/lib/chromiumflags/chromiumflags.go index 6da8c595..602be6f1 100644 --- a/server/lib/chromiumflags/chromiumflags.go +++ b/server/lib/chromiumflags/chromiumflags.go @@ -36,10 +36,11 @@ func appendCSVInto(dst *[]string, csv string) { } } -// parseTokenStream extracts extension-related flags and collects non-extension flags. -// It returns the list of non-extension tokens and, via references, fills the buckets for -// --load-extension, --disable-extensions-except and a possible --disable-extensions token for that stream. -func parseTokenStream(tokens []string, load, except *[]string, disableAll *string) (nonExt []string) { +// parseTokenStream extracts extension-related and feature-list flags and collects the rest. +// It returns the leftover tokens and, via references, fills the buckets for +// --load-extension, --disable-extensions-except, --enable-features, --disable-features +// and a possible --disable-extensions token for that stream. +func parseTokenStream(tokens []string, load, except, enableFeatures, disableFeatures *[]string, disableAll *string) (nonExt []string) { for _, tok := range tokens { switch { case strings.HasPrefix(tok, "--load-extension="): @@ -48,6 +49,12 @@ func parseTokenStream(tokens []string, load, except *[]string, disableAll *strin case strings.HasPrefix(tok, "--disable-extensions-except="): val := strings.TrimPrefix(tok, "--disable-extensions-except=") appendCSVInto(except, val) + case strings.HasPrefix(tok, "--enable-features="): + val := strings.TrimPrefix(tok, "--enable-features=") + appendCSVInto(enableFeatures, val) + case strings.HasPrefix(tok, "--disable-features="): + val := strings.TrimPrefix(tok, "--disable-features=") + appendCSVInto(disableFeatures, val) case tok == "--disable-extensions": *disableAll = tok default: @@ -128,22 +135,32 @@ func ReadOptionalFlagFile(path string) ([]string, error) { // extensions_enabled() returns true, which is false when --disable-extensions-except is used. // Any paths from --disable-extensions-except are merged into --load-extension instead. // -// Non-extension flags from both base and runtime are combined with deduplication (first occurrence preserved). +// Feature-list flags (--enable-features / --disable-features) from both sources are also +// merged into single comma-separated tokens. Chromium keeps only one value per switch, so +// emitting duplicates would silently drop all but the last list. When a feature appears in +// both lists Chromium disables it — disable wins over enable. +// +// Non-feature, non-extension flags from both base and runtime are combined with deduplication +// (first occurrence preserved). func MergeFlags(baseTokens, runtimeTokens []string) []string { // Buckets var ( - baseNonExt []string // Non-extension related flags contained in base - runtimeNonExt []string // Non-extension related flags contained in runtime - baseLoad []string // --load-extension flags contained in base - baseExcept []string // --disable-extensions-except flags for base (parsed but not re-emitted) - rtLoad []string // --load-extension flags contained in runtime - rtExcept []string // --disable-extensions-except flags contained in runtime (parsed but not re-emitted) - baseDisableAll string // --disable-extensions flag contained in base - rtDisableAll string // --disable-extensions flag contained in runtime + baseNonExt []string // Non-extension related flags contained in base + runtimeNonExt []string // Non-extension related flags contained in runtime + baseLoad []string // --load-extension flags contained in base + baseExcept []string // --disable-extensions-except flags for base (parsed but not re-emitted) + baseEnableFeat []string // --enable-features values contained in base + baseDisableFeat []string // --disable-features values contained in base + rtLoad []string // --load-extension flags contained in runtime + rtExcept []string // --disable-extensions-except flags contained in runtime (parsed but not re-emitted) + rtEnableFeat []string // --enable-features values contained in runtime + rtDisableFeat []string // --disable-features values contained in runtime + baseDisableAll string // --disable-extensions flag contained in base + rtDisableAll string // --disable-extensions flag contained in runtime ) - baseNonExt = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisableAll) - runtimeNonExt = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtDisableAll) + baseNonExt = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseEnableFeat, &baseDisableFeat, &baseDisableAll) + runtimeNonExt = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtEnableFeat, &rtDisableFeat, &rtDisableAll) // Merge extension lists - include paths from --disable-extensions-except in load paths // since we no longer emit --disable-extensions-except @@ -151,6 +168,19 @@ func MergeFlags(baseTokens, runtimeTokens []string) []string { mergedLoad = union(mergedLoad, baseExcept) mergedLoad = union(mergedLoad, rtExcept) + // Merge feature lists - a feature listed in both enable and disable stays in both; + // Chromium resolves that conflict as disabled. + mergedEnableFeat := union(baseEnableFeat, rtEnableFeat) + mergedDisableFeat := union(baseDisableFeat, rtDisableFeat) + + var featureFlags []string + if len(mergedEnableFeat) > 0 { + featureFlags = append(featureFlags, "--enable-features="+strings.Join(mergedEnableFeat, ",")) + } + if len(mergedDisableFeat) > 0 { + featureFlags = append(featureFlags, "--disable-features="+strings.Join(mergedDisableFeat, ",")) + } + // Construct final extension-related flags respecting override semantics: // 1) If runtime specifies --disable-extensions, it overrides everything extension related // 2) Else if base specifies --disable-extensions and runtime does NOT specify any --load-extension, keep base disable @@ -169,6 +199,7 @@ func MergeFlags(baseTokens, runtimeTokens []string) []string { // Combine and dedupe (preserving first occurrence) combined := append(append([]string{}, baseNonExt...), runtimeNonExt...) + combined = append(combined, featureFlags...) combined = append(combined, extFlags...) seen := make(map[string]struct{}, len(combined)) final := make([]string, 0, len(combined)) @@ -243,3 +274,32 @@ func WriteFlagFile(path string, tokens []string) error { data = append(data, '\n') return os.WriteFile(path, data, 0o644) } + +// KernelDisableFeaturesPrefix is a kernel-namespaced switch for per-session feature +// disabling. Chromium ignores unknown switches, so on images that predate translation +// the token is inert instead of last-win clobbering boot-time --disable-features lists. +const KernelDisableFeaturesPrefix = "--kernel-disable-features=" + +// TranslateKernelDisableFeatures folds any --kernel-disable-features tokens into the +// single --disable-features token and returns the result. Values are unioned with any +// existing disable list; ordering of unrelated tokens is preserved. Idempotent. +func TranslateKernelDisableFeatures(tokens []string) []string { + var kernelVals, disableVals []string + rest := make([]string, 0, len(tokens)) + for _, tok := range tokens { + switch { + case strings.HasPrefix(tok, KernelDisableFeaturesPrefix): + appendCSVInto(&kernelVals, strings.TrimPrefix(tok, KernelDisableFeaturesPrefix)) + case strings.HasPrefix(tok, "--disable-features="): + appendCSVInto(&disableVals, strings.TrimPrefix(tok, "--disable-features=")) + default: + rest = append(rest, tok) + } + } + if len(kernelVals) == 0 { + return tokens + } + merged := union(disableVals, kernelVals) + rest = append(rest, "--disable-features="+strings.Join(merged, ",")) + return rest +} diff --git a/server/lib/chromiumflags/chromiumflags_test.go b/server/lib/chromiumflags/chromiumflags_test.go index 3900457a..fb58ba74 100644 --- a/server/lib/chromiumflags/chromiumflags_test.go +++ b/server/lib/chromiumflags/chromiumflags_test.go @@ -41,12 +41,16 @@ func TestAppendCSVInto(t *testing.T) { func TestParseTokenStream_BaseAndRuntime(t *testing.T) { var ( - baseLoad []string - baseExcept []string - rtLoad []string - rtExcept []string - baseDisable string - rtDisable string + baseLoad []string + baseExcept []string + rtLoad []string + rtExcept []string + baseDisable string + rtDisable string + baseEnableFeat []string + baseDisFeat []string + rtEnableFeat []string + rtDisFeat []string ) baseTokens := []string{ @@ -62,8 +66,8 @@ func TestParseTokenStream_BaseAndRuntime(t *testing.T) { "--foo", } - baseNonExt := parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisable) - runtimeNonExt := parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtDisable) + baseNonExt := parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseEnableFeat, &baseDisFeat, &baseDisable) + runtimeNonExt := parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtEnableFeat, &rtDisFeat, &rtDisable) if !reflect.DeepEqual(baseLoad, []string{"/e1", "/e2"}) { t.Fatalf("base load-extension parsed incorrectly: %#v", baseLoad) @@ -127,16 +131,20 @@ func TestOverrideSemantics_DisableRuntime_Wins(t *testing.T) { runtimeTokens := parseFlags(runtimeFlags) var ( - baseLoad []string - baseExcept []string - rtLoad []string - rtExcept []string - baseDisable string - runtimeDisable string + baseLoad []string + baseExcept []string + rtLoad []string + rtExcept []string + baseEnableFeat []string + baseDisableFeat []string + rtEnableFeat []string + rtDisableFeat []string + baseDisable string + runtimeDisable string ) - _ = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseDisable) - _ = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &runtimeDisable) + _ = parseTokenStream(baseTokens, &baseLoad, &baseExcept, &baseEnableFeat, &baseDisableFeat, &baseDisable) + _ = parseTokenStream(runtimeTokens, &rtLoad, &rtExcept, &rtEnableFeat, &rtDisableFeat, &runtimeDisable) var extFlags []string if runtimeDisable != "" { @@ -287,6 +295,30 @@ func TestMergeFlags(t *testing.T) { runtimeFlags: []string{"--bar", "--load-extension=/e2", "--disable-extensions-except=/x2"}, want: []string{"--foo", "--bar", "--load-extension=/e1,/e2,/x1,/x2"}, }, + { + name: "merge enable-features from base and runtime", + baseFlags: []string{"--enable-features=WebMCPTesting"}, + runtimeFlags: []string{"--enable-features=Foo"}, + want: []string{"--enable-features=WebMCPTesting,Foo"}, + }, + { + name: "merge disable-features across sources", + baseFlags: []string{"--disable-features=A,B"}, + runtimeFlags: []string{"--disable-features=B,C"}, + want: []string{"--disable-features=A,B,C"}, + }, + { + name: "runtime can opt out of a base-enabled feature via disable list", + baseFlags: []string{"--enable-features=WebMCPTesting,DevToolsWebMCPSupport"}, + runtimeFlags: []string{"--disable-features=WebMCPTesting"}, + want: []string{"--enable-features=WebMCPTesting,DevToolsWebMCPSupport", "--disable-features=WebMCPTesting"}, + }, + { + name: "enable and disable features coexist across sources", + baseFlags: []string{"--enable-features=A", "--disable-features=B"}, + runtimeFlags: []string{"--enable-features=C", "--disable-features=D"}, + want: []string{"--enable-features=A,C", "--disable-features=B,D"}, + }, } for _, tt := range tests { @@ -298,3 +330,47 @@ func TestMergeFlags(t *testing.T) { }) } } + +func TestTranslateKernelDisableFeatures(t *testing.T) { + tests := []struct { + name string + tokens []string + want []string + }{ + { + name: "no kernel tokens is a no-op", + tokens: []string{"--foo", "--disable-features=A,B"}, + want: nil, + }, + { + name: "kernel token folds into existing disable list", + tokens: []string{"--foo", "--disable-features=A,B", "--kernel-disable-features=WebMCPTesting"}, + want: []string{"--foo", "--disable-features=A,B,WebMCPTesting"}, + }, + { + name: "kernel token creates a disable list when none exists", + tokens: []string{"--foo", "--kernel-disable-features=WebMCPTesting,DevToolsWebMCPSupport"}, + want: []string{"--foo", "--disable-features=WebMCPTesting,DevToolsWebMCPSupport"}, + }, + { + name: "multiple kernel tokens and duplicates are unioned", + tokens: []string{"--kernel-disable-features=A", "--bar", "--kernel-disable-features=B,A"}, + want: []string{"--bar", "--disable-features=A,B"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TranslateKernelDisableFeatures(tt.tokens) + if tt.want == nil { + if !reflect.DeepEqual(got, tt.tokens) { + t.Fatalf("expected unchanged input:\n got: %#v\nwant: %#v", got, tt.tokens) + } + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("TranslateKernelDisableFeatures() mismatch:\n got: %#v\nwant: %#v", got, tt.want) + } + }) + } +}