Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX
rm -rf /tmp/ffmpeg*
EOT

FROM ghcr.io/onkernel/neko/base:3.0.8-v1.3.0 AS neko
FROM ghcr.io/kernel/neko/base:3.0.8-v1.3.0 AS neko
# ^--- now has event.SYSTEM_PONG with legacy support to keepalive
FROM node:22-bullseye-slim AS node-22
FROM docker.io/ubuntu:22.04
Expand Down Expand Up @@ -276,8 +276,8 @@ RUN set -eux; \
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
fi

# Install TypeScript, Playwright, Patchright globally
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx
# Install TypeScript, Playwright, Patchright, esbuild globally
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright esbuild

# setup desktop env & app
ENV DISPLAY_NUM=1
Expand All @@ -303,8 +303,18 @@ COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/service
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher

# Copy the Playwright executor runtime
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
# Copy and compile the Playwright daemon
COPY server/runtime/playwright-daemon.ts /tmp/playwright-daemon.ts
RUN esbuild /tmp/playwright-daemon.ts \
--bundle \
--platform=node \
--target=node22 \
--format=esm \
--outfile=/usr/local/lib/playwright-daemon.js \
--external:playwright-core \
--external:patchright \
--external:esbuild \
&& rm /tmp/playwright-daemon.ts

RUN useradd -m -s /bin/bash kernel

Expand Down
18 changes: 14 additions & 4 deletions images/chromium-headless/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ RUN set -eux; \
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
fi

# Install TypeScript, Playwright, Patchright globally
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx
# Install TypeScript, Playwright, Patchright, esbuild globally
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright esbuild

ENV WITHDOCKER=true

Expand All @@ -202,7 +202,17 @@ COPY images/chromium-headless/image/supervisor/services/ /etc/supervisor/conf.d/
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher

# Copy the Playwright executor runtime
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
# Copy and compile the Playwright daemon
COPY server/runtime/playwright-daemon.ts /tmp/playwright-daemon.ts
RUN esbuild /tmp/playwright-daemon.ts \
--bundle \
--platform=node \
--target=node22 \
--format=esm \
--outfile=/usr/local/lib/playwright-daemon.js \
--external:playwright-core \
--external:patchright \
--external:esbuild \
&& rm /tmp/playwright-daemon.ts

ENTRYPOINT [ "/usr/bin/wrapper.sh" ]
7 changes: 7 additions & 0 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"sync"
"time"

Expand Down Expand Up @@ -44,6 +45,12 @@ type ApiService struct {
// playwrightMu serializes Playwright code execution (only one execution at a time)
playwrightMu sync.Mutex

// playwrightDaemonStarting is an atomic flag to prevent concurrent daemon starts
playwrightDaemonStarting int32

// playwrightDaemonCmd holds the daemon process for cleanup
playwrightDaemonCmd *exec.Cmd

// policy management
policy *policy.Policy
}
Expand Down
210 changes: 130 additions & 80 deletions server/cmd/api/api/playwright.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,141 @@
package api

import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"sync/atomic"
"time"

"github.com/google/uuid"
"github.com/onkernel/kernel-images/server/lib/logger"
"github.com/onkernel/kernel-images/server/lib/oapi"
)

// ExecutePlaywrightCode implements the Playwright code execution endpoint
const (
playwrightDaemonSocket = "/tmp/playwright-daemon.sock"
playwrightDaemonScript = "/usr/local/lib/playwright-daemon.js"
playwrightDaemonStartup = 5 * time.Second
)

type playwrightDaemonRequest struct {
ID string `json:"id"`
Code string `json:"code"`
TimeoutMs int `json:"timeout_ms,omitempty"`
}

type playwrightDaemonResponse struct {
ID string `json:"id"`
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Stack string `json:"stack,omitempty"`
}

func (s *ApiService) ensurePlaywrightDaemon(ctx context.Context) error {
log := logger.FromContext(ctx)

if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil {
conn.Close()
return nil
}

if !atomic.CompareAndSwapInt32(&s.playwrightDaemonStarting, 0, 1) {
deadline := time.Now().Add(playwrightDaemonStartup)
for time.Now().Before(deadline) {
if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil {
conn.Close()
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for daemon to start")
}
defer atomic.StoreInt32(&s.playwrightDaemonStarting, 0)

log.Info("starting playwright daemon")

cmd := exec.Command("node", playwrightDaemonScript)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()

if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start playwright daemon: %w", err)
}

s.playwrightDaemonCmd = cmd

deadline := time.Now().Add(playwrightDaemonStartup)
for time.Now().Before(deadline) {
if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil {
conn.Close()
log.Info("playwright daemon started successfully")
return nil
}
time.Sleep(100 * time.Millisecond)
}

cmd.Process.Kill()
return fmt.Errorf("playwright daemon failed to start within %v", playwrightDaemonStartup)
}

func (s *ApiService) executeViaUnixSocket(ctx context.Context, code string, timeout time.Duration) (*playwrightDaemonResponse, error) {
conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 2*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to connect to daemon: %w", err)
}
defer conn.Close()

if err := conn.SetDeadline(time.Now().Add(timeout + 5*time.Second)); err != nil {
return nil, fmt.Errorf("failed to set deadline: %w", err)
}

reqID := uuid.New().String()
req := playwrightDaemonRequest{
ID: reqID,
Code: code,
TimeoutMs: int(timeout.Milliseconds()),
}

reqBytes, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqBytes = append(reqBytes, '\n')

if _, err := conn.Write(reqBytes); err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}

