Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ RUN set -eux; \
RUN add-apt-repository -y ppa:xtradeb/apps
RUN apt update -y && apt install -y chromium sqlite3

# Install Node.js 20.x
RUN set -eux; \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo use v22: curl -fsSL https://deb.nodesource.com/setup_22.x

apt-get install -y nodejs; \
rm -rf /var/lib/apt/lists/*

# Install TypeScript and Playwright globally
RUN npm install -g typescript tsx playwright && \
npx playwright install-deps chromium
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo remove npx playwright install-deps chromium -- we don't want to install playwright's chromium


# setup desktop env & app
ENV DISPLAY_NUM=1
ENV HEIGHT=768
Expand All @@ -183,6 +193,9 @@ 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

RUN useradd -m -s /bin/bash kernel

ENTRYPOINT [ "/wrapper.sh" ]
13 changes: 13 additions & 0 deletions images/chromium-headless/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ RUN set -eux; \
# Remove upower to prevent spurious D-Bus activations and logs
RUN apt-get -yqq purge upower || true && rm -rf /var/lib/apt/lists/*

# Install Node.js 20.x
RUN set -eux; \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo curl -fsSL https://deb.nodesource.com/setup_22.x

apt-get install -y nodejs; \
rm -rf /var/lib/apt/lists/*

# Install TypeScript and Playwright globally
RUN npm install -g typescript tsx playwright && \
npx playwright install-deps chromium

ENV WITHDOCKER=true

# Create a non-root user with a home directory
Expand All @@ -93,4 +103,7 @@ 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

ENTRYPOINT [ "/usr/bin/wrapper.sh" ]
98 changes: 98 additions & 0 deletions server/cmd/api/api/playwright.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package api

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"time"

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

type ExecutePlaywrightRequest struct {
Code string `json:"code"`
TimeoutSec *int `json:"timeout_sec,omitempty"`
}

type ExecutePlaywrightResult struct {
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
}
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo request / result types should be pulled from the auto-generated types after running make oapi-generate


func (s *Service) ExecutePlaywrightCode(w http.ResponseWriter, r *http.Request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo after running make oapi-generate, there should be a new method for this endpoint on strictserverinterface in oapi.go, of which Service is an implementor of. use that

log := logger.FromContext(r.Context())

var req ExecutePlaywrightRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}

if req.Code == "" {
http.Error(w, "code is required", http.StatusBadRequest)
return
}

timeout := 30 * time.Second
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo 30 seconds is too low of a default timeout, make it 60

if req.TimeoutSec != nil && *req.TimeoutSec > 0 {
timeout = time.Duration(*req.TimeoutSec) * time.Second
}

ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()

cmd := exec.CommandContext(ctx, "tsx", "/usr/local/lib/playwright-executor.ts", req.Code)
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo seems awkward to pass the code as a string argument. i think a better approach would be to make playwright-executor.ts take in a path to a file that contains the user code


output, err := cmd.CombinedOutput()

if err != nil {
if ctx.Err() == context.DeadlineExceeded {
log.Error("playwright execution timed out", "timeout", timeout)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(ExecutePlaywrightResult{
Success: false,
Error: fmt.Sprintf("execution timed out after %v", timeout),
})
return
}

log.Error("playwright execution failed", "error", err, "output", string(output))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

var result ExecutePlaywrightResult
if jsonErr := json.Unmarshal(output, &result); jsonErr == nil {
json.NewEncoder(w).Encode(result)
} else {
json.NewEncoder(w).Encode(ExecutePlaywrightResult{
Success: false,
Error: fmt.Sprintf("execution failed: %v", err),
Stderr: string(output),
})
}
return
}

var result ExecutePlaywrightResult
if err := json.Unmarshal(output, &result); err != nil {
log.Error("failed to parse playwright output", "error", err, "output", string(output))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(ExecutePlaywrightResult{
Success: false,
Error: fmt.Sprintf("failed to parse output: %v", err),
Stdout: string(output),
})
return
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}
Copy link

Choose a reason for hiding this comment

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

Bug: Temp Directory Mismatch Causes Unused Subdirectory

The init() function creates a /tmp/playwright directory, but os.CreateTemp uses the system's default temp directory. This makes the playwright subdirectory creation in init() unused.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo address this by removing this init entirely

94 changes: 94 additions & 0 deletions server/cmd/api/api/playwright_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package api

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"

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

func TestExecutePlaywrightRequest_Validation(t *testing.T) {
s := &Service{}

tests := []struct {
name string
requestBody string
expectedStatus int
checkError bool
}{
{
name: "empty code",
requestBody: `{"code": ""}`,
expectedStatus: http.StatusBadRequest,
checkError: true,
},
{
name: "missing code field",
requestBody: `{}`,
expectedStatus: http.StatusBadRequest,
checkError: true,
},
{
name: "invalid json",
requestBody: `{invalid}`,
expectedStatus: http.StatusBadRequest,
checkError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/playwright/execute", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")

testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := logger.AddToContext(context.Background(), testLogger)
req = req.WithContext(ctx)

w := httptest.NewRecorder()

s.ExecutePlaywrightCode(w, req)

if w.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}

func TestExecutePlaywrightRequest_ValidCode(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

remove this test and add a new one to e2e_chromium_test.go that spins up the container and runs this api request against the running container, validating that the playwright script output is returned

t.Skip("Skipping integration test that requires Playwright to be installed")

s := &Service{}

reqBody := ExecutePlaywrightRequest{
Code: "return 'hello world';",
}

body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/playwright/execute", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")

testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := logger.AddToContext(context.Background(), testLogger)
req = req.WithContext(ctx)

w := httptest.NewRecorder()

s.ExecutePlaywrightCode(w, req)

if w.Code != http.StatusOK {
t.Logf("Response body: %s", w.Body.String())
}

var result ExecutePlaywrightResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Logf("Could not parse response as ExecutePlaywrightResult, this is expected if Playwright is not available")
}
}
3 changes: 3 additions & 0 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ func main() {
strictHandler := oapi.NewStrictHandler(apiService, nil)
oapi.HandlerFromMux(strictHandler, r)

// Add custom Playwright execution endpoint
r.Post("/playwright/execute", apiService.ExecutePlaywrightCode)

// endpoints to expose the spec
r.Get("/spec.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oai.openapi")
Expand Down
62 changes: 62 additions & 0 deletions server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,31 @@ paths:
$ref: "#/components/responses/BadRequestError"
"500":
$ref: "#/components/responses/InternalError"
/playwright/execute:
post:
summary: Execute Playwright/TypeScript code against the browser
description: |
Execute arbitrary Playwright code in a fresh execution context against the browser running
on localhost:9222. The code has access to 'page', 'context', and 'browser' variables.
The result of the code execution is returned in the response.
operationId: executePlaywrightCode
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ExecutePlaywrightRequest"
responses:
"200":
description: Code executed successfully
content:
application/json:
schema:
$ref: "#/components/schemas/ExecutePlaywrightResult"
"400":
$ref: "#/components/responses/BadRequestError"
"500":
$ref: "#/components/responses/InternalError"
components:
schemas:
StartRecordingRequest:
Expand Down Expand Up @@ -1487,6 +1512,43 @@ components:
description: Indicates success.
default: true
additionalProperties: false
ExecutePlaywrightRequest:
type: object
description: Request to execute Playwright code
required: [code]
properties:
code:
type: string
description: |
TypeScript/JavaScript code to execute. The code has access to 'page', 'context', and 'browser' variables.
Example: "await page.goto('https://example.com'); return await page.title();"
timeout_sec:
type: integer
description: Maximum execution time in seconds. Default is 30.
default: 30
minimum: 1
maximum: 300
additionalProperties: false
ExecutePlaywrightResult:
type: object
description: Result of Playwright code execution
required: [success]
properties:
success:
type: boolean
description: Whether the code executed successfully
result:
description: The value returned by the code (if any)
error:
type: string
description: Error message if execution failed
stdout:
type: string
description: Standard output from the execution
stderr:
type: string
description: Standard error from the execution
additionalProperties: false
responses:
BadRequestError:
description: Bad Request
Expand Down
48 changes: 48 additions & 0 deletions server/runtime/playwright-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { chromium } from 'playwright';

async function main() {
const userCode = process.argv[2];
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo this should pull user code from a file passed in argv[2]


if (!userCode) {
console.error('Usage: tsx playwright-executor.ts <code>');
process.exit(1);
}

let browser;
let result;

try {
browser = await chromium.connectOverCDP('http://localhost:9222');
Copy link
Contributor

Choose a reason for hiding this comment

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

@tembo this needs to be ws:// not http://

const contexts = browser.contexts();
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
const pages = context.pages();
const page = pages.length > 0 ? pages[0] : await context.newPage();

const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const userFunction = new AsyncFunction('page', 'context', 'browser', userCode);
result = await userFunction(page, context, browser);

if (result !== undefined) {
console.log(JSON.stringify({ success: true, result: result }));
} else {
console.log(JSON.stringify({ success: true, result: null }));
}
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message,
stack: error.stack
}));
process.exit(1);
} finally {
if (browser) {
try {
await browser.close();
} catch (e) {
// Ignore errors when closing CDP connection
}
}
Copy link

Choose a reason for hiding this comment

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

Bug: Shared Browser Process Terminated Prematurely

The script calls browser.close() after connecting to an existing Chromium instance via connectOverCDP. This terminates the shared browser process, which other parts of the system may rely on, potentially causing failures for subsequent operations.

Fix in Cursor Fix in Web

}
}

main();
Loading