From 4fd5aa42630eaa5ae99601ff46b117a55bc90bac Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:13:47 +0100 Subject: [PATCH 1/8] feat(renderer): two-step exec mode for plotly and bokeh (PB-1) The renderer's plotly and bokeh rows render in two steps; nothing serves them before PB-2. - harness.py figure mode (`--figure plot.py -- `): the run's CPU and file limits, then a fork before anything is armed. The child sets RLIMIT_AS and RLIMIT_NPROC, arms PlotGuard with the kernel exec filter first, runs the plot, which may write only plot-.json, and ends with os._exit. The parent is never armed: it checks the figure (regular file, not empty, at most 16 MiB) and starts the JavaScript row's harness on it in the sandbox's environment without the plot step's variables, with the package's directory found by find_spec. A report pipe carries the filter state, written before any plot code. Plot-step failures end stderr with the JS-2 final error line (plot, figure, crash, harness); the end line carries both steps' usage. - plotly's CODE_BUILDERS entry: _plotly_utils/basevalidators.py build_validator (plotly 7.1.0), which evaluates an info array item's validator class name. - render.mjs `--library= --figure=`, sandbox mode only: the figure read without following links, capped, parsed and shape-checked, carried as a JSON element and drawn with the installed package's own plotly.js or BokehJS (gl, widgets, tables; no MathJax), under the sandbox page rules with a stricter policy (scripts by a nonce of the page). The figure's own CSS size picks the orientation and the device scale makes the canvas exactly 3200x1800 or 2400x2400; a bokeh layout is measured first. WebGL traces fail as draw NoWebGL instead of drawing plotly's notice. The sandbox page rules move into shared helpers, unchanged for the snippet path. - commands.py RuntimeCommand.figure and the two rows (Python limits and filter for the plot step, JS_ENV for the screenshot step); executor argv and env. - The renderer image installs lib-plotly and lib-bokeh without kaleido, choreographer, Selenium and their dependencies, and trims plotly's Jupyter bundles and bokeh's compiler, ES sources and unminified bundles (+69 MB). - ci-image.yml renders a plotly and a bokeh figure through the rows. - Tests: test_figure_mode.py (51 cases, no plotly needed) and test_js_render_figure.py (35 cases through Node and Chromium). - Docs: the plan's "What PB-1 built", agent-network.md, agents/README.md, the changelog fragment. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8 --- .github/workflows/ci-image.yml | 94 +++ agentic/specs/261024-agent-all-libraries.md | 133 ++++ agents/README.md | 2 +- agents/anyplot/render/harness.py | 394 +++++++++++- agents/renderer/Dockerfile | 36 +- agents/renderer/commands.py | 41 +- agents/renderer/executor.py | 15 +- automation/js-render/render.mjs | 572 ++++++++++++++--- changelog.d/pb1-two-step-exec.md | 14 + docs/concepts/agent-network.md | 2 +- .../unit/agents/renderer/test_figure_mode.py | 590 ++++++++++++++++++ .../unit/automation/test_js_render_figure.py | 422 +++++++++++++ 12 files changed, 2212 insertions(+), 103 deletions(-) create mode 100644 changelog.d/pb1-two-step-exec.md create mode 100644 tests/unit/agents/renderer/test_figure_mode.py create mode 100644 tests/unit/automation/test_js_render_figure.py diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml index d3bd9de3298..34784e165b0 100644 --- a/.github/workflows/ci-image.yml +++ b/.github/workflows/ci-image.yml @@ -1268,6 +1268,100 @@ jobs: print(f'OK: {NODE_BINARY} is in the image, and no node is on {SANDBOX_PATH}') " + # plotly and bokeh (PB-1 of step 4 in agentic/specs/261024-agent-all-libraries.md) render in + # two steps: the harness forks the plot before it arms anything, the plot writes its figure as + # JSON under the plot guard and the exec filter with the Python limits, and the harness, never + # armed, then runs the JavaScript row's harness on it, which draws it with the package's own + # bundled JavaScript. The sandbox's environment is a Python row's plus the JavaScript row's, as + # `executor.sandbox_env` builds it; each plot sets its size the way the catalogue's save does, + # and the canvas follows (plotly 800x450 at scale 4, bokeh 4800x2700 at 2/3). The image carries + # plotly and bokeh, and none of their export stacks. + - name: Render plotly and bokeh plots through the harness in figure mode + if: steps.check.outputs.should_build == 'true' + run: | + set -euo pipefail + mkdir -p work-figure/plotly work-figure/bokeh + python3 - > work-figure/row.flags <<'EOF' + from agents.renderer.commands import FIGURE_FLAG, NODE_BINARY, command_for + + for library in ("plotly", "bokeh"): + row = command_for("python", library) + assert row.figure == library and row.exec_deny and row.argv[0] == NODE_BINARY, row + with open(f"work-figure/{library}.argv", "w") as handle: + handle.write("\n".join([FIGURE_FLAG, library, row.code_file, "--", *row.argv]) + "\n") + assert command_for("python", "plotly").env == command_for("python", "bokeh").env + for key, value in row.env.items(): + print(f"-e\n{key}={value}") + EOF + mapfile -t ROW_FLAGS < work-figure/row.flags + cat > work-figure/plotly/plot.py <<'EOF' + import os + + import plotly.graph_objects as go + + THEME = os.getenv("ANYPLOT_THEME", "light") + fig = go.Figure(go.Bar(x=["a", "b", "c"], y=[1, 3, 2], marker={"color": "#009E73"})) + # An axis range builds plotly's info-array validator, an `eval` the guard admits (CODE_BUILDERS). + fig.update_layout(title={"text": "Größe – plotly"}, yaxis={"range": [0, 4]}, width=800, height=450) + fig.write_json(f"plot-{THEME}.json") + EOF + cat > work-figure/bokeh/plot.py <<'EOF' + import json + import os + + from bokeh.embed import json_item + from bokeh.plotting import figure + + THEME = os.getenv("ANYPLOT_THEME", "light") + p = figure(x_range=["a", "b", "c"], width=4800, height=2700, title="Größe – bokeh", toolbar_location=None) + p.vbar(x=["a", "b", "c"], top=[1, 3, 2], width=0.8, color="#009E73") + with open(f"plot-{THEME}.json", "w", encoding="utf-8") as handle: + json.dump(json_item(p), handle) + EOF + for library in plotly bokeh; do + mapfile -t ROW_ARGV < "work-figure/$library.argv" + echo "the $library row: ${ROW_ARGV[*]}" + printf 'x\n1\n' > "work-figure/$library/data.csv" + start=$(date +%s.%N) + docker run --rm --network none --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" \ + -v "$PWD/work-figure/$library:/work" -w /work \ + -e ANYPLOT_THEME=light -e MPLBACKEND=Agg -e MPLCONFIGDIR=/tmp/mpl -e ANYPLOT_MPL_SEED=/opt/mplconfig \ + "${ROW_FLAGS[@]}" \ + -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 -e OPENBLAS_NUM_THREADS=1 -e OMP_NUM_THREADS=1 \ + -e ANYPLOT_RLIMIT_CPU_S=60 -e ANYPLOT_RLIMIT_FSIZE_MB=50 -e ANYPLOT_RLIMIT_AS_MB=1024 \ + -e ANYPLOT_DENY_EXEC=required \ + anyplot-renderer:ci /app/.venv/bin/python -I -X disable-remote-debug /opt/anyplot/harness.py \ + "${ROW_ARGV[@]}" \ + > "work-figure/$library/harness.out" 2> "work-figure/$library/harness.err" \ + || { echo "::error::the $library render failed"; cat "work-figure/$library/harness.out" "work-figure/$library/harness.err"; exit 1; } + echo "wall time: $(python3 -c "import sys; print(round(float(sys.argv[2]) - float(sys.argv[1]), 1))" "$start" "$(date +%s.%N)") s" + grep -qxF 'HARNESS {"event": "start"}' "work-figure/$library/harness.out" \ + || { echo "::error::no HARNESS start line"; cat "work-figure/$library/harness.out"; exit 1; } + python3 - "work-figure/$library" "$library" <<'EOF' + import json, os, struct, sys + + work, library = sys.argv[1:] + png = open(f"{work}/plot-light.png", "rb").read() + assert png[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + width, height = struct.unpack(">II", png[16:24]) + assert (width, height) == (3200, 1800), (width, height) + end = json.loads(open(f"{work}/harness.out").read().splitlines()[-1].split(" ", 1)[1]) + assert end["exec_deny"] == "on", f"the plot step ran without the exec filter: {end}" + assert set(end["steps"]) == {"plot", "screenshot"}, end + assert not os.path.exists(f"{work}/plot-light.html"), "the screenshot step wrote a page" + print(f"OK: {library} plot-light.png {width}x{height}, {len(png)} bytes; steps {end['steps']}") + EOF + done + docker run --rm --network none anyplot-renderer:ci /app/.venv/bin/python -c " + import importlib.util + for name in ('plotly', 'bokeh'): + assert importlib.util.find_spec(name), name + for name in ('kaleido', 'choreographer', 'selenium', 'webdriver_manager'): + assert importlib.util.find_spec(name) is None, f'{name} is in the image' + print('OK: plotly and bokeh are in the image, their export stacks are not') + " + # The downloadable plot.html of a JavaScript plot (JS-4b, step 3g of # agentic/specs/261024-agent-all-libraries.md): the row builds the page with # ANYPLOT_PAGE_ONLY=1 and starts no browser; the agents' wrapper (agents/anyplot/render/page.py, diff --git a/agentic/specs/261024-agent-all-libraries.md b/agentic/specs/261024-agent-all-libraries.md index 77c8adfdbab..c846af6b075 100644 --- a/agentic/specs/261024-agent-all-libraries.md +++ b/agentic/specs/261024-agent-all-libraries.md @@ -2166,6 +2166,139 @@ Revised on 2026-10-30 after the second opinion on steps 3 and 4. Two PRs after J user's machine; the run form carries the sandbox's JSON save instead. - Fixtures, matrix, docs. +#### What PB-1 built (2026-10-31): the two-step exec mode, not served yet + +The renderer's plotly and bokeh rows render in two steps; `AGENT_LIBRARIES`, +`RUNTIME_LIBRARIES`, the runtime registry and the admin deploy's `_LIBRARIES` are +unchanged, so the agents service sends no plotly or bokeh job before PB-2. + +- **Harness, figure mode** (`harness.py --figure plot.py -- `). The start line, then `CPU` and `FSIZE` limits for both steps, then + `os.fork` before anything is armed. The child sets `RLIMIT_AS` and `RLIMIT_NPROC`, arms + `PlotGuard` with the kernel filter first (`ANYPLOT_DENY_EXEC=required` holds as in plot + mode), runs the plot, which may write nothing but `plot-.json`, and ends with + `os._exit`, so no `atexit` callback and no thread of the plot outlives it. The parent is + never armed: it checks the figure (a regular file opened without following a link, not + empty, at most `FIGURE_MAX_BYTES`, 16 MiB, the wire's cap for a typed table) and starts + the screenshot command, the JavaScript row's harness with `--figure=plot-.json` + appended, in the sandbox's environment without the plot step's variables + (`ANYPLOT_DENY_EXEC`, `ANYPLOT_RLIMIT_AS_MB`, `ANYPLOT_RLIMIT_NPROC`) and with + `ANYPLOT_FIGURE_PACKAGE`, the package's directory as the harness's own interpreter would + import it (`importlib.util.find_spec`, which runs no package code). The child reports + through a pipe opened before the fork: its first line, the filter's state, is written + after the arming and before any plot code, so the plot cannot forge it; the error + record after it is advisory. The end line carries both steps' totals, the plot step's + `exec_deny`, and `steps` with each step's peak, CPU and wall time. +- **The final error line** of JS-2 for the plot step as well: `plot plot.py:` + for an exception (its traceback before it, as the interpreter prints it), `figure` + (`NoFigure`, `FigureError`, `FigureTooLarge`), `crash PlotCrashed` for a signal and + `harness` (`UsageError`, `PackageMissing`, `ExecDenyUnavailable`, `SpawnError`); the + screenshot step's own line otherwise. The exit code is the failed step's. +- **`render.mjs --library= --figure=`**, sandbox mode only. The figure + is read without following a link, capped again, parsed and shape-checked in Node, and + carried as data: a `type="application/json"` element with `<`, U+2028 and U+2029 + escaped, parsed in the page and handed to `Plotly.newPlot` (`staticPlot`, + `typesetMath: false`) or `Bokeh.embed.embed_item`. The bundles are the packages' own: + plotly's `package_data/plotly.min.js` (plotly.js 4.1.1 in plotly 7.1.0) and bokeh's + `bokeh.min.js` with its WebGL, widget and table bundles (bokeh 3.10.1; its MathJax bundle + is not loaded). The page is sandbox mode's, routes, console guard and heap cap included, + under a stricter policy: scripts by a nonce of the page instead of `'unsafe-inline'`. +- **The canvas.** The figure's own size in CSS pixels (plotly's layout width and height, + bokeh's root width and height; a bokeh layout, which has none, is drawn once at scale 1 + and measured) picks the orientation, landscape at 4:3 and wider, and the device scale + makes the box exactly 3200 x 1800 or 2400 x 2400: plotly 800 x 450 draws at scale 4, + 1600 x 900 at 2, bokeh 4800 x 2700 at 2/3. A figure narrower than its box is centred on + the page's background; a plotly figure without a size gets 1600 x 900; a side outside + 320 to 6400 is `figure FigureError`. A screenshot of any other size fails as `harness + CanvasError` instead of leaving a PNG. +- **plotly's `CODE_BUILDERS` entry** is `_plotly_utils/basevalidators.py: + build_validator`: `InfoArrayValidator.__init__` calls it for each item of an info-array + property (`xaxis.range`, a `domain`), and it evaluates the item's validator class name, + `datatype.title().replace("_", "") + "Validator"`. plotly's schema names five types + (`angle`, `any`, `enumerated`, `number`, `subplotid`); a plot that calls the builder + itself gets title-cased text without underscores, which reaches only capitalised names + (the module's 23 validator classes, the builtins' classes and constants) and runs under + the guard. Every plotly plot that sets an axis range needs it. +- **Renderer.** `RuntimeCommand.figure` names the package of a two-step row; plotly's and + bokeh's rows run `NODE_BINARY render.mjs --library=` as the screenshot command + with `JS_ENV`, and keep the Python limits and the filter for the plot step. + `executor.build_argv` passes `--figure plot.py -- `, `sandbox_env` a + Python row's settings plus `JS_ENV`. The image installs the `lib-plotly` and `lib-bokeh` + extras without their export stacks (kaleido, choreographer, logistro, Selenium, + webdriver-manager, trio and the WebSocket clients) and trims what neither step reads + (plotly's JupyterLab and widget bundles, bokeh's TypeScript compiler, ES module sources + and unminified bundles): plotly and bokeh take 68 MB, and the renderer image grows from + 4,451 to 4,520 MB (+69 MB, against main on the same toolchain image). + +Measured in local Docker on the developer machine (32 CPUs, WSL 2, kernel 6.18), as the CI +step runs it: `--network none --read-only`, `--cpus 2 --memory 4g`, an unprivileged user, +the row's environment, `RLIMIT_AS` 1 GiB and `ANYPLOT_DENY_EXEC=required`; a hand-written +figure per library and the catalogue's `scatter-basic`, `bar-basic`, `line-basic` and +`heatmap-basic` with the save block swapped by hand for the JSON save (and Selenium's +imports dropped), each in both themes: + +| Measurement | Result | +|---|---| +| Rendered | 20 of 20, every PNG 3200 x 1800 or 2400 x 2400 (the heatmaps), `exec_deny` `on` in every plot step | +| Wall time, container start included | plotly 1.93 to 2.35 s, bokeh 1.60 to 1.85 s | +| Plot step | 0.14 to 0.54 s wall, 0.14 to 0.53 s CPU, 53 to 156 MB peak (bokeh's heatmap the largest) | +| Screenshot step | plotly 1.40 to 1.47 s wall, 0.74 to 0.77 s CPU, 186 to 193 MB for its largest process; bokeh 0.91 to 0.97 s, 0.68 to 0.73 s, 166 to 170 MB | +| The whole container (`memory.peak`) | plotly 399 to 416 MiB, bokeh 518 to 561 MiB | +| Under gVisor (`runsc-ptrace`, release-20261026.0), 12 of the renders | all 12 rendered, `exec_deny` `on`: wall 4.65 to 5.78 s, the plot step 0.40 to 1.15 s, the screenshot step 3.68 to 4.63 s; the same image's Chart.js render takes 3.68 s there and 1.06 s under runc | +| Against the published catalogue PNGs (light) | bokeh `bar-basic` and `heatmap-basic` a mean difference of 0.01 of 255 (no pixel off by more than 40); plotly `bar-basic` 0.54 %, `line-basic` 0.11 %, `heatmap-basic` 1.09 % and `scatter-basic` 0.68 % of the pixels off by more than 40; bokeh `scatter-basic` 0.67 % and `line-basic` 2.03 %. The two largest are drift between the files and their published PNGs (the published bokeh `line-basic` has a band and an annotation the file does not draw; the published plotly `scatter-basic` has axis lines its `showline: False` turns off) | +| Canvas against the catalogue's | plotly `line-basic` (1600 x 900 at scale 3) and bokeh `scatter-basic` and `line-basic` (4800 x 2700) publish 4800 x 2700 PNGs; this route draws the same layouts at 3200 x 1800 | +| Fonts | the page resolves the files' Helvetica and Arial to Liberation Sans through the JavaScript row's fontconfig file; plotly's `Inter` first in a stack is not in the image and falls through to it | + +Also found: + +- **No WebGL in the headless shell.** plotly draws a notice in place of a WebGL trace + ("WebGL is not supported by your browser"), so the page now fails such a render as + `draw NoWebGL` instead of passing with the notice for a plot. In the catalogue that is + `Scattergl` (4 files), `Scatter3d` (5), `Surface` (2), `Mesh3d`, `Parcoords` and + `Densitymap` (1 each) and `Scattermap` (8, which also needs map tiles); plotly express + switches `scatter` to WebGL above 1,000 points. bokeh's `output_backend="webgl"` falls + back to its canvas (no catalogue file uses it). WebGL would need a software renderer + in Chromium (`--enable-unsafe-swiftshader`), an owner decision for PB-2. +- **Markup in a figure.** bokeh's `Div` renders its HTML into the page: under sandbox + mode's `script-src 'unsafe-inline'` an `` in it ran (its fetch then stopped + at the policy). The figure page admits scripts by its nonce alone, so the handler does + not run; `CustomJS` and `CustomJSTickFormatter` cannot compile without + `'unsafe-eval'` (a render with the formatter fails as `page EvalError`). Markup in a + plotly title stays text. +- kaleido's `write_image` is refused at choreographer's socket pair (`socket.__new__`) and + bokeh's `export_png` at its temporary HTML file (`open`), both under the guard on a + developer machine where both stacks are installed; the image installs neither. +- The renderer's language check (`GET /status`, `503 runtime_unavailable`) stays per + language: a Python job of plotly or bokeh needs Node too, which every renderer image + has (the toolchain base); a renderer without it would answer exit 127 with a `harness + SpawnError` line. + +**Tests**: `tests/unit/agents/renderer/test_figure_mode.py` (51 cases without plotly or +bokeh: the package's lookup and the screenshot command stood in): fork before arm (both +steps children of one harness, the plot step one seccomp filter and the no-new-privileges +bit more than the harness, the screenshot step none more and able to start a program), +the limits and variables per step, a refused spawn, fork, exec, socket pair and PNG write, +the kernel's refusal when the audit hook is blind to `os.exec`, nothing of the plot after +it (`atexit`, a thread), the step-1 failures and their lines, a signal death, the cap at +one byte over, a filter that is not in place, the screenshot step's own failure and a +command that cannot start, the rows, the argv and the environment, and the executor's one +timeout over either step (the launcher's process group killed with both children). +`tests/unit/automation/test_js_render_figure.py` (35 cases, skipped without Node, the +lock's packages, the browser and plotly): the canvas rule, a measured bokeh layout, markup +and handlers, `CustomJS`, WebGL, the figure checks, the usage errors, and the whole two +steps through the Python harness in both themes for both libraries. `ci-image.yml` +renders a plotly and a bokeh figure through the rows in the built image and checks that +the export stacks are absent. + +| Text above | Built | Why | +|---|---|---| +| `render.mjs --screenshot` | `render.mjs --library= --figure=`, refused without sandbox mode | the input is the figure; the page's rules must apply to it | +| `_plotly_utils/basevalidators.py: __init__` | `_plotly_utils/basevalidators.py: build_validator` | the `eval` sits in the static method that `InfoArrayValidator.__init__` calls (plotly 7.1.0, the lock's version); the guard judges the frame that calls `eval` | +| The page built inside `#container` at the canonical canvas | the figure's own CSS size, the device scale making the canvas | the catalogue's plotly files write 800 x 450 at scale 4 (146), 1600 x 900 at 3 (116), 600 x 600 at 4 (43) and 1200 x 1200 at 3 (9), and its bokeh files pass `width=4800` (113), `3200` (121) and `2400` (35): one CSS size would change every other file's type size against its plot | +| The vendored `plotly.min.js` or BokehJS | the installed packages' own bundles, BokehJS's WebGL, widget and table bundles included, found by the harness | the page's version is the plot's; a model of another bundle would not draw | +| Sandbox mode's page rules | the same with a nonce for scripts instead of `'unsafe-inline'` (tighter) | a bokeh `Div`'s handler ran under `'unsafe-inline'` (measured) | +| The `lib-plotly` and `lib-bokeh` extras | without the export stacks, and trimmed | they start browsers, which the guard refuses; 27 MB of Selenium drivers; the image grows by 69 MB | + ### 5. Julia: Makie Revised on 2026-10-29 after the second opinion on step 5 (Gemini 4 Argon and Fable; the diff --git a/agents/README.md b/agents/README.md index fe5d6695104..e2d3f1f99a2 100644 --- a/agents/README.md +++ b/agents/README.md @@ -33,7 +33,7 @@ The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default. | `anyplot/render/` | The render contract, the probe harness, the host gates (R1-R3, advisory G3/G5/G7/G8/G9/G10/G11/G12), PNG hardening, the render store, the `remote` (the renderer service, phase 1), `local`, `fake` and `sandbox` (an unused in-process stub) backends, `serial.py`, the one render semaphore in front of every backend, `watermark.py`, the footer strip the artifact route and the feedback bundle add to the raw render, and `page.py`, the wrapper of a JavaScript plot's downloadable `plot.html` (no script of its own, the harness's policy plus `frame-src 'none'`, the page in a sandboxed `srcdoc` frame), which `anyplot/page_export.py` builds on demand for the artifact route since 2026-10-31 | | `anyplot/render/runtimes/` | The runtime registry (`runtime_for(library)`, `RUNTIME_LIBRARIES`): one `RuntimeAdapter` per library, which binds the language's code modules (normaliser, regions, readiness, validator, edits, loader, export), its error grammar and its render command for the pipeline, the theme toggle, the local backend and the eval harness; `python.py` serves matplotlib, seaborn, plotnine, altair, pygal and lets-plot (`PYTHON_LIBRARIES`; plotnine and altair since 2026-10-25, pygal and `letsplot` since 2026-10-26), each with its own save rule (`anyplot/code/regions.py`, `SAVE_RULES`) and validator profile; `r.py` serves ggplot2 since 2026-10-29 (`R_LIBRARIES`): the R code profile (`anyplot/code/profiles/r/`) and the renderer's R row in exec mode; `julia.py` serves Makie since 2026-10-30 (`JULIA_LIBRARIES`): the Julia code profile (`anyplot/code/profiles/julia/`) and the renderer's Julia row in exec mode; `javascript.py` serves Chart.js, D3, ECharts and Highcharts since 2026-10-30 (`JAVASCRIPT_LIBRARIES`; MUI X follows once its fixtures exist): the JavaScript code profile (`anyplot/code/profiles/javascript/`), the renderer's JavaScript row in exec mode, and the table as typed JSON (`RenderJob.data_json`), which the harness puts into the page. `runtime_for` picks the class by the library's language (`RUNTIME_CLASSES`). A later language adds a runtime here, never a backend (`agentic/specs/261024-agent-all-libraries.md`) | | `anyplot/render/fonts/` | JetBrains Mono 2.304 Regular and Bold for the footer strip, unmodified, with `OFL.txt` (SIL Open Font License 1.1) and a notice with their SHA-256 values ([fonts README](anyplot/render/fonts/README.md)) | -| `renderer/` | The `anyplot-renderer` service: `main.py` (`POST /render`, `POST /run-data`, `POST /convert`, `POST /page` for a JavaScript version's self-contained page since 2026-10-31, `GET /status`, one render slot), `executor.py` (one `sandbox do` per theme with the kill path, the byte watchdog and the launcher retry), `commands.py` (the command table: one fixed render command per language, selected by the job's `(language, library)` and never taken from the request; the harness's exec mode runs the non-Python commands; `GET /status` reports under `languages` which toolchains this image carries, and a job in a language without one answers `503 runtime_unavailable`), `auth.py` (the caller check), `settings.py` (`RENDERER_*`), `wire.py` (the JSON contract the `remote` backend shares), `page_probe.mjs` (the CI probe of the downloadable page's boundary in the image's Chromium, and since JS-4b-2 of the chat's preview relay), `Dockerfile` and `cloudbuild.yaml`. No ADK import and no `__init__.py`, so `adk web agents` does not list it as an agent | +| `renderer/` | The `anyplot-renderer` service: `main.py` (`POST /render`, `POST /run-data`, `POST /convert`, `POST /page` for a JavaScript version's self-contained page since 2026-10-31, `GET /status`, one render slot), `executor.py` (one `sandbox do` per theme with the kill path, the byte watchdog and the launcher retry), `commands.py` (the command table: one fixed render command per language, selected by the job's `(language, library)` and never taken from the request; the harness's exec mode runs the non-Python commands, and since 2026-10-31 its figure mode runs plotly's and bokeh's rows in two steps, the plot armed in a forked child writing its figure as JSON and the JavaScript row's harness drawing it, not served before PB-2 of `agentic/specs/261024-agent-all-libraries.md`; `GET /status` reports under `languages` which toolchains this image carries, and a job in a language without one answers `503 runtime_unavailable`), `auth.py` (the caller check), `settings.py` (`RENDERER_*`), `wire.py` (the JSON contract the `remote` backend shares), `page_probe.mjs` (the CI probe of the downloadable page's boundary in the image's Chromium, and since JS-4b-2 of the chat's preview relay), `Dockerfile` and `cloudbuild.yaml`. No ADK import and no `__init__.py`, so `adk web agents` does not list it as an agent | | `anyplot/opening.py`, `session_state.py`, `services.py`, `briefs.py` | Opening a session and taking in a dataset, the server-set session state, the process-wide stores, and the spec and dataset briefs | | `anyplot/dev_fixture.py` | The development-only session seed from an eval fixture case | | `anyplot/settings.py` | `AgentSettings`, read from `AGENT_*` environment variables | diff --git a/agents/anyplot/render/harness.py b/agents/anyplot/render/harness.py index 1a16c79b7b4..26828173711 100644 --- a/agents/anyplot/render/harness.py +++ b/agents/anyplot/render/harness.py @@ -117,6 +117,21 @@ arms `PlotGuard` instead (step 5), which lets imports and reads through and refuses with `PermissionError`, so a plot that catches a refusal renders without what was refused. +**Figure mode** (`harness.py --figure plot.py -- `, the renderer's plotly and +bokeh rows, PB-1 of step 4 in agentic/specs/261024-agent-all-libraries.md): two steps, because a plot-mode harness +cannot start a browser once its guard is armed, and must not before. It prints the start line and sets the limits +of both steps (CPU seconds, the largest file), then forks the plot step before anything is armed: the child sets +the address-space and process limits, arms `PlotGuard` with the kernel filter first (`ANYPLOT_DENY_EXEC=required` +holds here as in plot mode), runs the plot, which may write nothing but `plot-.json`, the figure as JSON +(`fig.write_json` for plotly, `bokeh.embed.json_item` for bokeh), and ends with `os._exit`. The parent is never +armed: once the child ended with exit code 0, it checks the figure (a regular file of at most `FIGURE_MAX_BYTES`) +and starts the screenshot command, the JavaScript row's harness (`render.mjs --library=`) with +`--figure=plot-.json` appended, under the JavaScript row's limits and environment: the sandbox's without +`FIGURE_PLOT_ONLY_ENV`, with `ANYPLOT_FIGURE_PACKAGE` naming the package's directory, whose own bundled JavaScript +draws the figure, so the versions match. The plot step's failures end stderr with the final error line of the +JavaScript harness (`figure_error_line`), the screenshot step's with the harness's own; the exit code is the failed +step's, and the end line carries both steps' usage and the plot step's filter state (`main_figure`). + The probe feeds the advisory gates G3 (clipped text), G5 (annotation outside its axes), G7 (overlapping text: tick labels among themselves, any other text with any), G8 (more point marks than data rows), G9 (more plotted points than any dataset within the parser's caps can hold) and G10 @@ -240,6 +255,29 @@ before its `main`, so no user code is read.""" EXEC_DENY_PREFLIGHT_TIMEOUT_S = 30 """The preflight ends in its library's constructor; a program that outlives this ignored the preload.""" +FIGURE_FLAG = "--figure" +"""Figure mode (module docstring): `harness.py --figure plot.py -- `.""" +FIGURE_PACKAGES = ("plotly", "bokeh") +"""The packages whose figure the screenshot step draws with the package's own JavaScript (`render.mjs`, `BUNDLES`).""" +FIGURE_FILE = "plot-{theme}.json" +"""The one file the plot step may write: the figure as JSON, which the screenshot step reads (`--figure=`).""" +FIGURE_MAX_BYTES = 16 * 1024 * 1024 +"""The largest figure the screenshot step takes (`render.mjs` `FIGURE_MAX_BYTES`, pinned equal by a test): the wire's +cap for a JavaScript job's typed table (`wire.MAX_DATA_JSON_CHARS`), whose worst case at the parser's 20,000 rows and +50 columns measures 9.04 million characters; `RLIMIT_FSIZE` bounds the file itself.""" +FIGURE_PACKAGE_ENV = "ANYPLOT_FIGURE_PACKAGE" +"""The package's directory (`find_figure_package`), which the screenshot step reads the bundled JavaScript from.""" +FIGURE_PLOT_ONLY_ENV = ("ANYPLOT_DENY_EXEC", "ANYPLOT_RLIMIT_AS_MB", "ANYPLOT_RLIMIT_NPROC") +"""The variables of the plot step that the screenshot step does not get: the JavaScript row has no exec filter and +neither `RLIMIT_AS` nor `RLIMIT_NPROC` (`agents/renderer/commands.py`).""" +FIGURE_REPORT_BYTES = 64 * 1024 +"""What the parent reads of the plot step's report pipe (`plot_step`): its first line, the filter state, is written +before any plot code runs; the last, the error record, is advisory like every line the plot shares.""" +FIGURE_ERROR_CHARS = 200 +"""The final error line's message at most (`render.mjs` `ERROR_MESSAGE_CHARS`).""" +FIGURE_ERROR_KINDS = ("plot", "figure", "crash", "harness") +"""The final error line's kinds of the plot step: an exception in the plot, its figure file, a death by a signal, and +the harness itself (usage, the filter, the package); the screenshot step prints its own (`render.mjs`).""" DATA_FLAG = "--data" DATA_OUT_FILE = "out.csv" DATA_STATUS_FILE = "status.json" @@ -387,6 +425,7 @@ ("scipy/stats/_distn_infrastructure.py", "_attach_argparser_methods"), ("plotnine/mapping/_env.py", "eval"), ("cssselect2/compiler.py", "__init__"), + ("_plotly_utils/basevalidators.py", "build_validator"), } ) """The `(file suffix, function)` pairs whose `exec` and `compile` both guards let through when they call them: each @@ -410,7 +449,19 @@ classes, id, attributes and `lang`, nothing else. Only Python objects handed to `compile_selector_list` in place of tokens (a forged `dimension` token whose `int_value` is a string) reach an unquoted slot; the validator admits neither `cssselect2` nor `tinycss2` nor `cairosvg.css`/`cairosvg.parser`, and a lambda the compiler built runs under the same -guard as the plot (no write, process, socket or frame).""" +guard as the plot (no write, process, socket or frame). + +plotly's `InfoArrayValidator.build_validator` (plotly 7.1.0, figure mode) evaluates the name of the validator class an +info array's item needs: `eval(datatype.title().replace("_", "") + "Validator")`, the datatype read from the item's +`valType`. `InfoArrayValidator.__init__` calls it for each item when plotly builds the validator of an info-array +property, lazily, the first time a plot sets one (`xaxis.range`, a `domain`), so every plot that sets an axis range +needs it. The items come from plotly's own schema (`plotly/validators/_validators.json`, read by `ValidatorCache`), +whose info arrays name five types, `angle`, `any`, `enumerated`, `number` and `subplotid`, each a class of the module. +A plot that calls `build_validator` itself controls the text, but the text is title-cased with every underscore +removed and ends in `Validator` (a `#` can comment that out): no keyword but `True`, `False` and `None`, no lowercase +name (no builtin function, no module), no dunder, so it can only read and call capitalised names, the module's 23 +globals of that kind (all validator classes) and the builtins' classes and constants, and whatever that runs stays under +the same guard as the plot.""" IMPORT_FRAME_FILES = ("", "") """The files of the frames that run an import. The plot guard lets an `exec` through when, walking up from its caller, it meets one of these frames before a frame of the plot: a module body the plot imports runs (and builds code: scipy's @@ -1079,10 +1130,21 @@ def _limit(kind: int, value: int) -> None: def set_limits() -> None: + set_run_limits() + set_plot_limits() + + +def set_run_limits() -> None: + """CPU seconds and the largest file, which every process of a run inherits: both steps of figure mode.""" cpu = int(os.environ.get("ANYPLOT_RLIMIT_CPU_S", "60")) fsize = int(os.environ.get("ANYPLOT_RLIMIT_FSIZE_MB", "32")) * 1024 * 1024 _limit(resource.RLIMIT_CPU, cpu) _limit(resource.RLIMIT_FSIZE, fsize) + + +def set_plot_limits() -> None: + """The address space and the processes, each only when set: in figure mode the plot step's alone, because the + screenshot step runs under the JavaScript row's limits, which set neither.""" address_space = os.environ.get("ANYPLOT_RLIMIT_AS_MB") if address_space: _limit(resource.RLIMIT_AS, int(address_space) * 1024 * 1024) @@ -2163,7 +2225,7 @@ def frame_for_readers(): return None -def run_plot(path, guard): +def run_plot(path, guard, armed=None): """Run the plot file as `__main__` under `guard`, the way `runpy.run_path` would. The file is read and compiled before the guard is armed, and the guard lets exactly @@ -2174,7 +2236,8 @@ def run_plot(path, guard): cairocffi, with the same `ldconfig` lookups) when the plot names pygal or cairosvg. No bytecode is written: the guard refuses the writes. The plot's frames carry the path as given, as under `runpy`, so a traceback still names - `plot.py`. + `plot.py`. `armed`, when given, is called with the kernel filter's state once the guard is + armed and before the plot runs (figure mode's report, `plot_step`). """ import builtins import ctypes # noqa: F401 (see the docstring) @@ -2197,6 +2260,8 @@ def run_plot(path, guard): "__cached__": None, } guard.arm(code) + if armed is not None: + armed(guard.exec_deny) exec(code, namespace) @@ -2443,9 +2508,332 @@ def _child_usage(): return {"max_rss_mb": round(usage.ru_maxrss / 1024, 1), "cpu_s": round(usage.ru_utime + usage.ru_stime, 3)} +def main_figure(argv): + """Figure mode (module docstring): ` -- `. + + The start line goes out first and the end line last, as in exec mode; the end line's `max_rss_mb` and `cpu_s` are + both steps' (the largest process and the CPU time of all), `exec_deny` is the plot step's filter (`off` when the + plot never ran armed; the screenshot step has none by design), and `steps` holds each step's own figures. The exit + code is the failed step's: the plot's, 128 plus a signal, `EXEC_DENY_EXIT`, 1 for a missing or refused figure, or + the screenshot command's. Every failure of the plot step and of the harness ends stderr with the final error line + (`figure_error_line`); a failure of the screenshot step ends it with that step's own. + """ + _report("start") + run = {"exec_deny": "off", "steps": {}} + code = 1 + try: + code = run_figure(argv, run) + finally: + try: + _report("end", **_child_usage(), exec_deny=run["exec_deny"], steps=run["steps"]) + except Exception: + pass # a closed stdout must not hide the steps' own exit code + return code + + +def run_figure(argv, run): + """`main_figure` after the start line: the arguments, the shared limits, the plot step, the figure's check and the + screenshot step, each failure with its final error line.""" + if len(argv) < 4 or argv[2] != "--" or argv[0] not in FIGURE_PACKAGES: + packages = "|".join(FIGURE_PACKAGES) + print(f"usage: harness.py {FIGURE_FLAG} <{packages}> -- [args...]", file=sys.stderr) + figure_error_line("harness", "UsageError", None, "usage: the package, the plot file and the command") + return 2 + package, path, command = argv[0], argv[1], argv[3:] + theme = os.environ.get(THEME_ENV, "light") + if theme not in ("light", "dark"): + figure_error_line("harness", "UsageError", None, f"{THEME_ENV} must be light or dark") + return 2 + set_run_limits() + package_dir = find_figure_package(package) + if package_dir is None: + figure_error_line("harness", "PackageMissing", None, f"{package} is not installed where the harness runs") + return 2 + figure = FIGURE_FILE.format(theme=theme) + try: + os.unlink(figure) # only the plot step's own figure reaches the screenshot + except OSError: + pass # missing, or no file at all, which `check_figure` refuses + code = plot_step(path, figure, run) + if code != 0: + return code + refusal = check_figure(figure) + if refusal is not None: + figure_error_line("figure", *refusal) + return 1 + return screenshot_step(command, figure, package_dir, run) + + +def find_figure_package(package): + """The directory of `package` as this interpreter would import it, found without importing it (a top-level name + runs no package code), or None when it is missing: the plot step imports the package from there, and the screenshot + step reads the package's bundled JavaScript from there, so the two versions are one.""" + import importlib.util + + try: + spec = importlib.util.find_spec(package) + except ImportError, ValueError: + return None + if spec is None or not spec.submodule_search_locations: + return None + return os.path.abspath(next(iter(spec.submodule_search_locations))) + + +def plot_step(path, figure, run): + """Fork the plot step, wait for it and judge it: its exit code, 0 when it ran and ended well. + + The child is forked before anything is armed, so this process never is: it starts the screenshot step afterwards. + The child reports through a pipe opened before the fork, its write end non-blocking, so a plot that fills the pipe + cannot hold the child's last write: the first line, the filter's state (`exec_deny `), is written once the + guard is armed and before any plot code runs (`run_plot`'s `armed`), so the plot cannot forge it; the last, an error + record (`error {...}`), is advisory, like everything the plot shares with the harness. + """ + import time + + read_end, write_end = os.pipe() + os.set_blocking(write_end, False) + os.set_blocking(read_end, False) + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() # the child inherits the buffers; a buffered line must not print twice + except Exception: + pass + started = time.monotonic() + pid = os.fork() + if pid == 0: + os.close(read_end) + plot_child(path, figure, write_end) # never returns + os.close(write_end) + _, status, usage = os.wait4(pid, 0) + run["steps"]["plot"] = _step_usage(usage, time.monotonic() - started) + state, record = _read_figure_report(read_end) + run["exec_deny"] = state + if os.WIFSIGNALED(status): + number = os.WTERMSIG(status) + figure_error_line("crash", "PlotCrashed", None, f"the plot step died of {_signal_name(number)}") + return 128 + number + code = os.waitstatus_to_exitcode(status) + if code == EXEC_DENY_EXIT and state != "on": + figure_error_line( + "harness", "ExecDenyUnavailable", None, f"the sandbox could not refuse process starts ({state})" + ) + return code + if code != 0: + name, line, message = record if record is not None else ("Error", None, f"the plot step ended with {code}") + figure_error_line("plot", name, None if line is None else f"{os.path.basename(path)}:{line}", message) + return code + + +def plot_child(path, figure, report): + """The plot step, in the forked child: the plot's limits, `PlotGuard` that lets it write `figure` alone, the plot + run as in plot mode, then `os._exit`, so no handler of the parent's stack, no `atexit` callback and no thread of the + plot outlives it. Its exit code is 0, the plot's own `sys.exit` code, `EXEC_DENY_EXIT` (the plot never ran), or 1 + for an exception, whose traceback goes to stderr as the interpreter prints it and whose class, line and message go + to the report.""" + code = 1 + try: + set_plot_limits() + seed_mplconfig() + guard = PlotGuard((figure,)) + try: + run_plot(path, guard, armed=lambda state: _report_line(report, f"exec_deny {state}")) + code = 0 + except ExecDenyUnavailable as exc: + _report_line(report, f"exec_deny {guard.exec_deny}") + print(f"the sandbox could not refuse process starts ({exc}), which every run requires", file=sys.stderr) + code = EXEC_DENY_EXIT + except SystemExit as exc: + status = exc.code + if status is None or (isinstance(status, int) and status & 0xFF == 0): + code = 0 + else: + code = status & 0xFF if isinstance(status, int) else 1 + if not isinstance(status, int): + print(status, file=sys.stderr) + _report_line(report, "error " + json.dumps(_error_record(path, exc, guard, str(status)))) + except BaseException as exc: + try: + sys.__excepthook__(type(exc), exc, exc.__traceback__) + except BaseException: + pass + _report_line(report, "error " + json.dumps(_error_record(path, exc, guard))) + except BaseException: + pass # the harness's own failure: exit code 1, no record + finally: + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except BaseException: + pass + os._exit(code) + + +def _error_record(path, exc, guard, message=None): + """`[class, line, message]` of an exception the plot raised: the class's name when it is an identifier, the line of + the innermost frame of the plot file (a syntax error's own line), and the message. The traceback is read with the + guard paused and through C attributes only (`BaseException.__traceback__`'s own descriptor, which a subclass's + `__traceback__` cannot replace), so no plot code runs while the guard is paused; the name and the message may run + plot code (a metaclass, `__str__`), which the armed guard then judges.""" + try: + name = type(exc).__name__ + name = name if isinstance(name, str) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", name) else "Error" + except BaseException: + name = "Error" + line = None + try: + if type(exc) in (SyntaxError, IndentationError, TabError) and exc.filename == path: + line = exc.lineno # raised by `compile`, before the guard was armed + else: + with guard.paused(): + tb = BaseException.__traceback__.__get__(exc) + while tb is not None: + file_name = tb.tb_frame.f_code.co_filename + if type(file_name) is str and file_name == path: + line = tb.tb_lineno + tb = tb.tb_next + except BaseException: + line = None + if message is None: + try: + message = str(exc) + except BaseException: + message = "" + if not isinstance(line, int) or isinstance(line, bool) or line < 1: + line = None + return [name, line, message[:FIGURE_ERROR_CHARS] if isinstance(message, str) else ""] + + +def _report_line(report, line): + """One line to the plot step's report pipe, in one write (a pipe takes up to 4 KiB at once); a full pipe or a + closed descriptor loses the line, never the child.""" + try: + os.write(report, (line[:4000] + "\n").encode("utf-8", errors="replace")) + except OSError: + pass + + +def _read_figure_report(read_end): + """The plot step's filter state and error record from its report pipe, then closed: the state from the first line + (`off` without one: the plot never ran armed; `unavailable` for a value outside `install_exec_deny`'s), the record + from the last line that is one (None without).""" + data = b"" + try: + while len(data) < FIGURE_REPORT_BYTES: + chunk = os.read(read_end, FIGURE_REPORT_BYTES - len(data)) + if not chunk: + break + data += chunk + except BlockingIOError: + pass + finally: + os.close(read_end) + lines = data.decode("utf-8", errors="replace").splitlines() + state = "off" + if lines and lines[0].startswith("exec_deny "): + state = lines[0].split(" ", 1)[1] + state = state if state in ("on", "unavailable", "unsupported") else "unavailable" + record = None + for line in reversed(lines[1:] if state != "off" else lines): + if not line.startswith("error "): + continue + try: + name, number, message = json.loads(line.split(" ", 1)[1]) + except ValueError, TypeError, RecursionError: + continue + if isinstance(name, str) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", name) and isinstance(message, str): + number = number if isinstance(number, int) and not isinstance(number, bool) and number > 0 else None + record = (name, number, message) + break + return state, record + + +def check_figure(figure): + """Why the screenshot step must not take `figure`, as `(class, where, message)`, or None: it is missing, not a + regular file (opened without following a link), empty, or larger than `FIGURE_MAX_BYTES`.""" + import stat + + try: + descriptor = os.open(figure, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + return "NoFigure", None, f"the plot wrote no {figure}" + except OSError as exc: + return "FigureError", None, f"{figure} could not be opened as a regular file ({type(exc).__name__})" + try: + info = os.fstat(descriptor) + finally: + os.close(descriptor) + if not stat.S_ISREG(info.st_mode): + return "FigureError", None, f"{figure} is not a regular file" + if info.st_size == 0: + return "FigureError", None, f"{figure} is empty" + if info.st_size > FIGURE_MAX_BYTES: + return "FigureTooLarge", None, f"{figure} is {info.st_size} bytes, more than the {FIGURE_MAX_BYTES}-byte limit" + return None + + +def screenshot_step(command, figure, package_dir, run): + """Start the screenshot command with `--figure=
` appended, in the sandbox's environment without the plot + step's variables (`FIGURE_PLOT_ONLY_ENV`) and with the package's directory, and return its exit code (128 plus a + signal; 127 when it cannot start). It runs under the limits this process set and nothing else: no guard, no + filter, no address-space or process limit, as the JavaScript row runs.""" + import subprocess + import time + + env = {key: value for key, value in os.environ.items() if key not in FIGURE_PLOT_ONLY_ENV} + env[FIGURE_PACKAGE_ENV] = package_dir + argv = [*command, f"--figure={figure}"] + started = time.monotonic() + try: + # As exec mode starts its child: descriptors past stderr closed, SIGPIPE and SIGXFSZ back to their defaults. + child = subprocess.Popen(argv, env=env) + except OSError as exc: + print(f"{FIGURE_FLAG}: cannot run {argv[0]}: {type(exc).__name__}", file=sys.stderr) + figure_error_line("harness", "SpawnError", None, f"cannot run {argv[0]} ({type(exc).__name__})") + return 127 + # `wait4` for the step's own usage; the exit code goes back to the Popen, which then has nothing left to reap. + _, status, usage = os.wait4(child.pid, 0) + child.returncode = code = os.waitstatus_to_exitcode(status) + run["steps"]["screenshot"] = _step_usage(usage, time.monotonic() - started) + return code if code >= 0 else 128 - code + + +def figure_error_line(kind, name, where, message): + """The final error line of the JavaScript harness (`render.mjs`), for the plot step and the harness: + `render: error: :: `, `-` without a line, the message on one line and at most + `FIGURE_ERROR_CHARS` characters.""" + text = " ".join(str(message).split()) + if len(text) > FIGURE_ERROR_CHARS: + text = text[: FIGURE_ERROR_CHARS - 1] + "…" + try: + print(f"render: error: {kind} {name} {where or '-'}: {text}", file=sys.stderr, flush=True) + except Exception: + pass + + +def _step_usage(usage, wall): + """One step's figures for the end line: its largest process, its CPU time with its waited-for children's, its + wall time.""" + return { + "max_rss_mb": round(usage.ru_maxrss / 1024, 1), + "cpu_s": round(usage.ru_utime + usage.ru_stime, 3), + "wall_s": round(wall, 3), + } + + +def _signal_name(number): + import signal + + try: + return signal.Signals(number).name + except ValueError: + return f"signal {number}" + + def main(argv): if len(argv) >= 2 and argv[1] == EXEC_FLAG: return main_exec(argv[2:]) + if len(argv) >= 2 and argv[1] == FIGURE_FLAG: + return main_figure(argv[2:]) if len(argv) == 3 and argv[1] == DATA_FLAG: return main_data(argv[2]) if len(argv) == 3 and argv[1] == CONVERT_FLAG: diff --git a/agents/renderer/Dockerfile b/agents/renderer/Dockerfile index 614cc517dd4..276ab6eb65a 100644 --- a/agents/renderer/Dockerfile +++ b/agents/renderer/Dockerfile @@ -65,8 +65,24 @@ COPY pyproject.toml uv.lock README.md ./ # directory, which uvicorn puts on `sys.path`. # The `convert` extra (python-calamine, pyarrow) is the file conversion's readers # (`harness.py --convert`, agents/anyplot/render/convert.py). +# plotly and bokeh (PB-1 of step 4) run in the harness's figure mode: the plot +# writes its figure as JSON, and the JavaScript row's harness draws it with the +# package's own bundled JavaScript (plotly's package_data/plotly.min.js, bokeh's +# server/static/js/*.min.js), so the page's version is the plot's. Their image +# export stacks stay out: kaleido with choreographer and logistro, Selenium with +# webdriver-manager, trio and the WebSocket clients. Each starts a browser, which +# the plot guard refuses anyway, and Selenium alone carries 27 MB of drivers; a +# plot that saves through them fails with the package's own "not installed" +# message instead. What the two extras add is plotly, bokeh, tornado and +# xyzservices. RUN uv venv && uv sync --frozen --no-install-project --extra lib-matplotlib --extra lib-seaborn \ - --extra lib-plotnine --extra lib-altair --extra lib-pygal --extra lib-letsplot --extra convert + --extra lib-plotnine --extra lib-altair --extra lib-pygal --extra lib-letsplot --extra convert \ + --extra lib-plotly --extra lib-bokeh \ + --no-install-package kaleido --no-install-package choreographer --no-install-package logistro \ + --no-install-package simplejson --no-install-package selenium --no-install-package webdriver-manager \ + --no-install-package trio --no-install-package trio-websocket --no-install-package outcome \ + --no-install-package sortedcontainers --no-install-package wsproto --no-install-package websocket-client \ + --no-install-package pysocks # pyarrow ships far more than the converter reads (owner decision of 2026-10-15: slim it). # The conversion needs the core, compute, IPC, Feather and Parquet; it never touches @@ -91,6 +107,24 @@ RUN p=.venv/lib/python3.14/site-packages/pyarrow \ && du -sh "$p" \ && .venv/bin/python -c "import pyarrow.parquet, pyarrow.feather, pyarrow.ipc, pyarrow.compute" +# plotly and bokeh ship JavaScript that neither step of figure mode reads (PB-1): plotly's +# JupyterLab extension and Jupyter widget bundle, bokeh's TypeScript compiler for custom +# models (it runs Node, which plot code cannot start), its ES module sources and the +# unminified twins of its bundles. The page loads the minified bundles alone +# (`automation/js-render/render.mjs`, BUNDLES), which stay with MathJax's, and so does every +# Python module. The two packages' 94 MB installed (bytecode included) become 68 MB, and the +# image grows by 69 MB in all (2026-10-31); the imports below and the CI job's plotly and +# bokeh renders fail on a file that is needed. +RUN p=.venv/lib/python3.14/site-packages \ + && du -shc "$p/plotly" "$p/bokeh" | tail -n 1 \ + && rm -rf "$p/plotly/labextension" "$p/plotly/package_data/widgetbundle.js" \ + "$p/bokeh/server/static/js/compiler.js" "$p/bokeh/server/static/js/lib" \ + && for f in bokeh bokeh-api bokeh-gl bokeh-mathjax bokeh-tables bokeh-widgets; do \ + test -s "$p/bokeh/server/static/js/$f.min.js" && rm "$p/bokeh/server/static/js/$f.js"; \ + done \ + && du -shc "$p/plotly" "$p/bokeh" | tail -n 1 \ + && .venv/bin/python -c "import plotly.graph_objects, plotly.express, plotly.io, bokeh.plotting, bokeh.embed, bokeh.models, bokeh.layouts" + # The image does not install the project, so its version is written to a file # that `GET /status` reports. RUN .venv/bin/python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])" > VERSION diff --git a/agents/renderer/commands.py b/agents/renderer/commands.py index a0c88c1e0f0..c816a4d1e0a 100644 --- a/agents/renderer/commands.py +++ b/agents/renderer/commands.py @@ -14,6 +14,7 @@ | Language | Code file | Command | |---|---|---| | `python` | `plot.py` | `python -I -X disable-remote-debug harness.py plot.py`: the harness runs the plot in process, with the plot guard and the savefig probe | +| `python` (plotly, bokeh) | `plot.py` | `harness.py --figure plot.py -- /opt/node/bin/node /opt/anyplot/js-render/render.mjs --library=`: the plot in a forked, armed child writes `plot-.json`, then the JavaScript harness draws it | | `javascript` | `plot.js` (`plot.tsx` for MUI X) | `harness.py --exec -- /opt/node/bin/node /opt/anyplot/js-render/render.mjs --library= plot.js` | | `r` | `plot.R` | `harness.py --exec -- /opt/R/4.6.1/lib/R/bin/exec/R --no-echo --no-restore --no-save --file=plot.R` | | `julia` | `plot.jl` | `harness.py --exec -- /opt/julia-1.13.2/bin/julia --project=/opt/julia-project --startup-file=no --history-file=no --compiled-modules=strict --heap-size-hint=1536M -L /opt/anyplot/toolchain/warmup.jl plot.jl` | @@ -35,7 +36,10 @@ while the plot code runs in the page, which has no process API. Node runs by its path as well (`NODE_BINARY`), with the harness's packages, the headless shell and the row's fonts named in its environment (`JS_ENV`, JS-1 of step 3), and the harness's sandbox mode switched on there -(JS-2: the page rules, the closed imports, the bounded console, the final error line). A row also says which limits +(JS-2: the page rules, the closed imports, the bounded console, the final error line). plotly's and bokeh's rows +run in two steps (`FIGURE_FLAG`, `RuntimeCommand.figure`, PB-1): the plot under the plot guard, the exec filter and the +Python limits in a child the harness forks before it arms anything, then the JavaScript row's harness, with its +environment and limits, on the figure the plot wrote. A row also says which limits differ from the Python defaults: V8, Chromium and Julia's LLVM reserve address space far beyond their resident memory, so `RLIMIT_AS` is off for the JavaScript and Julia rows and for altair's Python row (vl-convert embeds V8); one Chromium render runs seven @@ -72,6 +76,11 @@ EXEC_FLAG = "--exec" """The harness's exec-mode flag (`harness.EXEC_FLAG`): what follows `--` is the language's command.""" +FIGURE_FLAG = "--figure" +"""The harness's two-step flag (`harness.FIGURE_FLAG`): `--figure plot.py -- `.""" +FIGURE_LIBRARIES: tuple[str, ...] = ("plotly", "bokeh") +"""The Python libraries whose rows run in two steps (`harness.FIGURE_PACKAGES`, pinned equal by a test): the plot +writes its figure as JSON under the plot guard, and the JavaScript row's harness draws it in Chromium.""" LIBRARY_LANGUAGES: Mapping[str, Language] = MappingProxyType( { @@ -260,14 +269,23 @@ class RuntimeCommand: (`executor.stderr_tail`); empty for a row whose tail is the plain last `MAX_STDERR_CHARS` characters. Julia's `ERROR:`: a deep Makie failure prints about 7,500 characters of stack, so the last 2,000 lose the line that names the exception (J2a, 2026-10-29).""" + figure: str = "" + """The package of a two-step row (`FIGURE_FLAG`, plotly and bokeh): the harness runs the plot itself in a forked, + armed child, and `argv` is then the screenshot step's command, which draws the figure the plot wrote with that + package's JavaScript; empty for every other row. `exec_deny`, `rlimit_as` and `rlimit_nproc` are the plot step's; + the screenshot step runs as the JavaScript row does (`harness.FIGURE_PLOT_ONLY_ENV`), and `env` is its + environment, which the sandbox carries after the Python settings.""" def __post_init__(self) -> None: if self.rlimit_as_mb is not None and (not self.rlimit_as or self.rlimit_as_mb <= 0): raise ValueError("a row's own RLIMIT_AS needs rlimit_as on and a positive size") + if self.figure and (self.language != "python" or not self.argv): + raise ValueError("a two-step row runs a Python plot and a screenshot command") @property def in_process(self) -> bool: - """Whether the harness runs the code itself (Python) instead of a child command.""" + """Whether the harness runs the code itself (Python) and nothing else; a two-step row runs it itself and then + the screenshot command (`figure`).""" return not self.argv @@ -296,6 +314,25 @@ def _row(language: Language, library: str) -> RuntimeCommand: rlimit_as_mb=2048, render_p95_s=15.0, ) + if library in FIGURE_LIBRARIES: + # plotly and bokeh draw in a browser, which plot code must not start (the plot guard refuses kaleido's + # and Selenium's starts) and which an armed process cannot start either: the harness forks the plot step + # before it arms anything, the plot writes `plot-.json` under the guard and the exec filter with + # the Python limits, and the harness, never armed, then runs the JavaScript row's harness on that file + # with the JavaScript row's environment (`env`, JS_ENV) and limits: no exec filter, no RLIMIT_AS, no + # RLIMIT_NPROC (PB-1 of step 4 in agentic/specs/261024-agent-all-libraries.md). The page draws the figure + # with the package's own bundled JavaScript, read from the installed Python package. The repair reserve + # stays 15 s: both steps of 20 renders took 1.6 to 2.4 s of wall time in the renderer image at 2 CPUs, + # container start included, and 4.7 to 5.8 s under gVisor's ptrace platform (2026-10-31). + return RuntimeCommand( + language, + code_file, + (NODE_BINARY, JS_HARNESS, f"--library={library}"), + env=JS_ENV, + toolchain=(NODE_BINARY,), + render_p95_s=15.0, + figure=library, + ) # altair renders in vl-convert's embedded V8, which reserves address space the way the JavaScript # row's Chromium does: under RLIMIT_AS up to 64 GiB it aborts at start ("Check failed: Start()", # SIGTRAP), measured on 2026-10-25 through the harness; its resident set is about 270 MiB and the diff --git a/agents/renderer/executor.py b/agents/renderer/executor.py index 8e239ad4931..acc9e619794 100644 --- a/agents/renderer/executor.py +++ b/agents/renderer/executor.py @@ -102,7 +102,7 @@ from pathlib import Path from typing import Any, Protocol -from .commands import EXEC_DENY_ENV, EXEC_FLAG, LANGUAGES, RuntimeCommand, command_for, toolchains +from .commands import EXEC_DENY_ENV, EXEC_FLAG, FIGURE_FLAG, LANGUAGES, RuntimeCommand, command_for, toolchains from .settings import RendererSettings from .wire import ( CONVERT_INPUT_FILE, @@ -491,9 +491,13 @@ def sandbox_env( `data_json` says the run directory holds the job's `data.json` (`RenderRequest.data_json`): a JavaScript row then names it in `ANYPLOT_DATA_JSON`, which the harness injects as `window.ANYPLOT_DATA`; no other row reads it. `page` (a page build, `POST /page`) adds `ANYPLOT_PAGE_ONLY=1` to the JavaScript row's environment. + + A two-step row (`RuntimeCommand.figure`, plotly and bokeh) gets a Python row's settings and then its own + environment, the JavaScript row's, which the screenshot step reads; its limits and the filter variable are the + plot step's, and the harness keeps them from the screenshot step (`harness.FIGURE_PLOT_ONLY_ENV`). """ plot: dict[str, str] = {} - if theme is not None and command is not None and not command.in_process: + if theme is not None and command is not None and not command.in_process and not command.figure: plot = {"ANYPLOT_THEME": theme, **command.env} if data_json and command.language == "javascript": plot[DATA_JSON_ENV] = f"{WORKDIR}/{DATA_JSON_FILE}" @@ -549,8 +553,9 @@ def build_argv( """The `sandbox do` command of one theme, of a data run (`theme` None, the harness in data mode) or of a conversion (`convert`, the harness in convert mode); asserted to carry neither `--write` nor `--allow-egress`. - A theme's `command` is its row of the command table: a Python row runs the harness in plot mode on `plot.py`; - any other row runs the harness in exec mode (`EXEC_FLAG`) with the row's own argv and code file. The argv + A theme's `command` is its row of the command table: a Python row runs the harness in plot mode on `plot.py`, a + two-step row (plotly, bokeh) in figure mode (`FIGURE_FLAG`) on `plot.py` with the row's argv as the screenshot + command; any other row runs the harness in exec mode (`EXEC_FLAG`) with the row's own argv and code file. The argv comes from the table alone; the request only selected the row. `data_json` and `page`: see `sandbox_env`.""" argv = [ config.sandbox_binary, @@ -572,6 +577,8 @@ def build_argv( argv += [CONVERT_FLAG, CONVERT_INPUT_FILE] elif theme is None: argv += [DATA_FLAG, SNIPPET_FILE] + elif command is not None and command.figure: + argv += [FIGURE_FLAG, command.figure, command.code_file, "--", *command.argv] elif command is not None and not command.in_process: argv += [EXEC_FLAG, "--", *command.argv, f"{command.code_flag}{command.code_file}"] else: diff --git a/automation/js-render/render.mjs b/automation/js-render/render.mjs index bb215902001..f5d9f2cc6a7 100644 --- a/automation/js-render/render.mjs +++ b/automation/js-render/render.mjs @@ -69,8 +69,9 @@ // agentic/specs/261024-agent-all-libraries.md). // ============================================================================= -import { readFile, writeFile } from "node:fs/promises"; -import { existsSync, realpathSync } from "node:fs"; +import { open, readFile, writeFile } from "node:fs/promises"; +import { constants, existsSync, realpathSync } from "node:fs"; +import { randomBytes } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -273,6 +274,26 @@ const BUNDLES = { muix: { framework: "react" }, }; +// ----------------------------------------------------------------------------- +// Figure libraries (figure mode, sandbox only; see renderFigure): the Python +// plot writes its figure as JSON and the page draws it with the Python +// package's own bundled JavaScript, read from the package's directory +// (ANYPLOT_FIGURE_PACKAGE), so the two versions are one; `files` are relative +// to it. bokeh's WebGL, widget and table models live in bundles of their own; +// its MathJax bundle is left out (MathJax off). No snippet runs for these. +// ----------------------------------------------------------------------------- +const FIGURES = { + plotly: { files: ["package_data/plotly.min.js"] }, + bokeh: { + files: [ + "server/static/js/bokeh.min.js", + "server/static/js/bokeh-gl.min.js", + "server/static/js/bokeh-widgets.min.js", + "server/static/js/bokeh-tables.min.js", + ], + }, +}; + // ----------------------------------------------------------------------------- // React (framework=react) bundling — the MUI X path. // @@ -532,11 +553,12 @@ function buildFailure(err, snippetPath) { } function parseArgs(argv) { - const args = { square: false, library: null, file: null }; + const args = { square: false, library: null, file: null, figure: null }; for (const arg of argv) { if (arg === "--square") args.square = true; else if (arg === "--landscape") args.square = false; else if (arg.startsWith("--library=")) args.library = arg.slice("--library=".length); + else if (arg.startsWith("--figure=")) args.figure = arg.slice("--figure=".length); else if (arg.startsWith("--orientation=")) args.square = arg.slice("--orientation=".length) === "square"; else if (!arg.startsWith("--")) args.file = arg; } @@ -821,14 +843,462 @@ const CONTEXT = { refused: { requests: 0, sockets: 0 }, }; +// Sandbox mode: no popup (window.open returns null, so no page outside the +// context's routes), no WebRTC connection and the page's V8 heap capped +// (PAGE_HEAP_MB). Neither the routes nor the content security policy see +// WebRTC: a page's RTCPeerConnection reached a local STUN server over UDP and a +// TURN server over TCP (measured in JS-2). With the forced policy WebRTC uses +// no UDP and TCP only through the proxy, which is a closed port, also for the +// loopback, which a proxy otherwise bypasses. +function launchArgs() { + const args = ["--no-sandbox", "--force-color-profile=srgb"]; + if (SANDBOX) { + args.push( + "--block-new-web-contents", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + `--proxy-server=${DEAD_PROXY}`, + "--proxy-bypass-list=<-loopback>", + `--js-flags=--max-old-space-size=${PAGE_HEAP_MB}`, + ); + } + return args; +} + +// Sandbox mode: `new Date("2026-03-01T00:00")` is midnight UTC whatever the +// host's zone; no service worker, whose requests no route sees. +const SANDBOX_PAGE_OPTIONS = { timezoneId: "UTC", serviceWorkers: "block", acceptDownloads: false }; + +// Sandbox mode's rules of a new page's context. +async function applyPageRules(page) { + // Every request of the context is aborted (fetch, XHR, sendBeacon, images, + // styles, frames), and every WebSocket closed, which the route does not see. + // setContent loads no URL, so the inlined page itself needs no request. + await page.context().route("**/*", (route) => { + CONTEXT.refused.requests += 1; + return route.abort("blockedbyclient"); + }); + await page.context().routeWebSocket("**", (ws) => { + CONTEXT.refused.sockets += 1; + ws.close(); + }); + // The context script guards the main frame's console; this, every frame's + // after it (setContent writes into the page's first document, which no init + // script reaches). + await page.context().addInitScript({ content: CONSOLE_GUARD }); + page.on("crash", () => { + CONTEXT.crashed = true; + }); +} + +// Sandbox mode: `promise`, or a `timeout` failure once `deadline` has passed. +function boundedBy(deadline) { + return (promise) => { + let timer; + const late = new Promise((_, reject) => { + timer = setTimeout( + () => reject(failure(`the page did not settle within ${SETTLE_TIMEOUT_MS / 1000} s`, "timeout", "TimeoutError")), + Math.max(0, deadline - Date.now()), + ); + }); + return Promise.race([promise, late]).finally(() => clearTimeout(timer)); + }; +} + +// Sandbox mode reads the page's own records cut in the page, as scalars, since +// the snippet can write them: the error record (returned), where the first +// error happened, and how many console errors the guard kept in the page. +async function readPageRecords(page, bounded) { + const inPageError = await bounded( + page.evaluate((limit) => { + try { + const value = window.__anyplotError; + return value == null ? null : String(value).slice(0, limit); + } catch (e) { + return "an error record that cannot be read"; + } + }, PAGE_ERROR_CHARS), + ); + CONTEXT.errorAt = await bounded( + page.evaluate(() => { + try { + const at = window.__anyplotErrorAt; + return at ? { file: String(at.file).slice(0, 200), line: Number(at.line) | 0 } : null; + } catch (e) { + return null; + } + }), + ); + // The console.error calls the guard kept in the page count too. + const seen = await bounded( + page.evaluate(() => { + try { + return Number(window.__anyplotConsoleSeen()) || 0; + } catch (e) { + return 0; + } + }), + ); + CONTEXT.console.total += Math.max(0, seen - CONSOLE_KEEP); + return inPageError; +} + function usage(...lines) { for (const line of lines) console.error(line); if (SANDBOX) console.error(`render: error: harness UsageError -: ${oneLine(lines[0].replace(/^render: /, ""), ERROR_MESSAGE_CHARS)}`); process.exit(2); } +// ----------------------------------------------------------------------------- +// Figure mode: plotly and bokeh (PB-1 of step 4 in +// agentic/specs/261024-agent-all-libraries.md). +// +// The renderer's Python harness runs the plot first, armed, in a child of its +// own, and the plot writes its figure as JSON (`fig.write_json`, +// `bokeh.embed.json_item`); the harness, never armed, then runs this file with +// `--library= --figure=plot-.json` and the package's +// directory in ANYPLOT_FIGURE_PACKAGE (agents/anyplot/render/harness.py, +// figure mode). The figure is the model-written plot's output, so it is data +// here: parsed in Node, carried by a `type="application/json"` element with `<`, +// U+2028 and U+2029 escaped, parsed again in the page and handed to +// `Plotly.newPlot` or `Bokeh.embed.embed_item`, never written into the page as +// markup or script. The page is sandbox mode's, its rules and console guard +// included, under a stricter policy (`figurePolicy`): scripts by the page's +// nonce alone and no `'unsafe-eval'`, so nothing the figure carries runs as +// code; figure mode refuses to run without ANYPLOT_SANDBOX=1. +// +// The canvas: the figure's own size in CSS pixels (plotly's layout width and +// height, bokeh's root width and height; a bokeh layout without one is drawn +// once at scale 1 and measured) picks the orientation, landscape at an aspect +// of 4:3 and wider, square below, and the box of that orientation around it; +// the device scale makes the box exactly 3200 x 1800 or 2400 x 2400 pixels. A +// plotly figure 800 x 450 is drawn at scale 4, 1600 x 900 at 2, a bokeh figure +// 4800 x 2700 at 2/3: the figure's layout and type sizes stay as the code wrote +// them and only the resolution follows the canvas, the way `write_image(width, +// height, scale)` and bokeh's `export_png` scale. A figure that names no size +// (plotly) gets 1600 x 900, and one narrower than its box is centred on the +// page's background. +// ----------------------------------------------------------------------------- +const FIGURE_PACKAGE = process.env.ANYPLOT_FIGURE_PACKAGE || null; +// harness.FIGURE_MAX_BYTES, pinned equal by a test: the harness refuses a larger +// figure before this file runs; this is the check behind it. +const FIGURE_MAX_BYTES = 16 * 1024 * 1024; +// The sides a figure may name, in CSS pixels: the device scale stays between 10 +// (320 wide) and 1/2 (6400 wide). +const FIGURE_MIN_SIDE = 320; +const FIGURE_MAX_SIDE = 6400; +const FIGURE_JSON_ID = "anyplot-figure-json"; +const FIGURE_ID = "anyplot-figure"; +// plotly.js's notice in place of a WebGL trace when the page has no WebGL, as +// the headless shell has none: a render that shows it fails as `draw NoWebGL` +// instead of passing with the notice for a plot. +const PLOTLY_NO_WEBGL = "WebGL is not supported by your browser"; + +// A figure page's policy: sandbox mode's, with the scripts admitted by a nonce +// of the page instead of 'unsafe-inline'. Every script of a figure page is the +// harness's own, so markup the figure carries (a bokeh Div's ``, +// measured running under 'unsafe-inline') runs no handler and no +// `javascript:` URL; no 'unsafe-eval' either, so CustomJS models do not compile. +function figurePolicy(nonce) { + const policy = CONTENT_SECURITY_POLICY.replace("script-src 'unsafe-inline'", `script-src 'nonce-${nonce}'`); + if (policy === CONTENT_SECURITY_POLICY) throw failure("render: the figure policy lost its script source", "harness", "PolicyError"); + return policy; +} +// The pixels of each canvas (ORIENTATIONS at DEVICE_SCALE_FACTOR). +const CANVAS_PIXELS = Object.fromEntries( + Object.entries(ORIENTATIONS).map(([name, css]) => [name, [css.cssWidth * DEVICE_SCALE_FACTOR, css.cssHeight * DEVICE_SCALE_FACTOR]]), +); + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// The figure file as the page carries it, and parsed: opened without following +// a link, at most FIGURE_MAX_BYTES, JSON of the library's shape. +async function readFigure(file, library) { + let handle; + try { + handle = await open(path.resolve(process.cwd(), file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + } catch (err) { + throw failure(`the figure ${file} could not be opened as a regular file (${err.code || err.name})`, "figure", "FigureError"); + } + let text; + try { + const info = await handle.stat(); + if (!info.isFile()) throw failure(`the figure ${file} is not a regular file`, "figure", "FigureError"); + if (info.size > FIGURE_MAX_BYTES) { + throw failure(`the figure ${file} is ${info.size} bytes, more than the ${FIGURE_MAX_BYTES}-byte limit`, "figure", "FigureTooLarge"); + } + text = await handle.readFile("utf-8"); + } finally { + await handle.close(); + } + if (Buffer.byteLength(text, "utf-8") > FIGURE_MAX_BYTES) { + throw failure(`the figure ${file} grew past the ${FIGURE_MAX_BYTES}-byte limit while it was read`, "figure", "FigureTooLarge"); + } + let figure; + try { + figure = JSON.parse(text); + } catch (err) { + throw failure(`the figure ${file} is not JSON: ${err.message}`, "figure", "FigureError"); + } + const shaped = + library === "plotly" + ? isRecord(figure) && Array.isArray(figure.data) && (figure.layout === undefined || isRecord(figure.layout)) + : isRecord(figure) && + typeof figure.root_id === "string" && + isRecord(figure.doc) && + Array.isArray(figure.doc.roots) && + figure.doc.roots.some((root) => isRecord(root) && root.id === figure.root_id); + if (!shaped) { + const expected = library === "plotly" ? '{"data": [...], "layout": {...}}' : "bokeh.embed.json_item's {root_id, doc}"; + throw failure(`the figure ${file} is not a ${library} figure (${expected})`, "figure", "FigureError"); + } + // Inside valid JSON `<`, U+2028 and U+2029 occur only in strings, where the escape is the same character. + return { figure, text: text.replace(SCRIPT_BREAKING, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`) }; +} + +// The figure's own size in CSS pixels, or null when only drawing it tells +// (a bokeh layout without a size of its own). +function figureSize(library, figure) { + if (library === "plotly") { + const layout = figure.layout ?? {}; + if (typeof layout.width === "number" && typeof layout.height === "number") { + return checkedSize({ width: layout.width, height: layout.height }); + } + return { width: ORIENTATIONS.landscape.cssWidth, height: ORIENTATIONS.landscape.cssHeight }; + } + const root = figure.doc.roots.find((model) => isRecord(model) && model.id === figure.root_id); + const attributes = isRecord(root.attributes) ? root.attributes : {}; + if (typeof attributes.sizing_mode === "string" && attributes.sizing_mode !== "fixed") { + // A responsive root fills its container: the landscape box. + return { width: ORIENTATIONS.landscape.cssWidth, height: ORIENTATIONS.landscape.cssHeight }; + } + if (typeof attributes.width === "number" && typeof attributes.height === "number") { + return checkedSize({ width: attributes.width, height: attributes.height }); + } + return null; +} + +function checkedSize(size) { + const fits = (side) => Number.isFinite(side) && side >= FIGURE_MIN_SIDE && side <= FIGURE_MAX_SIDE; + if (!fits(size.width) || !fits(size.height)) { + const shown = `${Number(size.width).toFixed(0)} x ${Number(size.height).toFixed(0)}`; + throw failure(`the figure is ${shown}, outside ${FIGURE_MIN_SIDE} to ${FIGURE_MAX_SIDE} CSS pixels a side`, "figure", "FigureError"); + } + return size; +} + +// The canvas around a figure: the orientation, its box in CSS pixels (the +// figure's own size at the box's aspect, a landscape width a multiple of 16, so +// both sides are whole pixels) and the device scale that makes the box the +// canvas's pixels. +function figureCanvas(size) { + if (3 * size.width < 4 * size.height) { + const side = Math.ceil(Math.max(size.width, size.height)); + return { orientation: "square", cssWidth: side, cssHeight: side, scale: CANVAS_PIXELS.square[0] / side }; + } + const width = 16 * Math.ceil(Math.max(size.width, (size.height * 16) / 9) / 16); + return { orientation: "landscape", cssWidth: width, cssHeight: (width * 9) / 16, scale: CANVAS_PIXELS.landscape[0] / width }; +} + +// The page's own drawing script: parse the figure from its JSON element, draw +// it with the library, and set the ready signal once it is drawn and the fonts +// are in, or the error record when the library refuses it. +function figureScript(library, size) { + const draw = + library === "plotly" + ? `var layout = figure.layout || {}; + layout.width = ${size.width}; + layout.height = ${size.height}; + layout.autosize = false; + var config = { staticPlot: true, displayModeBar: false, typesetMath: false, responsive: false, showTips: false }; + drawn = Plotly.newPlot(target, figure.data, layout, config);` + : `drawn = Bokeh.embed.embed_item(figure, ${JSON.stringify(FIGURE_ID)});`; + return ` +(function () { + var target = document.getElementById(${JSON.stringify(FIGURE_ID)}); + var failed = function (err) { window.__anyplotError = String(err && err.stack ? err.stack : err); }; + var drawn; + try { + var figure = JSON.parse(document.getElementById(${JSON.stringify(FIGURE_JSON_ID)}).textContent); + ${draw} + } catch (err) { + failed(err); + return; + } + Promise.resolve(drawn) + .then(function () { return document.fonts ? document.fonts.ready : null; }) + .then(function () { + requestAnimationFrame(function () { requestAnimationFrame(function () { window.__anyplotReady = true; }); }); + }, failed); +})(); +//# sourceURL=anyplot-figure.js`; +} + +// The figure's page: sandbox mode's policy, console guard and error records, +// the library's bundles, the figure as data and the drawing script. `canvas` +// null builds the measuring page, whose container takes the figure's own size. +function buildFigureHtml({ tokens, library, bundles, figureText, size, canvas }) { + const container = canvas + ? `width: ${canvas.cssWidth}px; height: ${canvas.cssHeight}px; display: flex; align-items: center; justify-content: center;` + : "display: inline-block;"; + const nonce = randomBytes(18).toString("base64"); + const scripts = bundles + .map( + (source, index) => + ``, + ) + .join("\n"); + return ` + + + + + +anyplot + + + +
+ + +${scripts} + + +`; +} + +// One page of a figure: sandbox mode's options and rules, the canvas's +// viewport and scale, drawn until the ready signal or the error record, both +// bounded by one deadline; a page error fails it as in a snippet's render. +async function drawFigurePage(browser, html, { width, height, scale, theme }) { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: scale, ...SANDBOX_PAGE_OPTIONS }); + await applyPageRules(page); + await page.emulateMedia({ reducedMotion: "reduce", colorScheme: theme }); + page.on("console", (msg) => { + if (msg.type() !== "error") return; + CONTEXT.console.total += 1; + if (CONTEXT.console.kept.length < CONSOLE_KEEP) CONTEXT.console.kept.push(oneLine(msg.text(), CONSOLE_MESSAGE_CHARS)); + }); + page.on("pageerror", (err) => { + if (CONTEXT.pageError === null) CONTEXT.pageError = cappedPageError(err); + }); + const bounded = boundedBy(Date.now() + SETTLE_TIMEOUT_MS); + await bounded(page.setContent(html, { waitUntil: "load" })); + await bounded(page.waitForFunction(() => window.__anyplotReady === true || window.__anyplotError != null, null, { timeout: 0 })); + const inPageError = await readPageRecords(page, bounded); + if (CONTEXT.pageError || inPageError) { + CONTEXT.inPageError = inPageError; + throw failure(`render: the figure failed to draw:\n${String(CONTEXT.pageError?.stack || inPageError)}`, "page", "Error"); + } + const drew = await bounded( + page.evaluate((id) => { + const marks = (root) => { + if (root.querySelector("canvas, svg, img")) return true; + for (const element of root.querySelectorAll("*")) if (element.shadowRoot && marks(element.shadowRoot)) return true; + return false; + }; + const target = document.getElementById(id); + return !!target && marks(target); + }, FIGURE_ID), + ); + if (!drew) throw failure("render: the figure drew nothing into #container (no / mounted).", "draw", "NoOutput"); + const noWebGl = await bounded( + page.evaluate( + ([id, notice]) => Array.from(document.getElementById(id).querySelectorAll("p")).some((p) => p.textContent.startsWith(notice)), + [FIGURE_ID, PLOTLY_NO_WEBGL], + ), + ); + if (noWebGl) throw failure("render: a WebGL trace cannot draw: the page has no WebGL", "draw", "NoWebGL"); + return { page, bounded }; +} + +async function renderFigure(args) { + const library = args.library; + if (!Object.hasOwn(FIGURES, library ?? "")) { + usage(`render: --figure is for ${Object.keys(FIGURES).join(" and ")}, not "${library}"`); + } + const info = FIGURES[library]; + if (args.figure === null || args.file !== null) { + usage(`render: ${library} draws a figure: pass --figure= and no code file`); + } + if (!SANDBOX) usage("render: --figure needs ANYPLOT_SANDBOX=1"); + if (PAGE_ONLY) usage("render: ANYPLOT_PAGE_ONLY=1 builds no page for a figure"); + if (!FIGURE_PACKAGE) usage("render: --figure needs ANYPLOT_FIGURE_PACKAGE, the package's directory"); + + const theme = process.env.ANYPLOT_THEME === "dark" ? "dark" : "light"; + const tokens = themeTokens(theme); + CONTEXT.snippetName = path.basename(args.figure); + CONTEXT.snippetPath = path.resolve(process.cwd(), args.figure); + CONTEXT.bundleName = `anyplot-${library}.js`; + + const bundles = []; + for (const file of info.files) { + const bundlePath = path.join(FIGURE_PACKAGE, file); + if (!existsSync(bundlePath)) usage(`render: bundle missing: ${bundlePath}`); + bundles.push(await readFile(bundlePath, "utf-8")); + } + const { figure, text } = await readFigure(args.figure, library); + let size = figureSize(library, figure); + + const browser = await chromium.launch({ args: launchArgs() }); + try { + if (size === null) { + // A bokeh layout: drawn once at scale 1 on a page as large as a figure + // may be, where the container takes its size. + const html = buildFigureHtml({ tokens, library, bundles, figureText: text, size: null, canvas: null }); + const measuring = { width: FIGURE_MAX_SIDE, height: FIGURE_MAX_SIDE, scale: 1, theme }; + const { page, bounded } = await drawFigurePage(browser, html, measuring); + const box = await bounded( + page.evaluate((id) => { + const rect = document.getElementById(id).getBoundingClientRect(); + return { width: Math.ceil(rect.width), height: Math.ceil(rect.height) }; + }, FIGURE_ID), + ); + await page.close(); + size = checkedSize(box); + } + const canvas = figureCanvas(size); + const html = buildFigureHtml({ tokens, library, bundles, figureText: text, size, canvas }); + const shape = { width: canvas.cssWidth, height: canvas.cssHeight, scale: canvas.scale, theme }; + const { page, bounded } = await drawFigurePage(browser, html, shape); + const png = await bounded(page.locator("#container").screenshot()); + const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)]; + const [expectedWidth, expectedHeight] = CANVAS_PIXELS[canvas.orientation]; + if (width !== expectedWidth || height !== expectedHeight) { + throw failure(`the screenshot is ${width}x${height}, not the ${canvas.orientation} canvas's ${expectedWidth}x${expectedHeight}`, "harness", "CanvasError"); + } + const pngOut = path.resolve(process.cwd(), `plot-${theme}.png`); + await writeFile(pngOut, png); + printSummary(CONTEXT); + console.log( + `render: ${library} ${theme} ${canvas.orientation} figure ${size.width}x${size.height} at scale ${canvas.scale.toFixed(3)} → ${pngOut} (${width}×${height})`, + ); + } finally { + await browser.close(); + } +} + async function main() { const args = parseArgs(process.argv.slice(2)); + if (args.figure !== null || Object.hasOwn(FIGURES, args.library ?? "")) { + await renderFigure(args); + return; + } if (!args.file) { usage("usage: node automation/js-render/render.mjs [--library=] [--square]"); } @@ -848,6 +1318,9 @@ async function main() { // Library id: explicit flag wins, else the file stem (chartjs.js → chartjs). const library = args.library || path.basename(filePath).replace(/\.(js|mjs|cjs|ts|tsx)$/i, ""); + if (Object.hasOwn(FIGURES, library)) { + usage(`render: ${library} draws a figure, not a code file: pass --library=${library} --figure=`); + } const bundleInfo = BUNDLES[library]; if (!bundleInfo) { usage( @@ -897,52 +1370,14 @@ async function main() { } if (!NO_HTML) await writeFile(htmlOut, html, "utf-8"); - // Sandbox mode: no popup (window.open returns null, so no page outside the - // context's routes), no WebRTC connection and the page's V8 heap capped - // (PAGE_HEAP_MB). Neither the routes nor the content security policy see - // WebRTC: a page's RTCPeerConnection reached a local STUN server over UDP and a - // TURN server over TCP (measured in JS-2). With the forced policy WebRTC uses - // no UDP and TCP only through the proxy, which is a closed port, also for the - // loopback, which a proxy otherwise bypasses. - const launchArgs = ["--no-sandbox", "--force-color-profile=srgb"]; - if (SANDBOX) { - launchArgs.push( - "--block-new-web-contents", - "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", - `--proxy-server=${DEAD_PROXY}`, - "--proxy-bypass-list=<-loopback>", - `--js-flags=--max-old-space-size=${PAGE_HEAP_MB}`, - ); - } - const browser = await chromium.launch({ args: launchArgs }); + const browser = await chromium.launch({ args: launchArgs() }); try { const page = await browser.newPage({ viewport: { width: css.cssWidth, height: css.cssHeight }, deviceScaleFactor: DEVICE_SCALE_FACTOR, - // Sandbox mode: `new Date("2026-03-01T00:00")` is midnight UTC whatever the - // host's zone; no service worker, whose requests no route sees. - ...(SANDBOX ? { timezoneId: "UTC", serviceWorkers: "block", acceptDownloads: false } : {}), + ...(SANDBOX ? SANDBOX_PAGE_OPTIONS : {}), }); - if (SANDBOX) { - // Every request of the context is aborted (fetch, XHR, sendBeacon, images, - // styles, frames), and every WebSocket closed, which the route does not see. - // setContent loads no URL, so the inlined page itself needs no request. - await page.context().route("**/*", (route) => { - CONTEXT.refused.requests += 1; - return route.abort("blockedbyclient"); - }); - await page.context().routeWebSocket("**", (ws) => { - CONTEXT.refused.sockets += 1; - ws.close(); - }); - // The context script guards the main frame's console; this, every frame's - // after it (setContent writes into the page's first document, which no init - // script reaches). - await page.context().addInitScript({ content: CONSOLE_GUARD }); - page.on("crash", () => { - CONTEXT.crashed = true; - }); - } + if (SANDBOX) await applyPageRules(page); // Static screenshot of an animated chart must catch the *final* frame, never // a mid-tween one. The library prompts already mandate disabling animation @@ -976,18 +1411,7 @@ async function main() { // Sandbox mode bounds every answer the page gives by one deadline // (SETTLE_TIMEOUT_MS), loading the page included: a snippet whose inline // script never returns, or a page that never settles, fails as `timeout`. - const deadline = Date.now() + SETTLE_TIMEOUT_MS; - const bounded = (promise) => { - if (!SANDBOX) return promise; - let timer; - const late = new Promise((_, reject) => { - timer = setTimeout( - () => reject(failure(`the page did not settle within ${SETTLE_TIMEOUT_MS / 1000} s`, "timeout", "TimeoutError")), - Math.max(0, deadline - Date.now()), - ); - }); - return Promise.race([promise, late]).finally(() => clearTimeout(timer)); - }; + const bounded = SANDBOX ? boundedBy(Date.now() + SETTLE_TIMEOUT_MS) : (promise) => promise; await bounded(page.setContent(html, { waitUntil: "load" })); @@ -1027,41 +1451,7 @@ async function main() { // main().catch turns the throw into a non-zero exit. // Sandbox mode reads the page's own records cut in the page, as scalars, since // the snippet can write them. - const inPageError = SANDBOX - ? await bounded( - page.evaluate((limit) => { - try { - const value = window.__anyplotError; - return value == null ? null : String(value).slice(0, limit); - } catch (e) { - return "an error record that cannot be read"; - } - }, PAGE_ERROR_CHARS), - ) - : await page.evaluate(() => window.__anyplotError); - if (SANDBOX) { - CONTEXT.errorAt = await bounded( - page.evaluate(() => { - try { - const at = window.__anyplotErrorAt; - return at ? { file: String(at.file).slice(0, 200), line: Number(at.line) | 0 } : null; - } catch (e) { - return null; - } - }), - ); - // The console.error calls the guard kept in the page count too. - const seen = await bounded( - page.evaluate(() => { - try { - return Number(window.__anyplotConsoleSeen()) || 0; - } catch (e) { - return 0; - } - }), - ); - CONTEXT.console.total += Math.max(0, seen - CONSOLE_KEEP); - } + const inPageError = SANDBOX ? await readPageRecords(page, bounded) : await page.evaluate(() => window.__anyplotError); if (pageError || inPageError) { CONTEXT.inPageError = inPageError; throw failure(`render: the snippet threw while drawing:\n${String(pageError?.stack || pageError || inPageError)}`, "page", "Error"); diff --git a/changelog.d/pb1-two-step-exec.md b/changelog.d/pb1-two-step-exec.md new file mode 100644 index 00000000000..48a1e9026e0 --- /dev/null +++ b/changelog.d/pb1-two-step-exec.md @@ -0,0 +1,14 @@ +### Added + +- **The renderer draws plotly and bokeh figures in two steps, not served yet.** + The render harness forks the plot before it arms anything: the plot runs + under the plot guard and the exec filter with the Python limits and may write + only its figure as JSON, and the harness, never armed, then hands the figure + to the JavaScript row's browser harness, which draws it as data with the + installed package's own plotly.js or BokehJS in the sandboxed page, with a + stricter policy that runs no script the figure carries. The figure keeps the + size its code gave it, and the scale makes the image exactly 3200x1800 or + 2400x2400, so the catalogue's layouts look as published. Failures in either + step end with the same final error line as a JavaScript render. The renderer + image now carries plotly and bokeh without their kaleido and Selenium export + stacks. The agent chat does not offer plotly or bokeh yet. diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md index fa64b888d12..5bb6b605016 100644 --- a/docs/concepts/agent-network.md +++ b/docs/concepts/agent-network.md @@ -383,7 +383,7 @@ Host gates: R1 (exit code and outputs present) and R2 (PNG hardening: `lstat` wi **Label density and number formats.** Two findings of the live tests of 2026-10-14/15 became measured gates. The catalogue draws some elements once per mark of its few example marks: bar-grouped puts a value label, a dot and a drop shadow on each of its 12 bars, and the adapter kept them on the 52 bars of 26 regions times two series, because "keep the same style" and the reviewer's house-style exemption covered them. The harness's `density_report` (probe field `density`, per axes) counts the bars and points, the texts at a data coordinate whose anchor sits at a bar's end, inside a bar's segment (`bar_label(label_type="center")`) or on a point (`ax.annotate`, `ax.bar_label`, `ax.text`; heatmap cell text and callouts in axes coordinates are not value labels, and neither is a text matplotlib clips away in a zoomed view), which of them are numbers, the dots on bar ends (scatter marks or `plot(..., "o")` markers) and the shadowed bars, and measures the median bar label width against the space between neighbouring labelled bars (unlabelled bars between them, such as an offset shadow series, do not shrink it). Bar ends come from the bars' data coordinates, so an inverted axis works. G11 writes a VQ-03 line (owner, 2026-10-15: the thresholds stay) when more than `MAX_VALUE_LABELS` (25) bar positions carry a label (a stacked bar's segment labels count once), when more than 25 numeric labels sit on points (named points, the point of scatter-annotated, never count), from six labels on when a bar label is wider than 90 % of the space per bar, or when more than `MAX_DECORATIONS` (25) dots or shadows are drawn. The adapter prompt and the reviewer's and scorer's house-style exemption now say that an element whose count grows with the data is not house style. The rule itself has one source shared with the catalogue pipeline: the "Per-mark Labels and Decorations" section of `prompts/default-style-guide.md` and the VQ-03 paragraph of `prompts/quality-criteria.md`, which the adapter reads verbatim; `adapter.md` only points at it. Second, number formats followed English whatever the plot language ("6.0 M" and "5.30" in German plots), and `locale` is a banned import. `language.NUMBER_FORMATS` is the server's table of the decimal mark, the thousands mark and the words for large numbers per language of `ENGLISH_NAMES` (`de`: `,`, `.`, Tsd./Mio./Mrd.); the adapter's static instruction carries it with the rule to format through a `FuncFormatter` or an f-string whose marks are swapped through a placeholder (`.replace(",", "_").replace(".", DECIMAL).replace("_", THOUSANDS)`), exact for the space-grouped languages too. A region such as `de-CH` with its apostrophe (7'184'003) is a follow-up of its own setting (owner, 2026-10-15). G12 reads the probe's `labels` field (the drawn texts by kind, from block F) and its `tick_axes` field (`harness.tick_axes`: whether each axis's tick labels are numbers, dates, categories or labels the code set itself), and writes a VQ-06 line when an annotation, or a tick label of a numeric axis, uses the other decimal mark, mathtext read as drawn: a single point followed by other than three digits in a decimal-comma language (`5.30`, `6.0 M`, `5.30 €`), a point after a whole part of 0 (`0.125`), a point after a comma, or commas grouping thousands; dates (a zero-padded `14.10` in an annotation reads as a day and month), years, version strings, titles, legend entries and tick labels the code set itself never count, and no label text reaches the line. -Later languages reuse the CI commands: R's binary on `plot.R` (since 2026-10-29 the row runs `/opt/R/4.6.1/lib/R/bin/exec/R --no-echo --no-restore --no-save --file=plot.R` with the environment R's `bin/R` script would set and `LANG=C.UTF-8`, not `Rscript`, whose wrapper starts would die under the exec filter; the harness preloads the filter into the child, `agents/renderer/execdeny/execdeny.c`, and refuses the run, exit 3, unless the library reported `on`; the agents service sends R jobs since 2026-10-29, through `RRuntime`); Julia's launcher on `plot.jl` (since 2026-10-29 the row runs `/opt/julia-1.13.2/bin/julia --project=/opt/julia-project --startup-file=no --history-file=no --compiled-modules=strict --heap-size-hint=1536M plot.jl` under the preloaded exec filter, with a stacked `JULIA_DEPOT_PATH=/tmp/depot:/opt/julia-depot`, the `JULIA_CPU_TARGET` the toolchain image precompiled the catalogue's `Project.toml` and `Manifest.toml` for, one thread and the offline switches; under `strict` a package image that does not load fails the render in about a second instead of compiling under its timeout; since J1.1 the row adds `-L /opt/anyplot/toolchain/warmup.jl` before `plot.jl`, which loads the toolchain's precompile workload, a package whose image holds the native code of the calls a plot makes most and of the data loader's read, compiled with CSV, DataFrames and TimeZones loaded, and cut an adapted render from 15.9 to 5.0 s locally; the agents service sends Makie jobs since 2026-10-30, through `JuliaRuntime`, and the row keeps Julia's first `ERROR:` line in front of the stderr tail); Node by its path on the catalogue's own harness (since 2026-10-30 the row runs `/opt/node/bin/node /opt/anyplot/js-render/render.mjs --library= plot.js`, `plot.tsx` for MUI X, without the exec filter, because Node starts esbuild and Chromium and Chromium starts its own processes, while the plot code runs in the page, which has no process API; the toolchain image carries Node 22, the pipeline's major, the repository's `package-lock.json` installed with `npm ci` under `/opt/anyplot/node_modules`, Playwright's Chromium headless shell alone under `/opt/pw-browsers`, and Liberation Sans, Serif and Mono for this row alone through its own `FONTCONFIG_FILE`, so the catalogue's page font stack resolves as on the pipeline's runner while the other rows keep their fonts; the agents service sends Chart.js, D3, ECharts and Highcharts jobs since 2026-10-30, through `JavaScriptRuntime`, each with its table as `data_json`; since 2026-10-30 the row's environment switches on the harness's sandbox mode, `ANYPLOT_SANDBOX=1` with `ANYPLOT_NO_HTML=1`, which the catalogue pipeline never sets and without which the harness renders as before: every request of the page aborted by a route and refused by a content security policy (`default-src 'none'`, inline scripts and styles, `data:` and `blob:` images, `data:` fonts, no form submission, no ``), every WebSocket closed, `window.open` returning null, WebRTC connecting nowhere, the zone UTC, the page's V8 heap capped at 1 GiB, the console kept to the first five errors, MUI X's imports closed to the image's allowlist of bare specifiers, and one final stderr line `render: error: :: ` that the renderer's 2,000-character tail always keeps; a job may carry the table as typed JSON, `data_json`, which the renderer writes as `data.json` and the harness injects as `window.ANYPLOT_DATA`, one object per row in column order); bokeh and plotly render through the same Chromium in a two-step exec mode, because a plot-mode harness cannot start a program once its guard is armed (the plot guard refuses a browser started from plot code; `agentic/specs/261024-agent-all-libraries.md`). Since 2026-10-24 the renderer holds these commands in one table (`agents/renderer/commands.py`, keyed by the job's `(language, library)`, the library checked against the catalogue's fifteen) and never takes a command from a request; a non-Python row runs through the harness's exec mode (`harness.py --exec -- plot.`: the start line, the limits set and inherited, the end line from the child's rusage), so the launcher retry, the watchdog and the kill path hold for every language; a row says which limits differ (`RLIMIT_AS` off for the JavaScript and Julia rows, whose V8, Chromium and LLVM reserve address space far beyond their resident memory, and `RLIMIT_NPROC` off for the JavaScript row alone, whose one Chromium render runs 77 tasks; since 2026-10-25 `RLIMIT_AS` is off for altair's Python row too, because vl-convert's embedded V8 aborts at start under any limit up to 64 GiB while its resident set is about 270 MiB), which limit it sets in place of the configured one (since 2026-10-26 lets-plot's Python row has its own 2048 MiB, `RuntimeCommand.rlimit_as_mb`, because its Kotlin/Native rasteriser aborts every 3200x1800 export under the configured 1 GiB while 2048 MiB still stops a runaway scale, which a 6400x3600 export shows at 2580 MiB; the renderer's switch still rules, so a configured limit of 0 leaves every row unlimited), which environment it adds (lets-plot's: Arrow's system allocator, `ARROW_DEFAULT_MEMORY_POOL=system`, because pyarrow's default pool reserves a 1 GiB arena on the first pandas string column, and `FONTCONFIG_FILE=/opt/anyplot/fonts.conf` for the fontconfig its bridge links; an in-process row's environment and limit reach the sandbox through `executor.sandbox_env` and the local Docker backend through `PythonRuntime.env`, which since 2026-10-26 is `sandbox_env` key for key, the sandbox's `PATH` and limits included, but `RLIMIT_NPROC`: the local container runs as the developer's user, whose processes on the whole machine count against it, so `--pids-limit` bounds the processes there) and what render time a repair reserves; `GET /status` reports under `languages` which toolchains the image carries, and a job in a language without one is `503 runtime_unavailable`. +Later languages reuse the CI commands: R's binary on `plot.R` (since 2026-10-29 the row runs `/opt/R/4.6.1/lib/R/bin/exec/R --no-echo --no-restore --no-save --file=plot.R` with the environment R's `bin/R` script would set and `LANG=C.UTF-8`, not `Rscript`, whose wrapper starts would die under the exec filter; the harness preloads the filter into the child, `agents/renderer/execdeny/execdeny.c`, and refuses the run, exit 3, unless the library reported `on`; the agents service sends R jobs since 2026-10-29, through `RRuntime`); Julia's launcher on `plot.jl` (since 2026-10-29 the row runs `/opt/julia-1.13.2/bin/julia --project=/opt/julia-project --startup-file=no --history-file=no --compiled-modules=strict --heap-size-hint=1536M plot.jl` under the preloaded exec filter, with a stacked `JULIA_DEPOT_PATH=/tmp/depot:/opt/julia-depot`, the `JULIA_CPU_TARGET` the toolchain image precompiled the catalogue's `Project.toml` and `Manifest.toml` for, one thread and the offline switches; under `strict` a package image that does not load fails the render in about a second instead of compiling under its timeout; since J1.1 the row adds `-L /opt/anyplot/toolchain/warmup.jl` before `plot.jl`, which loads the toolchain's precompile workload, a package whose image holds the native code of the calls a plot makes most and of the data loader's read, compiled with CSV, DataFrames and TimeZones loaded, and cut an adapted render from 15.9 to 5.0 s locally; the agents service sends Makie jobs since 2026-10-30, through `JuliaRuntime`, and the row keeps Julia's first `ERROR:` line in front of the stderr tail); Node by its path on the catalogue's own harness (since 2026-10-30 the row runs `/opt/node/bin/node /opt/anyplot/js-render/render.mjs --library= plot.js`, `plot.tsx` for MUI X, without the exec filter, because Node starts esbuild and Chromium and Chromium starts its own processes, while the plot code runs in the page, which has no process API; the toolchain image carries Node 22, the pipeline's major, the repository's `package-lock.json` installed with `npm ci` under `/opt/anyplot/node_modules`, Playwright's Chromium headless shell alone under `/opt/pw-browsers`, and Liberation Sans, Serif and Mono for this row alone through its own `FONTCONFIG_FILE`, so the catalogue's page font stack resolves as on the pipeline's runner while the other rows keep their fonts; the agents service sends Chart.js, D3, ECharts and Highcharts jobs since 2026-10-30, through `JavaScriptRuntime`, each with its table as `data_json`; since 2026-10-30 the row's environment switches on the harness's sandbox mode, `ANYPLOT_SANDBOX=1` with `ANYPLOT_NO_HTML=1`, which the catalogue pipeline never sets and without which the harness renders as before: every request of the page aborted by a route and refused by a content security policy (`default-src 'none'`, inline scripts and styles, `data:` and `blob:` images, `data:` fonts, no form submission, no ``), every WebSocket closed, `window.open` returning null, WebRTC connecting nowhere, the zone UTC, the page's V8 heap capped at 1 GiB, the console kept to the first five errors, MUI X's imports closed to the image's allowlist of bare specifiers, and one final stderr line `render: error: :: ` that the renderer's 2,000-character tail always keeps; a job may carry the table as typed JSON, `data_json`, which the renderer writes as `data.json` and the harness injects as `window.ANYPLOT_DATA`, one object per row in column order); bokeh and plotly render through the same Chromium in two steps, because a plot-mode harness cannot start a program once its guard is armed (the plot guard refuses a browser started from plot code; `agentic/specs/261024-agent-all-libraries.md`): since 2026-10-31 (PB-1, not served before PB-2) their rows run `harness.py --figure plot.py -- /opt/node/bin/node /opt/anyplot/js-render/render.mjs --library=`, in which the harness forks the plot step before it arms anything, the plot writes its figure as JSON (`fig.write_json`, `bokeh.embed.json_item`) under the plot guard, the exec filter and the Python limits, and the harness, never armed, then runs the JavaScript row's harness on it with that row's environment and limits, which draws it as data with the installed package's own bundled JavaScript in a sandbox-mode page whose policy admits scripts by a nonce alone, at the figure's own CSS size and the device scale that makes the canvas 3200x1800 or 2400x2400; the renderer image installs plotly and bokeh without kaleido or Selenium. Since 2026-10-24 the renderer holds these commands in one table (`agents/renderer/commands.py`, keyed by the job's `(language, library)`, the library checked against the catalogue's fifteen) and never takes a command from a request; a non-Python row runs through the harness's exec mode (`harness.py --exec -- plot.`: the start line, the limits set and inherited, the end line from the child's rusage), so the launcher retry, the watchdog and the kill path hold for every language; a row says which limits differ (`RLIMIT_AS` off for the JavaScript and Julia rows, whose V8, Chromium and LLVM reserve address space far beyond their resident memory, and `RLIMIT_NPROC` off for the JavaScript row alone, whose one Chromium render runs 77 tasks; since 2026-10-25 `RLIMIT_AS` is off for altair's Python row too, because vl-convert's embedded V8 aborts at start under any limit up to 64 GiB while its resident set is about 270 MiB), which limit it sets in place of the configured one (since 2026-10-26 lets-plot's Python row has its own 2048 MiB, `RuntimeCommand.rlimit_as_mb`, because its Kotlin/Native rasteriser aborts every 3200x1800 export under the configured 1 GiB while 2048 MiB still stops a runaway scale, which a 6400x3600 export shows at 2580 MiB; the renderer's switch still rules, so a configured limit of 0 leaves every row unlimited), which environment it adds (lets-plot's: Arrow's system allocator, `ARROW_DEFAULT_MEMORY_POOL=system`, because pyarrow's default pool reserves a 1 GiB arena on the first pandas string column, and `FONTCONFIG_FILE=/opt/anyplot/fonts.conf` for the fontconfig its bridge links; an in-process row's environment and limit reach the sandbox through `executor.sandbox_env` and the local Docker backend through `PythonRuntime.env`, which since 2026-10-26 is `sandbox_env` key for key, the sandbox's `PATH` and limits included, but `RLIMIT_NPROC`: the local container runs as the developer's user, whose processes on the whole machine count against it, so `--pids-limit` bounds the processes there) and what render time a repair reserves; `GET /status` reports under `languages` which toolchains the image carries, and a job in a language without one is `503 runtime_unavailable`. ### Footer strip diff --git a/tests/unit/agents/renderer/test_figure_mode.py b/tests/unit/agents/renderer/test_figure_mode.py new file mode 100644 index 00000000000..b16d7adc762 --- /dev/null +++ b/tests/unit/agents/renderer/test_figure_mode.py @@ -0,0 +1,590 @@ +"""The harness's figure mode (`harness.py --figure plot.py -- `, PB-1 of step 4 in +agentic/specs/261024-agent-all-libraries.md): the plot step forked before anything is armed, the plot armed in the +child, the parent never armed, the figure's checks, the final error line of either step, and the renderer's +two-step rows. + +The harness runs in a subprocess of its own (an audit hook and a seccomp filter cannot be removed), loaded by a +driver that stands in for the package's lookup, so these cases need neither plotly nor bokeh; the screenshot command +is a Python script that reports what it ran under. `tests/unit/automation/test_js_render_figure.py` draws real +figures through the JavaScript harness. +""" + +import json +import os +import platform +import re +import resource +import subprocess +import sys +import time +from dataclasses import replace +from pathlib import Path + +import pytest + +from agents.anyplot.render import harness +from agents.anyplot.render.runtimes.python import HARNESS_SOURCE +from agents.renderer import commands +from agents.renderer import executor as executor_module +from agents.renderer.commands import ( + FIGURE_FLAG, + FIGURE_LIBRARIES, + JS_ENV, + JS_HARNESS, + NODE_BINARY, + RuntimeCommand, + command_for, +) +from agents.renderer.executor import SANDBOX_PATH, ExecutorConfig, SandboxExecutor, build_argv, sandbox_env +from agents.renderer.wire import RenderRequest + +from .conftest import FakeLauncher + + +KERNEL = pytest.mark.skipif( + not sys.platform.startswith("linux") or platform.machine() not in harness.EXEC_DENY_MACHINES, + reason="the filter is installed on Linux on x86_64 and aarch64 only", +) +ERROR_LINE = re.compile(r"^render: error: (plot|figure|crash|harness) ([A-Za-z_][A-Za-z0-9_]*) (\S+:\d+|-): (.*)$") +"""The final error line's grammar for the plot step (`harness.figure_error_line`), `render.mjs`'s own.""" +DRIVER = ( + "import importlib.util, sys\n" + "spec = importlib.util.spec_from_file_location('harness', sys.argv[1])\n" + "harness = importlib.util.module_from_spec(spec)\n" + "spec.loader.exec_module(harness)\n" + "package = sys.argv[2]\n" + "harness.find_figure_package = lambda name: package\n" + "{patch}" + "sys.exit(harness.main(['harness.py', *sys.argv[3:]]))\n" +) +"""The harness's `main` with the package's lookup answering a stand-in directory (the image has plotly and bokeh, a +test environment need not) and a case's `{patch}`.""" +STATUS = ( + "def status(pid='self'):\n" + " with open(f'/proc/{pid}/status') as handle:\n" + " pairs = (line.partition(':') for line in handle)\n" + " return {key: value.strip() for key, _, value in pairs if key in ('Seccomp_filters', 'NoNewPrivs')}\n" +) +"""A process's seccomp filters and its no-new-privileges bit. The count, not the mode: a test may already run under a +filter of its own sandbox, which every process below it inherits.""" +OBSERVING_PLOT = ( + "import json, os, resource\n" + STATUS + "seen = {'pid': os.getpid(), 'ppid': os.getppid(), 'status': status(),\n" + " 'as': resource.getrlimit(resource.RLIMIT_AS)[0], 'nproc': resource.getrlimit(resource.RLIMIT_NPROC)[0],\n" + " 'cpu': resource.getrlimit(resource.RLIMIT_CPU)[0]}\n" + "with open('plot-light.json', 'w') as handle:\n" + " json.dump({'data': [], 'layout': {}, 'seen': seen}, handle)\n" +) +"""A plot that writes what it ran under into its figure, the one file it may write.""" +SCREENSHOT = ( + "import json, os, resource, subprocess, sys\n" + STATUS + "figure = sys.argv[-1].split('=', 1)[1]\n" + "report = {'argv': sys.argv[1:], 'harness': os.getppid(), 'harness_status': status(os.getppid()),\n" + " 'status': status(), 'as': resource.getrlimit(resource.RLIMIT_AS)[0],\n" + " 'nproc': resource.getrlimit(resource.RLIMIT_NPROC)[0], 'cpu': resource.getrlimit(resource.RLIMIT_CPU)[0],\n" + " 'env': {key: value for key, value in os.environ.items() if key.startswith('ANYPLOT_')},\n" + " 'figure': json.load(open(figure)), 'starts': subprocess.run(['/bin/true']).returncode}\n" + "open('shot.json', 'w').write(json.dumps(report))\n" + "open('plot-light.png', 'wb').write(b'\\x89PNG\\r\\n\\x1a\\n')\n" + "code = int(os.environ.get('SHOT_EXIT', '0'))\n" + "if code:\n" + " print('render: error: page TypeError -: the screenshot step failed', file=sys.stderr)\n" + "sys.exit(code)\n" +) +"""The screenshot step's stand-in: reports its own and its parent's state, then writes a PNG's signature.""" + + +def run_figure( + tmp_path: Path, + plot: str, + *, + env: dict[str, str] | None = None, + package: str = "plotly", + command: list[str] | None = None, + patch: str = "", + timeout: float = 120, +) -> subprocess.CompletedProcess[str]: + """The harness in figure mode on `plot` in `tmp_path`, the stand-in screenshot (or `command`) after it, with the + driver's `patch` applied before `main`.""" + work = tmp_path / "work" + work.mkdir(exist_ok=True) + (work / "plot.py").write_text(plot, encoding="utf-8") + (tmp_path / "shot.py").write_text(SCREENSHOT, encoding="utf-8") + stand_in = tmp_path / "site-packages" / package + stand_in.mkdir(parents=True, exist_ok=True) + command = command or [sys.executable, "-I", str(tmp_path / "shot.py")] + argv = [FIGURE_FLAG, package, "plot.py", "--", *command] + driver = DRIVER.replace("{patch}", patch) + return subprocess.run( + [sys.executable, *harness.INTERPRETER_FLAGS, "-c", driver, str(HARNESS_SOURCE), str(stand_in), *argv], + cwd=work, + capture_output=True, + text=True, + timeout=timeout, + env={ + "PATH": "/usr/bin:/bin", + "HOME": str(tmp_path), + "ANYPLOT_THEME": "light", + "ANYPLOT_RLIMIT_CPU_S": "120", + **(env or {}), + }, + ) + + +def end_line(stdout: str) -> dict[str, object]: + lines = [line for line in stdout.splitlines() if line.startswith(f"{harness.MARKER} ")] + report = json.loads(lines[-1].split(" ", 1)[1]) + assert report["event"] == "end" + return report + + +def last_line(stderr: str) -> str: + lines = stderr.strip().splitlines() + return lines[-1] if lines else "" + + +class TestTwoSteps: + @KERNEL + def test_the_plot_runs_armed_in_a_fork_and_the_screenshot_after_it_unarmed(self, tmp_path: Path) -> None: + """Fork before arm: both steps are children of one harness process, which no filter ever reached; the plot + step runs under the filter and the plot's limits, the screenshot step under neither and without the plot's + variables, and starts a program.""" + address_space = 4096 + done = run_figure( + tmp_path, OBSERVING_PLOT, env={"ANYPLOT_DENY_EXEC": "required", "ANYPLOT_RLIMIT_AS_MB": str(address_space)} + ) + + assert done.returncode == 0, done.stderr + shot = json.loads((tmp_path / "work" / "shot.json").read_text(encoding="utf-8")) + plot = shot["figure"]["seen"] + assert plot["ppid"] == shot["harness"] # a child of the harness, forked, not a program it started anew + inherited = int(shot["harness_status"]["Seccomp_filters"]) # the test's own sandbox may hold one + # The kernel filter in the plot step alone; the harness never armed, and the screenshot starts a program. + assert plot["status"] == {"Seccomp_filters": str(inherited + 1), "NoNewPrivs": "1"} + assert shot["harness_status"]["NoNewPrivs"] == "0" + assert shot["status"] == shot["harness_status"] and shot["starts"] == 0 + assert plot["as"] == address_space * 1024 * 1024 and shot["as"] == resource.RLIM_INFINITY + assert plot["cpu"] == shot["cpu"] == 120 # the run's limits reach both steps + assert shot["argv"] == ["--figure=plot-light.json"] + assert shot["env"]["ANYPLOT_FIGURE_PACKAGE"].endswith("site-packages/plotly") + assert not set(harness.FIGURE_PLOT_ONLY_ENV) & set(shot["env"]) + end = end_line(done.stdout) + assert end["exec_deny"] == "on" and set(end["steps"]) == {"plot", "screenshot"} + assert end["max_rss_mb"] >= end["steps"]["plot"]["max_rss_mb"] + assert end["cpu_s"] >= end["steps"]["plot"]["cpu_s"] + end["steps"]["screenshot"]["cpu_s"] - 0.01 + + def test_the_plots_process_limit_is_its_own(self, tmp_path: Path) -> None: + """`RLIMIT_NPROC` binds the plot step; the screenshot step keeps the harness's own, as the JavaScript row.""" + soft, hard = resource.getrlimit(resource.RLIMIT_NPROC) + wanted = hard if hard != resource.RLIM_INFINITY else 1_000_000 + done = run_figure(tmp_path, OBSERVING_PLOT, env={"ANYPLOT_RLIMIT_NPROC": str(wanted)}) + + assert done.returncode == 0, done.stderr + shot = json.loads((tmp_path / "work" / "shot.json").read_text(encoding="utf-8")) + assert shot["figure"]["seen"]["nproc"] == wanted and shot["nproc"] == soft + + def test_without_the_requirement_the_end_line_reports_the_state(self, tmp_path: Path) -> None: + done = run_figure(tmp_path, OBSERVING_PLOT) + + assert done.returncode == 0, done.stderr + assert end_line(done.stdout)["exec_deny"] in ("on", "unavailable", "unsupported") + + def test_a_stale_figure_never_reaches_the_screenshot(self, tmp_path: Path) -> None: + (tmp_path / "work").mkdir() + (tmp_path / "work" / "plot-light.json").write_text('{"data": [], "layout": {}}', encoding="utf-8") + done = run_figure(tmp_path, "x = 1\n") + + assert done.returncode == 1 + assert last_line(done.stderr) == "render: error: figure NoFigure -: the plot wrote no plot-light.json" + assert not (tmp_path / "work" / "shot.json").exists() + + +class TestThePlotStep: + @pytest.mark.parametrize( + ("body", "line", "event"), + [ + ("import subprocess\nsubprocess.run(['/bin/true'])\n", 2, "subprocess.Popen"), + ("import os\nos.posix_spawn('/bin/true', ['true'], {})\n", 2, "os.posix_spawn"), + ("import os\nos.fork()\n", 2, "os.fork"), + ("import os\nos.execv('/bin/true', ['true'])\n", 2, "os.exec"), + ("import socket\nsocket.socketpair()\n", 2, "socket"), + ("open('plot-light.png', 'wb').write(b'x')\n", 1, "open"), + ], + ids=["subprocess", "posix_spawn", "fork", "execv", "a socket pair", "the PNG itself"], + ) + def test_a_plot_that_starts_a_program_or_writes_the_png_fails_before_the_screenshot( + self, tmp_path: Path, body: str, line: int, event: str + ) -> None: + """No browser from plot code: kaleido's choreographer opens a socket pair, Selenium a socket to chromedriver + and both start a program; every such start, and the PNG the screenshot step alone writes, are refused.""" + done = run_figure(tmp_path, body) + + assert done.returncode == 1 + match = ERROR_LINE.match(last_line(done.stderr)) + assert match is not None, done.stderr + assert match.group(1) == "plot" and match.group(3) == f"plot.py:{line}" + assert match.group(2) == "PermissionError" and event in match.group(4) + assert not (tmp_path / "work" / "shot.json").exists() and not (tmp_path / "work" / "plot-light.png").exists() + assert set(end_line(done.stdout)["steps"]) == {"plot"} + + def test_nothing_of_the_plot_runs_after_it(self, tmp_path: Path) -> None: + """The child ends with `os._exit`: an `atexit` callback and a thread the plot left, each of which would + overwrite the figure (the one file the guard lets it write), never run on.""" + plot = ( + "import atexit, threading, time\n" + "atexit.register(lambda: open('plot-light.json', 'w').write('late-atexit'))\n" + "threading.Thread(target=lambda: (time.sleep(1), open('plot-light.json', 'w').write('late-thread'))).start()\n" + + OBSERVING_PLOT + ) + done = run_figure(tmp_path, plot) + time.sleep(1.5) + + assert done.returncode == 0, done.stderr + assert "seen" in json.loads((tmp_path / "work" / "plot-light.json").read_text(encoding="utf-8")) + + @KERNEL + def test_the_kernel_refuses_a_start_the_audit_hook_misses(self, tmp_path: Path) -> None: + """The plot step's second layer: with the hook made blind to `os.exec` (as it is to native code), the kernel + filter still refuses the start, so nothing after the arming starts a program in the plot step.""" + blind = "harness.PLOT_BLOCKED_PREFIXES = tuple(p for p in harness.PLOT_BLOCKED_PREFIXES if p != 'os.exec')\n" + plot = ( + "import json, os\n" + "try:\n" + " os.execv('/bin/true', ['true'])\n" + "except OSError as exc:\n" + " refused = type(exc).__name__\n" + "json.dump({'data': [], 'refused': refused}, open('plot-light.json', 'w'))\n" + ) + done = run_figure(tmp_path, plot, env={"ANYPLOT_DENY_EXEC": "required"}, patch=blind) + + assert done.returncode == 0, done.stderr + shot = json.loads((tmp_path / "work" / "shot.json").read_text(encoding="utf-8")) + assert shot["figure"]["refused"] == "PermissionError" and shot["starts"] == 0 + + @pytest.mark.parametrize( + ("plot", "code", "line"), + [ + ("x = 1\nraise ValueError('boom')\n", 1, "render: error: plot ValueError plot.py:2: boom"), + ("x = (\n", 1, None), + ("import sys\nsys.exit(4)\n", 4, "render: error: plot SystemExit plot.py:2: 4"), + ("x = 1\n", 1, "render: error: figure NoFigure -: the plot wrote no plot-light.json"), + ("import os\nos._exit(0)\n", 1, "render: error: figure NoFigure -: the plot wrote no plot-light.json"), + ( + "open('plot-light.json', 'w').close()\n", + 1, + "render: error: figure FigureError -: plot-light.json is empty", + ), + ], + ids=[ + "an exception", + "a syntax error", + "sys.exit", + "no figure", + "os._exit before the figure", + "an empty figure", + ], + ) + def test_a_failed_plot_step_ends_stderr_with_its_line_and_its_code( + self, tmp_path: Path, plot: str, code: int, line: str | None + ) -> None: + done = run_figure(tmp_path, plot) + + assert done.returncode == code + last = last_line(done.stderr) + assert ERROR_LINE.match(last), done.stderr + if line is not None: + assert last == line + else: + assert last.startswith("render: error: plot SyntaxError plot.py:1: ") + assert not (tmp_path / "work" / "shot.json").exists() + + def test_a_plot_that_succeeds_with_sys_exit_0_goes_on(self, tmp_path: Path) -> None: + done = run_figure(tmp_path, OBSERVING_PLOT + "import sys\nsys.exit(0)\n") + assert done.returncode == 0, done.stderr + assert (tmp_path / "work" / "shot.json").exists() + + def test_the_traceback_comes_before_the_final_line(self, tmp_path: Path) -> None: + done = run_figure(tmp_path, "def f():\n raise KeyError('k')\n\nf()\n") + + lines = done.stderr.strip().splitlines() + assert "Traceback (most recent call last):" in lines and 'File "plot.py", line 4, in ' in done.stderr + assert lines[-1] == "render: error: plot KeyError plot.py:2: 'k'" + + def test_a_plot_step_killed_by_a_signal_is_a_crash(self, tmp_path: Path) -> None: + """`RLIMIT_CPU` ends a busy plot: soft and hard limit alike, the kernel kills it with SIGKILL.""" + done = run_figure(tmp_path, "while True:\n pass\n", env={"ANYPLOT_RLIMIT_CPU_S": "1"}, timeout=60) + + assert done.returncode == 128 + 9 + assert last_line(done.stderr) == "render: error: crash PlotCrashed -: the plot step died of SIGKILL" + assert set(end_line(done.stdout)["steps"]) == {"plot"} + + def test_the_figure_cap_refuses_one_byte_more(self, tmp_path: Path) -> None: + plot = f"open('plot-light.json', 'w').write(' ' * {harness.FIGURE_MAX_BYTES} + '{{}}')\n" + done = run_figure(tmp_path, plot, env={"ANYPLOT_RLIMIT_FSIZE_MB": "64"}) + + assert done.returncode == 1 + size = harness.FIGURE_MAX_BYTES + 2 + assert last_line(done.stderr) == ( + f"render: error: figure FigureTooLarge -: plot-light.json is {size} bytes, " + f"more than the {harness.FIGURE_MAX_BYTES}-byte limit" + ) + assert not (tmp_path / "work" / "shot.json").exists() + + def test_a_required_filter_that_is_not_in_place_stops_the_plot_step(self, tmp_path: Path) -> None: + done = run_figure( + tmp_path, + "open('plot-light.json', 'w').write('ran')\n" + OBSERVING_PLOT, + env={"ANYPLOT_DENY_EXEC": "required"}, + patch="harness.install_exec_deny = lambda: 'unavailable'\n", + ) + + assert done.returncode == harness.EXEC_DENY_EXIT + assert last_line(done.stderr) == ( + "render: error: harness ExecDenyUnavailable -: the sandbox could not refuse process starts (unavailable)" + ) + assert not (tmp_path / "work" / "plot-light.json").exists() and not (tmp_path / "work" / "shot.json").exists() + assert end_line(done.stdout)["exec_deny"] == "unavailable" + + +class TestTheScreenshotStep: + def test_its_failure_is_the_runs_and_its_line_stays_last(self, tmp_path: Path) -> None: + done = run_figure(tmp_path, OBSERVING_PLOT, env={"SHOT_EXIT": "5"}) + + assert done.returncode == 5 + assert last_line(done.stderr) == "render: error: page TypeError -: the screenshot step failed" + assert set(end_line(done.stdout)["steps"]) == {"plot", "screenshot"} + + def test_a_command_that_cannot_start_is_127(self, tmp_path: Path) -> None: + missing = str(tmp_path / "no-such-node") + done = run_figure(tmp_path, OBSERVING_PLOT, command=[missing, "render.mjs"]) + + assert done.returncode == 127 + assert ( + last_line(done.stderr) == f"render: error: harness SpawnError -: cannot run {missing} (FileNotFoundError)" + ) + + +class TestHarnessUsage: + @pytest.mark.parametrize( + "argv", + [[FIGURE_FLAG], [FIGURE_FLAG, "plotly", "plot.py"], [FIGURE_FLAG, "matplotlib", "plot.py", "--", "x"]], + ids=["nothing", "no command", "another package"], + ) + def test_a_bad_call_is_a_usage_error_after_the_start_line( + self, argv: list[str], capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(harness, "set_run_limits", lambda: None) + assert harness.main(["harness.py", *argv]) == 2 + captured = capsys.readouterr() + assert captured.out.splitlines()[0] == executor_module.HARNESS_START + assert last_line(captured.err).startswith("render: error: harness UsageError -: ") + + def test_a_missing_package_stops_before_the_plot( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setattr(harness, "set_run_limits", lambda: None) + monkeypatch.setattr(harness, "find_figure_package", lambda name: None) + monkeypatch.setattr(harness, "plot_step", lambda *args: pytest.fail("the plot step started")) + monkeypatch.chdir(tmp_path) + assert harness.main(["harness.py", FIGURE_FLAG, "bokeh", "plot.py", "--", "x"]) == 2 + assert last_line(capsys.readouterr().err) == ( + "render: error: harness PackageMissing -: bokeh is not installed where the harness runs" + ) + + +class TestHelpers: + def test_the_package_is_found_without_importing_it(self) -> None: + found = harness.find_figure_package("json") + assert found is not None and Path(found, "__init__.py").is_file() + assert harness.find_figure_package("anyplot_no_such_package") is None + assert harness.find_figure_package("os") is None # a module, not a package + + def test_check_figure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + assert harness.check_figure("plot-light.json") == ("NoFigure", None, "the plot wrote no plot-light.json") + Path("plot-light.json").write_text("{}", encoding="utf-8") + assert harness.check_figure("plot-light.json") is None + Path("real.json").write_text("{}", encoding="utf-8") + Path("plot-dark.json").symlink_to("real.json") + name, _, message = harness.check_figure("plot-dark.json") + assert name == "FigureError" and "could not be opened as a regular file" in message + Path("dir.json").mkdir() + assert harness.check_figure("dir.json") == ("FigureError", None, "dir.json is not a regular file") + with open("at-cap.json", "wb") as handle: + handle.truncate(harness.FIGURE_MAX_BYTES) + assert harness.check_figure("at-cap.json") is None + + @pytest.mark.parametrize( + ("written", "state", "record"), + [ + (b"", "off", None), + (b"exec_deny on\n", "on", None), + (b"exec_deny forged\n", "unavailable", None), + (b'error ["SyntaxError", 3, "bad"]\n', "off", ("SyntaxError", 3, "bad")), + (b'exec_deny on\nerror ["ValueError", 0, "x"]\n', "on", ("ValueError", None, "x")), + (b'exec_deny on\nerror ["not a class", 2, "x"]\nerror [1, 2]\n', "on", None), + (b'exec_deny on\nerror ["A", 2, "x"]\nnoise\n', "on", ("A", 2, "x")), + ], + ids=["nothing", "on", "a forged state", "before arming", "line 0", "malformed", "noise after"], + ) + def test_the_report_pipe(self, written: bytes, state: str, record: tuple | None) -> None: + read_end, write_end = os.pipe() + os.write(write_end, written) + os.close(write_end) + os.set_blocking(read_end, False) + assert harness._read_figure_report(read_end) == (state, record) + + def test_the_final_line_is_one_line_within_its_cap(self, capsys: pytest.CaptureFixture[str]) -> None: + harness.figure_error_line("plot", "ValueError", "plot.py:3", "a\nb\t c" + "x" * 500) + line = capsys.readouterr().err.rstrip("\n") + assert "\n" not in line and ERROR_LINE.match(line) + assert line.startswith("render: error: plot ValueError plot.py:3: a b c") + assert len(line.split(": ", 3)[3]) == harness.FIGURE_ERROR_CHARS and line.endswith("…") + + def test_the_constants_the_javascript_harness_shares(self) -> None: + render = (Path(__file__).resolve().parents[4] / "automation" / "js-render" / "render.mjs").read_text() + cap = re.search(r"^const FIGURE_MAX_BYTES = (\d+) \* (\d+) \* (\d+);$", render, re.MULTILINE) + assert cap is not None and int(cap[1]) * int(cap[2]) * int(cap[3]) == harness.FIGURE_MAX_BYTES + assert re.search(r"^const ERROR_MESSAGE_CHARS = (\d+);", render, re.MULTILINE)[1] == str( + harness.FIGURE_ERROR_CHARS + ) + assert re.search(r"process\.env\.(ANYPLOT_FIGURE_PACKAGE)", render)[1] == harness.FIGURE_PACKAGE_ENV + figures = render[render.index("const FIGURES = {") : render.index("\n};", render.index("const FIGURES = {"))] + assert set(re.findall(r"^ (\w+): \{", figures, re.MULTILINE)) == set(harness.FIGURE_PACKAGES) + assert harness.EXEC_DENY_ENV in harness.FIGURE_PLOT_ONLY_ENV + + def test_the_figure_mode_flag_and_packages_are_the_renderers(self) -> None: + assert FIGURE_FLAG == harness.FIGURE_FLAG and FIGURE_LIBRARIES == harness.FIGURE_PACKAGES + + def test_plotlys_validator_builder_is_a_code_builder_and_nothing_else_of_it_is(self) -> None: + def frame(path: str, name: str) -> object: + code = type("Code", (), {"co_filename": path, "co_name": name}) + return type("Frame", (), {"f_code": code, "f_back": None}) + + site = "/app/.venv/lib/python3.14/site-packages" + assert harness.builds_code(frame(f"{site}/_plotly_utils/basevalidators.py", "build_validator")) + assert not harness.builds_code(frame(f"{site}/_plotly_utils/basevalidators.py", "__init__")) + assert not harness.builds_code(frame(f"{site}/plotly/basedatatypes.py", "build_validator")) + + +class TestTheRows: + @pytest.mark.parametrize("library", FIGURE_LIBRARIES) + def test_plotly_and_bokeh_run_in_two_steps(self, library: str) -> None: + row = command_for("python", library) + assert row.figure == library and not row.in_process and row.code_file == "plot.py" + assert row.argv == (NODE_BINARY, JS_HARNESS, f"--library={library}") and row.toolchain == (NODE_BINARY,) + assert row.env == JS_ENV + # The plot step's: the filter and the Python limits; the screenshot step drops them. + assert row.exec_deny and row.rlimit_as and row.rlimit_nproc and row.rlimit_as_mb is None + + def test_no_other_row_runs_in_two_steps(self) -> None: + for library, language in commands.LIBRARY_LANGUAGES.items(): + assert bool(command_for(language, library).figure) == (library in FIGURE_LIBRARIES), library + + def test_a_two_step_row_is_a_python_plot_with_a_command(self) -> None: + with pytest.raises(ValueError, match="two-step"): + RuntimeCommand("javascript", "plot.js", ("node",), figure="plotly") + with pytest.raises(ValueError, match="two-step"): + RuntimeCommand("python", "plot.py", (), figure="plotly") + + def test_the_argv_runs_the_harness_in_figure_mode(self, config: ExecutorConfig, tmp_path: Path) -> None: + row = command_for("python", "bokeh") + argv = build_argv(config, "r-a-dark", tmp_path, "dark", 60, command=row) + + assert argv[argv.index(config.harness) :] == [ + config.harness, + FIGURE_FLAG, + "bokeh", + "plot.py", + "--", + NODE_BINARY, + JS_HARNESS, + "--library=bokeh", + ] + + def test_the_environment_is_a_python_rows_plus_the_javascript_rows(self, config: ExecutorConfig) -> None: + limited = replace(config, rlimit_as_mb=1024, rlimit_nproc=64) + env = sandbox_env(limited, "dark", 60, command=command_for("python", "plotly")) + + assert env["ANYPLOT_THEME"] == "dark" and env["MPLBACKEND"] == "Agg" + assert {key: env[key] for key in JS_ENV} == dict(JS_ENV) + assert env["ANYPLOT_DENY_EXEC"] == "required" + assert env["ANYPLOT_RLIMIT_AS_MB"] == "1024" and env["ANYPLOT_RLIMIT_NPROC"] == "64" + assert env["PATH"] == SANDBOX_PATH and env["HOME"] == "/tmp" + # Every other row's environment is what it was. + matplotlib = sandbox_env(limited, "dark", 60, command=command_for("python", "matplotlib")) + assert "ANYPLOT_SANDBOX" not in matplotlib and "PLAYWRIGHT_BROWSERS_PATH" not in matplotlib + + +WRAPPER = ( + "import importlib.util, sys\n" + "spec = importlib.util.spec_from_file_location('harness', {harness!r})\n" + "harness = importlib.util.module_from_spec(spec)\n" + "spec.loader.exec_module(harness)\n" + "harness.find_figure_package = lambda name: {package!r}\n" + "sys.exit(harness.main(sys.argv))\n" +) +"""The harness for the executor's runs: the image's, with the package's lookup standing in.""" + + +class TestTheRenderersTimeout: + """The job's one timeout covers both steps: the executor kills the launcher's process group, the harness and both + of its children with it, whichever step runs.""" + + @pytest.fixture + def figure_executor( + self, config: ExecutorConfig, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> SandboxExecutor: + wrapper = tmp_path / "harness_wrapper.py" + stand_in = tmp_path / "site-packages" / "plotly" + stand_in.mkdir(parents=True) + wrapper.write_text(WRAPPER.format(harness=str(HARNESS_SOURCE), package=str(stand_in)), encoding="utf-8") + shot = tmp_path / "shot.py" + shot.write_text( + "import os, sys, time\n" + "if os.environ.get('ANYPLOT_THEME') == 'dark':\n" + " time.sleep(60)\n" + "open('plot-' + os.environ['ANYPLOT_THEME'] + '.png', 'wb').write(b'\\x89PNG\\r\\n\\x1a\\n')\n", + encoding="utf-8", + ) + row = replace(command_for("python", "plotly"), argv=(sys.executable, "-I", str(shot)), env={}) + monkeypatch.setattr(executor_module, "command_for", lambda language, library: row) + return SandboxExecutor(replace(config, harness=str(wrapper))) + + @staticmethod + def job(source: str, timeout_s: float) -> RenderRequest: + return RenderRequest( + job_id="figure1", + language="python", + library="plotly", + source=source, + data_csv="x\n1\n", + themes=["light"], + timeout_s=timeout_s, + ) + + async def test_a_plot_step_that_runs_on_is_stopped( + self, figure_executor: SandboxExecutor, launcher: FakeLauncher, config: ExecutorConfig + ) -> None: + started = time.monotonic() + run = await figure_executor.run_theme(self.job("import time\ntime.sleep(60)\n", 1.5), "light") + + assert run.timed_out and run.reason == "timeout" and run.exit_code is None + assert time.monotonic() - started < 15 + assert launcher.deletes() == launcher.launches() + assert list(config.runs_dir.iterdir()) == [] + + async def test_a_screenshot_step_that_runs_on_is_stopped( + self, figure_executor: SandboxExecutor, launcher: FakeLauncher + ) -> None: + started = time.monotonic() + plot = "open('plot-dark.json', 'w').write('{}')\n" + run = await figure_executor.run_theme(self.job(plot, 2.5), "dark") + + assert run.timed_out and run.reason == "timeout" and run.png_base64 is None + assert time.monotonic() - started < 15 + assert launcher.deletes() == launcher.launches() + + async def test_both_steps_within_the_timeout_render(self, figure_executor: SandboxExecutor) -> None: + run = await figure_executor.run_theme(self.job("open('plot-light.json', 'w').write('{}')\n", 30), "light") + + assert run.exit_code == 0 and not run.timed_out, run.stderr_tail + assert run.png_base64 is not None and run.probe is None diff --git a/tests/unit/automation/test_js_render_figure.py b/tests/unit/automation/test_js_render_figure.py new file mode 100644 index 00000000000..f8e84aba1df --- /dev/null +++ b/tests/unit/automation/test_js_render_figure.py @@ -0,0 +1,422 @@ +"""The JavaScript render harness's figure mode (`automation/js-render/render.mjs --library= +--figure=`, PB-1 of step 4 in agentic/specs/261024-agent-all-libraries.md), run through Node and Chromium, and +the whole two-step render through the Python harness. + +Each case draws a figure with the package's own bundled JavaScript, taken from the installed Python package as the +renderer takes it, so the module runs where Node, the lock's packages, Playwright's browser and plotly or bokeh are +present and is skipped elsewhere; the renderer image's CI step (`ci-image.yml`) renders through the built image. The +harness is copied into a checkout's layout, as in `test_js_render_sandbox.py`. +""" + +import importlib.util +import json +import os +import re +import shutil +import struct +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from agents.anyplot.render import harness as python_harness +from agents.anyplot.render.runtimes.python import HARNESS_SOURCE + + +REPO_ROOT = Path(__file__).resolve().parents[3] +HARNESS = REPO_ROOT / "automation" / "js-render" / "render.mjs" +SANDBOX_ENV = {"ANYPLOT_SANDBOX": "1", "ANYPLOT_NO_HTML": "1"} +ERROR_LINE = re.compile( + r"^render: error: (page|import|build|draw|crash|data|timeout|harness|figure) ([A-Za-z_$][\w$]*) (\S+:\d+|-): (.*)$" +) +"""The final error line's grammar, with figure mode's `figure` kind.""" +BAR = {"data": [{"type": "bar", "x": ["a", "b", "c"], "y": [3, 1, 2]}], "layout": {"title": {"text": "bars"}}} + + +def package_dir(name: str) -> Path | None: + spec = importlib.util.find_spec(name) + return Path(next(iter(spec.submodule_search_locations))) if spec and spec.submodule_search_locations else None + + +def plotly_figure(width: float | None = None, height: float | None = None, **layout: object) -> dict[str, object]: + figure = json.loads(json.dumps(BAR)) + if width is not None: + figure["layout"]["width"] = width + if height is not None: + figure["layout"]["height"] = height + figure["layout"].update(layout) + return figure + + +@dataclass +class Run: + returncode: int + stdout: str + stderr: str + work: Path + + @property + def last_line(self) -> str: + lines = self.stderr.strip().splitlines() + return lines[-1] if lines else "" + + def png_size(self, theme: str = "light") -> tuple[int, int]: + png = (self.work / f"plot-{theme}.png").read_bytes() + assert png[:8] == b"\x89PNG\r\n\x1a\n" + return struct.unpack(">II", png[16:24]) + + +@dataclass +class Harness: + root: Path + node: str + + @property + def render_mjs(self) -> Path: + return self.root / "automation" / "js-render" / "render.mjs" + + def environment(self, extra: dict[str, str] | None = None, *, sandbox: bool = True) -> dict[str, str]: + environment = {key: value for key, value in os.environ.items() if not key.startswith("ANYPLOT_")} + environment |= {"ANYPLOT_THEME": "light", "ANYPLOT_NODE_MODULES": str(self.root / "node_modules")} + if sandbox: + environment |= SANDBOX_ENV + return environment | (extra or {}) + + def draw( + self, + tmp_path: Path, + figure: object, + *, + library: str = "plotly", + args: list[str] | None = None, + env: dict[str, str] | None = None, + sandbox: bool = True, + package: bool = True, + ) -> Run: + work = tmp_path / "work" + work.mkdir(parents=True, exist_ok=True) + if figure is not None: + text = figure if isinstance(figure, str) else json.dumps(figure) + (work / "plot-light.json").write_text(text, encoding="utf-8") + extra = dict(env or {}) + found = package_dir(library) if library in ("plotly", "bokeh") else None + if package and found is not None: + extra["ANYPLOT_FIGURE_PACKAGE"] = str(found) + argv = args if args is not None else [f"--library={library}", "--figure=plot-light.json"] + done = subprocess.run( + [self.node, str(self.render_mjs), *argv], + cwd=work, + env=self.environment(extra, sandbox=sandbox), + capture_output=True, + text=True, + timeout=120, + check=False, + ) + return Run(done.returncode, done.stdout, done.stderr, work) + + +def _node_modules() -> Path | None: + root = Path(os.environ.get("ANYPLOT_NODE_MODULES", REPO_ROOT / "node_modules")) + return root if (root / "playwright" / "package.json").is_file() else None + + +@pytest.fixture(scope="module") +def harness(tmp_path_factory: pytest.TempPathFactory) -> Harness: + node = shutil.which("node") + modules = _node_modules() + if node is None or modules is None: + pytest.skip("needs node on PATH and the lock's node_modules (npm ci, or ANYPLOT_NODE_MODULES)") + if package_dir("plotly") is None: + pytest.skip("needs plotly, whose bundled plotly.js draws the figures (the lib-plotly extra)") + root = tmp_path_factory.mktemp("checkout") + (root / "automation" / "js-render").mkdir(parents=True) + shutil.copy(HARNESS, root / "automation" / "js-render" / "render.mjs") + (root / "node_modules").symlink_to(modules.resolve(), target_is_directory=True) + probe = Harness(root, node).draw(tmp_path_factory.mktemp("probe"), plotly_figure(800, 450)) + if probe.returncode != 0: + pytest.skip(f"the harness cannot render here (no browser?): {probe.stderr.strip()[-300:]}") + return Harness(root, node) + + +def bokeh_or_skip(): + if package_dir("bokeh") is None: + pytest.skip("needs bokeh, whose bundled BokehJS draws the figures (the lib-bokeh extra)") + import bokeh.embed + import bokeh.layouts + import bokeh.models + import bokeh.plotting + + return bokeh + + +def bokeh_item(width: int, height: int, **kwargs: object) -> dict[str, object]: + bokeh = bokeh_or_skip() + plot = bokeh.plotting.figure(width=width, height=height, toolbar_location=None, **kwargs) + plot.line([1, 2, 3], [3, 1, 2], line_width=4) + return bokeh.embed.json_item(plot) + + +def assert_final_line(run: Run, kind: str, cls: str) -> str: + assert run.returncode != 0, run.stderr + match = ERROR_LINE.match(run.last_line) + assert match is not None, run.stderr + assert (match.group(1), match.group(2), match.group(3)) == (kind, cls, "-"), run.last_line + return match.group(4) + + +class TestTheCanvas: + @pytest.mark.parametrize( + ("size", "pixels", "scale"), + [ + ((800, 450), (3200, 1800), "4.000"), + ((1600, 900), (3200, 1800), "2.000"), + ((600, 600), (2400, 2400), "4.000"), + ((1200, 1200), (2400, 2400), "2.000"), + ((3600, 3600), (2400, 2400), "0.667"), + ((1000, 700), (3200, 1800), "2.564"), + ((700, 1000), (2400, 2400), "2.400"), + ((None, None), (3200, 1800), "2.000"), + ], + ids=["800x450", "1600x900", "600x600", "1200x1200", "3600x3600", "wide", "tall", "no size"], + ) + def test_a_plotly_figure_keeps_its_size_and_the_scale_makes_the_canvas( + self, harness: Harness, tmp_path: Path, size: tuple, pixels: tuple[int, int], scale: str + ) -> None: + """The catalogue writes plotly at 800x450 scale 4 (146 files), 1600x900 scale 3 (116), 600x600 scale 4 (43) + and 1200x1200 scale 3 (9): the layout stays, the scale follows the canvas.""" + run = harness.draw(tmp_path, plotly_figure(*size)) + + assert run.returncode == 0, run.stderr + assert run.png_size() == pixels + assert f"at scale {scale}" in run.stdout + assert not (run.work / "plot-light.html").exists() + + def test_a_bokeh_figure_at_the_catalogues_4800_wide_draws_at_two_thirds( + self, harness: Harness, tmp_path: Path + ) -> None: + run = harness.draw(tmp_path, bokeh_item(4800, 2700), library="bokeh") + + assert run.returncode == 0, run.stderr + assert run.png_size() == (3200, 1800) and "figure 4800x2700 at scale 0.667" in run.stdout + + def test_a_bokeh_layout_is_measured_first(self, harness: Harness, tmp_path: Path) -> None: + bokeh = bokeh_or_skip() + plots = [] + for _ in range(2): + plot = bokeh.plotting.figure(width=800, height=450, toolbar_location=None) + plot.line([1, 2], [3, 4]) + plots.append(plot) + run = harness.draw(tmp_path, bokeh.embed.json_item(bokeh.layouts.row(*plots)), library="bokeh") + + assert run.returncode == 0, run.stderr + assert run.png_size() == (3200, 1800) and "figure 1600x450 at scale 2.000" in run.stdout + + def test_the_dark_theme_draws_too(self, harness: Harness, tmp_path: Path) -> None: + run = harness.draw(tmp_path, plotly_figure(800, 450), env={"ANYPLOT_THEME": "dark"}, args=None) + assert run.returncode == 0, run.stderr + assert run.png_size("dark") == (3200, 1800) + + +class TestTheFigureIsData: + def test_markup_in_a_text_stays_text(self, harness: Harness, tmp_path: Path) -> None: + """A title that would close the JSON element and open a script draws as text and runs nothing.""" + title = "