From 0f30a68e686c56e2ee44b067c94a24781aad3b37 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:58:45 +0000 Subject: [PATCH 1/6] Add opt-in CDP extension configure path --- server/cmd/api/api/chromium_configure.go | 70 ++- server/cmd/api/api/chromium_configure_test.go | 71 ++- .../e2e_chromium_configure_powerset_test.go | 4 +- server/e2e/e2e_chromium_configure_test.go | 244 +++++++++- server/lib/oapi/oapi.go | 421 ++++++++++-------- server/openapi.yaml | 34 +- 6 files changed, 637 insertions(+), 207 deletions(-) diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index 98927e86..7a8ea5ca 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -53,6 +53,11 @@ func (st *chromiumConfigureState) cleanup() { func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.ChromiumConfigureRequestObject) (oapi.ChromiumConfigureResponseObject, error) { start := time.Now() + extensionLoadStrategy, strategyErr := chromiumConfigureExtensionLoadStrategy(request.Params) + if strategyErr != "" { + return cfg400(strategyErr), nil + } + if request.Body == nil { return cfg400("request body required"), nil } @@ -81,11 +86,13 @@ func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.Chromiu defer s.chromiumConfigMu.Unlock() var configureResp oapi.ChromiumConfigureResponseObject - switch chromiumConfigureModeFor(st) { + switch chromiumConfigureModeFor(st, extensionLoadStrategy) { case chromiumConfigureModeLive: configureResp = s.chromiumConfigureLive(ctx, st, spec) case chromiumConfigureModeRestart: configureResp = s.chromiumConfigureRestart(ctx, st, spec) + case chromiumConfigureModeCandidateCDPExtensions: + configureResp = s.chromiumConfigureCandidateCDPExtensions(ctx, st, spec) default: return cfg500Configure("unhandled configure mode"), nil } @@ -102,15 +109,31 @@ type chromiumConfigureMode uint8 const ( chromiumConfigureModeLive chromiumConfigureMode = iota chromiumConfigureModeRestart + chromiumConfigureModeCandidateCDPExtensions ) -func chromiumConfigureModeFor(st *chromiumConfigureState) chromiumConfigureMode { +func chromiumConfigureExtensionLoadStrategy(params oapi.ChromiumConfigureParams) (oapi.ChromiumConfigureParamsExtensionLoadStrategy, string) { + if params.ExtensionLoadStrategy == nil { + return oapi.Restart, "" + } + if !params.ExtensionLoadStrategy.Valid() { + return "", "extension_load_strategy must be restart or prefer_cdp" + } + return *params.ExtensionLoadStrategy, "" +} + +func chromiumConfigureModeFor(st *chromiumConfigureState, strategy oapi.ChromiumConfigureParamsExtensionLoadStrategy) chromiumConfigureMode { if st.hasProfile || - len(st.extItems) > 0 || policiesContentNonEmpty(st.chromePoliciesJSON) || flagsContentNonEmpty(st.chromiumFlagsJSON) { return chromiumConfigureModeRestart } + if len(st.extItems) > 0 { + if strategy == oapi.PreferCdp { + return chromiumConfigureModeCandidateCDPExtensions + } + return chromiumConfigureModeRestart + } return chromiumConfigureModeLive } @@ -140,6 +163,47 @@ func chromiumConfigureNavigate(ctx context.Context, s *ApiService, spec startURL } } +func (s *ApiService) chromiumConfigureCandidateCDPExtensions(ctx context.Context, st *chromiumConfigureState, spec startURLParsed) oapi.ChromiumConfigureResponseObject { + prepared, reqMsg, err := s.prepareExtensionZipItems(ctx, st.extItems) + if prepared != nil { + defer prepared.cleanup() + } + if reqMsg != "" { + return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)) + } + if err != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error()) + } + + if prepared.requiresRestart { + return s.chromiumConfigureRestart(ctx, st, spec) + } + + _, _, reqMsg, err = s.commitPreparedExtensions(ctx, prepared) + if reqMsg != "" { + return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)) + } + if err != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error()) + } + + loadErr := s.loadUnpackedExtensions(ctx, prepared.extensions) + if loadErr == nil { + return s.chromiumConfigureLive(ctx, st, spec) + } + logger.FromContext(ctx).Warn("CDP extension load failed during configure, restarting Chromium", "error", loadErr) + + restartState := *st + restartState.extItems = nil // already persisted; the fallback only restarts Chromium + if resp := s.chromiumConfigureRestart(ctx, &restartState, spec); resp != nil { + return resp + } + if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, errors.Join(loadErr, err).Error()) + } + return nil +} + func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed) (resp oapi.ChromiumConfigureResponseObject) { var stoppedRecordings []stoppedRecordingInfo chromiumStopped := false diff --git a/server/cmd/api/api/chromium_configure_test.go b/server/cmd/api/api/chromium_configure_test.go index f1b46c50..468b8ec4 100644 --- a/server/cmd/api/api/chromium_configure_test.go +++ b/server/cmd/api/api/chromium_configure_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + oapi "github.com/kernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" ) @@ -33,31 +34,69 @@ func TestChromiumConfigureModeFor(t *testing.T) { stringPtr := func(value string) *string { return &value } tests := []struct { - name string - state chromiumConfigureState - want chromiumConfigureMode + name string + state chromiumConfigureState + strategy oapi.ChromiumConfigureParamsExtensionLoadStrategy + want chromiumConfigureMode }{ - {name: "no restart fields", want: chromiumConfigureModeLive}, - {name: "display only", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`)}, want: chromiumConfigureModeLive}, - {name: "start URL only", state: chromiumConfigureState{startURLRaw: stringPtr("https://example.com")}, want: chromiumConfigureModeLive}, - {name: "empty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{}`)}, want: chromiumConfigureModeLive}, - {name: "nonempty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{"QuicAllowed":false}`)}, want: chromiumConfigureModeRestart}, - {name: "invalid policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{bad-json`)}, want: chromiumConfigureModeRestart}, - {name: "empty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":[]}`)}, want: chromiumConfigureModeLive}, - {name: "nonempty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":["--kiosk"]}`)}, want: chromiumConfigureModeRestart}, - {name: "invalid flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{bad-json`)}, want: chromiumConfigureModeRestart}, - {name: "profile", state: chromiumConfigureState{hasProfile: true}, want: chromiumConfigureModeRestart}, - {name: "extensions", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, want: chromiumConfigureModeRestart}, - {name: "display and extension", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`), extItems: []extensionZipItem{{name: "test"}}}, want: chromiumConfigureModeRestart}, + {name: "no restart fields", strategy: oapi.Restart, want: chromiumConfigureModeLive}, + {name: "display only", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`)}, strategy: oapi.Restart, want: chromiumConfigureModeLive}, + {name: "start URL only", state: chromiumConfigureState{startURLRaw: stringPtr("https://example.com")}, strategy: oapi.Restart, want: chromiumConfigureModeLive}, + {name: "empty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{}`)}, strategy: oapi.Restart, want: chromiumConfigureModeLive}, + {name: "nonempty policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{"QuicAllowed":false}`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart}, + {name: "invalid policies", state: chromiumConfigureState{chromePoliciesJSON: stringPtr(`{bad-json`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart}, + {name: "empty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":[]}`)}, strategy: oapi.Restart, want: chromiumConfigureModeLive}, + {name: "nonempty flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{"flags":["--kiosk"]}`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart}, + {name: "invalid flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{bad-json`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart}, + {name: "profile", state: chromiumConfigureState{hasProfile: true}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart}, + {name: "extensions default restart", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.Restart, want: chromiumConfigureModeRestart}, + {name: "extensions prefer CDP", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModeCandidateCDPExtensions}, + {name: "display and extensions prefer CDP", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`), extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModeCandidateCDPExtensions}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, chromiumConfigureModeFor(&tt.state)) + require.Equal(t, tt.want, chromiumConfigureModeFor(&tt.state, tt.strategy)) }) } } +func TestChromiumConfigureExtensionLoadStrategy(t *testing.T) { + strategy, msg := chromiumConfigureExtensionLoadStrategy(oapi.ChromiumConfigureParams{}) + require.Empty(t, msg) + require.Equal(t, oapi.Restart, strategy) + + preferCDP := oapi.PreferCdp + strategy, msg = chromiumConfigureExtensionLoadStrategy(oapi.ChromiumConfigureParams{ExtensionLoadStrategy: &preferCDP}) + require.Empty(t, msg) + require.Equal(t, oapi.PreferCdp, strategy) + + invalid := oapi.ChromiumConfigureParamsExtensionLoadStrategy("invalid") + _, msg = chromiumConfigureExtensionLoadStrategy(oapi.ChromiumConfigureParams{ExtensionLoadStrategy: &invalid}) + require.Equal(t, "extension_load_strategy must be restart or prefer_cdp", msg) +} + +func TestChromiumConfigureGeneratedClientQuery(t *testing.T) { + preferCDP := oapi.PreferCdp + req, err := oapi.NewChromiumConfigureRequestWithBody( + "http://example.test", + &oapi.ChromiumConfigureParams{ExtensionLoadStrategy: &preferCDP}, + "multipart/form-data; boundary=test", + strings.NewReader("--test--"), + ) + require.NoError(t, err) + require.Equal(t, "prefer_cdp", req.URL.Query().Get("extension_load_strategy")) + + req, err = oapi.NewChromiumConfigureRequestWithBody( + "http://example.test", + nil, + "multipart/form-data; boundary=test", + strings.NewReader("--test--"), + ) + require.NoError(t, err) + require.Empty(t, req.URL.RawQuery) +} + func TestChromiumConfigureActionables(t *testing.T) { emptyFlags := `{"flags":[]}` realFlags := `{"flags":["--kiosk"]}` diff --git a/server/e2e/e2e_chromium_configure_powerset_test.go b/server/e2e/e2e_chromium_configure_powerset_test.go index 0342da87..c1073573 100644 --- a/server/e2e/e2e_chromium_configure_powerset_test.go +++ b/server/e2e/e2e_chromium_configure_powerset_test.go @@ -86,7 +86,7 @@ func TestChromiumConfigureMultipartPowerset(t *testing.T) { client, err := c.APIClient() require.NoError(t, err) - rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, w.FormDataContentType(), io.NopCloser(bytes.NewReader(body.Bytes()))) + rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, nil, w.FormDataContentType(), io.NopCloser(bytes.NewReader(body.Bytes()))) require.NoError(t, err) require.Equal(t, http.StatusOK, rsp.StatusCode(), @@ -217,7 +217,7 @@ func TestChromiumConfigureStartURLBareHost(t *testing.T) { client, err := c.APIClient() require.NoError(t, err) - rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, mw.FormDataContentType(), io.NopCloser(bytes.NewReader(buf.Bytes()))) + rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, nil, mw.FormDataContentType(), io.NopCloser(bytes.NewReader(buf.Bytes()))) require.NoError(t, err) require.Equal(t, http.StatusOK, rsp.StatusCode(), "%s", string(rsp.Body)) require.True(t, rsp.JSON200.Ok) diff --git a/server/e2e/e2e_chromium_configure_test.go b/server/e2e/e2e_chromium_configure_test.go index 282771a5..8f543928 100644 --- a/server/e2e/e2e_chromium_configure_test.go +++ b/server/e2e/e2e_chromium_configure_test.go @@ -6,14 +6,256 @@ import ( "io" "mime/multipart" "net/http" + "os" "os/exec" + "path/filepath" + "strconv" + "strings" "testing" "time" + "github.com/kernel/kernel-images/server/lib/cdpclient" instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" ) +type configureExtensionPart struct { + name string + zip []byte +} + +type configureE2ERequest struct { + params *instanceoapi.ChromiumConfigureParams + extensions []configureExtensionPart + chromiumFlags string + startURL string +} + +func chromiumConfigureE2E(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, request configureE2ERequest) *instanceoapi.ChromiumConfigureResponse { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for _, extension := range request.extensions { + part, err := writer.CreateFormFile("extensions.zip_file", extension.name+".zip") + require.NoError(t, err) + _, err = part.Write(extension.zip) + require.NoError(t, err) + require.NoError(t, writer.WriteField("extensions.name", extension.name)) + } + if request.chromiumFlags != "" { + require.NoError(t, writer.WriteField("chromium_flags", request.chromiumFlags)) + } + if request.startURL != "" { + require.NoError(t, writer.WriteField("start_url", request.startURL)) + } + require.NoError(t, writer.Close()) + + response, err := client.ChromiumConfigureWithBodyWithResponse(ctx, request.params, writer.FormDataContentType(), bytes.NewReader(body.Bytes())) + require.NoError(t, err) + return response +} + +func chromiumConfigureStartCount(t *testing.T, ctx context.Context, c *TestContainer) int { + t.Helper() + output, err := execCombinedOutputWithClient(ctx, c, "sh", []string{"-lc", `grep -c 'starting chromium via supervisorctl' /var/log/supervisord/kernel-images-api || true`}) + require.NoError(t, err) + count, err := strconv.Atoi(strings.TrimSpace(output)) + require.NoError(t, err) + return count +} + +func requireConfiguredExtensionActive(t *testing.T, ctx context.Context, c *TestContainer, name string) { + t.Helper() + path := filepath.Join("/home/kernel/extensions", name) + require.Eventually(t, func() bool { + client, err := cdpclient.Dial(ctx, c.CDPURL()) + if err != nil { + return false + } + defer client.Close() + extensions, err := client.GetExtensions(ctx) + if err != nil { + return false + } + for _, extension := range extensions { + if extension.Enabled && filepath.Clean(extension.Path) == path { + return true + } + } + return false + }, 10*time.Second, 200*time.Millisecond) +} + +func TestChromiumConfigureExtensionLoadStrategies(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + for _, test := range []struct { + name string + image string + testPartialFailure bool + }{ + {name: "headless", image: headlessImage, testPartialFailure: true}, + {name: "headful", image: headfulImage}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + testChromiumConfigureExtensionLoadStrategies(t, test.image, test.testPartialFailure) + }) + } +} + +func testChromiumConfigureExtensionLoadStrategies(t *testing.T, image string, testPartialFailure bool) { + ordinaryDir, err := filepath.Abs("test-extension") + require.NoError(t, err) + ordinaryZip, err := zipDirToBytes(ordinaryDir) + require.NoError(t, err) + enterpriseDir, err := filepath.Abs("test-extension-enterprise") + require.NoError(t, err) + enterpriseZip, err := zipDirToBytes(enterpriseDir) + require.NoError(t, err) + invalidDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "manifest.json"), []byte(`{ + "manifest_version": 3, + "name": "Invalid Configure Extension" + }`), 0o600)) + invalidZip, err := zipDirToBytes(invalidDir) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + c := NewTestContainer(t, image) + require.NoError(t, c.Start(ctx, ContainerConfig{})) + defer c.Stop(context.WithoutCancel(ctx)) + require.NoError(t, c.WaitReady(ctx)) + require.NoError(t, c.WaitDevTools(ctx)) + client, err := c.APIClient() + require.NoError(t, err) + + preferCDP := instanceoapi.PreferCdp + preferCDPParams := &instanceoapi.ChromiumConfigureParams{ExtensionLoadStrategy: &preferCDP} + + t.Run("default restart", func(t *testing.T) { + before, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + starts := chromiumConfigureStartCount(t, ctx, c) + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + extensions: []configureExtensionPart{{name: "configure-default", zip: ordinaryZip}}, + }) + require.Equal(t, http.StatusOK, response.StatusCode(), "%s", response.Body) + after, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + require.NotEqual(t, before, after) + require.Equal(t, starts+1, chromiumConfigureStartCount(t, ctx, c)) + requireConfiguredExtensionActive(t, ctx, c, "configure-default") + }) + + t.Run("prefer CDP", func(t *testing.T) { + before, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + params: preferCDPParams, + extensions: []configureExtensionPart{{name: "configure-cdp", zip: ordinaryZip}}, + }) + require.Equal(t, http.StatusOK, response.StatusCode(), "%s", response.Body) + after, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + require.Equal(t, before, after) + requireConfiguredExtensionActive(t, ctx, c, "configure-cdp") + }) + + t.Run("startup field restart", func(t *testing.T) { + before, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + starts := chromiumConfigureStartCount(t, ctx, c) + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + params: preferCDPParams, + extensions: []configureExtensionPart{{name: "configure-startup", zip: ordinaryZip}}, + chromiumFlags: `{"flags":["--disable-background-networking"]}`, + }) + require.Equal(t, http.StatusOK, response.StatusCode(), "%s", response.Body) + after, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + require.NotEqual(t, before, after) + require.Equal(t, starts+1, chromiumConfigureStartCount(t, ctx, c)) + requireConfiguredExtensionActive(t, ctx, c, "configure-startup") + }) + + t.Run("enterprise restart", func(t *testing.T) { + before, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + starts := chromiumConfigureStartCount(t, ctx, c) + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + params: preferCDPParams, + extensions: []configureExtensionPart{{name: "configure-enterprise", zip: enterpriseZip}}, + }) + require.Equal(t, http.StatusOK, response.StatusCode(), "%s", response.Body) + after, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + require.NotEqual(t, before, after) + require.Equal(t, starts+1, chromiumConfigureStartCount(t, ctx, c)) + _, err = execCombinedOutputWithClient(ctx, c, "grep", []string{"-q", "configure-enterprise", "/etc/chromium/policies/managed/policy.json"}) + require.NoError(t, err) + }) + + t.Run("CDP failure restart", func(t *testing.T) { + before, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + starts := chromiumConfigureStartCount(t, ctx, c) + _, err = execCombinedOutputWithClient(ctx, c, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "stop", "chromium"}) + require.NoError(t, err) + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + params: preferCDPParams, + extensions: []configureExtensionPart{ + {name: "configure-fallback-one", zip: ordinaryZip}, + {name: "configure-fallback-two", zip: ordinaryZip}, + }, + }) + require.Equal(t, http.StatusOK, response.StatusCode(), "%s", response.Body) + after, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + require.NotEqual(t, before, after) + require.Equal(t, starts+1, chromiumConfigureStartCount(t, ctx, c)) + requireConfiguredExtensionActive(t, ctx, c, "configure-fallback-one") + requireConfiguredExtensionActive(t, ctx, c, "configure-fallback-two") + }) + + if testPartialFailure { + // The malformed fixture prevents headful Chromium from becoming ready after restart. + t.Run("partial CDP failure restart", func(t *testing.T) { + before, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + starts := chromiumConfigureStartCount(t, ctx, c) + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + params: preferCDPParams, + extensions: []configureExtensionPart{ + {name: "configure-partial-one", zip: ordinaryZip}, + {name: "configure-partial-invalid", zip: invalidZip}, + }, + }) + require.Equal(t, http.StatusInternalServerError, response.StatusCode(), "%s", response.Body) + after, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + require.NotEqual(t, before, after) + require.Equal(t, starts+1, chromiumConfigureStartCount(t, ctx, c)) + requireConfiguredExtensionActive(t, ctx, c, "configure-partial-one") + _, err = execCombinedOutputWithClient(ctx, c, "grep", []string{"-q", "loaded unpacked extension over CDP.*name=configure-partial-one", "/var/log/supervisord/kernel-images-api"}) + require.NoError(t, err) + }) + } + + t.Run("invalid strategy", func(t *testing.T) { + invalid := instanceoapi.ChromiumConfigureParamsExtensionLoadStrategy("invalid") + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + params: &instanceoapi.ChromiumConfigureParams{ExtensionLoadStrategy: &invalid}, + startURL: "https://example.com", + }) + require.Equal(t, http.StatusBadRequest, response.StatusCode(), "%s", response.Body) + }) +} + func TestChromiumConfigureStartURLBare(t *testing.T) { t.Parallel() @@ -44,7 +286,7 @@ func TestChromiumConfigureStartURLBare(t *testing.T) { require.NoError(t, w.WriteField("start_url", startURL)) require.NoError(t, w.Close()) - rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, w.FormDataContentType(), io.NopCloser(&buf)) + rsp, err := client.ChromiumConfigureWithBodyWithResponse(ctx, nil, w.FormDataContentType(), io.NopCloser(&buf)) require.NoError(t, err) require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status=%s body=%s", rsp.Status(), string(rsp.Body)) diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 7610a6db..502db1ca 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -2903,6 +2903,24 @@ func (e TelemetryEventCategory) Valid() bool { } } +// Defines values for ChromiumConfigureParamsExtensionLoadStrategy. +const ( + PreferCdp ChromiumConfigureParamsExtensionLoadStrategy = "prefer_cdp" + Restart ChromiumConfigureParamsExtensionLoadStrategy = "restart" +) + +// Valid indicates whether the value is a known member of the ChromiumConfigureParamsExtensionLoadStrategy enum. +func (e ChromiumConfigureParamsExtensionLoadStrategy) Valid() bool { + switch e { + case PreferCdp: + return true + case Restart: + return true + default: + return false + } +} + // Defines values for DownloadDirZstdParamsCompressionLevel. const ( Best DownloadDirZstdParamsCompressionLevel = "best" @@ -6695,6 +6713,15 @@ type ChromiumConfigureMultipartBody struct { StripComponents *string `json:"strip_components,omitempty"` } +// ChromiumConfigureParams defines parameters for ChromiumConfigure. +type ChromiumConfigureParams struct { + // ExtensionLoadStrategy Extension activation strategy. Omit or use `restart` to preserve unconditional restart behavior for requests containing extensions. `prefer_cdp` attempts live activation for eligible unpacked extensions and falls back to one restart when required. + ExtensionLoadStrategy *ChromiumConfigureParamsExtensionLoadStrategy `form:"extension_load_strategy,omitempty" json:"extension_load_strategy,omitempty"` +} + +// ChromiumConfigureParamsExtensionLoadStrategy defines parameters for ChromiumConfigure. +type ChromiumConfigureParamsExtensionLoadStrategy string + // DownloadDirZipParams defines parameters for DownloadDirZip. type DownloadDirZipParams struct { // Path Absolute directory path to archive and download. @@ -9343,7 +9370,7 @@ type ClientInterface interface { TypeText(ctx context.Context, body TypeTextJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // ChromiumConfigureWithBody request with any body - ChromiumConfigureWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + ChromiumConfigureWithBody(ctx context.Context, params *ChromiumConfigureParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) // PatchDisplayWithBody request with any body PatchDisplayWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -9842,8 +9869,8 @@ func (c *Client) TypeText(ctx context.Context, body TypeTextJSONRequestBody, req return c.Client.Do(req) } -func (c *Client) ChromiumConfigureWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewChromiumConfigureRequestWithBody(c.Server, contentType, body) +func (c *Client) ChromiumConfigureWithBody(ctx context.Context, params *ChromiumConfigureParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewChromiumConfigureRequestWithBody(c.Server, params, contentType, body) if err != nil { return nil, err } @@ -11167,7 +11194,7 @@ func NewTypeTextRequestWithBody(server string, contentType string, body io.Reade } // NewChromiumConfigureRequestWithBody generates requests for ChromiumConfigure with any type of body -func NewChromiumConfigureRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { +func NewChromiumConfigureRequestWithBody(server string, params *ChromiumConfigureParams, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -11185,6 +11212,28 @@ func NewChromiumConfigureRequestWithBody(server string, contentType string, body return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.ExtensionLoadStrategy != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "extension_load_strategy", *params.ExtensionLoadStrategy, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("POST", queryURL.String(), body) if err != nil { return nil, err @@ -12973,7 +13022,7 @@ type ClientWithResponsesInterface interface { TypeTextWithResponse(ctx context.Context, body TypeTextJSONRequestBody, reqEditors ...RequestEditorFn) (*TypeTextResponse, error) // ChromiumConfigureWithBodyWithResponse request with any body - ChromiumConfigureWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ChromiumConfigureResponse, error) + ChromiumConfigureWithBodyWithResponse(ctx context.Context, params *ChromiumConfigureParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ChromiumConfigureResponse, error) // PatchDisplayWithBodyWithResponse request with any body PatchDisplayWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, error) @@ -14732,8 +14781,8 @@ func (c *ClientWithResponses) TypeTextWithResponse(ctx context.Context, body Typ } // ChromiumConfigureWithBodyWithResponse request with arbitrary body returning *ChromiumConfigureResponse -func (c *ClientWithResponses) ChromiumConfigureWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ChromiumConfigureResponse, error) { - rsp, err := c.ChromiumConfigureWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) ChromiumConfigureWithBodyWithResponse(ctx context.Context, params *ChromiumConfigureParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ChromiumConfigureResponse, error) { + rsp, err := c.ChromiumConfigureWithBody(ctx, params, contentType, body, reqEditors...) if err != nil { return nil, err } @@ -17477,7 +17526,7 @@ type ServerInterface interface { TypeText(w http.ResponseWriter, r *http.Request) // Apply batched session and browser configuration plus optional navigation // (POST /configure) - ChromiumConfigure(w http.ResponseWriter, r *http.Request) + ChromiumConfigure(w http.ResponseWriter, r *http.Request, params ChromiumConfigureParams) // Update display configuration // (PATCH /display) PatchDisplay(w http.ResponseWriter, r *http.Request) @@ -17702,7 +17751,7 @@ func (_ Unimplemented) TypeText(w http.ResponseWriter, r *http.Request) { // Apply batched session and browser configuration plus optional navigation // (POST /configure) -func (_ Unimplemented) ChromiumConfigure(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) ChromiumConfigure(w http.ResponseWriter, r *http.Request, params ChromiumConfigureParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -18182,8 +18231,21 @@ func (siw *ServerInterfaceWrapper) TypeText(w http.ResponseWriter, r *http.Reque // ChromiumConfigure operation middleware func (siw *ServerInterfaceWrapper) ChromiumConfigure(w http.ResponseWriter, r *http.Request) { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ChromiumConfigureParams + + // ------------- Optional query parameter "extension_load_strategy" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "extension_load_strategy", r.URL.Query(), ¶ms.ExtensionLoadStrategy, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "extension_load_strategy", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ChromiumConfigure(w, r) + siw.Handler.ChromiumConfigure(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -19892,7 +19954,8 @@ func (response TypeText500JSONResponse) VisitTypeTextResponse(w http.ResponseWri } type ChromiumConfigureRequestObject struct { - Body *multipart.Reader + Params ChromiumConfigureParams + Body *multipart.Reader } type ChromiumConfigureResponseObject interface { @@ -22432,9 +22495,11 @@ func (sh *strictHandler) TypeText(w http.ResponseWriter, r *http.Request) { } // ChromiumConfigure operation middleware -func (sh *strictHandler) ChromiumConfigure(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) ChromiumConfigure(w http.ResponseWriter, r *http.Request, params ChromiumConfigureParams) { var request ChromiumConfigureRequestObject + request.Params = params + if reader, err := r.MultipartReader(); err != nil { sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err)) return @@ -24032,170 +24097,174 @@ var swaggerSpec = []string{ "+9/XdIJgd9jQFWqNuxul13WB7kdz1KdV0j+9FJaOCCysts6aqJq1Yz7+Fsnh7CBRNStzWI212YpVVI+U", "lO2IjfgyynalMsyYZruSQrFbxmEGHWSH6lHpk5FtBokvVqYtH8GXnSi5q7/qafx6longPrPmNIcbHTI4", "6TnwKk0X8oIyXmU0XcTS2kiF5dRqjdEBbTr5HXqI3m3KJ3MUsQVsl7lA3Haj1z9jwXr+zP756+qBvRK3", - "TEmBD48qCAPrZIGpWPF6Tv4a8/8/9r59uY0b6/NVUKqtsjTLpmRHmZ3Y9dWWfEu0sROVJU928tElQd0g", - "iVET6ABoSXTKU/sQ+4T7JFs4B5duEs2rFFn55p+MR+wG0DgXAAfn/H5zhRTr1U50C7C7RALFudQMt6qP", - "oE2jCwIL3zFvhAvPg2/C93cdBtN8B+yWm/NO5mhkfPCElx30fVAucX7518N0bmsDORkfJZf1cNgRM8Fy", - "iVUbk7XpbuxLt/R+5BEqYU0QWaT7BO0VIbbW0N62yBD1suXUds7efHi/s7jdZoate/zH43fvdno7xz+d", - "7fR2fvh4sjyx1vW9QIk/wFZ009UEOXnIydk/skusXeichlyWCZX9id1Evt9clvVE6GWFc70dJW+WtWUf", - "WbMCD1rt4UAXzNhpRW9Ec8JWwu9OLN3z7O2O44adGzNdvgoeuacJJZVmdSGz8PW7J2f/2Jt1rLizh4Uo", - "ZAteM1yROpbLtNCOgey5nBOcw9NrfAREFGfrNtcQ6VxP9rHNu/mS5Mtuy3UDf37cuLWhl9YhUaJta4vs", - "IclQ8/NpEFYXU6rnAEq9fsrUNVMZ1dbuWdFkVE8ssiGCW9e86CA6t9vxc2rSlzXIUjnHG+teW+O+ptPU", - "AtP6OgCsDfxMJGCnC7xSVZ9XeeL73mjDJ1BC8OrkI6nhUqtiKmfC0FFzFRRQQLxkGY180bzNcTSm2jGu", - "r7JHQaK/jiKcOGJPm+ZZ23D0oT6nYwVPhltOokxNq+gjchHj8NNrUbdgCy42W3ReU0OtJ7tRHAOgM6qH", - "FZUckMTnt0/U0JU2FkWzl+VUwaHdT0u/eav9oh2OQ6/Qtrn5L3S3NV1KEsvd4QF/udPfWTWk4j5FMRoL", - "rNbZO52+Cex4ilWKaeuhGtTorhRWqjnOlG2lGa7TorIA3H7y6JO+LH/XHtJcJZQ1hSSOyUquIThSbJxr", - "MoAXBztdJmvHn1gFMBDuKpBkg7A4H9fiqo0+CZXJod55RSPGEiKQ/3ZxiEtZTGFpclVJHjoZJ0A4656t", - "quovZJlOlaxF2O0QI4M4RXHNtVTT5w4h/0rIG9+7Q8nzTPxMEVxWZ2ClW/eoJRLdIIaGbmBD98kxIhOL", - "coo34rbDWmCHea2N1c1pxXTPqgHGXgG9FH1Mm7DXk3FFAqWeJ29r0j1FVqwGiVCLcizQ0LTYdEJ1UawX", - "WMjV3cVBgPPorL2/NTH3korExmZnub/uhGvDnAGm0hXJQy6gdG6VHVG8tPdvde2HloaWcKs3/2cdMhwa", - "v7fgWlbev82kGGw82Jl5hn1lc5ypOY+ZkB/YaBUozNWuoH5wBBQ+WWPk4iELUL46LiV+gcuIdRpaMUEB", - "23piT2ZVVrKhXQiUYFulLKzRZvJW2M9Cz0/sMpFtcrmigqCX4Fm2FSO5GrVRL9e9sC4NPb9dfMfzg1T8", - "sxSAqQh9ETqRtTB9gpkq9gwNf9cEoE56RLARbf3dyiG9iOMIlmCc/d2OOF+h/0LeiET3dZXufJukjIC7", - "uXp8f5lVUOOQxiM4aLur9Y1i7SZXzpSYQ0xd02vxomBiCYgLZnTE6zL30tLrfvdcx7Df8pKdMDXhkPqn", - "Nxv/SMm6Ssfg4CeHZqDI961AxrqwGQko078eHu6th1wqb0TqyseOFX6CSx4/3o8d410FYgGr/as4t3iz", - "i5eIjplhQ1TRBZAXTQjeNel0aa1ZE1IJefcqllvbL8I1wpr3EM1LccDeTV1DNMGrWvljB0uNstl5ckLs", - "Fuat/oWa/E6BYgOKL0QGAFA7DT9lDZdfs+Uh3GDtrj0S3i2nK6T1dCYpwQxsmc08VHTC0kk4H+Le1j9k", - "RTysrMVeM6V4ARw6cGxyM7DXlPmzg2Xx4GR01J/d5uKacFSayWl2qcf2DIl5kryBsIXsSjHFmjBROFTF", - "XW1k1XMZ2XZBRXpWBLVFFmJalvLGvjUB/CuAYReejCW0qe8MULcRUV0rS3tCb70tHotTtL3u69PYdfP6", - "0KeRLhbsQllO6C3A8vDP7Fi8f9k9AiiI8MTq71+uqEyz+KZPO9LK7Ncd1QWXy+3ylaO2o/ZxxIjVvGDk", - "mhdM9skHtEHdjA7YLRK9ZoQK95bLR7T6clKXmh25v+ZXzDQJZ3ZtI4A3Q4Az6FKacYNvZs9pC6ZatdPB", - "ucYRZVJ0+ouEb5DVtq5BqpzZdpbP5PFkwgpODSunxBpWoF0cKZqzYV0SPa6NNTOHtjOB5D4IeAILUi6V", - "qoEwDz4VdCR9WbVF+QWa/B+Djm37qu4EHTvC7ohrVspq3YzUMwAhxldJuDQy0u4BGoiBZAYyKEHD5MOl", - "CyH028BNQE/wW+eNQzaRQhopeB5S1AhetcSR0lxJrR1T6pBB0oeTMholEpBCdtA7qk0GPWfHr10OZu3q", - "jU5P3/hoqVsguEawYIy7zZU6rHGpbL/Rx5M/LZRhV33WDGIVlm/ccMWykl2z0oXZAGUJsFCrBpqVk1xY", - "3cAbecQrh1kVv75PjtQlN4oqDzzldt5ID+1QrCJmk3WQBTbWJ2+lCiBby6G1eilMLBgxUxmE81BtSCFz", - "SCUDwkzkzHXxwb84sKn9mb+8hnYbaYI9Mo+olSQXWTWI/FhCsVGa/+v0559CJDYlqpJrN8WLQcYQcxHv", - "b2ZF1yaISQkFZWrnfttgsKqFFUfyDtx4hXMrc7hXwWsgoJ+5oZAxgI3YDxg69lvYfZR8wjtqO0xiA/VR", - "8FsSqgvxsGNd0wxwb5wot1MEh3XTWD1Wqqv6o0LhQfan/mp4g0v4Lq7a+ezSqip5R6z6F1qWWQ68ir6a", - "zQV1GpPZZjy28nVNYmGT8WDdLSLAJgHu6hkLPccXtzZvamBLLRTsAc7R+lIUD5PaoZGLeGMLWW4RuI8G", - "YDwy4bBtuWQ5nOjtSYeVJblkY+7YmzCAomu7HfMLp38d3Xt7AjFcYY8wRHFtDTqXNaQUUHcF5lZMrskl", - "cze4UKdLhlRDgeeYCneNhQ8oRosXAKXIaIGUUNia5zoeU/soE6SUGvZbN3SqibsktssVLB0aqfQc+Tk3", - "Lwi99A9Q94x9qaDGxyrB5ntOaZzYm1ycn5mS/YULfRprfrP9i9uilFSbua0VeS0Zjg8YRBuSSshm3RHP", - "pbiCOuJ3JD3ADOnR2rH17bg7rthUGyWvrBYm8PaTSV9pOW1UDujzlOM4fDlkoyzQrie3rCDwsf2BaLl6", - "VTOy63Vs4gtB9wvPvLLXJ6fIMxzqaAbCFT5YR277gs0rFUT62Eejv9ZMkV34238c2Hlx1Yp7/YFocEAA", - "b52dtWmFa/2NVEWmkU1/XIsrl0kfvpwLo2hmn8IO9UBYTyEoAqHCDgd/rqzf0bg3xbHhOmvHskB0Se7T", - "XgcRn1VFmFdgEsMlfSyhWgM58DqAbOW5NZicLdbFE6ayfEztjs06r2klCRf/dDzUihr2wnpZQ68Y7nxh", - "twObSpizS5pf6YrmLCoBOeiTn0U5dQuRTs0A2dW8ZMKU09Y8DUR8DHRjD6cqxDwO+k+TWu+z0VYlIfxF", - "ccMCbeJmhr5YWq08LQ+87DvclD3RPsbdFT0ACew833HHi2NcI45Ojnd6O9dMaRzOQf9p/wAuAyomaMV3", - "nu980z/of+Ngh+FD9n0Z3T5SqGIgOE9Egt8zNWJQEgdPogqwW65hyZGC6R6pK7uFIDONJgrxrrk9b1dM", - "QTJK0UMjA5KJWhheIpe7f/o1uz6TstRksAObdsHFaLADmBklF8B5Ky9h52v3A0OpPNsBhCFcxSgok5Uh", - "xgYLuAsw+dj38tZRyDo0xZeymGIOd6TVjBAh+//UePOA+55E2oSfzZlNjv8knEMjyQSm1WGl/+dgJ8uu", - "uNRXWK2VZY79PBtV9WDn097mBVY4oLRaxeesfWKNJRTrQj/PDg4Sl1YwfpQ38heET3PCnuVg+NLbOcSW", - "UvvH0OP+S+ptEllgvvR2vl3lPQCqErR0bwFrxGRC7dl25yPqZRhiSWuRj50Q7ODdmHd6O7dZ2C1n8XQc", - "T7C24ajfgaJ4md3UmqnM03w22A6AvEhxzQjSPZMY/g25YJc0/AxsB72BWGpQZH17Goh1DeoVU8A35WeB", - "TKigI4w5XLnIhxgq6oHEnZ6TwKhx6ljOewMBiIwZEBKxIrSI3xHa94oKVyCvXp/se9gGKfZghQImelYM", - "BAS1/Fwutf2TyDS9qfmnF4/UnmsV4ffJj75I1v0k6ITpgdh1pZhuvX0l5RVn2s3jYAfvaoDwxV3EjkML", - "+Nf+QJwyRjzdD1Jtx5H0R1KOShYUex8vSEMhuf+7y87DUlT7/S+p5vlRbcY/XzP1gzHVG89Tj3OQHDBE", - "E+3D+mM1UrRgOrzllt339PZViBjpE4eouPP8m2e9nRNZ1ZU+Kkt5w4q3Un1UpYZUgHkqo51PX+7K83ld", - "ebTOb1bt7Lds4wORwyZrk99UUic2SMigg0QAikysX4l8L58jSwwOiN0aoEU3YzZBDpuBWJXEpk9+hnwY", - "NY0cMw3qHYjjOsKdgvDGhY01wYF49fok3Nq4ebGuz89hz7G3mTHjiijrYifMLycKIroao4vWeoa1QUp8", - "qgy4TfQpBGfGDQYDN1il7PZUFezqELMAexJ2gxkxeRE+AQKcA/GmQSyEwQJQ7YSX0YaXZQIcwa8Ydsx+", - "ibBLDS24YFqvtbVCSb9p0vZ3e9aJp1Tat+qV+chudK6LIk6d9EwrGfjTVHZ6mEhUbdYmaCoe2JrnGKNa", - "9pTQ9u0tOqOiyLx/2N66ewnTXpmeqjcQmplgdLEHND8uNjh+BN0fiD/w+DFrI0ei+BB88CO2ll5iOcQp", - "DjMJySu0mH4NprTEesgu1VZ79V5zlQyfuLJxuYDN/qU/JqSN6I3HqLDevc0N65twfPIakwU0F6OS2cM/", - "MMz0yZH71S1Edgh2R4whWcNpWU7d8jWWZeHLT27zstb8mhG7g+4RLYmQLmUICstI0F1NciowEFYyes1g", - "7fHpdNrISvtI1ZArbRxtIPVBeScawgOmE8aoHUk+siL0B8LzENUaUibsopSPHXNcwbA61i6UMdgMhY8I", - "VmZ7u2JTCLj46RoIv6JXdGpbcdeXRMlaFJlRvCL29CFyrM9hAN4iCn7Ni5qWrpmUIb+Es4STzpG/3Nz0", - "JLHwemW+p0jPv9l+Fprs4E18SOsMhkDAYpIG0NTpbkP099BtOwT2mnNQl6Y1tiULUNsQuL4ngcYOtpUj", - "spA7Kwp2/6AiPOVwpWdliGYJc+7H2BHOXleIGFXdt8tJtxw/MFq8akRgU9N5V/LETtwmH8U5EwDwzxDX", - "JayFc5a39fTbj8YLkJADmghGbzjfEOPunvB2kP2ejCcdyd/UgCB675FfjYyT9PX4xF/wYsFfCt2FQBHt", - "tVOOoeTjnkQ4V1KyuvTupP8GtGXKUrEa5Zp79r0Q9PlqVOIHXjiQLXnTxu9dSw8KRUfzi+HsNT+ghIkC", - "C6O8U7+sjZGiF65r7fbShxGoHZcyeD8KqVQC0VshvxjGO+LXnuYf99clo5rBBrDJdes3wf952yPTT816", - "p4pylTxfvfaXzveku6H9bT2PbegrWbJhKBE6GcVEiavHWUulRsygRp1XDt262818z0wLB/s+l+g04Hba", - "+iHNCqcifMRdTPP3zLQyudz2CN2N7+lOdkjW2pbtcgNg9z0Zyhwg+HZ7XDdN9sse1ljeexzqlvj8yhxK", - "yqKv0nciUgAXRdbMha7aF2uEgUDODLjlRnJNKHjDK6dYedmANB2IFFApZtUCmGal2JgJjB/MI6L2iGZs", - "IOxg0qimhJp4IzXipj9UjBVMXxlZ9aUa7d/a/1RKGrl/+/Qp/qMqKRf72FjBhv0xLhkuA3YshVS6mWXl", - "csf992pSa1fOlbupgMI97eKRKCZZJC8PHczuPdnLLIrvpuYCAgVt+Zp2LLiNaEbdQC/vwjKaFJ9dzu6M", - "XrHTZrr5vWxr58ACvjghLlzUIJFzv0Jwi9hTyHi8hButRAbR3NoVB4DZoQ8q8VBaRqKAfErotvKWZdnt", - "BhEngVw7LAHEqtmX1jt4fAP7N9PYiDacdXtL24qYttCm3V61BVSA4VcuSClHAGNgeH6lya6QxoFouLLK", - "qGLkko3pNbdGQafkmqrpC2JqiHdOIPGxCY0DKY5QNhc/Be/+PW4CoCy4KLDLO+m1oH1chh5cwbSCw7uh", - "Ddivxw72ME0L4nGY2+drcbwzvfCpnBjpyTLFKkYN+YlkGeZIHhC80MFTA17pXKR87KmHK7gn+2wAaGzq", - "X516fSXBNhxM3I6geKix2/e73FH6UowO9+oSqO9JcLP52VsFezAp+KtZGO23YXBnKzG50o5urxgx8/3t", - "H7H/weqR6WxVCfi9cKGnDZ2Gek4iRc7ILqYV9AbCXbfGu7OedT1Q4eyufnuNfaejPdD8MxejPRccCB3F", - "EnDCbmluyulAQHete8SYnMA1oTcUUCUj4NkFUkXUqryA/pzjouSSaZOx4VAqMxCNhApPEOFb9TdGtmXY", - "LNrjGR0xglVlL613tVJCPyvsOl1CbrmRA3Hht7QXjmiIiinMNJnKmhQSah4EsyM+MqRk1G6chY/hY7qV", - "fXpSa2Pdv6sK6Q/EB58H15aVNnb7qmoRkP3hCvF5I52uKRsngR7enPdggy5mJdZPigRA3VAcuHgyUWAm", - "fCi7xJqTgTCKCu232M8JHxIK12wqZvPZccPFnx0gVaVdWKNVEihDZ8Mhy42vlZ5QLqw+QN+Y+Z+zmJRD", - "hBTZs9tbd/dYKVnRkV3S+wNxotiQOQAFaRdCzSoKcA4XMXHgLxdY/rnv5ugC7lZdOntAQHB3wZlRfDRi", - "dis2ECgDtCQuQJ6+EDqYZmq587P8KtjvVjkAM2WekOV33sxWnUndOHub/c2VTLZTEcmEVuT//Z//i4VD", - "mk2oMDwHsoCTo7NXP5D5ZNg0tr976rwjM7oxAkzQIxe/DzBrebDzvJkY/enLxYoDgreTo3FiXWUYE+s0", - "YG+TPqvN8wldkF3AE9tHNLF9ZvK+hzRAXg1fQTGvQFhDonv+rhyAIUJd36w3jqX17SzElqW2jTQJ/dlK", - "3evI74AcCAi2+tHndknLayjZi030AVUIPyOWAi1MKdprcWakOQmbuAFH2a80+3yQfdc/zz79/rT37Ntv", - "0yhIn3l1bp3ZaoeodsJ8eLfXTbk4i/blfOe5853zs2mo6n/WxjtXuAB0OQ0IoqBhei9ayVCQO+4K0Z1z", - "dq5A94lzZyFxEvGYgBjEsanGPGD3sv2P3vd8Gv4MoFlp39+F1AfMnCUXLmt3H3uBJIuLPYQYuLDzVp1H", - "k7jAVQFcJIrb5Zb4j4W0REdao+16Bw/cKFpVLDLC8pkqvy5xOaxHu7gnzPjDu3BN5pZ35hb36IUXLt8h", - "HtUjJVBzWqPKKdqaIc8ODv+GeMK9aHpWgDnk7mNKC/gIJwAcxWXJOvgf2nO5YNMWKyr9DMIlSXwXwT0U", - "r/Dad0Yng1bs2jUywOa50kHggGG3aJFL4Ti+qqu61k7I+csXcbsZtMC2XLLZO7z+Njv/w4Pvlr9nB1jy", - "fO68cDdpB7O7B3++6JwnBhsu+7/gy0OJRkGqMYUpbh5NjmA/gwf/ImxoIBjgQBXaO9GqrPXc3OO9zkrZ", - "co31OZTVJOo33Lp7X2HYBFXgH6zzrndfRT8vzo/uPtqfplpieDCd3rpYIf05KyrPUO/nilHDzgNnFChS", - "nUrwggcDyt19ZXm1e1lLmZ4uAuXD7/yKYhj4pYRCoWjRmNZVJYeYcytI7jU8eN+Sw16a9LAbX/IHoeEn", - "FttZ5+Hy936S5q2sRXGH2QEwckK3kazfjy8Q6lvcdn/d8gTY1j+BKN0ZZ2UpOnRIa6HnnznA4Y2YSQFm", - "mloJTSj59fiEhFNL47TjDzEBwCyCsHr16s8n9bj+X3P1K6+glF3RCTNMaSCk6qJgDtYHu2Ujw6nEbmL8", - "R8E51L73W81At/H06eFo21rSa4ZblsHbflprk+DmdasbQDvr/hsDDiCoXnOCH6PmOmE13ZA9t6Ci+aP3", - "phqtTbGCSvtz/K6hqnGYn/jLdthT27b2Fmr+QCxQffKrNgWRwyFTmmg+EnzIcwq4GQ5uyHfo9uIDUbDm", - "n+y/qcLT7GdeueARzcecXQPFPTOzrYChpZPpGnZn5+ixGF7v93nC1vC5kBHSJz/w0Zgp/H/agzoRPaFl", - "2QytXNaGGHrFSCnFiKn+QGQoCW2ek39ZaWMT5GmPONQSK1hWkN1/fXNwkH17cEDev9zXe/ZFh8rSfvGb", - "HrmkJRW53dLZN/dBAmT3X0+/bbyLgmu/+j96Xp7+lW8Psr+1Xpob5tMe/DW88ewgOwxvdEikoS3n0MxO", - "UxyR7tH/K+IGuqna6TV+wyHDP3SKRmhdv+msdyvHeTYTo/sv4jxnQpNrOFAIL3loGuc4287D7pWAXmZV", - "rwG+wk08OFCp2puCr2GVXm/nGeYgoXKwl+SRUvERKtb3zDS/IJBCzklvDcUquTZwXtCdmvWOayB30Bsu", - "SI9Tl+JXJ5QpHjRLBBJ4hNoEtPogeSxy3UR7JvK6+6D5Xl7DKfAeM57v4pAJGcYxuPMIJQlfADgVcC+4", - "nUNQjBYhgJD0Bx8YLVz4YDV3AMPxW1Pb/tfiEWRumMki4eFWexpYYJJVho9MnaCmsXUFuob6aIbLyXmD", - "rqbTQ8yzBt1fCVwHPdHGEEUNNh5XsPYIRX3KzLyzaDIN7QOTkR5DGGhVHcCb6e7kOICT0o0LbIevIFXM", - "+8GFydV5KDaRzo9gMWa/A+jDb1PuLKsn7Iw6UicKps35Eg4n+wwX7tLOeUEHX+m23quwN/V2Ns2ycNHH", - "ONTlaRaJFjYFNnyaDus2gH8eubtMQJwMnRquZzA+1LsQ+YdCmAmzBxv4XdzoGOudq46a1cAu88Fo750Z", - "z7rGUTSJsBrwRTG7Ra5mKXeUk7TIYjZU/V951Ua8cp/5pzED2kShmlHRDSzCBZuWmMS6oeIuyxmI5aaz", - "PGTcihAPxEyIuBulysV878z8OjPkzsZsNhQVlqEVcsIezKzTGVxdyNs/rZ7E5fhH3djsmHcBq92qU5bB", - "M1l8b6+/HiB+jPbdg0M5cnP4J3cqs+q6sWO5mYUGmzmRNDge7+sskqCRXF36G2Jlw2efp6jMPgr+W83m", - "uQ+bUbwbNx0rZSvOkqyYfEzuGrD1gdQRP6YZ1neQaWK01n4P5nP/dy+UL47XgiHaz6xGyioq5EzABYIo", - "LmriYihB0oviKMvDJocpvhwUJSbDP3JRngJpoK872Cz6OSvG/ci7kwycnUKg6a1+c+2CKn+YNGeDYIbd", - "GhxtMvq17I7lFA7hjnAvURgdie/ksHFqd3WswAFPC/jq33f+d3Z6+iZzUF7ZWZKD6j0rOHUEHUNglgPO", - "LVcWuzvrCPda96X+bnTOXSauQr88RkVGhsHZWXbYQN51r6zTii9LIAOErFUCwK8bm0A6Fwz+A/MRfo4s", - "N54HvJMCvEWr9tfDw65hAm92x7AWEoejea6yr9gyPL1hZCbgsz32xRpCbHZ99vmy66ThlXKk9+PUpy9G", - "5Uij+XX48hmVcdSEi3TbOytnBBFAOeWteuluhrIs5U06ZwT7m+fznVUEKDMKxaN86GmHufY4VQtMt3tl", - "Wqefxrene4sPnFfIjrbzYKviOzlacTm0ivVVr4Cp1cUOGit5T0/frGpCVUmnNwrLMxFodgVI5sAtehLe", - "Jrl12HBHPVRMeyBdV+4LJWh0RLnQGFXw1TKqFoAiLqQgpcxpOZbaPP/u2bNnWEUNrY6pBnZbDe7+SUVH", - "7EmPPHHtPsHCsyeuySeBwszjkThGaZdFAy3GwQEAt6mViCSzXgH7AzEQ2BsQGVrPB3l7AhHnGTH00nER", - "Ih4UFr/1ybFwtyXZDReFvPGFQlDOCmjOCAI1lIqNFLZLL11lbLhd8XOEbWD1nKyY6PkiO+xah1p5HNFA", - "VOGlgDYdWHDd37kmtQgBNCxk9BPaB76F3b0nEHuBFwH5bsQGgt1a7eSmnKZCZE5Bola8wvX3Pk7oc309", - "UGVSYhxW3dIlZ0H1vkaA6fgJgC5yCiNHe0mY7oowHM7pg3Ppjug44kw7knuDEgs9PJCitEbQpSIRQF65", - "Z74K5PFcTibWleipyMdKClnrcuVDuFcBXdEbsVQHTuGpe1UC6OJhtcANoUsN4OcHxlGalz7dSvy/u39A", - "EOaKt+HKkqrwIwfcq+UBmNjywn17OJDVNS+2OfNtJHL7NV8luPPPPz7KpBjrjvhI0NJuU+LZYnOdRJSR", - "pVr5AR/70+glfs+/NfPuMvMArIaSk7N/ZJfIonMX6qkNNXV33NovLPjUH62d97xa4kelFkr3y6MsE3AC", - "INrLbBvlKPgKeyt46k/jueBzHngfh0Po2se9nAKvE8ZqH214Nq6vRDsN2kpTZW2WRW3j9MraLAzfPpBP", - "2yIMGb7NvrZiQNLPv6xNVRsI65R8yPJpXrJ/39jd341dQ+9lbdaOriqWA4ryaD9mDqQ9NMIQfPDP3yvq", - "Q+hlOSb3bN23e/Hh8B4eCI4noERUil1zOP8SFC4ryDUvmFzr4qqhF64OtdMT+kLVpmosvNA9jklCoWLX", - "i80DVhkZKs57hGpSUUjBNJI0hgb5QC4oLSd2CXPA2e6iKtEu16Fd1llABB43fSVLs89H2a8H2XfZp//+", - "3zbyyyCL/Ul1uHWpUFR2J9mWdw2/Zm+54HrMiuwocWVyxidMGzqprCwAEbAtkKF7uU++r6miwjAUwyUj", - "H96++uabb77rL76raw3lFDO4NhqJy/7adCB2KM8Oni3yGQDGycuScADXHSmmdY9UQHNEjJpilBkxcdvT", - "/QGs6Whof5gHH69HI6xHB7YlIMPlgiDXRZOsWU3ReuJHhPzQp4n80C+PuKgdwc81mCiDtOc7cVYlx6Wr", - "swIZhW2ltuXWO1TyLFrNfG9YTT5XHjNn0e8cI4sKo7yzEl0KROLx49ec2AlVV933rvidmlBiPWhBHK60", - "QF13edHxVjI0C3DaQy4AyxN1gqorpjwnwz/xSpD7hHq3uXx/cmjXhHxMK8OUf2e+HOU9VVf3vWFp9XGP", - "ibhrjKHrrPce5ikY2n+ZrdFRUQTNRF1xl+RcZN7NR51c3zbmyNYTyeD3rYbtThZum58uWgLdIvsI8Shh", - "BgJxTdPH/IwQ+M29RMUUOX4N9NjA1jLi2gCDN5BwWK/V30QPZLVIDWR1/1rQ6GPzs5NLzn5YkhQjq/YG", - "cFWB6JyWzMjPTMn9gmt6WS5mysRggu3q7+8RiNm2AABgkthWelZBqCpKiG8MyQ9nZyfEKDoc8pzYM4Xp", - "k1e0LD1m2NHJMfKCcG2bvLE7yht6xQg35JLltNaMfBT8StGhwV9pbeSEeuYjeBbJ36YezMhXY/79fRLy", - "Cz/z1H75mfyVKbmzSio+PJ8ZmdmvJG6uijsR33HBJpU0uLVzLcO8Mj+rjSnqbyJaJhZL9gPTRiqmHVg4", - "dh4+NjA4xVH07B5J3sBBAOa7PVzc+8O5hBclQ5Hju+Gw8vf3REgHOgZ8IdqdUMasLAi1gk1mJYntpYfT", - "cQ/Cw4a3l114ZCloX5NuM7zVBhjuE//w4cEh4cPGc8hmEsHjk7SA3zNzFsZzj0H40MmpoSZ5g3iW/sBN", - "N1nz3KUd7a8gtV5E9J5xmlQ5AjLErECRdYoK1l/XA2eaxBQ9opmJSY3o6C5lMYXtPxZEFS98aKfZhGKG", - "4ntcBV3RzBguRnot5SCn+BZh16w5dKvzflag4hTt6zkZ0hL48RlV2kNENr42xUFpZ7Gtbne/9L/EpLfQ", - "TROI/I+7dNpY3x8x+okDQt/O0OoUJyIzSyzL6/mzg6dtPb+hqOiNYHDU+Rcuodi+d2Df48a+YE2hZLlP", - "OpaVybh4TmjcgoypcXZgW2/a4y6doRfAYnkhzRijr7iBUTXrEam8rXnz8juPvU6zeuHTkgkPa5Nbdtdz", - "/Ce1eThL/Oot7y6DEpsPSLOHzSo93W7ZbG12GsWc6W3qMQS5NKECrzVjsCsOAW9Ze2REXRo/wB64fPqZ", - "gTadwgFaITytNR8JVhAmrlkpKxY3ra5bTWjh71CeHRwmfh/yEg/Ju0L67v29iiv2hmef6GjaXEfrBtM/", - "PDiwu8drWvICxe3YTdLWellyHddOvIu+p5QN7Au6eKCUjfidTkjJBGwQR4Wjtc48SDSnynNERXkjX2zO", - "+mjfiXMENkjznFWgXrWJkl6say9wjfFD2YKZp007jQ2uYBLrm+NcVsdsiScD1PDSfm47wSH2jSbdJ29o", - "PiZDRSdYAOTqZSbkghfPye+a/fZlMBAFNfQ5+d0LKbMaYf8+GIgLu+KidBxXVCABzpnW2UQKaaTgOWRT", - "VExpCOTnSmo94zIdeMALQsk7qk0GMs2OX2M8A9gs3U7AvijiKg92CMEGxXQ98SEM/Ow+ea1khYPCTFZU", - "iRGttN+2X/DiAjnkgDHSRWwYv2YF/sY1olmZMRXkKaFjRgt/71vasWrGBDza84kdN0xZV8Ih+A9fAGUd", - "9XDIVJ+8Kjk85fjvjaL5VaI1uEJmhuUGxtsnb6HqK36+9nuUmSmDEGjsNp4unKisMKDgUDMG5Ck46hdw", - "R00u/qdiVUmn/0HL8gKxYVrNybIAIG84wFh/7DRcG0YdMecNt/M9phUUMALhNRNM8ZxctD3hBfL6+52X", - "mz3mjkvOdn8EajrkFie79vEpUGRabUMqaEoKmdcTJuxbF2ZasQskeQ3u/AI57azOSTUJ0GCRcNHtef4C", - "w3oND6NT68XCrsupazzJIQ0K1/68pUjBH6zKerY42CDqtj05NlepiGaiIAcJeXjxeuLlVW2yR7RsG9Y1", - "LWus5Zswa2ZKsRzwnLAravBarE/O6BUDtv+cFdARJO1coN5c4MILhOHYMVDJQnfWIdHayEwxp8axu5JR", - "AUSmoEh4iZhhk1ZCY64BkDuixePtdUx6aBnBeuW3J6D46yh8n3wAXgMwaZJbf0INeXrw7PAFvBCUmTY8", - "AdT31GpIc4ZA6EOutEFjH0F1tnJept8Jio8zks4TK8vNcO23yLRbacV/t8Ji9OhqgWe/wEr0FPjus1Nr", - "j8EDLF/gv3z5/wEAAP//OIBHLnhYAwA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index d58ba504..24d9743b 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1260,19 +1260,35 @@ paths: post: summary: Apply batched session and browser configuration plus optional navigation description: | - Optional multipart parts apply configuration while Chromium stays stopped once (policy, - flags, extensions, profile archive, optional display sizing), then Chromium is started exactly - once and DevTools readiness is awaited. Optional `start_url` dispatches a best-effort - navigation after readiness without waiting for page load. Bare hosts are normalized to - `https://`. Omit any part you do not need. At least one actionable part must be present. - Required configuration steps run in this order: policies, extensions, display, flags, - then profile archive. Chromium is started only once at the end. The endpoint is not - transactional: if a later required step fails, earlier successful side effects may remain - on the instance while the non-2xx error propagates. + Optional multipart parts apply browser and session configuration in one serialized request. + Profiles, nonempty policies, nonempty flags, and extensions use one Chromium stop/apply/start + cycle by default. Display-only and start_url-only requests stay live. Set + `extension_load_strategy=prefer_cdp` to attempt live CDP activation when extensions are present + without a profile, nonempty policies, or nonempty flags. Enterprise-policy extensions and CDP + activation failures fall back to one restart within the same request. The query parameter is + ignored by older images, which retain the default restart behavior. + Optional `start_url` dispatches a best-effort navigation after configuration and readiness + without waiting for page load. Bare hosts are normalized to `https://`. Omit any part you do + not need. At least one actionable part must be present. Required configuration steps run in + this order: policies, extensions, display, flags, then profile archive. The endpoint is not + transactional: if a later required step fails, earlier successful side effects may remain on + the instance while the non-2xx error propagates. Prefer this over separate `/chromium/*` and `/display` calls when multiple restart-triggering steps apply in one session configure. operationId: chromiumConfigure x-telemetry-category: platform + parameters: + - name: extension_load_strategy + in: query + required: false + description: >- + Extension activation strategy. Omit or use `restart` to preserve unconditional restart + behavior for requests containing extensions. `prefer_cdp` attempts live activation for + eligible unpacked extensions and falls back to one restart when required. + schema: + type: string + enum: [restart, prefer_cdp] + default: restart requestBody: required: true content: From ea5b72d5ed75d1ca54d7bf7306f4e00ec0cfe61d Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:06:01 +0000 Subject: [PATCH 2/6] Use inline URL in configure e2e --- server/e2e/e2e_chromium_configure_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/e2e/e2e_chromium_configure_test.go b/server/e2e/e2e_chromium_configure_test.go index 8f543928..1a85646b 100644 --- a/server/e2e/e2e_chromium_configure_test.go +++ b/server/e2e/e2e_chromium_configure_test.go @@ -250,7 +250,7 @@ func testChromiumConfigureExtensionLoadStrategies(t *testing.T, image string, te invalid := instanceoapi.ChromiumConfigureParamsExtensionLoadStrategy("invalid") response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ params: &instanceoapi.ChromiumConfigureParams{ExtensionLoadStrategy: &invalid}, - startURL: "https://example.com", + startURL: "data:text/html,invalid-strategy", }) require.Equal(t, http.StatusBadRequest, response.StatusCode(), "%s", response.Body) }) From 40d73b45265f77b80c1742b0e22a48b40ed0e5d9 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:23:57 +0000 Subject: [PATCH 3/6] Address configure feature review --- server/cmd/api/api/chromium_configure.go | 30 ++++++++++++++++------- server/e2e/e2e_chromium_configure_test.go | 26 +++++++++++++++++++- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index 7a8ea5ca..35862a40 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -90,7 +90,7 @@ func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.Chromiu case chromiumConfigureModeLive: configureResp = s.chromiumConfigureLive(ctx, st, spec) case chromiumConfigureModeRestart: - configureResp = s.chromiumConfigureRestart(ctx, st, spec) + configureResp = s.chromiumConfigureRestart(ctx, st, spec, nil) case chromiumConfigureModeCandidateCDPExtensions: configureResp = s.chromiumConfigureCandidateCDPExtensions(ctx, st, spec) default: @@ -176,9 +176,11 @@ func (s *ApiService) chromiumConfigureCandidateCDPExtensions(ctx context.Context } if prepared.requiresRestart { - return s.chromiumConfigureRestart(ctx, st, spec) + return s.chromiumConfigureRestart(ctx, st, spec, prepared) } + // Configure keeps the default restart path's non-transactional install semantics. + // Rolling back after fallback verification would require a second restart. _, _, reqMsg, err = s.commitPreparedExtensions(ctx, prepared) if reqMsg != "" { return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)) @@ -194,8 +196,10 @@ func (s *ApiService) chromiumConfigureCandidateCDPExtensions(ctx context.Context logger.FromContext(ctx).Warn("CDP extension load failed during configure, restarting Chromium", "error", loadErr) restartState := *st - restartState.extItems = nil // already persisted; the fallback only restarts Chromium - if resp := s.chromiumConfigureRestart(ctx, &restartState, spec); resp != nil { + // Extensions are already persisted. The fallback re-launches Chromium with + // the merged flags and still applies any pending display change. + restartState.extItems = nil + if resp := s.chromiumConfigureRestart(ctx, &restartState, spec, nil); resp != nil { return resp } if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil { @@ -204,7 +208,7 @@ func (s *ApiService) chromiumConfigureCandidateCDPExtensions(ctx context.Context return nil } -func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed) (resp oapi.ChromiumConfigureResponseObject) { +func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, preparedExtensions *preparedExtensionBatch) (resp oapi.ChromiumConfigureResponseObject) { var stoppedRecordings []stoppedRecordingInfo chromiumStopped := false restartAfterStop := func() error { @@ -247,10 +251,18 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC return cfgResponseFromStepError(chromiumConfigureStepPolicies, err) } - if reqMsgs, ierr := chromiumApplyExtensions(ctx, s, st.extItems); reqMsgs != "" { - return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsgs)) - } else if ierr != nil { - return cfg500ConfigureStep(chromiumConfigureStepExtensions, ierr.Error()) + var extensionReqMsg string + var extensionErr error + if preparedExtensions == nil { + extensionReqMsg, extensionErr = chromiumApplyExtensions(ctx, s, st.extItems) + } else { + _, _, extensionReqMsg, extensionErr = s.commitPreparedExtensions(ctx, preparedExtensions) + } + if extensionReqMsg != "" { + return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, extensionReqMsg)) + } + if extensionErr != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, extensionErr.Error()) } if st.displayJSON != nil && strings.TrimSpace(*st.displayJSON) != "" { diff --git a/server/e2e/e2e_chromium_configure_test.go b/server/e2e/e2e_chromium_configure_test.go index 1a85646b..d1c6e834 100644 --- a/server/e2e/e2e_chromium_configure_test.go +++ b/server/e2e/e2e_chromium_configure_test.go @@ -27,6 +27,7 @@ type configureExtensionPart struct { type configureE2ERequest struct { params *instanceoapi.ChromiumConfigureParams extensions []configureExtensionPart + display string chromiumFlags string startURL string } @@ -43,6 +44,9 @@ func chromiumConfigureE2E(t *testing.T, ctx context.Context, client *instanceoap require.NoError(t, err) require.NoError(t, writer.WriteField("extensions.name", extension.name)) } + if request.display != "" { + require.NoError(t, writer.WriteField("display", request.display)) + } if request.chromiumFlags != "" { require.NoError(t, writer.WriteField("chromium_flags", request.chromiumFlags)) } @@ -127,7 +131,9 @@ func testChromiumConfigureExtensionLoadStrategies(t *testing.T, image string, te ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() c := NewTestContainer(t, image) - require.NoError(t, c.Start(ctx, ContainerConfig{})) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{"WIDTH": "1024", "HEIGHT": "768"}, + })) defer c.Stop(context.WithoutCancel(ctx)) require.NoError(t, c.WaitReady(ctx)) require.NoError(t, c.WaitDevTools(ctx)) @@ -166,6 +172,24 @@ func testChromiumConfigureExtensionLoadStrategies(t *testing.T, image string, te requireConfiguredExtensionActive(t, ctx, c, "configure-cdp") }) + t.Run("prefer CDP with display", func(t *testing.T) { + before, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + starts := chromiumConfigureStartCount(t, ctx, c) + response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ + params: preferCDPParams, + extensions: []configureExtensionPart{{name: "configure-cdp-display", zip: ordinaryZip}}, + display: `{"width":1280,"height":720,"refresh_rate":60,"restart_chromium":false,"require_idle":true}`, + }) + require.Equal(t, http.StatusOK, response.StatusCode(), "%s", response.Body) + after, err := fetchBrowserWebSocketURL(ctx, c) + require.NoError(t, err) + require.Equal(t, before, after) + require.Equal(t, starts, chromiumConfigureStartCount(t, ctx, c)) + requireConfiguredExtensionActive(t, ctx, c, "configure-cdp-display") + waitForXRootResolution(t, ctx, c, 1280, 720, 10*time.Second) + }) + t.Run("startup field restart", func(t *testing.T) { before, err := fetchBrowserWebSocketURL(ctx, c) require.NoError(t, err) From d63fa63be10483b2094e318b871e9aa089feee88 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:51:43 +0000 Subject: [PATCH 4/6] Simplify configure extension staging --- server/cmd/api/api/chromium.go | 44 ++++++---- server/cmd/api/api/chromium_configure.go | 86 +++++++++---------- server/cmd/api/api/chromium_configure_test.go | 4 +- 3 files changed, 70 insertions(+), 64 deletions(-) diff --git a/server/cmd/api/api/chromium.go b/server/cmd/api/api/chromium.go index fedb7db2..e221d17b 100644 --- a/server/cmd/api/api/chromium.go +++ b/server/cmd/api/api/chromium.go @@ -108,7 +108,7 @@ func (s *ApiService) uploadExtensions(ctx context.Context, mr *multipart.Reader, s.chromiumConfigMu.Lock() defer s.chromiumConfigMu.Unlock() - requiresRestart, transaction, reqMsg, err := s.commitPreparedExtensions(ctx, prepared) + transaction, reqMsg, err := s.commitPreparedExtensions(ctx, prepared) if reqMsg != "" { return badExtensionUpload(reqMsg) } @@ -116,7 +116,7 @@ func (s *ApiService) uploadExtensions(ctx context.Context, mr *multipart.Reader, return internalExtensionUpload(err.Error()) } - restarted := forceRestart || requiresRestart + restarted := forceRestart || prepared.requiresRestart var loadErr error if restarted { if err := s.restartChromiumAndWait(ctx, "extension upload"); err != nil { @@ -259,22 +259,25 @@ func (batch *preparedExtensionBatch) cleanup() { } } -// applyExtensionZipItems installs name+zipTemp extension pairs and persists their startup -// configuration. The boolean result reports whether enterprise policy requires a restart. -func (s *ApiService) applyExtensionZipItems(ctx context.Context, items []extensionZipItem) (bool, string, error) { +// installExtensionZipItems extracts, validates, and persists extension archives. +// The caller must hold chromiumConfigMu while it commits the prepared batch. +func (s *ApiService) installExtensionZipItems(ctx context.Context, items []extensionZipItem) (string, error) { + if len(items) == 0 { + return "", nil + } prepared, reqMsg, err := s.prepareExtensionZipItems(ctx, items) if prepared != nil { defer prepared.cleanup() } if reqMsg != "" || err != nil { - return false, reqMsg, err + return reqMsg, err } - requiresRestart, _, reqMsg, err := s.commitPreparedExtensions(ctx, prepared) - return requiresRestart, reqMsg, err + _, reqMsg, err = s.commitPreparedExtensions(ctx, prepared) + return reqMsg, err } -// prepareExtensionZipItems performs archive extraction and validation before the global Chromium -// configuration lock is acquired. commitPreparedExtensions rechecks destination names under lock. +// prepareExtensionZipItems extracts and validates archives into staging paths. +// commitPreparedExtensions rechecks destination names while the caller holds the config lock. func (s *ApiService) prepareExtensionZipItems(ctx context.Context, items []extensionZipItem) (*preparedExtensionBatch, string, error) { log := logger.FromContext(ctx) if err := os.MkdirAll(extensionsBaseDir, 0o755); err != nil { @@ -438,22 +441,25 @@ func (batch *committedExtensionBatch) rollback() error { return rollbackErr } -func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *preparedExtensionBatch) (requiresRestart bool, transaction *committedExtensionBatch, reqMsg string, err error) { +// commitPreparedExtensions moves a prepared batch into place and updates policy and +// flags, rolling back partial commit failures. Callers may retain the returned +// transaction to roll back a later activation failure. +func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *preparedExtensionBatch) (transaction *committedExtensionBatch, reqMsg string, err error) { for _, extension := range batch.extensions { if _, statErr := os.Stat(extension.finalPath); statErr == nil { - return false, nil, fmt.Sprintf("extension name already exists: %s", extension.name), nil + return nil, fmt.Sprintf("extension name already exists: %s", extension.name), nil } else if !os.IsNotExist(statErr) { - return false, nil, "", fmt.Errorf("failed to check extension dir: %w", statErr) + return nil, "", fmt.Errorf("failed to check extension dir: %w", statErr) } } flagsSnapshot, err := captureOptionalFileSnapshot(chromiumFlagsPath) if err != nil { - return false, nil, "", fmt.Errorf("failed to snapshot chromium flags: %w", err) + return nil, "", fmt.Errorf("failed to snapshot chromium flags: %w", err) } policySnapshot, err := captureOptionalFileSnapshot(policy.PolicyPath) if err != nil { - return false, nil, "", fmt.Errorf("failed to snapshot chromium policy: %w", err) + return nil, "", fmt.Errorf("failed to snapshot chromium policy: %w", err) } transaction = &committedExtensionBatch{ @@ -477,7 +483,7 @@ func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *prepar registrations := make([]policy.ExtensionRegistration, 0, len(batch.extensions)) for _, extension := range batch.extensions { if err := os.Rename(extension.stagingPath, extension.finalPath); err != nil { - return false, nil, "", fmt.Errorf("commit extension directory %s: %w", extension.name, err) + return nil, "", fmt.Errorf("commit extension directory %s: %w", extension.name, err) } transaction.paths = append(transaction.paths, extension.finalPath) registrations = append(registrations, policy.ExtensionRegistration{ @@ -488,7 +494,7 @@ func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *prepar } if err := s.policy.AddExtensions(registrations); err != nil { - return false, nil, "", fmt.Errorf("failed to update enterprise policy: %w", err) + return nil, "", fmt.Errorf("failed to update enterprise policy: %w", err) } var newTokens []string @@ -496,7 +502,7 @@ func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *prepar newTokens = []string{fmt.Sprintf("--load-extension=%s", strings.Join(batch.flagPaths, ","))} } if _, err := s.mergeAndWriteChromiumFlags(ctx, newTokens); err != nil { - return false, nil, "", err + return nil, "", err } committed = true @@ -506,7 +512,7 @@ func (s *ApiService) commitPreparedExtensions(ctx context.Context, batch *prepar "chromeExtensionID", extension.chromeExtensionID, "requiresEnterprisePolicy", extension.requiresEnterprisePolicy) } - return batch.requiresRestart, transaction, "", nil + return transaction, "", nil } func (s *ApiService) loadUnpackedExtensions(ctx context.Context, extensions []preparedExtension) error { diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index 35862a40..1b568200 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -82,17 +82,36 @@ func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.Chromiu return cfg400("no configuration fields provided"), nil } + mode := chromiumConfigureModeFor(st, extensionLoadStrategy) + var preparedExtensions *preparedExtensionBatch + if mode == chromiumConfigureModePreferCDPExtensions { + var reqMsg string + var err error + preparedExtensions, reqMsg, err = s.prepareExtensionZipItems(ctx, st.extItems) + if preparedExtensions != nil { + defer preparedExtensions.cleanup() + } + if reqMsg != "" { + return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)), nil + } + if err != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error()), nil + } + } + s.chromiumConfigMu.Lock() defer s.chromiumConfigMu.Unlock() var configureResp oapi.ChromiumConfigureResponseObject - switch chromiumConfigureModeFor(st, extensionLoadStrategy) { + switch mode { case chromiumConfigureModeLive: configureResp = s.chromiumConfigureLive(ctx, st, spec) case chromiumConfigureModeRestart: - configureResp = s.chromiumConfigureRestart(ctx, st, spec, nil) - case chromiumConfigureModeCandidateCDPExtensions: - configureResp = s.chromiumConfigureCandidateCDPExtensions(ctx, st, spec) + configureResp = s.chromiumConfigureRestart(ctx, st, spec, func() (string, error) { + return s.installExtensionZipItems(ctx, st.extItems) + }) + case chromiumConfigureModePreferCDPExtensions: + configureResp = s.chromiumConfigurePreferCDPExtensions(ctx, st, spec, preparedExtensions) default: return cfg500Configure("unhandled configure mode"), nil } @@ -109,7 +128,7 @@ type chromiumConfigureMode uint8 const ( chromiumConfigureModeLive chromiumConfigureMode = iota chromiumConfigureModeRestart - chromiumConfigureModeCandidateCDPExtensions + chromiumConfigureModePreferCDPExtensions ) func chromiumConfigureExtensionLoadStrategy(params oapi.ChromiumConfigureParams) (oapi.ChromiumConfigureParamsExtensionLoadStrategy, string) { @@ -130,7 +149,7 @@ func chromiumConfigureModeFor(st *chromiumConfigureState, strategy oapi.Chromium } if len(st.extItems) > 0 { if strategy == oapi.PreferCdp { - return chromiumConfigureModeCandidateCDPExtensions + return chromiumConfigureModePreferCDPExtensions } return chromiumConfigureModeRestart } @@ -163,25 +182,18 @@ func chromiumConfigureNavigate(ctx context.Context, s *ApiService, spec startURL } } -func (s *ApiService) chromiumConfigureCandidateCDPExtensions(ctx context.Context, st *chromiumConfigureState, spec startURLParsed) oapi.ChromiumConfigureResponseObject { - prepared, reqMsg, err := s.prepareExtensionZipItems(ctx, st.extItems) - if prepared != nil { - defer prepared.cleanup() +func (s *ApiService) chromiumConfigurePreferCDPExtensions(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, prepared *preparedExtensionBatch) oapi.ChromiumConfigureResponseObject { + commitExtensions := func() (string, error) { + _, reqMsg, err := s.commitPreparedExtensions(ctx, prepared) + return reqMsg, err } - if reqMsg != "" { - return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)) - } - if err != nil { - return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error()) - } - if prepared.requiresRestart { - return s.chromiumConfigureRestart(ctx, st, spec, prepared) + return s.chromiumConfigureRestart(ctx, st, spec, commitExtensions) } // Configure keeps the default restart path's non-transactional install semantics. // Rolling back after fallback verification would require a second restart. - _, _, reqMsg, err = s.commitPreparedExtensions(ctx, prepared) + _, reqMsg, err := s.commitPreparedExtensions(ctx, prepared) if reqMsg != "" { return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)) } @@ -195,11 +207,9 @@ func (s *ApiService) chromiumConfigureCandidateCDPExtensions(ctx context.Context } logger.FromContext(ctx).Warn("CDP extension load failed during configure, restarting Chromium", "error", loadErr) - restartState := *st // Extensions are already persisted. The fallback re-launches Chromium with // the merged flags and still applies any pending display change. - restartState.extItems = nil - if resp := s.chromiumConfigureRestart(ctx, &restartState, spec, nil); resp != nil { + if resp := s.chromiumConfigureRestart(ctx, st, spec, nil); resp != nil { return resp } if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil { @@ -208,7 +218,9 @@ func (s *ApiService) chromiumConfigureCandidateCDPExtensions(ctx context.Context return nil } -func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, preparedExtensions *preparedExtensionBatch) (resp oapi.ChromiumConfigureResponseObject) { +type chromiumConfigureExtensionInstaller func() (string, error) + +func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, installExtensions chromiumConfigureExtensionInstaller) (resp oapi.ChromiumConfigureResponseObject) { var stoppedRecordings []stoppedRecordingInfo chromiumStopped := false restartAfterStop := func() error { @@ -251,18 +263,14 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC return cfgResponseFromStepError(chromiumConfigureStepPolicies, err) } - var extensionReqMsg string - var extensionErr error - if preparedExtensions == nil { - extensionReqMsg, extensionErr = chromiumApplyExtensions(ctx, s, st.extItems) - } else { - _, _, extensionReqMsg, extensionErr = s.commitPreparedExtensions(ctx, preparedExtensions) - } - if extensionReqMsg != "" { - return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, extensionReqMsg)) - } - if extensionErr != nil { - return cfg500ConfigureStep(chromiumConfigureStepExtensions, extensionErr.Error()) + if installExtensions != nil { + extensionReqMsg, extensionErr := installExtensions() + if extensionReqMsg != "" { + return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, extensionReqMsg)) + } + if extensionErr != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, extensionErr.Error()) + } } if st.displayJSON != nil && strings.TrimSpace(*st.displayJSON) != "" { @@ -880,14 +888,6 @@ func chromiumApplyPolicies(ctx context.Context, s *ApiService, overrides policy. return nil } -func chromiumApplyExtensions(ctx context.Context, s *ApiService, items []extensionZipItem) (string, error) { - if len(items) == 0 { - return "", nil - } - _, reqMsg, err := s.applyExtensionZipItems(ctx, items) - return reqMsg, err -} - func chromiumValidateFlags(raw *string) (*chromiumFlagsPlan, error) { if raw == nil || strings.TrimSpace(*raw) == "" { return nil, nil diff --git a/server/cmd/api/api/chromium_configure_test.go b/server/cmd/api/api/chromium_configure_test.go index 468b8ec4..607acafa 100644 --- a/server/cmd/api/api/chromium_configure_test.go +++ b/server/cmd/api/api/chromium_configure_test.go @@ -50,8 +50,8 @@ func TestChromiumConfigureModeFor(t *testing.T) { {name: "invalid flags", state: chromiumConfigureState{chromiumFlagsJSON: stringPtr(`{bad-json`)}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart}, {name: "profile", state: chromiumConfigureState{hasProfile: true}, strategy: oapi.PreferCdp, want: chromiumConfigureModeRestart}, {name: "extensions default restart", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.Restart, want: chromiumConfigureModeRestart}, - {name: "extensions prefer CDP", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModeCandidateCDPExtensions}, - {name: "display and extensions prefer CDP", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`), extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModeCandidateCDPExtensions}, + {name: "extensions prefer CDP", state: chromiumConfigureState{extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModePreferCDPExtensions}, + {name: "display and extensions prefer CDP", state: chromiumConfigureState{displayJSON: stringPtr(`{"width":1280}`), extItems: []extensionZipItem{{name: "test"}}}, strategy: oapi.PreferCdp, want: chromiumConfigureModePreferCDPExtensions}, } for _, tt := range tests { From 990b037dcb27237f855708b6948c711b8845c3b5 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:55:12 +0000 Subject: [PATCH 5/6] Reuse configure extension commit step --- server/cmd/api/api/chromium_configure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index 1b568200..fa163968 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -193,7 +193,7 @@ func (s *ApiService) chromiumConfigurePreferCDPExtensions(ctx context.Context, s // Configure keeps the default restart path's non-transactional install semantics. // Rolling back after fallback verification would require a second restart. - _, reqMsg, err := s.commitPreparedExtensions(ctx, prepared) + reqMsg, err := commitExtensions() if reqMsg != "" { return cfg400(fmt.Sprintf("%s: %s", chromiumConfigureStepExtensions, reqMsg)) } From 03694418e6270f80beeaee03c099403420aa2765 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:20:38 +0000 Subject: [PATCH 6/6] Verify mixed extension restart activation --- server/cmd/api/api/chromium_configure.go | 30 +++++++++++++++-------- server/e2e/e2e_chromium_configure_test.go | 8 ++++-- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/server/cmd/api/api/chromium_configure.go b/server/cmd/api/api/chromium_configure.go index fa163968..18d43c2b 100644 --- a/server/cmd/api/api/chromium_configure.go +++ b/server/cmd/api/api/chromium_configure.go @@ -109,7 +109,7 @@ func (s *ApiService) ChromiumConfigure(ctx context.Context, request oapi.Chromiu case chromiumConfigureModeRestart: configureResp = s.chromiumConfigureRestart(ctx, st, spec, func() (string, error) { return s.installExtensionZipItems(ctx, st.extItems) - }) + }, nil) case chromiumConfigureModePreferCDPExtensions: configureResp = s.chromiumConfigurePreferCDPExtensions(ctx, st, spec, preparedExtensions) default: @@ -188,7 +188,12 @@ func (s *ApiService) chromiumConfigurePreferCDPExtensions(ctx context.Context, s return reqMsg, err } if prepared.requiresRestart { - return s.chromiumConfigureRestart(ctx, st, spec, commitExtensions) + return s.chromiumConfigureRestart(ctx, st, spec, commitExtensions, func() oapi.ChromiumConfigureResponseObject { + if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, err.Error()) + } + return nil + }) } // Configure keeps the default restart path's non-transactional install semantics. @@ -209,18 +214,18 @@ func (s *ApiService) chromiumConfigurePreferCDPExtensions(ctx context.Context, s // Extensions are already persisted. The fallback re-launches Chromium with // the merged flags and still applies any pending display change. - if resp := s.chromiumConfigureRestart(ctx, st, spec, nil); resp != nil { - return resp - } - if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil { - return cfg500ConfigureStep(chromiumConfigureStepExtensions, errors.Join(loadErr, err).Error()) - } - return nil + return s.chromiumConfigureRestart(ctx, st, spec, nil, func() oapi.ChromiumConfigureResponseObject { + if err := s.verifyUnpackedExtensions(ctx, prepared.extensions); err != nil { + return cfg500ConfigureStep(chromiumConfigureStepExtensions, errors.Join(loadErr, err).Error()) + } + return nil + }) } type chromiumConfigureExtensionInstaller func() (string, error) +type chromiumConfigurePostRestartCheck func() oapi.ChromiumConfigureResponseObject -func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, installExtensions chromiumConfigureExtensionInstaller) (resp oapi.ChromiumConfigureResponseObject) { +func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumConfigureState, spec startURLParsed, installExtensions chromiumConfigureExtensionInstaller, postRestartCheck chromiumConfigurePostRestartCheck) (resp oapi.ChromiumConfigureResponseObject) { var stoppedRecordings []stoppedRecordingInfo chromiumStopped := false restartAfterStop := func() error { @@ -319,6 +324,11 @@ func (s *ApiService) chromiumConfigureRestart(ctx context.Context, st *chromiumC if err := restartAfterStop(); err != nil { return cfg500ConfigureStep(chromiumConfigureStepStart, err.Error()) } + if postRestartCheck != nil { + if resp := postRestartCheck(); resp != nil { + return resp + } + } chromiumConfigureNavigate(ctx, s, spec) if len(stoppedRecordings) > 0 { go s.startNewRecordingSegments(context.WithoutCancel(ctx), stoppedRecordings) diff --git a/server/e2e/e2e_chromium_configure_test.go b/server/e2e/e2e_chromium_configure_test.go index d1c6e834..985d1021 100644 --- a/server/e2e/e2e_chromium_configure_test.go +++ b/server/e2e/e2e_chromium_configure_test.go @@ -212,8 +212,11 @@ func testChromiumConfigureExtensionLoadStrategies(t *testing.T, image string, te require.NoError(t, err) starts := chromiumConfigureStartCount(t, ctx, c) response := chromiumConfigureE2E(t, ctx, client, configureE2ERequest{ - params: preferCDPParams, - extensions: []configureExtensionPart{{name: "configure-enterprise", zip: enterpriseZip}}, + params: preferCDPParams, + extensions: []configureExtensionPart{ + {name: "configure-enterprise", zip: enterpriseZip}, + {name: "configure-enterprise-unpacked", zip: ordinaryZip}, + }, }) require.Equal(t, http.StatusOK, response.StatusCode(), "%s", response.Body) after, err := fetchBrowserWebSocketURL(ctx, c) @@ -222,6 +225,7 @@ func testChromiumConfigureExtensionLoadStrategies(t *testing.T, image string, te require.Equal(t, starts+1, chromiumConfigureStartCount(t, ctx, c)) _, err = execCombinedOutputWithClient(ctx, c, "grep", []string{"-q", "configure-enterprise", "/etc/chromium/policies/managed/policy.json"}) require.NoError(t, err) + requireConfiguredExtensionActive(t, ctx, c, "configure-enterprise-unpacked") }) t.Run("CDP failure restart", func(t *testing.T) {