reader := bufio.NewReader(conn)
respLine, err := reader.ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}

var resp playwrightDaemonResponse
if err := json.Unmarshal(respLine, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

if resp.ID != reqID {
return nil, fmt.Errorf("response ID mismatch: expected %s, got %s", reqID, resp.ID)
}

return &resp, nil
}

func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.ExecutePlaywrightCodeRequestObject) (oapi.ExecutePlaywrightCodeResponseObject, error) {
// Serialize Playwright execution - only one execution at a time
s.playwrightMu.Lock()
defer s.playwrightMu.Unlock()

log := logger.FromContext(ctx)

// Validate request
if request.Body == nil || request.Body.Code == "" {
return oapi.ExecutePlaywrightCode400JSONResponse{
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
Expand All @@ -29,107 +144,42 @@ func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.Exe
}, nil
}

// Determine timeout (default to 60 seconds)
timeout := 60 * time.Second
if request.Body.TimeoutSec != nil && *request.Body.TimeoutSec > 0 {
timeout = time.Duration(*request.Body.TimeoutSec) * time.Second
}

// Create a temporary file for the user code
tmpFile, err := os.CreateTemp("", "playwright-code-*.ts")
if err != nil {
log.Error("failed to create temp file", "error", err)
if err := s.ensurePlaywrightDaemon(ctx); err != nil {
log.Error("failed to ensure playwright daemon", "error", err)
return oapi.ExecutePlaywrightCode500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: fmt.Sprintf("failed to create temp file: %v", err),
Message: fmt.Sprintf("failed to start playwright daemon: %v", err),
},
}, nil
}
tmpFilePath := tmpFile.Name()
defer os.Remove(tmpFilePath) // Clean up the temp file

// Write the user code to the temp file
if _, err := tmpFile.WriteString(request.Body.Code); err != nil {
tmpFile.Close()
log.Error("failed to write code to temp file", "error", err)
return oapi.ExecutePlaywrightCode500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: fmt.Sprintf("failed to write code to temp file: %v", err),
},
}, nil
}
tmpFile.Close()

// Create context with timeout
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

// Execute the Playwright code via the executor script
cmd := exec.CommandContext(execCtx, "tsx", "/usr/local/lib/playwright-executor.ts", tmpFilePath)

output, err := cmd.CombinedOutput()

resp, err := s.executeViaUnixSocket(ctx, request.Body.Code, timeout)
if err != nil {
if execCtx.Err() == context.DeadlineExceeded {
log.Error("playwright execution timed out", "timeout", timeout)
success := false
errorMsg := fmt.Sprintf("execution timed out after %v", timeout)
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Error: &errorMsg,
}, nil
}

log.Error("playwright execution failed", "error", err)

// Try to parse the error output as JSON
var result struct {
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Stack string `json:"stack,omitempty"`
}
if jsonErr := json.Unmarshal(output, &result); jsonErr == nil {
success := result.Success
errorMsg := result.Error
stderr := string(output)
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Error: &errorMsg,
Stderr: &stderr,
}, nil
}

// If we can't parse the output, return a generic error
success := false
errorMsg := fmt.Sprintf("execution failed: %v", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth distinguishing daemon vs code execution errors to make debugging easier

stderr := string(output)
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Success: false,
Error: &errorMsg,
Stderr: &stderr,
}, nil
}

// Parse successful output
var result struct {
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
}
if err := json.Unmarshal(output, &result); err != nil {
log.Error("failed to parse playwright output", "error", err)
success := false
errorMsg := fmt.Sprintf("failed to parse output: %v", err)
stdout := string(output)
if !resp.Success {
errorMsg := resp.Error
stderr := resp.Stack
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Success: false,
Error: &errorMsg,
Stdout: &stdout,
Stderr: &stderr,
}, nil
}

return oapi.ExecutePlaywrightCode200JSONResponse{
Success: result.Success,
Result: &result.Result,
Success: true,
Result: &resp.Result,
}, nil
}
Loading
Loading