GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — GitHub - 11ways/sketerm-java: A library for working with Sketerm from java

BranchesTags
Open more actions menu
 
 

Latest commit

 

History

34 Commits
34 Commits

Folders and files

NameName
Last commit message
Last commit date
gradle/wrapper
gradle/wrapper
 
 
src
src
 
 
.gitignore
.gitignore
 
 
README.md
README.md
 
 
build.gradle
build.gradle
 
 
gradlew
gradlew
 
 
gradlew.bat
gradlew.bat
 
 
settings.gradle
settings.gradle
 
 

Repository files navigation

sketerm-java

A typed Java SDK for driving the Sketerm MCP server.

It speaks MCP over newline-delimited JSON to a sketerm mcp child process. Protoblast is the only runtime dependency: Dry supplies the JSON codec and JobRunner the reader and stderr threads. There is no Jackson, no Reactor and no SLF4J.

Layering

Each layer knows only the one below it, so a future HTTP transport is a swap and not a rewrite.

Package Responsibility
json Json, a facade over one shared Dry: parseObject, an NDJSON-safe write, and typed getters that fail with the offending key named
process SketermProcess: argv/cwd/env, UTF-8 line streams and a 200-line stderr tail, supervised by protoblast's Subprocess. close() sends end-of-file (Sketerm then stops its own helpers and flushes profile cookies), waits up to the close timeout, and only then stops the WHOLE process tree (SIGTERM, SIGKILL after STOP_GRACE); a crashed JVM never orphans a browser
rpc SketermTransport (the seam), StdioTransport (newline framing), JsonRpcConnection (id correlation, per-call timeouts, EOF drains every pending call with the child's exit code and stderr tail)
mcp McpSession (initialize, tools/list, tools/call, ping), ToolDescriptor, ToolResult, Content, ToolException
api The Playwright-SHAPED synchronous face over the browser tool group: Sketerm, Browser, Page, Snapshot/Ref, NetworkPolicy/PolicyStatus, Evidence, and one typed exception per error code

The result decode rule

Sketerm has two structured-result shapes in the wild, so ToolResult.decode applies one rule:

  1. use structuredContent when the server sent it;
  2. otherwise, if the first content block is text whose trimmed form starts with { and parses as a JSON object, adopt that (what the installed 0.1.3 build emits);
  3. otherwise the result is prose only and structured() is null.

A current sketerm mcp answers text only unless it runs with --structured, so SketermOptions.command() always passes it. A server older than the flag refuses it as an unknown flag (exit 2) before it speaks MCP, and Sketerm.launch relaunches it once with legacyCommand(); such a server sends structuredContent anyway. A prose-only answer where the api layer needs facts is a ProtocolMismatchException that names the flag.

Tool failures arrive as a successful JSON-RPC result carrying isError, a channel distinct from a JSON-RPC error object. ToolResult.orThrow(name) turns one into a ToolException, reading structuredContent.error's code, message and retryable when the newer server shape is present. A text-only error reads error CODE: message ( (retryable) after a retryable code), which gives the same code and flag.

Usage

SketermProcess process = SketermProcess.start(
        List.of("sketerm", "mcp", "--tools", "browser,core"), null, null);

try (McpSession session = McpSession.initialize(
        new JsonRpcConnection(new StdioTransport(process)))) {

    for (ToolDescriptor tool : session.listTools()) {
        System.out.println(tool.name());
    }

    ToolResult result = session.callToolOrThrow("web_open", Map.of("url", "about:blank"));
    System.out.println(Json.str(result.structured(), "url"));
}

The api layer

Sketerm.launch composes process, transport, connection and session into one AutoCloseable, and its browser() opens views. It is shaped like Playwright, not compatible with it: everything is synchronous, and a Ref is a semantic id from the server's own accessibility tree rather than a CSS selector.

SketermOptions options = SketermOptions.builder()
        .binaryPath("sketerm")
        .toolGroups("browser,core")
        .defaultTimeout(Duration.ofSeconds(30))
        .build();

try (Sketerm sketerm = Sketerm.launch(options)) {

    Page page = sketerm.browser().openPage("https://example.com/");

    // web_open already sent the first tree; asking again would only get a delta
    Snapshot snapshot = page.lastSnapshot();
    Ref moreInfo = snapshot.find("More information").orElseThrow();

    page.click(moreInfo);
    page.waitFor(WaitFor.LOAD);

    Article article = page.read();
    System.out.println(article.markdown());

    System.out.println(page.evaluate("document.querySelectorAll('a').length"));

    page.screenshot().writeTo(Path.of("page.png"));

    for (NetworkLog.NetworkRequest request : page.networkRequests()) {
        System.out.println(request.method() + " " + request.status() + " " + request.url());
    }
}

page.navigation() is what the last web_open or web_navigate of the view produced (Navigation: a NavigationOutcome, the main document's HTTP status, the final url and its redirect hops), so a 429 or a refused connection is never mistaken for a page. waitFor(WaitFor.SELECTOR, css) waits for an element (a selector list waits for the first of several), WaitFor.NETWORK_IDLE for a quiet network, and a NetworkLog.NetworkRequest links a redirect hop to the one it continues (redirect, prevSeq), a hop the policy refused included. A BLOCKED navigation names why the engine refused it (blockedReason: the filter list or a policy rule).

Sketerm.connect(options, transport) completes the same handshake over a transport someone else opened (a scripted server in a test, a future non-stdio transport); its process() is then null. SketermOptions.startTimeout bounds the launch's handshake on its own (a caller with a deadline gives what is left; a start past it is a CallTimeoutException), and sketerm.isAlive() says whether the server still answers (not closed, its connection not failed, its child alive), so a pool notices a crashed browser before using it. browser().isAvailable() reads the capabilities report's web flag: whether this server can run the web tools at all, the preflight before opening anything. browser().isHeadless() says whether they drive the server's own headless helper rather than a GUI the user owns (the web_gui grant, where policies, profiles and capture are refused); a server that must never touch the user's browser launches with SKETERM_MCP_WEB_GUI=0, which overrides a config grant.

Review tools

Four more page methods cover the reads and inputs an accessibility or layout review needs:

KeyResult keys = page.key("Tab Tab Enter");           // TRUSTED key chords, the path a keystroke rides
Viewport size = page.resize(390, 844);                // in place; 320-3840 by 240-2160 logical pixels
ConsoleLog log = page.console();                      // every console line still held for the view
ConsoleLog newer = page.console(log.lastId(), 50);    // only what arrived since
Inspection review = page.inspect();                   // landmarks, focus, control names, overflow, errors

key (chords) and act on a Ref are the inputs a script normally needs. A person driving a headless view by hand (a remote viewer) needs raw input at coordinates and the painted frames; a Sketerm that advertises web_input and web_frames (browser.supportsInput(), browser.supportsFrames()) takes both:

page.input(InputEvent.click(120, 80));                                // a down and an up edge at one point
page.input(InputEvent.text("hello"), InputEvent.press("Enter"), InputEvent.wheel(400, 300, 0, 240));
page.input(InputEvent.keyDown("Shift"), InputEvent.keyUp("Shift"));    // raw edges; a viewer releases them
Frame frame = page.frame(0, Duration.ofSeconds(2));                   // the current paint
Frame next = page.frame(frame.serial(), FrameFormat.JPEG, 70, 960, Duration.ofMillis(120));
if (!next.unchanged()) show(next.bytes());                            // only a newer paint carries pixels

Coordinates are viewport (CSS) pixels; a frame reports its own pixel size and the viewport it shows, so a viewer scales its surface coordinates by the two. input takes at most Page.MAX_INPUT_EVENTS events per call, in order; a refused event stops the batch and the result says how many were sent. frame long-polls: it answers as soon as a paint newer than since exists, or unchanged once the timeout passes. Sketerm answers one call at a time, so a long frame poll delays every other call on that session.

Every call names its view handle explicitly (the pane argument), so several pages can be driven from one session without the server's "current view" ever deciding for you.

Binary page streams

Headless pages default to 60 FPS, or the MCP config's web_max_fps. Set an explicit per-view cap with OpenOptions.defaults().withMaxFps(30), or use page.stream(listener, 15) to override an existing view at stream open. Caps are integers from 1 through 240 and control CEF's own paint scheduler, not Java callback timing. An omitted stream cap preserves the view's rate; a stream override remains after that stream closes. GUI monitor-driven pacing is unchanged. browser.supportsMaxFps() reads web_max_fps; explicit caps fail closed on unsupported servers/helpers, and the SDK requires the applied-rate echo. Other fluent open options preserve maxFps.

Configure the server with [mcp] web_max_fps = 30; a selected [mcp.<name>] section can override it. capabilities.web_default_max_fps reports the resolved headless default before the helper starts.

browser.supportsStreams() reads capabilities.web_stream (missing/null means false). A supported headless view can push raw paints through a local Unix socket independently of MCP:

PageStream stream = page.stream(new PageStream.Listener() {
    @Override
    public void surfaceSize(PageStream stream, PageStream.SurfaceSize size) {
        resizeSurface(size.pixelWidth(), size.pixelHeight());
    }

    @Override
    public void damage(PageStream stream, PageStream.Damage damage) {
        uploadPackedBgra(damage.x(), damage.y(), damage.width(), damage.height(), damage.pixels());
    }

    @Override
    public void frameEnd(PageStream stream, PageStream.FrameEnd frame) {
        presentSurface();
        stream.ack(frame.serial());
    }
});
stream.input(InputEvent.click(120, 80));
stream.input(InputEvent.press("Enter"), InputEvent.text("hello"));
stream.key(KeyAction.PRESS, KeyCode.ENTER); // protoblast's shared physical-key vocabulary
stream.resize(640, 480);
stream.focus();
stream.blur();
stream.close();
  • Page.stream(listener) installs the listener before AUTH; callbacks may run before the method returns and receive their stream explicitly. There is no listener-less overload that could lose the initial surface or damage. Callbacks run serially on a protoblast JobRunner reader.
  • Damage.pixels() is a read-only, reusable-buffer view of exactly width * height * 4 packed BGRA-premultiplied bytes, with no stride padding; bands carry at most 1 MiB of pixel bytes. Damage, cursor-image and Opus buffers are valid only inside their callback. Upload or copy them before returning; retaining the ByteBuffer does not retain its contents.
  • SurfaceSize reports physical pixel size and logical input size separately. Pointer coordinates use the logical viewport. Cursor carries either a UTF-8 name or a borrowed BGRA image with its hotspot; cursor pixels are premultiplied like damage pixels. Audio carries unsigned ptsUs (CEF capture time in microseconds with an unspecified epoch), sample rate, channel count, per-channel sample count and a borrowed Opus packet; the SDK does not decode or play audio.
  • Audio normally starts at the next audible start after stream_open, not immediately when attaching to an already-playing page; an already-active capture can be attached immediately. hasAudio() reports stream support, not whether packets are currently arriving. CEF capture diverts the page's normal audio output into the stream and cannot be switched off mid-capture; it remains active until the page has been quiet for two seconds. Closing the stream is not an immediate restoration of normal audio output while the page keeps playing.
  • The listener must explicitly ack(serial) after presenting a FrameEnd. ACK is cumulative through that delivered serial; no automatic acknowledgement is sent. The server permits two unacknowledged frames, then unions pending damage and sends the latest pixels when credit opens. Java long preserves all unsigned serial/timestamp bits; use unsigned comparison when needed.
  • Input, resize, focus/blur and ACK use binary messages, not web_input or another MCP request. Input remains responsive while an MCP wait is pending. KeyAction.PRESS sends down and up; blur and disconnect release held keys/buttons at the helper. stream.close() closes only the stream, not the Page. Page and Sketerm shutdown close their owned streams.
  • stream.key(action, KeyCode, modifiers...) translates supported protoblast physical key codes to the helper's US-layout key names; unsupported codes are refused before sending. Use text events for layout-dependent characters. Binary reading and writing use protoblast's shared BinaryReader and BinaryWriter; the decoder still borrows its bounded receive buffer.
  • Text events carry at most PageStream.MAX_TEXT (4096) UTF-8 bytes, and raw key names at most PageStream.MAX_KEY_NAME (64) UTF-8 bytes; these are byte limits, not Java character counts. An oversized or malformed event refuses the entire input batch before its first edge is sent.
  • Stream V1 uses little-endian u32 length including the tag byte, capped at 4 MiB, and exactly 32 lowercase ASCII hex token bytes in AUTH. Unknown tags, invalid UTF-8, truncation, incorrect lengths, out-of-surface damage, overflow and incompatible endpoint facts fail closed. The closed(stream, failure) callback reports termination; failure() is null for orderly EOF or local close. A stream socket must be reachable on the JVM's own machine; no remote socket forwarding or Java-side pixel JSON encoding is implied.

The first semantic snapshot is opportunistic, not the view's identity. A slow web_open may return a still-live Page whose lastSnapshot() is null; wasOpeningSettled() and openingSnapshotError() preserve that outcome, and a later snapshot(FULL) can recover. If the returned handle is not actually present in web_tabs, the SDK closes it best-effort and throws UnavailableException instead of exposing a phantom page.

Operation timeouts and JSON-RPC deadlines are separate on the wire. The SDK keeps the RPC request alive beyond the operation's timeout_ms so Sketerm's bounded timeout result can arrive. If the RPC deadline of a tool call itself expires, it throws UnansweredCallException, a TimeoutException whose outcomeUnknown() is true: the request was sent, so a mutating call must not be retried blindly, and the server did not answer at all, so a pool should not trust that browser again. The handshake (initialize) still throws the transport's CallTimeoutException.

Vocabularies and refusals

The tool schemas own the vocabularies; the enums mirror them one for one and carry their wire token (Action, NavigateAction, NavigationOutcome, WaitFor, ScrollTo, SnapshotMode, QueryKind, NetworkAction, ErrorCode, ProfileKind, ResourceType, UrlScheme, PolicySource, DenialReason, DownloadState, EvalFormat, CaptureState, CaptureTruncation, BodyEncoding, BodyPart). An unknown token fails closed rather than being folded into a neighbour.

Failures are typed off structuredContent.error.code by one switch in SketermApiException.from: InvalidArgsException, NotFoundException, UnavailableException, TimeoutException, RefusedException, ConflictException, IoFailedException, UnknownToolException, FailedException, plus isRetryable() on all of them. Four more are the api layer's own:

  • StaleRefException - a Ref from a document the page has left, refused before the call goes out, and also what the server's own "stale reader id" / "unknown id" refusal decodes to.
  • ProtocolMismatchException - the answer had no structuredContent, or no view handle in it. That is a Sketerm predating the result-shape migration, and it is named as such rather than guessed at.
  • AmbiguousMatchException - Snapshot.find matched several nodes. An exact name (ignoring case) wins over names that merely contain the text, so find("button", "Subscribe") never lands on "Unsubscribe"; two nodes at the deciding level are refused, never guessed. matches() lists them; act on one by its id.
  • UnansweredCallException - a tool call Sketerm did not answer before its client-side deadline (above).

Lifecycle and browsing profiles

page.close() calls web_close and decodes a CloseResult (closed, remaining, current, profile, profileReleased). What it destroys depends on the backend and the result says which: headless it ends a helper view, but with a GUI attached it closes the USER'S pane, exactly as close_pane does (closedAPane()). Every later call on that Page is refused locally with a PageClosedException, because the handle is free to be handed to the next view somebody opens.

Headless views can hold an isolated browsing identity:

Browser browser = sketerm.browser();

if (browser.supportsProfiles()) {

    // Its own cookie jar and cache; logins survive the close and a server restart
    Page work = browser.openPage("https://example.com/", OpenOptions.inProfile("work"));
    // ... sign in, then commit every persistent jar to disk while the view stays open
    browser.saveProfiles();
    work.close();

    // A throwaway identity instead, destroyed with the view
    Page once = browser.openPage("https://example.com/", OpenOptions.ephemeralIdentity());
    once.close();

    for (BrowserProfile profile : browser.profiles().profiles()) {
        System.out.println(profile.name() + " " + profile.views() + " views");
    }

    browser.resetProfile("work");
}

The rules the wire contract insists on, mirrored here:

  • profiles are HEADLESS ONLY; with a GUI attached the identity containers belong to the user and both the open and the profile tools refuse;
  • the refusal is FAIL CLOSED - nothing is opened and no page is loaded, never a silent fall back to the shared jar - so UnavailableException from openPage means there is no view;
  • profile and ephemeral are opposite answers to the same question and OpenOptions refuses both at build time, as the server does at call time;
  • a name is [a-z0-9_-]{1,64} minus default and none, checked client-side by ProfileNames so a doomed call never goes out;
  • SESSION cookies never persist: they die with the browser process, profile or not;
  • a context id is opaque and is retired by a reset, so a profile is addressed by NAME everywhere;
  • supportsProfiles() reads the capabilities report's web_profiles flag, the preflight before offering the feature at all.

Enforced network policy

A headless view can be opened under a policy the browser engine enforces itself, deciding every request before it leaves the process:

NetworkPolicy policy = NetworkPolicy.builder()
        .allowHosts("example.com", "cdn.example.com")
        .allowSubresourceHosts("static.example.com")
        .blockTypes(ResourceType.MEDIA, ResourceType.FONT)
        .allowSchemes(UrlScheme.HTTPS)
        .maxRequests(60)
        .maxBytes(8_000_000L)
        .maxNavigations(4)
        .deadline(Duration.ofSeconds(30))
        .build();

Page page = browser.openPage("https://example.com/", OpenOptions.withNetworkPolicy(policy));

PolicyStatus status = page.policy();
System.out.println(status.requests() + " requests, " + status.bytes() + " bytes");
System.out.println(status.denied(DenialReason.SUB_HOST) + " subresource hosts refused");

// A live policy can only ever be narrowed; a field that would widen it is named, not applied
PolicyUpdate update = page.tightenPolicy(NetworkPolicy.builder()
        .blockTypes(ResourceType.IMAGE)
        .maxRequests(20)
        .build());

System.out.println(update.tightened() + " narrowed, " + update.ignored() + " refused as loosenings");

// And a profile can carry a session default for every later open in it
browser.setProfilePolicy("work", policy);

The rules the wire contract insists on, mirrored here:

  • policies are HEADLESS ONLY, and are installed at OPEN and never added to a live view, whose earlier requests would predate them;
  • the refusal is FAIL CLOSED: a helper without the net-policy capability answers unavailable and NOTHING is opened, never a view running unpoliced, and past the helper's policied-view cap it is a conflict instead;
  • budgets LATCH, permanently and per view. Past the first one that is hit, web_navigate, web_act, web_eval and a load wait all throw RefusedException (non-retryable), while read tools keep answering and carry the fact - so page.isPolicyExhausted() and page.policyExhaustedReason() are up to date after any call, and page.policy() has the accounting;
  • max_bytes is accounted at response completion, so the response that CROSSES the cap completes and the NEXT request is refused; a redirect hop counts as a navigation;
  • nothing is durable: PolicyStatus.durable() is always false, because a policy and a profile default live for this MCP server's lifetime by design;
  • host entries are ASCII/punycode names or complete IP literals, optionally host:port or [IPv6]:port (ports 1–65535). Names match their subdomains; IP literals match exactly (equivalent spellings included), never as suffixes. *, schemes, paths, userinfo, scoped IPv6, malformed addresses and numeric fragments such as 0.1 are refused locally by PolicyHosts;
  • explicit ports apply in every mode. A bare entry permits all ports ordinarily, but only the scheme's default port in untrusted mode. An omitted/empty top-level list in untrusted mode defaults to the opened URL's host and effective port.

The vocabularies mirror the schema enums: ResourceType, UrlScheme, PolicySource and DenialReason. exhausted_reason is derived from members whose latches() is true, including POLICY_REFUSED; unknown reasons fail closed even when their refusal count is zero.

Untrusted loading and colour scheme

Minimum Sketerm build: commit 22c4b452 ("web: untrusted headless mode and native media emulation") or later, whose version field is 0.2.1. No release tag contains that commit at the time of writing; an older binary reporting 0.2.1 is not sufficient. Check capability facts and use matching server/helper builds. Ordinary policies keep their existing wire shape and behavior.

Browser browser = sketerm.browser();
if (!browser.supportsUntrusted()) {
    throw new IllegalStateException("This renderer requires Sketerm untrusted loading");
}
NetworkPolicy policy = NetworkPolicy.builder()
        .untrusted()                         // policy.untrusted:true; untrusted(boolean) also exists
        .allowHosts("content.example:443")
        .allowSchemes(UrlScheme.HTTPS)
        .deadline(Duration.ofSeconds(30))
        .build();
Page page = browser.openPage("https://content.example/", OpenOptions.viewport(1200, 800)
        .withEphemeral()                     // required; shared and named identities are refused
        .withRoute("direct")                 // required; omitted route also means direct
        .withPolicy(policy)
        .withColorScheme(ColorScheme.LIGHT)  // LIGHT -> light; DARK -> dark
        .withTimeout(Duration.ofSeconds(30)));
assert page.isPolicyActive();
assert page.policy().untrusted();             // fresh, correlated enforcement attestation
  • browser.supportsUntrusted() reads capabilities.web_untrusted; missing/null/false means no support. This is a Linux headless build/platform fact, not proof of a particular installation.
  • browser.supportsPolicyAcknowledgement() reads capabilities.web_policy_ack. Missing/null means false; null is normal before the first helper handshake, so do not require this pre-open. Each untrusted open independently requires the helper's verified untrusted-web and net-policy-ack capabilities and successful correlated installation acknowledgement before browser creation.
  • openPage preflights untrusted support, requires an active untrusted echo from web_open, then reads web_policy and verifies its restricted enforced facts. It throws a typed exception and closes the returned handle best-effort if the attestation is missing or disagrees. It never retries with ordinary loading. isPolicyActive() cannot report an unacknowledged untrusted install as active; PolicyStatus.untrusted() reports verified enforcement, not requested mode.
  • Untrusted policies require ephemeral identity, a direct, tor or proxy: route and HTTP/HTTPS-only schemes. Java rejects invalid combinations with InvalidArgsException before a call. Mode cannot be set in either direction on a live patch (omit untrusted entirely), and untrusted policies cannot be named-profile defaults. Set .withEphemeral() before attaching an untrusted policy.
  • withRoute takes the grammar in Routes.GRAMMAR: direct, tor, via:host, on:host, or a forward proxy you run, proxy:socks5h://HOST:PORT or proxy:http://HOST:PORT. Unknown routes fail locally, and so do socks5:// (it would resolve page hosts locally), credentials, paths and missing ports. An untrusted open without a route sends route:"direct"; with one, it sends that route, and openPage refuses (closing the view) when the server's echo names another route, or names none for a proxy route.

Proxy routes

Page page = browser.openPage("http://abc.folio.localhost:4420/", OpenOptions.ephemeralIdentity()
        .withRoute("proxy:socks5h://127.0.0.1:1080")   // or proxy:http://127.0.0.1:3128
        .withPolicy(NetworkPolicy.builder().untrusted()
                .allowHosts("abc.folio.localhost:4420")
                .build()));

Every request of the view goes through that proxy, which resolves every host and decides what is reachable: a loopback render host is served through it too, there is no bypass list, and allowPrivateAddresses does not need to be set for it. SOCKS5 carries the hostname (ATYP 3); an HTTP proxy gets a CONNECT for https and an absolute-form request for http. An unreachable proxy fails the load and nothing falls back to a direct connection. In untrusted mode the restricted loader connects to the proxy's address alone, and the web_policy attestation reads http_broker:"route-proxy-only", which PolicyStatus.untrusted() checks against the view's route. Views opened on the same proxy url share one browser instance. Needs a Sketerm build with capabilities.web_route_proxy; an older server refuses the route text and opens nothing.

Route exits (connections outside the browser)

RouteExit exit = sketerm.routeExit("via:me@fra-1");   // needs sketerm.supportsRouteExit()
String proxy = exit.proxy();                          // socks5h://127.0.0.1:PORT; null for direct

web_route_exit hands a client that makes its own connections (an HTTP client beside the browser) the proxy a route leaves through: a via: host's loopback SOCKS5 bridge (kept and shared per host for the server's life, DNS on the host), the proxy a tor or proxy: route names, or none for direct. on: has no exit (UnavailableException). A bridge connects in the background: an unreachable host refuses every CONNECT, and the next ask makes a new one (reused() false). ScriptedSketerm answers it after exitingThrough(proxyUrl).

  • withColorScheme is headless-only and requires verified web-emulation. Sketerm applies it before initial loading and requires a successful native media acknowledgement. The SDK checks the returned colour echo, so an older server ignoring the option fails closed with ProtocolMismatchException. Null clears the preference; every other fluent option preserves it.
  • Restricted loading confines native Internet sockets, including WebSocket, WebRTC and WebTransport, uses an actual-address-validating HTTP broker, persists nothing, disables kernel core dumps and independently supervises helper cleanup. JavaScript constructors may still exist: test network reachability rather than their presence.
  • Typed refusal accounting includes RESOLVED_PRIVATE_ADDRESS, UNTRUSTED_HTTP, UNTRUSTED_TRANSPORT, UNTRUSTED_BROKER, UNTRUSTED_TIMEOUT, UNTRUSTED_QUEUE_FULL, URL_TOO_LONG, MALFORMED_URL and POLICY_REFUSED through PolicyStatus.denied(reason) and NetworkLog.NetworkRequest.reason().

Folio development/test origins work in untrusted mode. Sketerm explicitly honors .allowPrivateAddresses(true) after DNS resolution as well as for literals. For http://abc.folio.localhost:4420/, use .allowHosts("abc.folio.localhost:4420") and .allowSchemes(UrlScheme.HTTP) with that opt-in. Use the concrete host, not *.folio.localhost; folio.localhost:4420 also admits its subdomains. The scheme/host/port checks and native socket restrictions remain active. Without the opt-in loopback-resolving origins are refused. The ordinary policy's private-address check remains literal-only.

Downloads and bulk results

Bytes leave a page in two ways, and neither passes through the session. page.download fetches a url INSIDE this view's browser, so its cookies, session and route carry and a file behind a login needs no token of its own; page.evaluateToFile writes a whole eval result to disk.

if (sketerm.browser().supportsDownloads()) {

    Download one = page.download("https://example.com/report.pdf", Path.of("/tmp/report.pdf"));
    System.out.println(one.state() + " " + one.bytes() + " " + one.sha256());

    DownloadBatch batch = page.download(urls, Path.of("/tmp/out"));
    System.out.println(batch.completed() + " done, " + batch.failed() + " failed");

    for (Download known : page.downloads().downloads()) {
        System.out.println(known.path() + " " + known.state());
    }
}

EvaluatedFile rows = page.evaluateToFile("[...document.links].map(a => a.href)",
        Path.of("/tmp/rows.json"));
System.out.println(rows.bytes() + " bytes of " + rows.format());

The rules the wire contract insists on, mirrored here:

  • a TIMED_OUT file is DATA, not a failure - the call's budget ran out while the transfer was still running - so only FAILED says the file will never arrive, and page.downloads() can say later what became of it;
  • a batch is capped at DownloadBatch.MAX_URLS (64) and its files are fetched ONE AT A TIME, so the urls the budget never reached come back NOT_STARTED and the call is repeated with what is left;
  • a batch is never all-or-nothing: one bad url is one failed entry beside the files that landed;
  • page.downloads() LISTS what this view knows, page-initiated downloads included - those land in the user's XDG download directory and are the only way a save button's file is found afterwards;
  • every destination is an absolute path on the machine running the server, refused here before the call goes out;
  • supportsDownloads() reads the capabilities report's web_downloads flag, the preflight before offering the feature at all.

page.printPdf prints a view to a PDF file with its own browser (vector text, embedded fonts, the page's print CSS); the answer names the path, size and SHA-256. PrintOptions.cssPage() lets the document's @page rule decide the sheet and drops the engine's margins (a 1920x1080px slide prints as one 1440x810pt page). supportsPrintPdfCssPage() is its preflight, the report's web_print_pdf_css_page. Sketerm reports it only once the headless helper started: before that it is null and answers true, because nobody can know yet. Ask again with a view open for the helper's own answer; a helper too old for the layout refuses it at print (UnavailableException) rather than print on the wrong paper.

if (sketerm.browser().supportsPrintPdfCssPage()) {
    PdfFile pdf = page.printPdf(Path.of("/tmp/slides.pdf"), PrintOptions.cssPage());
    System.out.println(pdf.bytes() + " bytes, sha256 " + pdf.sha256());
}

Response-body capture

A headless view can record the RESPONSES its page receives - the JSON a single-page app fetches from its own API, exactly as the page got it (decompressed), with the request body and the response headers - instead of scraping a virtualized DOM or calling a private API yourself:

if (browser.supportsCapture()) {

    CaptureFilter filter = CaptureFilter.builder()
            .hosts("spotify.com")
            .urlContains("/pathfinder/")
            .methods("POST", "GET")
            .mimePrefixes("application/json")
            .maxBodyBytes(8L * 1024 * 1024)
            .build();

    Page page = browser.openPage("https://open.spotify.com/playlist/...", OpenOptions.withCapture(filter));

    // Mark BEFORE acting, then wait from the mark: a response that finishes in between still counts
    long mark = page.captureMark();
    page.scrollTo(ScrollTo.BOTTOM);
    WaitedResponse next = page.waitForResponse(filter, mark, Duration.ofSeconds(20));

    CapturedBody json = page.responseBody(next.exchange().seq());
    CapturedBody query = page.requestBody(next.exchange().seq());      // the GraphQL POST body
    page.responseBodyToFile(next.exchange().seq(), Path.of("/tmp/page-2.json"));

    for (CapturedExchange exchange : page.captured(0, 100).exchanges()) {
        System.out.println(exchange.seq() + " " + exchange.status() + " " + exchange.url());
    }

    page.clearCaptured(next.nextSince());   // free what has been read
}

The rules the wire contract insists on, mirrored here:

  • capture is HEADLESS ONLY, installed at OPEN and never added to a live view, so it is live before the page's first request and survives SPA route changes and full navigations;
  • the refusal is FAIL CLOSED: a helper without the capture capability answers unavailable and NOTHING is opened, never a view that silently records nothing;
  • every clause of a CaptureFilter must hold, an omitted one does not restrict, and nothing that fails one is ever buffered. ResourceType.XHR (the default) covers fetch() and XMLHttpRequest alike, because the engine reports them as one class;
  • nothing is cut silently: a body past maxBodyBytes is kept up to it and flagged with the size the page received (CaptureTruncation), exchanges the total cap could not hold are counted in CapturedExchanges.dropped(), and an inline answer carrying a prefix says inlineTruncated();
  • seq is the request's web_network seq (the join key with page.network()), while the list CURSOR follows the order exchanges finished: responses finish out of request order, and paging by seq would skip a slow one;
  • a body the page never reads keeps its load open in the engine, so that exchange stays in flight (capturedWithInFlight lists it, and its bytes are readable) until the page reads it or leaves;
  • a live capture can only be NARROWED: clearCaptured frees what was read, disableCapture stops recording, and a wider capture needs a new view;
  • supportsCapture() reads the capabilities report's web_capture flag.

Evidence capture

Evidence freezes one page at one moment out of the reads that already exist, so the pieces are known to describe the same moment rather than three round trips apart:

Evidence evidence = Evidence.capture(page);

System.out.println(evidence.finalUrl() + " at " + evidence.capturedAt());
System.out.println("screenshot sha256: " + evidence.screenshotSha256());

evidence.writeTo(Path.of("build/evidence/run-1"));
// -> evidence.json, screenshot.png, snapshot.txt, article.md

capture(page, EnumSet.of(Part.SCREENSHOT, Part.NETWORK)) takes only the parts named. Every part is a READ tool, so a capture still works on a view whose budgets have latched - which is exactly the view whose evidence someone wants. A policy is attested only when one is actually installed, rather than recorded as a row of zeroes that would read like enforcement.

Testing without a browser

The testFixtures artifact publishes ScriptedSketerm, a scripted MCP server, FakeTransport, and SketermBuild, which finds the real Sketerm a consumer's own real-binary test runs (see Building). A consumer's tests drive the real api layer over it instead of a browser:

testImplementation(testFixtures(libs.sketerm.java))
ScriptedSketerm server = ScriptedSketerm.browser()   // capable headless Sketerm: capabilities, open, tabs,
        .on("web_read", ScriptedSketerm.map("markdown", "# Hi"));   // policy (attested), screenshot, wait
try (Sketerm sketerm = server.connect()) {
    Page page = sketerm.browser().openPage("https://example.com/");
}
assert server.callsTo("web_open").size() == 1;

on replaces a tool's answer (a map, or a function of the arguments), onEval answers web_eval with the value of its code (inline, or written to its out_file as the server writes one), landing(asked, landed, status) makes an open of a url land elsewhere with a status (a redirect, a refusal page), onError makes it fail with a typed error code, answeringAfter(tool, delay) delivers a tool's (or a method's, such as initialize) answers after the delay on another thread, so calls on several pages overlap as against a real server and a slow start can be scripted, crash() ends every session it answers, and calls()/lastArguments(tool) show what reached the server. The browser preset also answers web_input (every event repaints the view) and web_frame (a long poll that answers a ScriptedSketerm.PIXEL_JPEG image once the view repainted); frameSize(w, h) sets the frame size it reports against its 1280x800 viewport. Its web_print_pdf writes ScriptedSketerm.PRINTED_PDF (a one-page 1440x810pt document) to the path it was given.

The socket fixture is opt-in and publishes the same typed stream API without an engine:

try (ScriptedSketerm server = ScriptedSketerm.browser().onStream(socket -> {
    socket.surfaceSize(1, 1, 320, 240);
    socket.damage(0, 0, 1, 1, new byte[]{0, 0, (byte) 255, (byte) 255});
    socket.frameEnd(1);
    // socket.receive(Duration.ofSeconds(5)) reads a binary upstream tag and body.
}); Sketerm sketerm = server.connect()) {
    Page page = sketerm.browser().openPage();
    PageStream stream = page.stream(listener);
}

ScriptedPageStream scripts named/image cursors, timed Opus packets, damage and frame ends; message/raw deliberately permit hostile bytes for decoder tests. Its script begins after AUTH, receive(timeout) records socket input/ACKs (never MCP calls), and ended() observes disconnect. Close the fixture as well as Sketerm to release any unauthenticated listener.

Building

./gradlew build

Protoblast must be published to mavenLocal first (zenit-dev build in the javaweb workspace).

Every real-binary test finds Sketerm through the test fixture SketermBuild: the checkout build named by -Dsketerm.it.bin=<dir> (with zenit-dev, --define sketerm.it.bin=<dir>) or the SKETERM_IT_BIN environment variable, a zig-out/bin holding sketerm, sketerm-webengine and sketerm-mux. copy(dir) copies all three into the test's own directory, so a rebuild mid-run cannot swap a binary underneath it, and refuses a build missing one of them; resolve(dir) falls back to the installed sketerm on PATH with both helpers beside it. No sibling checkout is guessed.

SketermSessionIT drives sketerm mcp from resolve and skips itself when there is none. PageApiIT drives the whole api layer against a real headless view from resolve, and skips itself when there is none. Its second journey proves close and the profile lifecycle over a loopback HTTP fixture (a data: document carries no cookies at all in Chromium), under its own XDG_STATE_HOME and instance name so the profile store is per run; it skips those steps when the copied binary predates the profile tools. Its review journey proves key, resize, console and inspect against the real browser; its hand journey proves input and frame and skips itself on a binary that does not advertise web_input and web_frames.

UntrustedPageIT reuses the copied binaries and controls HTTP, WebSocket and UDP/STUN listeners. It proves ordinary network reachability, restricted HTTP loading at a loopback-resolving abc.folio.localhost:<port>, native dark emulation, and zero WebSocket/WebRTC traffic from the untrusted view. Missing binaries or an absent web_untrusted capability skip the journey; startup/enforcement failures on an advertised supported build fail the test.

PageStreamTest covers the binary decoder, borrowed-buffer reuse, exact upstream bytes, hostile framing/UTF-8/ranges and stream ownership through the published socket fixture. PageStreamIT runs only a checkout build (SketermBuild.copy), never installed binaries, and walks one real page through pixel equality with web_screenshot, trusted input during a pending MCP wait, typing and clicking with damage limited to the changed box (input-to-damage under 100ms), wheel scrolling, a composed <select> popup, a premultiplied custom cursor, an idle helper that sends nothing, an animation's frame rate, a stalled reader with bounded helper memory that then gets the newest pixels, resize, blur releasing held input, single-use tokens, decodable non-silent Opus (system libopus) and teardown without leaked helper descriptors or socket files. After both checkouts are ready, run the targeted wave through zenit-dev test --unit --class PageStreamTest,PageStreamIT,PageApiTest --define sketerm.it.bin=<sketerm checkout>/zig-out/bin --skip-deps.

About

A library for working with Sketerm from java

Resources

Readme
Activity
Custom properties

Stars

0 stars

Watchers

0 watching

Forks

0 forks
Report repository

Releases

Packages

Contributors

Languages

You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.