Skip to content
2 changes: 1 addition & 1 deletion images/chromium-headful/run-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ RUN_ARGS=(
-e WIDTH=1920
-e TZ=${TZ:-'America/Los_Angeles'}
-e RUN_AS_ROOT="$RUN_AS_ROOT"
--mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags,ro
--mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags
)

if [[ -n "${PLAYWRIGHT_ENGINE:-}" ]]; then
Expand Down
90 changes: 74 additions & 16 deletions server/cmd/api/api/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/onkernel/kernel-images/server/lib/chromiumflags"
"github.com/onkernel/kernel-images/server/lib/logger"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
"github.com/onkernel/kernel-images/server/lib/policy"
"github.com/onkernel/kernel-images/server/lib/ziputil"
)

Expand Down Expand Up @@ -157,49 +158,106 @@ func (s *ApiService) UploadExtensionsAndRestart(ctx context.Context, request oap
log.Error("failed to chown extension dir", "error", err)
return oapi.UploadExtensionsAndRestart500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to chown extension dir"}}, nil
}

log.Info("installed extension", "name", p.name)
}

// Update enterprise policy for extensions that require it
// Track which extensions need --load-extension flags (those NOT using policy installation)
var pathsNeedingFlags []string

for _, p := range items {
extensionPath := filepath.Join(extBase, p.name)
extensionID := s.policy.GenerateExtensionID(p.name)
extensionName := p.name
manifestPath := filepath.Join(extensionPath, "manifest.json")
updateXMLPath := filepath.Join(extensionPath, "update.xml")

// Check if this extension requires enterprise policy
requiresEntPolicy, err := s.policy.RequiresEnterprisePolicy(manifestPath)
if err != nil {
log.Warn("failed to read manifest for policy check", "error", err, "extension", p.name)
log.Warn("failed to read manifest for policy check", "error", err, "extension", extensionName)
// Continue with requiresEntPolicy = false
}

// Try to extract Chrome extension ID from update.xml
chromeExtensionID := extensionName
var extractionErr error
if extractedID, err := policy.ExtractExtensionIDFromUpdateXML(updateXMLPath); err == nil {
chromeExtensionID = extractedID
log.Info("extracted Chrome extension ID from update.xml", "name", extensionName, "chromeExtensionID", chromeExtensionID)
} else {
extractionErr = err
log.Info("no Chrome extension ID in update.xml, using name as ID", "name", extensionName, "error", err)
}

if requiresEntPolicy {
log.Info("extension requires enterprise policy", "name", p.name)
log.Info("extension requires enterprise policy", "name", extensionName)

// Validate that update.xml and .crx files are present for policy-installed extensions
// These files are required for ExtensionInstallForcelist to work
hasUpdateXML := false
hasCRX := false

if _, err := os.Stat(updateXMLPath); err == nil {
// For policy extensions, update.xml must exist AND be parseable
if extractionErr != nil {
return oapi.UploadExtensionsAndRestart400JSONResponse{
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
Message: fmt.Sprintf("extension %s requires enterprise policy but update.xml is invalid: %v", extensionName, extractionErr),
},
}, nil
}
hasUpdateXML = true
log.Info("found update.xml in extension zip", "name", extensionName)
}

// Look for any .crx file in the directory
entries, err := os.ReadDir(extensionPath)
if err == nil {
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".crx" {
hasCRX = true
log.Info("found .crx file in extension zip", "name", extensionName, "crx_file", entry.Name())
break
}
}
}

// Fail if policy extension is missing required files
if !hasUpdateXML || !hasCRX {
return oapi.UploadExtensionsAndRestart400JSONResponse{
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
Message: fmt.Sprintf("extension %s requires enterprise policy (ExtensionInstallForcelist) but is missing required files: update.xml (present: %v), .crx file (present: %v). These files are required for Chrome to install the extension.", extensionName, hasUpdateXML, hasCRX),
},
}, nil
}
} else {
// Only add --load-extension flags for non-policy extensions
pathsNeedingFlags = append(pathsNeedingFlags, extensionPath)
}

