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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ jobs:
# tests/unit/test_agent_instructions.py and tests/unit/skills read CLAUDE.md,
# the Copilot instructions, .claude/guardrails.md, agentic/docs/project-guide.md,
# the skills and the commands (#12493 changed only those, skipped this job, and
# left main red).
CONFIG_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(pyproject\.toml|pytest\.ini|conftest\.py|uv\.lock|package\.json|package-lock\.json|Project\.toml|Manifest\.toml|\.python-version|CLAUDE\.md|AGENTS\.md|\.github/copilot-instructions\.md|\.claude/guardrails\.md|agentic/docs/project-guide\.md)$|^(\.github/workflows/|\.github/actions/|prompts/|\.claude/skills/|agentic/commands/)' || true)
# left main red), and tests/unit/test_cloudbuild_configs.py and
# tests/unit/agents/test_image.py read the Cloud Build configs and the renderer's
# deploy smoke script.
CONFIG_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(pyproject\.toml|pytest\.ini|conftest\.py|uv\.lock|package\.json|package-lock\.json|Project\.toml|Manifest\.toml|\.python-version|CLAUDE\.md|AGENTS\.md|\.github/copilot-instructions\.md|\.claude/guardrails\.md|agentic/docs/project-guide\.md|([^/]+/)+cloudbuild\.yaml|agents/renderer/deploy_smoke\.sh)$|^(\.github/workflows/|\.github/actions/|prompts/|\.claude/skills/|agentic/commands/)' || true)

# Force run on workflow_dispatch with force_run=true
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.force_run }}" == "true" ]]; then
Expand Down
2 changes: 1 addition & 1 deletion agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ The image's `fonts` stage installs Debian's `fonts-noto-core` and `fonts-noto-cj

Since 2026-10-30 the toolchain image also carries the JavaScript row's toolchain (JS-1 of step 3 of `agentic/specs/261024-agent-all-libraries.md`; the agents service sends JavaScript jobs since JS-4a, 2026-10-30): Node 22 under `/opt/node`, which the row runs by its path and the sandbox's `PATH` never carries; the repository's `package-lock.json` installed with `npm ci --ignore-scripts` under `/opt/anyplot/node_modules`; Playwright's Chromium headless shell alone under `/opt/pw-browsers`; the catalogue's harness, `automation/js-render/render.mjs`, under `/opt/anyplot/js-render/` with the import allowlist of MUI X snippets that the build derives from the installed packages' exports maps (`import-allowlist.json`); and Liberation Sans, Serif and Mono under `/opt/anyplot/js-fonts`, which only the row's `FONTCONFIG_FILE` (`renderer/toolchain/js-fonts.conf`) names, so Python's renders keep their fonts. Its build renders a Chart.js chart and a MUI X component through the harness with the row's environment, the CI job renders a catalogue file of each, and the deploy's candidate smoke renders a Chart.js chart from a typed table and, since JS-4a, refuses the promotion when it fails twice. Since JS-2 (2026-10-30) the row's environment switches on the harness's sandbox mode (`ANYPLOT_SANDBOX=1`, `ANYPLOT_NO_HTML=1`, `ANYPLOT_IMPORT_ALLOWLIST`): every request and WebSocket of the page refused, no popup, no WebRTC, a content security policy, UTC, a 1 GiB V8 heap for the page, MUI X's imports closed to the allowlist, a bounded console and one final stderr line `render: error: <kind> <Class> <file>:<line>: <message>`; a JavaScript job may carry the table as typed JSON (`data_json`, from `anyplot/data/typed_json.py`), which the harness injects as `window.ANYPLOT_DATA`. The CI job checks the injection, the route and the import plugin in the built image. Since JS-4b (2026-10-31) `POST /page` runs the same row with `ANYPLOT_PAGE_ONLY=1`: the harness writes the page a render would screenshot and starts no browser, and the agents service wraps it into the downloadable `plot.html`. The CI job builds such a page for Chart.js and, since JS-4c (2026-10-31), for MUI X, whose page is a React bundle, wraps each and opens it from `file://` in the image's Chromium with `renderer/page_probe.mjs`: the chart draws and answers a hover, the frame's origin is `null`, and the plot code's attempts to navigate the window or its own frame, open a popup, fetch, or read storage and cookies all fail. Since JS-4b-2 (2026-10-31) the chat shows that page in the result card (`.interactive()`) through the API's relay page, `GET /preview/relay` (`api/preview_relay.py`): a static page of opaque origin that only the site's origins may frame, that tells its parent it is ready with a constant signal without content, and that writes the first page its parent then posts into a sandboxed `srcdoc` frame. The CI job's next step runs the probe's relay mode on the same wrapper: the relay with its real headers on an origin of its own, framed and scaled by a parent at an allowed origin that posts only on the relay's signal, shows the plot, which draws and answers a hover; the plot code cannot navigate the wrapper or the relay, a second page and a page posted by the plot, a sibling frame or another origin are ignored, the plot's own ready signal starts no post, another origin cannot frame the relay, and a listening 404 page at the relay's URL receives nothing.