// Add to enterprise policy
if err := s.policy.AddExtension(extensionID, extensionPath, requiresEntPolicy); err != nil {
log.Error("failed to update enterprise policy", "error", err, "extension", p.name)
// Pass both extensionName (for URL paths) and chromeExtensionID (for policy entries)
if err := s.policy.AddExtension(extensionName, chromeExtensionID, extensionPath, requiresEntPolicy); err != nil {
log.Error("failed to update enterprise policy", "error", err, "extension", extensionName)
return oapi.UploadExtensionsAndRestart500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", p.name, err),
Message: fmt.Sprintf("failed to update enterprise policy for %s: %v", extensionName, err),
},
}, nil
}

log.Info("updated enterprise policy", "extension", p.name, "id", extensionID, "requiresEnterprisePolicy", requiresEntPolicy)
log.Info("updated enterprise policy", "extension", extensionName, "chromeExtensionID", chromeExtensionID, "requiresEnterprisePolicy", requiresEntPolicy)
}

// Build flags overlay file in /chromium/flags, merging with existing flags
var paths []string
for _, p := range items {
paths = append(paths, filepath.Join(extBase, p.name))
}

// Create new flags for the uploaded extensions
newTokens := []string{
fmt.Sprintf("--disable-extensions-except=%s", strings.Join(paths, ",")),
fmt.Sprintf("--load-extension=%s", strings.Join(paths, ",")),
// Only add --load-extension flags for extensions that don't use policy installation
var newTokens []string
if len(pathsNeedingFlags) > 0 {
newTokens = []string{
fmt.Sprintf("--disable-extensions-except=%s", strings.Join(pathsNeedingFlags, ",")),
fmt.Sprintf("--load-extension=%s", strings.Join(pathsNeedingFlags, ",")),
}
}

// Merge and write flags
Expand Down
9 changes: 9 additions & 0 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ func main() {
apiService.HandleProcessAttach(w, r, id)
})

// Serve extension files for Chrome policy-installed extensions
// This allows Chrome to download .crx and update.xml files via HTTP
extensionsDir := "/home/kernel/extensions"
r.Get("/extensions/*", func(w http.ResponseWriter, r *http.Request) {
// Serve files from /home/kernel/extensions/
fs := http.StripPrefix("/extensions/", http.FileServer(http.Dir(extensionsDir)))
fs.ServeHTTP(w, r)
})

srv := &http.Server{
Addr: fmt.Sprintf(":%d", config.Port),
Handler: r,
Expand Down
97 changes: 63 additions & 34 deletions server/e2e/e2e_chromium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -834,22 +834,37 @@ func TestWebBotAuthInstallation(t *testing.T) {

// Build mock web-bot-auth extension zip in-memory
extDir := t.TempDir()
manifest := `{
"manifest_version": 3,
"version": "1.0.0",
"name": "Web Bot Auth Mock",
"description": "Mock web-bot-auth extension for testing",
"permissions": [
"webRequest",
"webRequestBlocking"
],
"host_permissions": [
"*://*/*"
]
}`
err = os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600)

// Create manifest with webRequest permissions to trigger enterprise policy requirement
manifest := map[string]interface{}{
"manifest_version": 3,
"version": "1.0.0",
"name": "Web Bot Auth Mock",
"description": "Mock web-bot-auth extension for testing",
"permissions": []string{"webRequest", "webRequestBlocking"},
"host_permissions": []string{"<all_urls>"},
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
require.NoError(t, err, "marshal manifest: %v", err)

err = os.WriteFile(filepath.Join(extDir, "manifest.json"), manifestJSON, 0600)
require.NoError(t, err, "write manifest: %v", err)

// Create update.xml required for enterprise policy
updateXMLContent := `<?xml version="1.0" encoding="UTF-8"?>
<gupdate xmlns="http://www.google.com/update2/response" protocol="2.0">
<app appid="aaaabbbbccccddddeeeeffffgggghhhh">
<updatecheck codebase="http://localhost:10001/extensions/web-bot-auth/web-bot-auth.crx" version="1.0.0"/>
</app>
</gupdate>`

err = os.WriteFile(filepath.Join(extDir, "update.xml"), []byte(updateXMLContent), 0600)
require.NoError(t, err, "write update.xml: %v", err)

// Create a minimal .crx file (just needs to exist for the test)
err = os.WriteFile(filepath.Join(extDir, "web-bot-auth.crx"), []byte("mock crx content"), 0600)
require.NoError(t, err, "write .crx: %v", err)

extZip, err := zipDirToBytes(extDir)
require.NoError(t, err, "zip ext: %v", err)

Expand Down Expand Up @@ -889,30 +904,44 @@ func TestWebBotAuthInstallation(t *testing.T) {
err = json.Unmarshal([]byte(policyContent), &policy)
require.NoError(t, err, "failed to parse policy.json: %v", err)

// Check ExtensionSettings exists
extensionSettings, ok := policy["ExtensionSettings"].(map[string]interface{})
require.True(t, ok, "ExtensionSettings not found in policy.json")
// Check ExtensionInstallForcelist exists
extensionInstallForcelist, ok := policy["ExtensionInstallForcelist"].([]interface{})
require.True(t, ok, "ExtensionInstallForcelist not found in policy.json")
require.GreaterOrEqual(t, len(extensionInstallForcelist), 1, "ExtensionInstallForcelist should have at least 1 entry")

// Check web-bot-auth entry exists
webBotAuth, ok := extensionSettings["web-bot-auth"].(map[string]interface{})
require.True(t, ok, "web-bot-auth entry not found in ExtensionSettings")

// Verify installation_mode is force_installed
installationMode, ok := webBotAuth["installation_mode"].(string)
require.True(t, ok, "installation_mode not found in web-bot-auth entry")
require.Equal(t, "force_installed", installationMode, "expected installation_mode to be force_installed")
// Find the web-bot-auth entry in the forcelist
var webBotAuthEntry string
for _, entry := range extensionInstallForcelist {
if entryStr, ok := entry.(string); ok && strings.Contains(entryStr, "web-bot-auth") {
webBotAuthEntry = entryStr
break
}
}
require.NotEmpty(t, webBotAuthEntry, "web-bot-auth entry not found in ExtensionInstallForcelist")

// Verify path
path, ok := webBotAuth["path"].(string)
require.True(t, ok, "path not found in web-bot-auth entry")
require.Equal(t, "/home/kernel/extensions/web-bot-auth", path, "expected path to be /home/kernel/extensions/web-bot-auth")
// Verify the entry format: "extension-id;update_url"
parts := strings.Split(webBotAuthEntry, ";")
require.Len(t, parts, 2, "expected web-bot-auth entry to have format 'extension-id;update_url'")

// Verify runtime_allowed_hosts
runtimeAllowedHosts, ok := webBotAuth["runtime_allowed_hosts"].([]interface{})
require.True(t, ok, "runtime_allowed_hosts not found in web-bot-auth entry")
require.Len(t, runtimeAllowedHosts, 1, "expected runtime_allowed_hosts to have 1 entry")
require.Equal(t, "*://*/*", runtimeAllowedHosts[0].(string), "expected runtime_allowed_hosts to contain *://*/*")
extensionID := parts[0]
updateURL := parts[1]

logger.Info("[test]", "extension_id", extensionID, "update_url", updateURL)
logger.Info("[test]", "result", "web-bot-auth policy verified successfully")
}

// Verify the extension directory exists
{
logger.Info("[test]", "action", "checking extension directory")
dirList, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/web-bot-auth/"})
require.NoError(t, err, "failed to list extension directory: %v", err)
logger.Info("[test]", "extension_directory_contents", dirList)

// Verify manifest.json exists (uploaded as part of the extension)
manifestContent, err := execCombinedOutput(ctx, "cat", []string{"/home/kernel/extensions/web-bot-auth/manifest.json"})
require.NoError(t, err, "failed to read manifest.json: %v", err)
require.Contains(t, manifestContent, "Web Bot Auth Mock", "manifest.json should contain extension name")

logger.Info("[test]", "result", "extension directory verified successfully")
}
}
Loading
Loading