`renderer/cloudbuild.yaml` builds, deploys a `candidate` revision without traffic, smokes it (IAM refuses an anonymous caller; with `_SMOKE_RENDER=true`, `/status` reports the launcher and the volume and a real matplotlib render goes through the candidate, with a token minted for the service URL), and promotes it. The first build also creates the service; the deploy adds the service URL to `RENDERER_AUDIENCES` itself, and `_MIN_INSTANCES=1` keeps an instance warm. CI builds the same image before merge and renders a seaborn, a plotnine, an altair, a pygal and a lets-plot plot through the harness in it (`.github/workflows/ci-image.yml`). The deploy flags and the owner tasks are in [Agent network design](../docs/concepts/agent-network.md#serving-and-infrastructure).
`renderer/cloudbuild.yaml` builds, deploys a `candidate` revision without traffic, smokes it (IAM refuses an anonymous caller; with `_SMOKE_RENDER=true`, `/status` reports the launcher and the volume, and a matplotlib, a Makie, a Chart.js and a plotly render and a data run go through the candidate with a token minted for the service URL, each one a gate; these render probes live in `renderer/deploy_smoke.sh`, because Cloud Build refuses a step argument over 10,000 characters), and promotes it. The first build also creates the service; the deploy adds the service URL to `RENDERER_AUDIENCES` itself, and `_MIN_INSTANCES=1` keeps an instance warm. CI builds the same image before merge and renders a seaborn, a plotnine, an altair, a pygal and a lets-plot plot through the harness in it (`.github/workflows/ci-image.yml`). The deploy flags and the owner tasks are in [Agent network design](../docs/concepts/agent-network.md#serving-and-infrastructure).

### Test

Expand Down
132 changes: 5 additions & 127 deletions agents/renderer/cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -272,133 +272,11 @@ steps:
curl -fsS --retry 5 --retry-delay 5 --retry-all-errors -H "Authorization: Bearer $$TOKEN" -o status.json "$$URL/status" \
|| { echo "/status failed with this build's token: check _ALLOWED_CALLERS, roles/run.invoker and RENDERER_AUDIENCES ($$SERVICE_URL)"; exit 1; }
python3 -c "import json; s=json.load(open('status.json')); assert s['name']=='anyplot-renderer' and s['sandbox'] and s['volume'] is True, s; print('OK: /status', s['version'], s['mem_available_mb'], 'MiB available, volume in place')"
python3 - <<'EOF'
import json
source = "\n".join([
"import matplotlib.pyplot as plt",
"fig, ax = plt.subplots(figsize=(16, 9), dpi=200)",
"ax.plot([1, 2, 3, 4], [3, 1, 4, 2], linewidth=6)",
"fig.savefig('plot-light.png')",
])
job = {"job_id": "smoke", "language": "python", "library": "matplotlib", "source": source,
"data_csv": "x\n1\n", "themes": ["light"], "timeout_s": 60}
json.dump(job, open("job.json", "w"))
EOF
curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" --data @job.json -o render.json "$$URL/render" \
|| { echo "the smoke render request failed"; exit 1; }
python3 -c "import json; r=json.load(open('render.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s')}; print('OK: smoke render', o['wall_s'], 's,', o['attempts'], 'attempt(s)')"
# 2b. A Makie render through the Julia row (step 5 of
# agentic/specs/261024-agent-all-libraries.md): the depot's package images
# load under gVisor with --compiled-modules=strict and the preloaded exec
# filter, or the render fails within seconds and nothing is promoted. The
# first Julia render of a fresh instance, so its wall time is the cold one.
python3 - <<'EOF'
import json
source = "\n".join([
"using CairoMakie",
"THEME = get(ENV, \"ANYPLOT_THEME\", \"light\")",
"fig = Figure(size = (1600, 900))",
"ax = Axis(fig[1, 1], title = \"Größe – smoke\")",
"lines!(ax, [1, 2, 3, 4], [3, 1, 4, 2], linewidth = 6)",
"save(\"plot-$$(THEME).png\", fig; px_per_unit = 2)",
])
job = {"job_id": "smoke-makie", "language": "julia", "library": "makie", "source": source,
"data_csv": "x\n1\n", "themes": ["light"], "timeout_s": 90}
json.dump(job, open("makie_job.json", "w"))
EOF
curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 240 -H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" --data @makie_job.json -o makie.json "$$URL/render" \
|| { echo "the Makie smoke render request failed"; exit 1; }
python3 -c "import json; r=json.load(open('makie.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: Makie smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')"
# 2c. A Chart.js render through the JavaScript row (JS-1 of step 3): Node by its path
# starts Chromium's headless shell under gVisor, which no local run can, and the
# page reads the job's typed table (`data_json`, `window.ANYPLOT_DATA`), as every
# agent job does. A gate like the Makie render since JS-4a served JavaScript: a
# failure refuses the promotion, and the revision serving now keeps serving. curl's
# retries cover the transport only, and a render that fails answers 200, so the
# render gets a second try 30 s later; each failure prints the exit code, the
# reason, the attempts and the stderr tail (JS renders failed on Cloud Run for about
# 40 minutes after a fresh toolchain build on 2026-10-30, cause unproven).
python3 - <<'EOF'
import json
source = "\n".join([
"const t = window.ANYPLOT_TOKENS;",
"const data = window.ANYPLOT_DATA;",
"const canvas = document.createElement('canvas');",
"document.getElementById('container').appendChild(canvas);",
"new Chart(canvas, {",
" type: 'bar',",
" data: {labels: data.map((d) => d.Gruppe), datasets: [{data: data.map((d) => d.Wert), backgroundColor: t.palette[0]}]},",
" options: {responsive: true, maintainAspectRatio: false, animation: false,",
" plugins: {title: {display: true, text: 'Größe – smoke', color: t.ink}}},",
"});",
])
table = {"columns": ["Gruppe", "Wert"], "data": [["a", 3], ["b", 1], ["c", 2]]}
job = {"job_id": "smoke-chartjs", "language": "javascript", "library": "chartjs", "source": source,
"data_csv": "Gruppe,Wert\na,3\nb,1\nc,2\n", "data_json": json.dumps(table),
"themes": ["light"], "timeout_s": 60}
json.dump(job, open("chartjs_job.json", "w"))
EOF
chartjs_ok=""
for try in 1 2; do
if curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" --data @chartjs_job.json -o chartjs.json "$$URL/render" \
&& python3 -c "import json; r=json.load(open('chartjs.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: Chart.js smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')"; then
chartjs_ok=1
break
fi
echo "the Chart.js smoke render failed (try $$try of 2)"
if [ "$$try" = 1 ]; then sleep 30; fi
done
[ -n "$$chartjs_ok" ] || { echo "the Chart.js smoke render failed twice"; exit 1; }
# 2d. A plotly render through the two-step figure mode (PB-1, a gate since PB-2a served
# plotly and bokeh): the harness forks the plot under its guard and the exec filter,
# the plot reads data.csv and writes its figure as JSON through the pinned save (an
# axis range included, which runs plotly's `build_validator` under the guard), and
# the JavaScript row's harness draws it in Chromium under gVisor, which no local run
# can. The same two tries as the Chart.js render; a failure refuses the promotion.
python3 - <<'EOF'
import json
source = "\n".join([
"import os",
"",
"import pandas as pd",
"import plotly.graph_objects as go",
"",
"THEME = os.getenv('ANYPLOT_THEME', 'light')",
"df = pd.read_csv('data.csv')",
"fig = go.Figure(go.Bar(x=df['Gruppe'], y=df['Wert'], marker_color='#009E73'))",
"fig.update_layout(title={'text': 'Größe – smoke'}, yaxis={'range': [0, 3.5]})",
"fig.update_layout(width=800, height=450).write_json(f'plot-{THEME}.json')",
])
job = {"job_id": "smoke-plotly", "language": "python", "library": "plotly", "source": source,
"data_csv": "Gruppe,Wert\na,3\nb,1\nc,2\n", "themes": ["light"], "timeout_s": 60}
json.dump(job, open("plotly_job.json", "w"))
EOF
plotly_ok=""
for try in 1 2; do
if curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" --data @plotly_job.json -o plotly.json "$$URL/render" \
&& python3 -c "import json; r=json.load(open('plotly.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: plotly figure smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')"; then
plotly_ok=1
break
fi
echo "the plotly figure smoke render failed (try $$try of 2)"
if [ "$$try" = 1 ]; then sleep 30; fi
done
[ -n "$$plotly_ok" ] || { echo "the plotly figure smoke render failed twice"; exit 1; }
# 3. A real data run (POST /run-data, run_data_code): the harness in
# data mode with its audit-hook guard armed returns a 2-row table.
python3 - <<'EOF'
import json
source = "import pandas as pd\ndf = pd.DataFrame({'x': [1, 2], 'y': [3, 4]})\n"
json.dump({"job_id": "smoke-data", "source": source, "timeout_s": 30}, open("data_job.json", "w"))
EOF
curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 120 -H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" --data @data_job.json -o data.json "$$URL/run-data" \
|| { echo "the smoke data run request failed"; exit 1; }
python3 -c "import json; r=json.load(open('data.json')); assert r['exit_code']==0 and (r['csv_text'] or '').splitlines()==['x,y','1,3','2,4'], {k: r[k] for k in ('exit_code','reason','attempts','wall_s')}; print('OK: smoke data run', r['wall_s'], 's,', r['attempts'], 'attempt(s)')"
# The render probes (matplotlib, Makie, Chart.js, plotly and a data run, each a gate)
# live in their own script: Cloud Build refuses a step argument over 10,000
# characters, and this step passed it when the plotly probe joined (build dc1cf3ae,
# 2026-11-01). The token goes through the environment, not the command line.
SMOKE_TOKEN="$$TOKEN" bash agents/renderer/deploy_smoke.sh "$$URL"
else
echo "render smoke skipped (_SMOKE_RENDER=${_SMOKE_RENDER})"
fi
Expand Down
Loading
Loading