From 340e0090689253e6b64b115d5ddd1b92a2d45316 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Tue, 23 Jun 2026 10:25:10 +0200 Subject: [PATCH 01/20] Pin specific graalos versions --- mx.graalpython/graalos_versions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mx.graalpython/graalos_versions.json b/mx.graalpython/graalos_versions.json index 55c7eb4e65..c663674ea0 100644 --- a/mx.graalpython/graalos_versions.json +++ b/mx.graalpython/graalos_versions.json @@ -1,4 +1,4 @@ { - "runtime": "graalos/graalos_prod_pkeyson_sandboxon-runtime-2026_06_21_v1.0.0_13143_gdf587994b631-1.el8.x86_64.tar.gz", - "toolchain": "graal/graalvm-graalos-java25-linux-amd64-25.1.3-dev-g03c51fe.tar.gz" + "runtime": "graalos/graalos_prod_pkeyson_sandboxon-runtime-2026_06_23_v1.0.0_13164_g20dfaf4db006-1.el8.x86_64.tar.gz", + "toolchain": "graal/graalvm-graalos-java25-linux-amd64-25.2.4-dev-ga44f8e9.tar.gz" } From 5b0d78b43d5d117d9cc9379969a994b28fd743ee Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 11:54:51 +0200 Subject: [PATCH 02/20] Add a README with some common scenarios to the GRAALOS_STANDALONE --- .../CMakeLists.txt | 4 +- .../README_GRAALOS_STANDALONE.md | 260 ++++++++++++++++++ .../config.json | 7 + .../graalpy-sandbox-launcher.sh | 203 +++++++++++++- 4 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md diff --git a/graalpython/graalpy_graalos_standalone_payload/CMakeLists.txt b/graalpython/graalpy_graalos_standalone_payload/CMakeLists.txt index 268e254b0d..8c7b295d63 100644 --- a/graalpython/graalpy_graalos_standalone_payload/CMakeLists.txt +++ b/graalpython/graalpy_graalos_standalone_payload/CMakeLists.txt @@ -49,7 +49,8 @@ file(REMOVE_RECURSE "${PAYLOAD_DIR}/bin" "${PAYLOAD_DIR}/libexec" "${PAYLOAD_DIR}/lib" - "${PAYLOAD_DIR}/config.json") + "${PAYLOAD_DIR}/config.json" + "${PAYLOAD_DIR}/README_GRAALOS_STANDALONE.md") file(MAKE_DIRECTORY "${PAYLOAD_DIR}/bin" "${PAYLOAD_DIR}/libexec" @@ -169,6 +170,7 @@ _write_launcher("${PAYLOAD_DIR}/bin/${GRAALPY_CONFIG_LAUNCHER}" "/bin/graalpy-co _write_launcher("${PAYLOAD_DIR}/libexec/${GRAALPY_POLYGLOT_GET_LAUNCHER}" "/libexec/graalpy-polyglot-get") _copy_file("${CMAKE_CURRENT_LIST_DIR}/config.json" "${PAYLOAD_DIR}/config.json") +_copy_file("${CMAKE_CURRENT_LIST_DIR}/README_GRAALOS_STANDALONE.md" "${PAYLOAD_DIR}/README_GRAALOS_STANDALONE.md") _copy_executable("${CMAKE_CURRENT_LIST_DIR}/graalpy-sandbox-launcher.sh" "${GRAALOS_DIR}/graalpy-sandbox-launcher") _copy_executable( "${CMAKE_CURRENT_LIST_DIR}/graalpy-sandbox-expand-config.sh" diff --git a/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md new file mode 100644 index 0000000000..1ecfd7ec26 --- /dev/null +++ b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md @@ -0,0 +1,260 @@ +# GraalPy GraalOS Standalone + +This package runs GraalPy through `graalhost` with a sandboxed default +configuration. The goal is to feel like a normal Python installation for local +command-line use, while making resource usage, filesystem, and network access +explicit and sandboxing the entire execution to prevent untrusted Python code +or native extensions from compromising the system. + +## Quick Start + +Run Python normally: + +```bash +bin/graalpy +bin/graalpy -c 'print(42)' +bin/python +bin/python3 +``` + +Show Python help: + +```bash +bin/graalpy --help +``` + +The launcher passes Python's own options through unchanged. After Python prints +its help, the launcher appends a short section describing the additional +`--graalhost.*` options. + +Enable graalhost diagnostics for one run: + +```bash +bin/graalpy --graalhost.verbose -c 'print(42)' +``` + +## What Is Sandboxed By Default + +By default, the standalone: + +- keeps `stdin`, `stdout`, and `stderr` attached to your terminal +- allows access to the standalone tree and the small set of system files needed + for runtime startup +- denies general outbound network access +- denies bind and listen on TCP and UDP ports + +This means `bin/graalpy` behaves like a local Python process, but it does not +automatically get unrestricted filesystem or internet access. + +## Package Layout + +- `bin/graalpy`, `bin/python`, `bin/python3`: launch GraalPy inside graalhost +- `config.json`: sandbox and launcher configuration +- `lib/graalos/graalpy-sandbox-launcher`: shell wrapper used by the launchers +- `lib/graalos/graalpy-sandbox-expand-config`: fills in generated filesystem + mappings +- `lib/graalos/graalhost`: embedded GraalOS runtime + +## `config.json` + +`config.json` is the main file you edit to change sandbox behavior. The +launcher expands it before starting `graalhost`. + +Common top-level fields: + +- `env`: environment variables visible inside the sandboxed process +- `working_dir`: initial working directory inside the virtual filesystem +- `fds`: how standard input, output, error, and other file descriptors are + wired +- `allowed_ports`: explicit bind and listen allowlist +- `netmappings`: outbound and inbound network policy +- `allow_signal_self_snapshot`: allows the process to create a snapshot by + signaling itself +- `memlimit`: memory budget in GiB +- `testing_default_mappings`: keep this enabled for the packaged standalone + +The launcher also understands a `graalhost` section: + +```json +"graalhost": { + "seccomp": null, + "log_level": null, + "log_to": null, + "visorcalloutput": null, + "extra_args": [] +} +``` + +Meaning: + +- `seccomp`: forwarded as `--seccomp` +- `log_level`: forwarded as `--log_level` +- `log_to`: forwarded as `--log_to` +- `visorcalloutput`: forwarded as `--visorcalloutput` +- `extra_args`: additional raw graalhost arguments, one item per array entry + +If you do not set graalhost logging options, the launcher stays quiet by +default. `--graalhost.verbose` overrides that for a single invocation. + +## Launcher Options + +These options are consumed by the launcher and are not passed to Python: + +- `--graalhost.verbose` +- `--graalhost.run_snapshot=PATH` +- `--graalhost.log_level=LEVEL` +- `--graalhost.log_to=DEST` +- `--graalhost.visorcalloutput=DEST` +- `--graalhost.seccomp=MODE` +- `--graalhost.extra_arg=ARG` + +`--graalhost.run_snapshot=PATH` restores a previously created GraalOS snapshot +instead of starting a fresh Python process. It should be used by itself: + +```bash +bin/graalpy --graalhost.run_snapshot=/path/to/persistIso... +``` + +## Common Scenarios + +### Use It Like Normal Python + +```bash +bin/graalpy -c 'print("hello")' +printf 'hello\n' | bin/graalpy -c 'print(input())' +``` + +The default config keeps the terminal connected: + +```json +"fds": { + "stdin": "stdin", + "stdout": "stdout", + "stderr": "stderr" +} +``` + +### Redirect Standard Streams + +To write output to files, edit `config.json`: + +```json +"fds": { + "stdin": "null", + "stdout": "file:/tmp/graalpy.stdout", + "stderr": "file:/tmp/graalpy.stderr" +} +``` + +Use `append:/path` instead of `file:/path` if you want append mode. + +### Keep Networking Disabled + +This is the default. If you do not add `netmappings`, the process does not get +general outbound network access. If `allowed_ports` is empty, it also cannot +bind or listen on ports. + +### Allow an Outbound TCP Connection + +To allow connections to `127.0.0.1:6010`, add: + +```json +"netmappings": [ + { + "networks": [ + { + "ips": ["127.0.0.1/32"], + "protocols": [ + { "type": "tcp", "outgoing_ports": ["6010"] } + ] + } + ] + } +] +``` + +If you use hostnames instead of literal IPs, your network policy also needs to +allow DNS. + +### Allow an Incoming Listener + +To allow listening on `127.0.0.1:6006`, add: + +```json +"allowed_ports": [6006], +"netmappings": [ + { + "networks": [ + { + "ips": ["127.0.0.1/32"], + "protocols": [ + { "type": "tcp", "incoming_ports": ["6006"] } + ] + } + ] + } +] +``` + +### Create and Resume a Snapshot + +If you want to resume a warm Python process later, enable self-snapshotting in +`config.json`: + +```json +"allow_signal_self_snapshot": true +``` + +Then run a Python program that saves its snapshot path and signals itself with +`SIGSTOP` when it is ready: + +```python +import os +import signal + +print("Ready to snapshot") +os.kill(os.getpid(), signal.SIGSTOP) +``` + +After GraalOS writes the snapshot file, resume it with: + +```bash +bin/graalpy --graalhost.run_snapshot=/path/to/persistIso... +``` + +Restoring a snapshot uses the saved process state. It does not take additional +Python command-line arguments on the same invocation. + +### Show Graalhost Diagnostics + +For launcher-level troubleshooting: + +```bash +bin/graalpy --graalhost.verbose -c 'print("hello")' +``` + +For more control, use one-run overrides such as: + +```bash +bin/graalpy \ + --graalhost.log_level=debug \ + --graalhost.log_to=stderr \ + --graalhost.visorcalloutput=@stderr \ + -c 'print("hello")' +``` + +## Notes About Graalhost + +The standalone wraps `graalhost`, which is the GraalOS runtime responsible for: + +- launching the Python isolate +- applying filesystem and network policy +- routing file descriptors +- creating and restoring snapshots +- emitting host-side diagnostics + +The launcher covers common usage. If you need the full host CLI, run: + +```bash +lib/graalos/graalhost --help +``` diff --git a/graalpython/graalpy_graalos_standalone_payload/config.json b/graalpython/graalpy_graalos_standalone_payload/config.json index 909725a003..3a4418a37e 100644 --- a/graalpython/graalpy_graalos_standalone_payload/config.json +++ b/graalpython/graalpy_graalos_standalone_payload/config.json @@ -6,11 +6,18 @@ }, "working_dir": "/", "allow_runtime_codegen": true, + "fds": { + "stdin": "stdin", + "stdout": "stdout", + "stderr": "stderr" + }, "testing_default_mappings": true, "allowed_ports": [], "graalhost": { "seccomp": null, "log_level": null, + "log_to": null, + "visorcalloutput": null, "extra_args": [] }, "fsmappings": [] diff --git a/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh b/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh index 2d30dc5295..d4f2da340a 100644 --- a/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh +++ b/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh @@ -64,6 +64,9 @@ graalhost="${standalone_home}/lib/graalos/graalhost" libc="${standalone_home}/lib/graalos/libc.so" expand_config="${standalone_home}/lib/graalos/graalpy-sandbox-expand-config" config="${standalone_home}/config.json" +tmp_root="${standalone_home}/tmp" +launcher_verbose=false +launcher_show_help=false if [ ! -x "$graalhost" ]; then echo "missing or non-executable GraalHost binary: $graalhost" >&2 @@ -85,20 +88,204 @@ if [ ! -f "$config" ]; then exit 126 fi -tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/graalpy-sandbox.XXXXXXXXXX")" +mkdir -p "$tmp_root" +tmp_base="${TMPDIR:-}" +if [ -z "$tmp_base" ] || [ ! -d "$tmp_base" ]; then + tmp_base="$tmp_root" +fi + +tmpdir="$(mktemp -d "${tmp_base}/graalpy-sandbox.XXXXXXXXXX")" trap 'rm -rf "$tmpdir"' EXIT endpoint_config="${tmpdir}/config.json" "$expand_config" "$standalone_home" "$config" "$endpoint_config" +graalhost_config="$( + awk ' + /"graalhost"[[:space:]]*:/ { in_obj = 1 } + in_obj { print } + in_obj && /^[[:space:]]*}[[:space:]]*,?[[:space:]]*$/ { exit } + ' "$config" +)" + +print_graalhost_help() { + cat <<'EOF' + +Additional graalhost launcher options: + --graalhost.verbose + Enable graalhost verbose logging on stderr for this launch. + --graalhost.run_snapshot=PATH + Restore and run a GraalOS snapshot instead of starting a new Python process. + --graalhost.log_level=LEVEL + Override graalhost log level for this launch. + --graalhost.log_to=DEST + Override graalhost log sink(s) for this launch. + --graalhost.visorcalloutput=DEST + Override graalhost visorcall logging destination for this launch. + --graalhost.seccomp=MODE + Override graalhost seccomp mode for this launch. + --graalhost.extra_arg=ARG + Append one raw graalhost argument for this launch. May be repeated. +EOF +} + +extract_graalhost_string() { + local key="$1" + printf '%s\n' "$graalhost_config" | sed -n "s/^[[:space:]]*\"${key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n 1 +} + +extract_graalhost_scalar() { + local key="$1" + printf '%s\n' "$graalhost_config" | sed -n "s/^[[:space:]]*\"${key}\"[[:space:]]*:[[:space:]]*\\([^,][^,}]*\\).*/\\1/p" | head -n 1 | sed 's/[[:space:]]*$//' +} + +extract_graalhost_array() { + local key="$1" + printf '%s\n' "$graalhost_config" | awk -v key="$key" ' + $0 ~ ("\"" key "\"[[:space:]]*:[[:space:]]*\\[") { + in_arr = 1 + line = substr($0, index($0, "[") + 1) + } + in_arr { + if (!length(line)) { + line = $0 + } + while (match(line, /"([^"]*)"/)) { + print substr(line, RSTART + 1, RLENGTH - 2) + line = substr(line, RSTART + RLENGTH) + } + line = "" + if ($0 ~ /\]/) { + exit + } + } + ' +} + graalhost_args=() -log_level="$(sed -n 's/^[[:space:]]*"log_level"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$config" | head -n 1)" +python_args=() +cli_run_snapshot="" +cli_log_level="" +cli_log_to="" +cli_visorcalloutput="" +cli_seccomp="" +cli_extra_args=() +for arg in "$@"; do + case "$arg" in + --graalhost.verbose) + launcher_verbose=true + ;; + --graalhost.run_snapshot=*) + cli_run_snapshot="${arg#--graalhost.run_snapshot=}" + ;; + --graalhost.log_level=*) + cli_log_level="${arg#--graalhost.log_level=}" + ;; + --graalhost.log_to=*) + cli_log_to="${arg#--graalhost.log_to=}" + ;; + --graalhost.visorcalloutput=*) + cli_visorcalloutput="${arg#--graalhost.visorcalloutput=}" + ;; + --graalhost.seccomp=*) + cli_seccomp="${arg#--graalhost.seccomp=}" + ;; + --graalhost.extra_arg=*) + cli_extra_args+=("${arg#--graalhost.extra_arg=}") + ;; + -h|--help) + launcher_show_help=true + python_args+=("$arg") + ;; + *) + python_args+=("$arg") + ;; + esac +done + +if [ "$launcher_verbose" = "true" ]; then + graalhost_args+=(--verbose --log_to stderr) +else + graalhost_args+=(--log_level off --log_to visorbase --visorcalloutput @none) +fi + +seccomp="$(extract_graalhost_scalar seccomp)" +case "$seccomp" in + "" | "null") ;; + *) graalhost_args+=(--seccomp "$seccomp") ;; +esac + +if [ "$launcher_verbose" != "true" ]; then + log_level="$(extract_graalhost_string log_level)" + log_to="$(extract_graalhost_string log_to)" + visorcalloutput="$(extract_graalhost_string visorcalloutput)" +else + log_level="" + log_to="" + visorcalloutput="" +fi + if [ -n "$log_level" ]; then graalhost_args+=(--log_level "$log_level") fi -exec "$graalhost" \ - ${graalhost_args[@]+"${graalhost_args[@]}"} \ - --musl_path "$libc" \ - --run_config=@"$endpoint_config" \ - --run_virtual "$virtual_executable" \ - "$@" +if [ -n "$log_to" ]; then + graalhost_args+=(--log_to "$log_to") +fi + +if [ -n "$visorcalloutput" ]; then + graalhost_args+=(--visorcalloutput "$visorcalloutput") +fi + +while IFS= read -r extra_arg; do + [ -n "$extra_arg" ] || continue + graalhost_args+=("$extra_arg") +done < <(extract_graalhost_array extra_args) + +if [ -n "$cli_seccomp" ]; then + graalhost_args+=(--seccomp "$cli_seccomp") +fi + +if [ -n "$cli_log_level" ]; then + graalhost_args+=(--log_level "$cli_log_level") +fi + +if [ -n "$cli_log_to" ]; then + graalhost_args+=(--log_to "$cli_log_to") +fi + +if [ -n "$cli_visorcalloutput" ]; then + graalhost_args+=(--visorcalloutput "$cli_visorcalloutput") +fi + +for extra_arg in "${cli_extra_args[@]}"; do + graalhost_args+=("$extra_arg") +done + +set +e +if [ -n "$cli_run_snapshot" ]; then + if [ "${#python_args[@]}" -gt 0 ]; then + echo "--graalhost.run_snapshot cannot be combined with Python arguments" >&2 + status=2 + else + "$graalhost" \ + ${graalhost_args[@]+"${graalhost_args[@]}"} \ + --musl_path "$libc" \ + --run "$cli_run_snapshot" + status=$? + fi +else + "$graalhost" \ + ${graalhost_args[@]+"${graalhost_args[@]}"} \ + --musl_path "$libc" \ + --run_config=@"$endpoint_config" \ + --run_virtual "$virtual_executable" \ + "${python_args[@]}" + status=$? +fi +set -e + +if [ "$launcher_show_help" = "true" ]; then + print_graalhost_help +fi + +exit "$status" From 074ee5fcbe08c823c82f8494def1d6796097f164 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 13:57:13 +0200 Subject: [PATCH 03/20] Configure GraalOS standalone runtime --- graalpython/graalpy_graalos_standalone_payload/config.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graalpython/graalpy_graalos_standalone_payload/config.json b/graalpython/graalpy_graalos_standalone_payload/config.json index 3a4418a37e..3bfd8b5e0d 100644 --- a/graalpython/graalpy_graalos_standalone_payload/config.json +++ b/graalpython/graalpy_graalos_standalone_payload/config.json @@ -6,6 +6,7 @@ }, "working_dir": "/", "allow_runtime_codegen": true, + "memlimit": 64.0, "fds": { "stdin": "stdin", "stdout": "stdout", @@ -18,7 +19,7 @@ "log_level": null, "log_to": null, "visorcalloutput": null, - "extra_args": [] + "extra_args": ["--disable_core_scheduling"] }, "fsmappings": [] } From bb943e8e40339cfbf40946c3778c31e90fb6fbcc Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 14:14:33 +0200 Subject: [PATCH 04/20] Add GraalOS sandbox demo --- GRAALOS_DEMO.md | 80 ++++++++++++++++ graalos_sandbox_chat.py | 207 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 GRAALOS_DEMO.md create mode 100644 graalos_sandbox_chat.py diff --git a/GRAALOS_DEMO.md b/GRAALOS_DEMO.md new file mode 100644 index 0000000000..fc513efaeb --- /dev/null +++ b/GRAALOS_DEMO.md @@ -0,0 +1,80 @@ +# GraalOS Standalone Sandbox Demo + +This demo shows a small chat-style expression evaluator running inside the +GraalPy GraalOS standalone. + +The story: + +1. `rich` renders a friendly terminal UI. +2. `asteval` evaluates normal user expressions with an application-level + restricted evaluator. This demo also removes `open` from that evaluator. +3. `/unsafe ...` expressions deliberately bypass `asteval` and use Python + `eval` directly. +4. The process is still inside the GraalOS sandbox, so file, subprocess, + network, and native library attempts remain contained. + +## Setup + +From the rebuilt standalone directory, I ran this. This needs a patched +graalhost right now (I reported GRAALOS-8260), so the bundle currently includes +it. + +```bash +cd mxbuild/linux-amd64/GRAALPY_NATIVE_GRAALOS_STANDALONE +./bin/graalpy -Im ensurepip +python3 -m pip download --only-binary=:all: --dest demo-wheels rich asteval +./bin/graalpy -Im pip install --no-index --find-links /demo-wheels rich asteval +``` + +The online download is intentionally done outside the sandbox. The sandboxed +standalone has no outbound network mapping by default, which is one of the +things the demo can show. + +Copy or place `graalos_sandbox_chat.py` in the standalone root, then run: + +```bash +./bin/graalpy graalos_sandbox_chat.py +``` + +For a non-interactive walkthrough: + +```bash +./bin/graalpy graalos_sandbox_chat.py --demo +``` + +## Demo Beats + +Start with a normal expression: + +```python +sum([i*i for i in range(1000)]) +``` + +Then show that the app-level evaluator blocks direct file access: + +```python +open('/etc/passwd').read() +``` + +Switch to `/unsafe` mode to bypass the app-level evaluator while keeping the +outer GraalOS sandbox: + +```python +/unsafe open('/etc/passwd').read().splitlines()[:3] +/unsafe open('/etc/shadow').read() +/unsafe __import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True) +/unsafe __import__('socket').create_connection(('example.com', 80), timeout=2) +/unsafe __import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow') +``` + +Expected result: harmless operations work or fail normally; sensitive host +resources are unavailable because the process only sees the sandboxed virtual +filesystem, process namespace, and configured network policy. The native +`system()` probe returns `-1`, which the demo renders as blocked. + +## Why This Is Useful + +`asteval` is an application-level guardrail. It reduces accidental exposure but +it is not a complete containment boundary. GraalOS is the outer boundary: even +if application logic accidentally evaluates dangerous code in `/unsafe` mode, +the runtime still mediates filesystem, subprocess, native, and network behavior. diff --git a/graalos_sandbox_chat.py b/graalos_sandbox_chat.py new file mode 100644 index 0000000000..bc01fce230 --- /dev/null +++ b/graalos_sandbox_chat.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# The Universal Permissive License (UPL), Version 1.0 +# +# Subject to the condition set forth below, permission is hereby granted to any +# person obtaining a copy of this software, associated documentation and/or +# data (collectively the "Software"), free of charge and under any and all +# copyright rights in the Software, and any and all patent rights owned or +# freely licensable by each licensor hereunder covering either (i) the +# unmodified Software as contributed to or provided by such licensor, or (ii) +# the Larger Works (as defined below), to deal in both +# +# (a) the Software, and +# +# (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +# one is included with the Software each a "Larger Work" to which the Software +# is contributed by such licensors), +# +# without restriction, including without limitation the rights to copy, create +# derivative works of, display, perform, and distribute the Software and make, +# use, sell, offer for sale, import, export, have made, and have sold the +# Software and the Larger Work(s), and to sublicense the foregoing rights on +# either these or other terms. +# +# This license is subject to the following condition: +# +# The above copyright notice and either this complete permission notice or at a +# minimum a reference to the UPL must be included in all copies or substantial +# portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Small Rich + asteval demo for the GraalOS standalone sandbox.""" + +from __future__ import annotations + +import argparse +import textwrap +import time +from dataclasses import dataclass + +from asteval import Interpreter +from rich.console import Console +from rich.panel import Panel +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + + +console = Console() + + +@dataclass +class EvalResult: + mode: str + ok: bool + output: str + elapsed_ms: float + + +def make_interpreter() -> Interpreter: + aeval = Interpreter() + # Keep the demo's "safe" lane expression-oriented. GraalOS is the real + # containment boundary; this prevents the app-level evaluator from opening files. + aeval.symtable.pop("open", None) + return aeval + + +def render_message(role: str, body: str, style: str) -> None: + console.print(Panel(Text(body), title=role, title_align="left", border_style=style)) + + +def safe_eval(aeval: Interpreter, expr: str) -> EvalResult: + start = time.perf_counter() + aeval.error = [] + try: + value = aeval(expr) + except Exception as exc: # asteval normally records errors instead of raising + elapsed = (time.perf_counter() - start) * 1000 + return EvalResult("asteval", False, f"{type(exc).__name__}: {exc}", elapsed) + + elapsed = (time.perf_counter() - start) * 1000 + if aeval.error: + errors = "\n".join(str(err.get_error()) for err in aeval.error) + return EvalResult("asteval", False, errors, elapsed) + return EvalResult("asteval", True, repr(value), elapsed) + + +def unsafe_eval(expr: str) -> EvalResult: + start = time.perf_counter() + try: + value = eval(expr) + elapsed = (time.perf_counter() - start) * 1000 + if value == -1: + return EvalResult("python eval", False, "-1 (operation denied by sandbox/runtime)", elapsed) + return EvalResult("python eval", True, repr(value), elapsed) + except Exception as exc: + elapsed = (time.perf_counter() - start) * 1000 + return EvalResult("python eval", False, f"{type(exc).__name__}: {exc}", elapsed) + + +def render_result(result: EvalResult) -> None: + table = Table.grid(padding=(0, 1)) + table.add_column(style="bold") + table.add_column() + table.add_row("mode", result.mode) + table.add_row("status", "[green]ok[/green]" if result.ok else "[red]blocked/error[/red]") + table.add_row("time", f"{result.elapsed_ms:.1f} ms") + console.print(table) + render_message("sandbox", result.output, "green" if result.ok else "red") + + +def evaluate(aeval: Interpreter, line: str) -> None: + line = line.strip() + if not line: + return + if line.startswith("/unsafe "): + expr = line[len("/unsafe ") :].strip() + render_result(unsafe_eval(expr)) + else: + render_result(safe_eval(aeval, line)) + + +def demo_script() -> list[str]: + return [ + "sum([i*i for i in range(1000)])", + "sin(pi / 4) ** 2 + cos(pi / 4) ** 2", + "open('/etc/passwd').read()", + "/unsafe open('/etc/passwd').read().splitlines()[:3]", + "/unsafe open('/etc/shadow').read()", + "/unsafe __import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True)", + "/unsafe __import__('socket').create_connection(('example.com', 80), timeout=2)", + "/unsafe __import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow')", + ] + + +def print_intro() -> None: + body = textwrap.dedent( + """ + Type Python expressions and get chat-style results. + + Normal input uses asteval, a restricted expression evaluator. + Prefix with /unsafe to bypass asteval and use Python eval directly. + The process is still inside the GraalOS sandbox, so filesystem, + subprocess, native library, and network attempts remain contained. + + Commands: /demo, /help, /quit + """ + ).strip() + render_message("graalos sandbox chat", body, "cyan") + + +def print_help() -> None: + examples = "\n".join(demo_script()) + console.print(Syntax(examples, "python", theme="ansi_dark", word_wrap=True)) + + +def interactive() -> int: + aeval = make_interpreter() + print_intro() + while True: + try: + line = console.input("[bold cyan]you>[/bold cyan] ") + except (EOFError, KeyboardInterrupt): + console.print() + return 0 + command = line.strip() + if command in {"/quit", "/exit"}: + return 0 + if command == "/help": + print_help() + continue + if command == "/demo": + run_demo(aeval) + continue + evaluate(aeval, line) + + +def run_demo(aeval: Interpreter | None = None) -> None: + if aeval is None: + aeval = make_interpreter() + for line in demo_script(): + render_message("you", line, "blue") + evaluate(aeval, line) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--demo", action="store_true", help="run the prepared demo script and exit") + args = parser.parse_args(argv) + + if args.demo: + print_intro() + run_demo() + return 0 + return interactive() + + +if __name__ == "__main__": + raise SystemExit(main()) From e7ff138b5bb8a728b60546e33c7624dd06909916 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 17:32:46 +0200 Subject: [PATCH 05/20] Map standalone launcher interpreter --- .../graalpy-sandbox-fsmappings.sh | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-fsmappings.sh b/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-fsmappings.sh index a418f208f1..f216b01fa6 100644 --- a/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-fsmappings.sh +++ b/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-fsmappings.sh @@ -75,6 +75,32 @@ emit_pseudo_mapping() { emit_mapping "$outfile" "$concrete" "$virt" "$extra" } +emit_musl_interpreter_mapping() { + local outfile="$1" + local executable="$2" + local safe_libc="$3" + local interp + + if ! command -v readelf >/dev/null 2>&1; then + return 0 + fi + + interp="$(readelf -l "$executable" 2>/dev/null | sed -n 's/.*Requesting program interpreter: \([^]]*\).*/\1/p' | head -n 1)" + case "$interp" in + /*) + case "${emitted_musl_interpreters:-}" in + *" +${interp} +"*) return 0 ;; + esac + emitted_musl_interpreters="${emitted_musl_interpreters:-} +${interp} +" + emit_mapping "$outfile" "$safe_libc" "$interp" ' "verif": true,' + ;; + esac +} + graalpy_sandbox_emit_fsmappings() { local standalone_home="$1" local outfile="$2" @@ -93,6 +119,7 @@ graalpy_sandbox_emit_fsmappings() { fi need_comma=false + emitted_musl_interpreters="" emit_mapping "$outfile" "$standalone_home" "/" ' "using": {"handler": "host_fs"}, "mutable": true, "allow_set_x_bit": true, @@ -106,11 +133,13 @@ graalpy_sandbox_emit_fsmappings() { while IFS= read -r file; do virt="/bin/${file#"$native_bin"/}" emit_mapping "$outfile" "$file" "$virt" ' "verif": true,' + emit_musl_interpreter_mapping "$outfile" "$file" "$safe_libc" done < <(find "$native_bin" -maxdepth 1 -type f -perm -111 | sort) else while IFS= read -r file; do virt="/${file#"$standalone_home"/}" emit_mapping "$outfile" "$file" "$virt" ' "verif": true,' + emit_musl_interpreter_mapping "$outfile" "$file" "$safe_libc" done < <(find "${standalone_home}/bin" -maxdepth 1 -type f -perm -111 | sort) fi From f1118cbd74aedb5d4c1127bd841bf5d60060c4fa Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 20:05:05 +0200 Subject: [PATCH 06/20] Document GraalOS demo package workaround --- GRAALOS_DEMO.md | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/GRAALOS_DEMO.md b/GRAALOS_DEMO.md index fc513efaeb..ef4a8a5737 100644 --- a/GRAALOS_DEMO.md +++ b/GRAALOS_DEMO.md @@ -15,9 +15,8 @@ The story: ## Setup -From the rebuilt standalone directory, I ran this. This needs a patched -graalhost right now (I reported GRAALOS-8260), so the bundle currently includes -it. +From the rebuilt standalone directory, install `pip` inside the sandbox, then +install the demo wheels from a host-downloaded wheel cache: ```bash cd mxbuild/linux-amd64/GRAALPY_NATIVE_GRAALOS_STANDALONE @@ -30,6 +29,34 @@ The online download is intentionally done outside the sandbox. The sandboxed standalone has no outbound network mapping by default, which is one of the things the demo can show. +### GRAALOS-8260 workaround + +If the standalone uses a vanilla GraalOS runtime where the in-sandbox +`ensurepip` subprocess path is not fixed yet, install the pure-Python wheels +from the host directly into the standalone's `site-packages`: + +```bash +cd mxbuild/linux-amd64/GRAALPY_NATIVE_GRAALOS_STANDALONE +python3 -m pip download \ + --only-binary=:all: \ + --implementation py \ + --python-version 3.12 \ + --abi none \ + --platform any \ + --dest demo-wheels \ + rich asteval + +python3 -m pip install \ + --target lib/python3.12/site-packages \ + --no-index \ + --find-links demo-wheels \ + --no-compile \ + rich asteval +``` + +Use this workaround only for pure-Python wheels such as `py3-none-any`; native +wheels need GraalOS/GraalPy-specific handling. + Copy or place `graalos_sandbox_chat.py` in the standalone root, then run: ```bash From 31c991d0af8b4eda556c6fd79afd4a0f15529afc Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 20:26:22 +0200 Subject: [PATCH 07/20] Test and publish GraalOS standalone --- ci.jsonnet | 19 ++-- ci/python-gate.libsonnet | 14 +++ .../src/tests/test_graalos_standalone.py | 54 +++++++-- mx.graalpython/mx_graalpython.py | 4 +- mx.graalpython/mx_graalpython_graalos.py | 104 +++++++++++++++--- 5 files changed, 155 insertions(+), 40 deletions(-) diff --git a/ci.jsonnet b/ci.jsonnet index bff43aa641..75c91597b4 100644 --- a/ci.jsonnet +++ b/ci.jsonnet @@ -146,6 +146,7 @@ local GPY_JVM21_STANDALONE = "graalpy-jvm21-standalone", local GPY_JVM_STANDALONE = "graalpy-jvm-standalone", local GPY_NATIVE_STANDALONE = "graalpy-native-standalone", + local GPY_NATIVE_GRAALOS_STANDALONE = "graalpy-native-graalos-standalone", local GPYEE_JVM_STANDALONE = "graalpy-ee-jvm-standalone", local GPYEE_NATIVE_STANDALONE = "graalpy-ee-native-standalone", local GRAAL_JDK_LATEST = "graal-jdk-latest", @@ -310,15 +311,15 @@ "tox-example": gpgate_ee + require(GPYEE_NATIVE_STANDALONE) + platform_spec(no_jobs) + platform_spec({ "linux:amd64:jdk-latest" : tier3, }), - // "python-svm-graalos-standalone-build": gpgate_ee + internet_access_env + platform_spec(no_jobs) + platform_spec({ - // "linux:amd64:jdk-latest": tier3 + $.ol8 + task_spec({ - // environment +: { - // GRAALPY_GRAALOS_TOOLCHAIN_URL: $.overlay_imports.GRAALPY_GRAALOS_TOOLCHAIN_URL, - // GRAALPY_GRAALOS_RUNTIME_URL: $.overlay_imports.GRAALPY_GRAALOS_RUNTIME_URL, - // GRAALPY_GRAALOS_ARTIFACT_BASE_URL: $.overlay_imports.GRAALPY_GRAALOS_ARTIFACT_BASE_URL, - // }, - // }), - // }), + "python-svm-graalos-standalone-build": gpgate_ee + internet_access_env + platform_spec(no_jobs) + platform_spec({ + "linux:amd64:jdk-latest": tier3 + $.ol8 + $.provide_graalpy_graalos_standalone_artifact(GPY_NATIVE_GRAALOS_STANDALONE) + task_spec({ + environment +: { + GRAALPY_GRAALOS_TOOLCHAIN_URL: $.overlay_imports.GRAALPY_GRAALOS_TOOLCHAIN_URL, + GRAALPY_GRAALOS_RUNTIME_URL: $.overlay_imports.GRAALPY_GRAALOS_RUNTIME_URL, + GRAALPY_GRAALOS_ARTIFACT_BASE_URL: $.overlay_imports.GRAALPY_GRAALOS_ARTIFACT_BASE_URL, + }, + }), + }), }, local need_pgo = task_spec({runAfter: ["python-pgo-profile-post_merge-linux-amd64-jdk-latest"]}), diff --git a/ci/python-gate.libsonnet b/ci/python-gate.libsonnet index 5be4dcfa44..c092b32454 100644 --- a/ci/python-gate.libsonnet +++ b/ci/python-gate.libsonnet @@ -303,6 +303,20 @@ ), provide:: $.provide_graalpy_standalone_artifact, + provide_graalpy_graalos_standalone_artifact(name):: task_spec(evaluate_late( + // use 2 after _ to make sure we evaluate this right after _1 late eval keys like _1_os_arch_jdk + "_2_provide_graalos_artifact", { + local os = self.os, + local arch = if self.arch == "amd64" then "" else "_" + self.arch, + local artifact_name = name + os + arch, + publishArtifacts+: [{ + "dir": "../", + "name": artifact_name, + "patterns": ["main/mxbuild/*/GRAALPY_NATIVE_GRAALOS_STANDALONE"] + }] + }) + ), + require_graalpy_standalone_artifact(name):: task_spec({ local os = self.os, local arch = if self.arch == "amd64" then "" else "_" + self.arch, diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py b/graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py index 49b73eddb8..7b0cb4714c 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py @@ -37,23 +37,53 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import subprocess +import sys import sysconfig import unittest -def test_graalos_sqlite3_native_extension_smoke(): +def skip_unless_graalos(): soabi = sysconfig.get_config_var("SOABI") or "" if "graalos" not in soabi: raise unittest.SkipTest(f"requires GraalOS SOABI, got {soabi!r}") - import _sqlite3 - import sqlite3 - - assert _sqlite3.sqlite_version - conn = sqlite3.connect(":memory:") - try: - conn.execute("create table values_for_sum(value integer)") - conn.executemany("insert into values_for_sum(value) values (?)", [(1,), (2,), (3,)]) - assert conn.execute("select sum(value) from values_for_sum").fetchone()[0] == 6 - finally: - conn.close() + +class GraalOSStandaloneTests(unittest.TestCase): + + def setUp(self): + skip_unless_graalos() + + def test_sqlite3_native_extension_smoke(self): + import _sqlite3 + import sqlite3 + + self.assertTrue(_sqlite3.sqlite_version) + conn = sqlite3.connect(":memory:") + try: + conn.execute("create table values_for_sum(value integer)") + conn.executemany("insert into values_for_sum(value) values (?)", [(1,), (2,), (3,)]) + self.assertEqual(conn.execute("select sum(value) from values_for_sum").fetchone()[0], 6) + finally: + conn.close() + + def test_demo_packages(self): + import asteval + import rich + + self.assertTrue(asteval.__version__) + self.assertTrue(rich.get_console()) + + def test_sandbox_chat_demo(self): + result = subprocess.run( + [sys.executable, "/graalos_sandbox_chat.py", "--demo"], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("sum([i*i for i in range(1000)])", result.stdout) + self.assertIn("__import__('socket').create_connection", result.stdout) + self.assertIn("gaierror", result.stdout) + self.assertIn("FileNotFoundError", result.stdout) + self.assertIn("operation denied", result.stdout) diff --git a/mx.graalpython/mx_graalpython.py b/mx.graalpython/mx_graalpython.py index 68c3b6c0d3..aea1ce9b84 100644 --- a/mx.graalpython/mx_graalpython.py +++ b/mx.graalpython/mx_graalpython.py @@ -1572,7 +1572,7 @@ def graalpytest(args): def run_python_unittests(python_binary, args=None, paths=None, exclude=None, env=None, cwd=None, lock=None, out=None, err=None, nonZeroIsFatal=True, timeout=None, - report: Union[Task, bool, None] = False, parallel=None, runner_args=None): + report: Union[Task, bool, None] = False, parallel=None, runner_args=None, test_runner=None): if lock: lock.acquire() @@ -1609,7 +1609,7 @@ def run_python_unittests(python_binary, args=None, paths=None, exclude=None, env # index in in that case env["PIP_EXTRA_INDEX_URL"] = pip_index - args += [_python_test_runner(), "run", "--durations", "10", "-n", parallelism, f"--subprocess-args={shlex.join(args)}"] + args += [test_runner or _python_test_runner(), "run", "--durations", "10", "-n", parallelism, f"--subprocess-args={shlex.join(args)}"] if runner_args: args += runner_args diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index 4ee60c0a79..fc5ad26e86 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -41,7 +41,6 @@ # pylint: disable=cyclic-import -import base64 import gzip import json import os @@ -192,8 +191,74 @@ def _ensure_graalos_runtime_inputs(runtime_home: Path, on_fail=mx.abort): on_fail("Extracted GraalOS runtime artifact is missing required files:\n" + "\n".join([str(p) for p in missing])) +def _prepare_graalos_demo(standalone_home: Path, env): + demo_wheels = standalone_home / "demo-wheels" + site_packages = standalone_home / "lib" / "python3.12" / "site-packages" + demo_wheels.mkdir(parents=True, exist_ok=True) + site_packages.mkdir(parents=True, exist_ok=True) + + run([ + sys.executable, "-m", "pip", "download", + "--only-binary=:all:", + "--implementation", "py", + "--python-version", "3.12", + "--abi", "none", + "--platform", "any", + "--dest", str(demo_wheels), + "rich", + "asteval", + ], env=env) + run([ + sys.executable, "-m", "pip", "install", + "--target", str(site_packages), + "--no-index", + "--find-links", str(demo_wheels), + "--no-compile", + "--upgrade", + "rich", + "asteval", + ], env=env) + + shutil.copy2( + Path(SUITE.dir) / "graalos_sandbox_chat.py", + standalone_home / "graalos_sandbox_chat.py", + ) + + +def _stage_graalos_test_harness(standalone_home: Path): + harness_dir = standalone_home / "test-harness" + tests_dir = harness_dir / "tests" + tests_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2( + Path(SUITE.dir) / "graalpython" / "com.oracle.graal.python.test" / "src" / "runner.py", + harness_dir / "runner.py", + ) + shutil.copy2( + Path(SUITE.dir) / "graalpython" / "com.oracle.graal.python.test" / "src" / "tests" / "test_graalos_standalone.py", + tests_dir / "test_graalos_standalone.py", + ) + + +def _set_graalos_standalone_env(standalone_home: Path, key, value, on_fail=mx.abort): + config_path = standalone_home / "config.json" + original_config = config_path.read_text(encoding="utf-8") + _ = json.dumps({key: value}) # Validate that both values can be represented in JSON. + if re.search(rf'^\s*"{re.escape(key)}"\s*:', original_config, flags=re.MULTILINE): + return config_path, original_config + env_match = re.search(r'("env"\s*:\s*\{\n)(.*?)(\n\s*\})', original_config, flags=re.DOTALL) + if not env_match: + on_fail(f"Could not find env object in GraalOS standalone config: {config_path}") + env_body = env_match.group(2) + indent_match = re.search(r'^(\s*)"', env_body, flags=re.MULTILINE) + indent = indent_match.group(1) if indent_match else " " + separator = "," if env_body.strip() else "" + entry = f'{separator}\n{indent}{json.dumps(key)}: {json.dumps(value)}' + config = original_config[:env_match.end(2)] + entry + original_config[env_match.end(2):] + config_path.write_text(config, encoding="utf-8") + return config_path, original_config + + def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): - del report # This gate executes an in-sandbox smoke test directly instead of using the source-tree test runner. artifact_base_url = os.environ.get("GRAALPY_GRAALOS_ARTIFACT_BASE_URL") if not artifact_base_url: mx.log("Skipping GRAALPY_NATIVE_GRAALOS_STANDALONE build: GRAALPY_GRAALOS_ARTIFACT_BASE_URL is not configured") @@ -223,7 +288,7 @@ def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): graalos_runtime_home = _find_graalos_runtime_home(runtime_root, on_fail=on_fail) _ensure_graalos_runtime_inputs(graalos_runtime_home, on_fail=on_fail) - from mx_graalpython import extend_os_env, run_mx, _graalpy_launcher + from mx_graalpython import extend_os_env, run_mx, run_python_unittests, _graalpy_launcher env = extend_os_env( JAVA_HOME=str(graalvm_home), MUSL_TOOLCHAIN=str(musl_toolchain), @@ -244,17 +309,22 @@ def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): if not launcher.exists(): on_fail(f"GRAALPY_NATIVE_GRAALOS_STANDALONE launcher was not built: {launcher}") - test_path = Path(SUITE.dir) / "graalpython" / "com.oracle.graal.python.test" / "src" / "tests" / "test_graalos_standalone.py" - with open(test_path, "r", encoding="utf-8") as f: - smoke_test = f.read() - smoke_test += """ -try: - test_graalos_sqlite3_native_extension_smoke() -except unittest.SkipTest as e: - print(f"skipped: {e}") -""" - smoke_test_arg = base64.b64encode(smoke_test.encode("utf-8")).decode("ascii") - smoke_test_command = f"import base64; exec(base64.b64decode({smoke_test_arg!r}).decode('utf-8'))" - result = run([str(launcher), "-c", smoke_test_command], env=env, nonZeroIsFatal=(on_fail == mx.abort)) # pylint: disable=comparison-with-callable - if result != 0: - on_fail("Testing GraalOS standalone failed") + _prepare_graalos_demo(standalone_home, env) + _stage_graalos_test_harness(standalone_home) + config_path, original_config = _set_graalos_standalone_env( + standalone_home, + "GRAALPYTEST_ALLOW_NO_JAVA_ASSERTIONS", + "true", + on_fail=on_fail, + ) + try: + run_python_unittests( + str(launcher), + paths=["/test-harness/tests/test_graalos_standalone.py"], + env=env, + report=report, + parallel=0, + test_runner="/test-harness/runner.py", + ) + finally: + config_path.write_text(original_config, encoding="utf-8") From 6c713962850fcd6bb64c8d4bff0c82773ace7124 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 20:43:25 +0200 Subject: [PATCH 08/20] Upload GraalOS standalone artifact directly --- ci.jsonnet | 4 +-- ci/python-gate.libsonnet | 14 ---------- mx.graalpython/mx_graalpython_graalos.py | 35 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/ci.jsonnet b/ci.jsonnet index 75c91597b4..c1add9cb78 100644 --- a/ci.jsonnet +++ b/ci.jsonnet @@ -146,7 +146,6 @@ local GPY_JVM21_STANDALONE = "graalpy-jvm21-standalone", local GPY_JVM_STANDALONE = "graalpy-jvm-standalone", local GPY_NATIVE_STANDALONE = "graalpy-native-standalone", - local GPY_NATIVE_GRAALOS_STANDALONE = "graalpy-native-graalos-standalone", local GPYEE_JVM_STANDALONE = "graalpy-ee-jvm-standalone", local GPYEE_NATIVE_STANDALONE = "graalpy-ee-native-standalone", local GRAAL_JDK_LATEST = "graal-jdk-latest", @@ -312,7 +311,8 @@ "linux:amd64:jdk-latest" : tier3, }), "python-svm-graalos-standalone-build": gpgate_ee + internet_access_env + platform_spec(no_jobs) + platform_spec({ - "linux:amd64:jdk-latest": tier3 + $.ol8 + $.provide_graalpy_graalos_standalone_artifact(GPY_NATIVE_GRAALOS_STANDALONE) + task_spec({ + "linux:amd64:jdk-latest": tier3 + $.ol8 + task_spec({ + deploysArtifacts: true, environment +: { GRAALPY_GRAALOS_TOOLCHAIN_URL: $.overlay_imports.GRAALPY_GRAALOS_TOOLCHAIN_URL, GRAALPY_GRAALOS_RUNTIME_URL: $.overlay_imports.GRAALPY_GRAALOS_RUNTIME_URL, diff --git a/ci/python-gate.libsonnet b/ci/python-gate.libsonnet index c092b32454..5be4dcfa44 100644 --- a/ci/python-gate.libsonnet +++ b/ci/python-gate.libsonnet @@ -303,20 +303,6 @@ ), provide:: $.provide_graalpy_standalone_artifact, - provide_graalpy_graalos_standalone_artifact(name):: task_spec(evaluate_late( - // use 2 after _ to make sure we evaluate this right after _1 late eval keys like _1_os_arch_jdk - "_2_provide_graalos_artifact", { - local os = self.os, - local arch = if self.arch == "amd64" then "" else "_" + self.arch, - local artifact_name = name + os + arch, - publishArtifacts+: [{ - "dir": "../", - "name": artifact_name, - "patterns": ["main/mxbuild/*/GRAALPY_NATIVE_GRAALOS_STANDALONE"] - }] - }) - ), - require_graalpy_standalone_artifact(name):: task_spec({ local os = self.os, local arch = if self.arch == "amd64" then "" else "_" + self.arch, diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index fc5ad26e86..0d74d157d1 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -258,6 +258,40 @@ def _set_graalos_standalone_env(standalone_home: Path, key, value, on_fail=mx.ab return config_path, original_config +def _upload_graalos_standalone_artifact(standalone_home: Path, work_dir: Path): + script = os.environ.get("ARTIFACT_UPLOADER_SCRIPT") + if not script: + mx.log("Skipping GRAALPY_NATIVE_GRAALOS_STANDALONE artifact upload: ARTIFACT_UPLOADER_SCRIPT is not set") + return + + revision = str(SUITE.vc.tip(SUITE.dir)).strip() + short_revision = revision[:10] + archive_base = work_dir / f"graalpy-native-graalos-standalone-linux-amd64-dev-g{short_revision}" + archive_path = shutil.make_archive( + str(archive_base), + "gztar", + root_dir=str(standalone_home.parent), + base_dir=standalone_home.name, + ) + artifact_name = Path(archive_path).name + upload_cmd = [ + sys.executable, + script, + archive_path, + f"graalpy/{artifact_name}", + "graalpy", + "--artifact-type", "graalpy-native-graalos-standalone", + "--version", f"dev-g{short_revision}", + "--revision", revision, + "--edition", "ee", + "--lifecycle", "snapshot", + "--platform", "linux-amd64", + ] + if repo_key := os.environ.get("ARTIFACT_REPO_KEY_LOCATION"): + upload_cmd += ["--artifact-repo-key", repo_key] + run(upload_cmd) + + def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): artifact_base_url = os.environ.get("GRAALPY_GRAALOS_ARTIFACT_BASE_URL") if not artifact_base_url: @@ -328,3 +362,4 @@ def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): ) finally: config_path.write_text(original_config, encoding="utf-8") + _upload_graalos_standalone_artifact(standalone_home, work_dir) From 976d331709db934189addaf25572aad11652fd14 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 20:52:17 +0200 Subject: [PATCH 09/20] Move GraalOS demo tests under graalos --- .../src/tests/graalos/GRAALOS_DEMO.md | 17 +++++++++-------- .../tests/graalos/test_graalos_sandbox_chat.py | 0 .../{ => graalos}/test_graalos_standalone.py | 2 +- mx.graalpython/mx_graalpython_graalos.py | 16 ++++++++++------ 4 files changed, 20 insertions(+), 15 deletions(-) rename GRAALOS_DEMO.md => graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md (82%) rename graalos_sandbox_chat.py => graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py (100%) rename graalpython/com.oracle.graal.python.test/src/tests/{ => graalos}/test_graalos_standalone.py (98%) diff --git a/GRAALOS_DEMO.md b/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md similarity index 82% rename from GRAALOS_DEMO.md rename to graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md index ef4a8a5737..b8949b4f1f 100644 --- a/GRAALOS_DEMO.md +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md @@ -15,11 +15,10 @@ The story: ## Setup -From the rebuilt standalone directory, install `pip` inside the sandbox, then +From the standalone directory, install `pip` inside the sandbox, then install the demo wheels from a host-downloaded wheel cache: ```bash -cd mxbuild/linux-amd64/GRAALPY_NATIVE_GRAALOS_STANDALONE ./bin/graalpy -Im ensurepip python3 -m pip download --only-binary=:all: --dest demo-wheels rich asteval ./bin/graalpy -Im pip install --no-index --find-links /demo-wheels rich asteval @@ -31,9 +30,9 @@ things the demo can show. ### GRAALOS-8260 workaround -If the standalone uses a vanilla GraalOS runtime where the in-sandbox -`ensurepip` subprocess path is not fixed yet, install the pure-Python wheels -from the host directly into the standalone's `site-packages`: +There is currently a bug in GraalOS that prevents the above ensurepip command +from working. Until it is fixed, we can install the pure-Python wheels from the +host directly into the standalone's `site-packages`: ```bash cd mxbuild/linux-amd64/GRAALPY_NATIVE_GRAALOS_STANDALONE @@ -57,16 +56,18 @@ python3 -m pip install \ Use this workaround only for pure-Python wheels such as `py3-none-any`; native wheels need GraalOS/GraalPy-specific handling. -Copy or place `graalos_sandbox_chat.py` in the standalone root, then run: +There should be a file `test_graalos_sandbox_chat.py` in this directory. If +not, find it in and copy it from the GraalPy source repository. From inside the +sandbox that file is available as `/test_graalos_sandbox_chat.py`, so run: ```bash -./bin/graalpy graalos_sandbox_chat.py +./bin/graalpy /test_graalos_sandbox_chat.py ``` For a non-interactive walkthrough: ```bash -./bin/graalpy graalos_sandbox_chat.py --demo +./bin/graalpy /test_graalos_sandbox_chat.py --demo ``` ## Demo Beats diff --git a/graalos_sandbox_chat.py b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py similarity index 100% rename from graalos_sandbox_chat.py rename to graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py similarity index 98% rename from graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py rename to graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py index 7b0cb4714c..507a9c3a97 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py @@ -76,7 +76,7 @@ def test_demo_packages(self): def test_sandbox_chat_demo(self): result = subprocess.run( - [sys.executable, "/graalos_sandbox_chat.py", "--demo"], + [sys.executable, "/test_graalos_sandbox_chat.py", "--demo"], check=False, capture_output=True, text=True, diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index 0d74d157d1..e344ca49ad 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -208,6 +208,8 @@ def _prepare_graalos_demo(standalone_home: Path, env): "rich", "asteval", ], env=env) + # Work around GRAALOS-8260 by installing pure-Python demo wheels from the + # host. Remove this once in-sandbox ensurepip/pip subprocesses work there. run([ sys.executable, "-m", "pip", "install", "--target", str(site_packages), @@ -219,22 +221,24 @@ def _prepare_graalos_demo(standalone_home: Path, env): "asteval", ], env=env) - shutil.copy2( - Path(SUITE.dir) / "graalos_sandbox_chat.py", - standalone_home / "graalos_sandbox_chat.py", - ) + from mx_graalpython import _python_unittest_root + graalos_tests = Path(_python_unittest_root()) / "graalos" + shutil.copy2(graalos_tests / "test_graalos_sandbox_chat.py", standalone_home / "test_graalos_sandbox_chat.py") + shutil.copy2(graalos_tests / "GRAALOS_DEMO.md", standalone_home / "GRAALOS_DEMO.md") def _stage_graalos_test_harness(standalone_home: Path): + from mx_graalpython import _python_test_runner, _python_unittest_root + graalos_tests = Path(_python_unittest_root()) / "graalos" harness_dir = standalone_home / "test-harness" tests_dir = harness_dir / "tests" tests_dir.mkdir(parents=True, exist_ok=True) shutil.copy2( - Path(SUITE.dir) / "graalpython" / "com.oracle.graal.python.test" / "src" / "runner.py", + _python_test_runner(), harness_dir / "runner.py", ) shutil.copy2( - Path(SUITE.dir) / "graalpython" / "com.oracle.graal.python.test" / "src" / "tests" / "test_graalos_standalone.py", + graalos_tests / "test_graalos_standalone.py", tests_dir / "test_graalos_standalone.py", ) From d628375429e60f73067496cfe34b8b8f38b54d41 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jun 2026 21:11:27 +0200 Subject: [PATCH 10/20] Run GraalOS chat demo test in process --- .../graalos/test_graalos_sandbox_chat.py | 39 +++++++++++++++++++ .../tests/graalos/test_graalos_standalone.py | 23 ----------- mx.graalpython/mx_graalpython_graalos.py | 9 ++++- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py index bc01fce230..9412f52ae2 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py @@ -42,8 +42,11 @@ from __future__ import annotations import argparse +import io +import sysconfig import textwrap import time +import unittest from dataclasses import dataclass from asteval import Interpreter @@ -203,5 +206,41 @@ def main(argv: list[str] | None = None) -> int: return interactive() +def skip_unless_graalos(): + soabi = sysconfig.get_config_var("SOABI") or "" + if "graalos" not in soabi: + raise unittest.SkipTest(f"requires GraalOS SOABI, got {soabi!r}") + + +class GraalOSSandboxChatTests(unittest.TestCase): + + def setUp(self): + skip_unless_graalos() + + def test_demo_packages(self): + import asteval + import rich + + self.assertTrue(asteval.__version__) + self.assertTrue(rich.get_console()) + + def test_sandbox_chat_demo(self): + global console + old_console = console + output = io.StringIO() + console = Console(file=output, force_terminal=False, color_system=None, width=120) + try: + self.assertEqual(main(["--demo"]), 0) + finally: + console = old_console + + stdout = output.getvalue() + self.assertIn("sum([i*i for i in range(1000)])", stdout) + self.assertIn("__import__('socket').create_connection", stdout) + self.assertIn("gaierror", stdout) + self.assertIn("FileNotFoundError", stdout) + self.assertIn("operation denied", stdout) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py index 507a9c3a97..ff7c02798b 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py @@ -37,8 +37,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import subprocess -import sys import sysconfig import unittest @@ -66,24 +64,3 @@ def test_sqlite3_native_extension_smoke(self): self.assertEqual(conn.execute("select sum(value) from values_for_sum").fetchone()[0], 6) finally: conn.close() - - def test_demo_packages(self): - import asteval - import rich - - self.assertTrue(asteval.__version__) - self.assertTrue(rich.get_console()) - - def test_sandbox_chat_demo(self): - result = subprocess.run( - [sys.executable, "/test_graalos_sandbox_chat.py", "--demo"], - check=False, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertIn("sum([i*i for i in range(1000)])", result.stdout) - self.assertIn("__import__('socket').create_connection", result.stdout) - self.assertIn("gaierror", result.stdout) - self.assertIn("FileNotFoundError", result.stdout) - self.assertIn("operation denied", result.stdout) diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index e344ca49ad..adbaaf7798 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -241,6 +241,10 @@ def _stage_graalos_test_harness(standalone_home: Path): graalos_tests / "test_graalos_standalone.py", tests_dir / "test_graalos_standalone.py", ) + shutil.copy2( + graalos_tests / "test_graalos_sandbox_chat.py", + tests_dir / "test_graalos_sandbox_chat.py", + ) def _set_graalos_standalone_env(standalone_home: Path, key, value, on_fail=mx.abort): @@ -358,7 +362,10 @@ def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): try: run_python_unittests( str(launcher), - paths=["/test-harness/tests/test_graalos_standalone.py"], + paths=[ + "/test-harness/tests/test_graalos_standalone.py", + "/test-harness/tests/test_graalos_sandbox_chat.py", + ], env=env, report=report, parallel=0, From 47c66fc39d2c60b28d8643034128d028fd4197f0 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Sat, 20 Jun 2026 07:16:52 +0200 Subject: [PATCH 11/20] Ignore host Python for GraalOS demo install --- mx.graalpython/mx_graalpython_graalos.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index adbaaf7798..2813f11c1b 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -215,6 +215,7 @@ def _prepare_graalos_demo(standalone_home: Path, env): "--target", str(site_packages), "--no-index", "--find-links", str(demo_wheels), + "--ignore-requires-python", "--no-compile", "--upgrade", "rich", From 5b6a8eb8aa1aeb3bd0908208838f5fcd02e05148 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Sat, 20 Jun 2026 08:56:22 +0200 Subject: [PATCH 12/20] Constrain GraalOS standalone CI hosts --- ci.jsonnet | 1 + 1 file changed, 1 insertion(+) diff --git a/ci.jsonnet b/ci.jsonnet index c1add9cb78..2d6e0b06ca 100644 --- a/ci.jsonnet +++ b/ci.jsonnet @@ -312,6 +312,7 @@ }), "python-svm-graalos-standalone-build": gpgate_ee + internet_access_env + platform_spec(no_jobs) + platform_spec({ "linux:amd64:jdk-latest": tier3 + $.ol8 + task_spec({ + capabilities+: ["mpk", "!fast", "!x82", "!x82_16_367"], deploysArtifacts: true, environment +: { GRAALPY_GRAALOS_TOOLCHAIN_URL: $.overlay_imports.GRAALPY_GRAALOS_TOOLCHAIN_URL, From 47bd574880208c9eb73aca0605b9d52686e12678 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Sat, 20 Jun 2026 14:49:25 +0200 Subject: [PATCH 13/20] Enable GraalOS standalone runtime codegen --- .../README_GRAALOS_STANDALONE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md index 1ecfd7ec26..716e2607b3 100644 --- a/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md +++ b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md @@ -68,6 +68,8 @@ Common top-level fields: wired - `allowed_ports`: explicit bind and listen allowlist - `netmappings`: outbound and inbound network policy +- `allow_runtime_codegen`: allow runtime-generated code after GraalOS + binsweep verification - `allow_signal_self_snapshot`: allows the process to create a snapshot by signaling itself - `memlimit`: memory budget in GiB From 437039a41c346016315b46a52d5d9adc90264bba Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Sat, 20 Jun 2026 15:14:41 +0200 Subject: [PATCH 14/20] Fix GraalOS standalone test reports --- mx.graalpython/mx_graalpython.py | 13 ++++++++----- mx.graalpython/mx_graalpython_graalos.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/mx.graalpython/mx_graalpython.py b/mx.graalpython/mx_graalpython.py index aea1ce9b84..e4567b026c 100644 --- a/mx.graalpython/mx_graalpython.py +++ b/mx.graalpython/mx_graalpython.py @@ -1572,7 +1572,8 @@ def graalpytest(args): def run_python_unittests(python_binary, args=None, paths=None, exclude=None, env=None, cwd=None, lock=None, out=None, err=None, nonZeroIsFatal=True, timeout=None, - report: Union[Task, bool, None] = False, parallel=None, runner_args=None, test_runner=None): + report: Union[Task, bool, None] = False, parallel=None, runner_args=None, test_runner=None, + reportfile=None, runner_reportfile=None): if lock: lock.acquire() @@ -1623,12 +1624,14 @@ def run_python_unittests(python_binary, args=None, paths=None, exclude=None, env # at once it generates so much data we run out of heap space args.append('--separate-workers') - reportfile = None t0 = time.time() if report: - with tempfile.NamedTemporaryFile(prefix="test-report-", suffix=".json", delete=False) as report_tmp: - reportfile = os.path.abspath(report_tmp.name) - args += ["--mx-report", reportfile] + if reportfile is None: + with tempfile.NamedTemporaryFile(prefix="test-report-", suffix=".json", delete=False) as report_tmp: + reportfile = os.path.abspath(report_tmp.name) + else: + reportfile = os.path.abspath(reportfile) + args += ["--mx-report", runner_reportfile or reportfile] if paths is not None: args += paths diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index 2813f11c1b..c32e1ba3e8 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -48,6 +48,7 @@ import shutil import sys import tarfile +import tempfile import urllib.parse import urllib.request from pathlib import Path @@ -360,6 +361,16 @@ def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): "true", on_fail=on_fail, ) + reportfile = None + runner_reportfile = None + if report: + report_dir = standalone_home / "tmp" + report_dir.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix="test-report-", suffix=".json", dir=report_dir, delete=False + ) as report_tmp: + reportfile = report_tmp.name + runner_reportfile = f"/tmp/{os.path.basename(reportfile)}" try: run_python_unittests( str(launcher), @@ -371,6 +382,8 @@ def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): report=report, parallel=0, test_runner="/test-harness/runner.py", + reportfile=reportfile, + runner_reportfile=runner_reportfile, ) finally: config_path.write_text(original_config, encoding="utf-8") From 9ee28c9ab68e39ac589f22d4ffe113a770625608 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Mon, 22 Jun 2026 16:27:00 +0200 Subject: [PATCH 15/20] Skip test_graalossandbox_chat.py successfully --- .../graalos/test_graalos_sandbox_chat.py | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py index 9412f52ae2..3f0c6f0d03 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py @@ -49,15 +49,8 @@ import unittest from dataclasses import dataclass -from asteval import Interpreter -from rich.console import Console -from rich.panel import Panel -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text - -console = Console() +console = None @dataclass @@ -68,7 +61,8 @@ class EvalResult: elapsed_ms: float -def make_interpreter() -> Interpreter: +def make_interpreter(): + from asteval import Interpreter aeval = Interpreter() # Keep the demo's "safe" lane expression-oriented. GraalOS is the real # containment boundary; this prevents the app-level evaluator from opening files. @@ -77,10 +71,12 @@ def make_interpreter() -> Interpreter: def render_message(role: str, body: str, style: str) -> None: + from rich.panel import Panel + from rich.text import Text console.print(Panel(Text(body), title=role, title_align="left", border_style=style)) -def safe_eval(aeval: Interpreter, expr: str) -> EvalResult: +def safe_eval(aeval, expr: str): start = time.perf_counter() aeval.error = [] try: @@ -96,7 +92,7 @@ def safe_eval(aeval: Interpreter, expr: str) -> EvalResult: return EvalResult("asteval", True, repr(value), elapsed) -def unsafe_eval(expr: str) -> EvalResult: +def unsafe_eval(expr: str): start = time.perf_counter() try: value = eval(expr) @@ -109,7 +105,8 @@ def unsafe_eval(expr: str) -> EvalResult: return EvalResult("python eval", False, f"{type(exc).__name__}: {exc}", elapsed) -def render_result(result: EvalResult) -> None: +def render_result(result) -> None: + from rich.table import Table table = Table.grid(padding=(0, 1)) table.add_column(style="bold") table.add_column() @@ -120,7 +117,7 @@ def render_result(result: EvalResult) -> None: render_message("sandbox", result.output, "green" if result.ok else "red") -def evaluate(aeval: Interpreter, line: str) -> None: +def evaluate(aeval, line: str) -> None: line = line.strip() if not line: return @@ -162,6 +159,7 @@ def print_intro() -> None: def print_help() -> None: examples = "\n".join(demo_script()) + from rich.syntax import Syntax console.print(Syntax(examples, "python", theme="ansi_dark", word_wrap=True)) @@ -186,7 +184,7 @@ def interactive() -> int: evaluate(aeval, line) -def run_demo(aeval: Interpreter | None = None) -> None: +def run_demo(aeval=None) -> None: if aeval is None: aeval = make_interpreter() for line in demo_script(): @@ -195,6 +193,9 @@ def run_demo(aeval: Interpreter | None = None) -> None: def main(argv: list[str] | None = None) -> int: + global console, Console + from rich.console import Console + console = Console() parser = argparse.ArgumentParser() parser.add_argument("--demo", action="store_true", help="run the prepared demo script and exit") args = parser.parse_args(argv) @@ -226,14 +227,9 @@ def test_demo_packages(self): def test_sandbox_chat_demo(self): global console - old_console = console output = io.StringIO() console = Console(file=output, force_terminal=False, color_system=None, width=120) - try: - self.assertEqual(main(["--demo"]), 0) - finally: - console = old_console - + self.assertEqual(main(["--demo"]), 0) stdout = output.getvalue() self.assertIn("sum([i*i for i in range(1000)])", stdout) self.assertIn("__import__('socket').create_connection", stdout) From bfe89fcea5e2181987fb2625ed93098245db1382 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Mon, 22 Jun 2026 16:33:26 +0200 Subject: [PATCH 16/20] Put package install instructions into readme --- .../src/tests/graalos/GRAALOS_DEMO.md | 17 +++++---------- .../README_GRAALOS_STANDALONE.md | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md b/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md index b8949b4f1f..9852684773 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md @@ -35,20 +35,13 @@ from working. Until it is fixed, we can install the pure-Python wheels from the host directly into the standalone's `site-packages`: ```bash -cd mxbuild/linux-amd64/GRAALPY_NATIVE_GRAALOS_STANDALONE -python3 -m pip download \ +python3 -m pip install \ + --target GRAALPY_NATIVE_GRAALOS_STANDALONE/lib/python3.12/site-packages \ --only-binary=:all: \ - --implementation py \ --python-version 3.12 \ - --abi none \ - --platform any \ - --dest demo-wheels \ - rich asteval - -python3 -m pip install \ - --target lib/python3.12/site-packages \ - --no-index \ - --find-links demo-wheels \ + --implementation py --implementation graalpy \ + --abi none --abi graalpy250_312_native \ + --platform any --platform graalos_x86_64 \ --no-compile \ rich asteval ``` diff --git a/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md index 716e2607b3..7042f1567a 100644 --- a/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md +++ b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md @@ -245,6 +245,27 @@ bin/graalpy \ -c 'print("hello")' ``` +### Install extra packages + +You may install additional packages directly into the standalone from the +outside, by selecting compatible tags, for example: + +```bash +python3 -m pip install \ + --target GRAALPY_NATIVE_GRAALOS_STANDALONE/lib/python3.12/site-packages \ + --only-binary=:all: \ + --python-version 3.12 \ + --implementation py --implementation graalpy \ + --abi none --abi graalpy250_312_native \ + --platform any --platform graalos_x86_64 \ + --no-compile \ + rich asteval +``` + +You may have to set --extra-index-url to an index that provides provides +pre-built binary wheels for GraalOS, since this building these requires a +special toolchain. + ## Notes About Graalhost The standalone wraps `graalhost`, which is the GraalOS runtime responsible for: From 3d6baa24103bb5fa19358bf5e37007baae83f7d8 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Mon, 22 Jun 2026 18:09:53 +0200 Subject: [PATCH 17/20] Fix snapshot restoration --- .../README_GRAALOS_STANDALONE.md | 6 ++++++ .../graalpy-sandbox-launcher.sh | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md index 7042f1567a..57a8562a9c 100644 --- a/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md +++ b/graalpython/graalpy_graalos_standalone_payload/README_GRAALOS_STANDALONE.md @@ -227,6 +227,12 @@ bin/graalpy --graalhost.run_snapshot=/path/to/persistIso... Restoring a snapshot uses the saved process state. It does not take additional Python command-line arguments on the same invocation. +When `allow_signal_self_snapshot` is enabled, the launcher keeps the generated +expanded endpoint config under `tmp/graalpy-sandbox.*` instead of deleting it +at process exit. Snapshot restore needs that original directory to remain +available because the saved endpoint configuration records it as +`endpoint_config_path`. + ### Show Graalhost Diagnostics For launcher-level troubleshooting: diff --git a/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh b/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh index d4f2da340a..e526c07537 100644 --- a/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh +++ b/graalpython/graalpy_graalos_standalone_payload/graalpy-sandbox-launcher.sh @@ -67,6 +67,7 @@ config="${standalone_home}/config.json" tmp_root="${standalone_home}/tmp" launcher_verbose=false launcher_show_help=false +cleanup_tmpdir=true if [ ! -x "$graalhost" ]; then echo "missing or non-executable GraalHost binary: $graalhost" >&2 @@ -94,8 +95,17 @@ if [ -z "$tmp_base" ] || [ ! -d "$tmp_base" ]; then tmp_base="$tmp_root" fi +# Snapshot restore reuses the endpoint config persisted in the snapshot. When +# self-snapshotting is enabled, keep the generated config directory so the +# snapshotted endpoint_config_path still exists on resume. +if grep -Eq '^[[:space:]]*"allow_signal_self_snapshot"[[:space:]]*:[[:space:]]*true([[:space:]]*[,}])' "$config"; then + cleanup_tmpdir=false +fi + tmpdir="$(mktemp -d "${tmp_base}/graalpy-sandbox.XXXXXXXXXX")" -trap 'rm -rf "$tmpdir"' EXIT +if [ "$cleanup_tmpdir" = "true" ]; then + trap 'rm -rf "$tmpdir"' EXIT +fi endpoint_config="${tmpdir}/config.json" "$expand_config" "$standalone_home" "$config" "$endpoint_config" From a4b108ea6ea4792dcdf99fca3fadd0c632100676 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Mon, 22 Jun 2026 20:40:38 +0200 Subject: [PATCH 18/20] Remove asteval from graalos demo story, it's confusing --- .../src/tests/graalos/GRAALOS_DEMO.md | 65 ++++++----------- .../graalos/test_graalos_sandbox_chat.py | 71 +++++-------------- mx.graalpython/mx_graalpython_graalos.py | 2 - 3 files changed, 39 insertions(+), 99 deletions(-) diff --git a/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md b/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md index 9852684773..65fe1a72e0 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/GRAALOS_DEMO.md @@ -1,38 +1,24 @@ # GraalOS Standalone Sandbox Demo -This demo shows a small chat-style expression evaluator running inside the +This demo shows a small chat-style Python evaluator running inside the GraalPy GraalOS standalone. The story: 1. `rich` renders a friendly terminal UI. -2. `asteval` evaluates normal user expressions with an application-level - restricted evaluator. This demo also removes `open` from that evaluator. -3. `/unsafe ...` expressions deliberately bypass `asteval` and use Python - `eval` directly. -4. The process is still inside the GraalOS sandbox, so file, subprocess, +2. The demo treats each entered expression as untrusted Python code, such as + code produced by an LLM agent or pasted by a human operator. +3. The process is inside the GraalOS sandbox, so file, subprocess, network, and native library attempts remain contained. ## Setup -From the standalone directory, install `pip` inside the sandbox, then -install the demo wheels from a host-downloaded wheel cache: - -```bash -./bin/graalpy -Im ensurepip -python3 -m pip download --only-binary=:all: --dest demo-wheels rich asteval -./bin/graalpy -Im pip install --no-index --find-links /demo-wheels rich asteval -``` - -The online download is intentionally done outside the sandbox. The sandboxed -standalone has no outbound network mapping by default, which is one of the -things the demo can show. - -### GRAALOS-8260 workaround - -There is currently a bug in GraalOS that prevents the above ensurepip command -from working. Until it is fixed, we can install the pure-Python wheels from the -host directly into the standalone's `site-packages`: +We can install the `rich` wheel directly into the standalone's `site-packages` +using any standard Python. While we could run `ensurepip` and `pip` inside the +sandbox by configuring the appropriate network access, we do this here +intentionally done outside the sandbox. The sandboxed standalone has no +outbound network mapping by default, which is one of the things the demo can +show. ```bash python3 -m pip install \ @@ -43,12 +29,9 @@ python3 -m pip install \ --abi none --abi graalpy250_312_native \ --platform any --platform graalos_x86_64 \ --no-compile \ - rich asteval + rich ``` -Use this workaround only for pure-Python wheels such as `py3-none-any`; native -wheels need GraalOS/GraalPy-specific handling. - There should be a file `test_graalos_sandbox_chat.py` in this directory. If not, find it in and copy it from the GraalPy source repository. From inside the sandbox that file is available as `/test_graalos_sandbox_chat.py`, so run: @@ -71,21 +54,15 @@ Start with a normal expression: sum([i*i for i in range(1000)]) ``` -Then show that the app-level evaluator blocks direct file access: +Then move on to untrusted code that tries to access host resources: ```python open('/etc/passwd').read() -``` - -Switch to `/unsafe` mode to bypass the app-level evaluator while keeping the -outer GraalOS sandbox: - -```python -/unsafe open('/etc/passwd').read().splitlines()[:3] -/unsafe open('/etc/shadow').read() -/unsafe __import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True) -/unsafe __import__('socket').create_connection(('example.com', 80), timeout=2) -/unsafe __import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow') +open('/etc/passwd').read().splitlines()[:3] +open('/etc/shadow').read() +__import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True) +__import__('socket').create_connection(('example.com', 80), timeout=2) +__import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow') ``` Expected result: harmless operations work or fail normally; sensitive host @@ -95,7 +72,7 @@ filesystem, process namespace, and configured network policy. The native ## Why This Is Useful -`asteval` is an application-level guardrail. It reduces accidental exposure but -it is not a complete containment boundary. GraalOS is the outer boundary: even -if application logic accidentally evaluates dangerous code in `/unsafe` mode, -the runtime still mediates filesystem, subprocess, native, and network behavior. +This is a deliberately unsafe application pattern: it evaluates untrusted Python +code directly. That is useful for demonstrating the actual containment boundary. +GraalOS is that boundary, and it mediates filesystem, subprocess, native, and +network behavior even when the application itself offers no extra guardrails. diff --git a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py index 3f0c6f0d03..d9eb30994d 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_sandbox_chat.py @@ -37,7 +37,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Small Rich + asteval demo for the GraalOS standalone sandbox.""" +"""Small Rich demo for the GraalOS standalone sandbox.""" from __future__ import annotations @@ -59,39 +59,12 @@ class EvalResult: ok: bool output: str elapsed_ms: float - - -def make_interpreter(): - from asteval import Interpreter - aeval = Interpreter() - # Keep the demo's "safe" lane expression-oriented. GraalOS is the real - # containment boundary; this prevents the app-level evaluator from opening files. - aeval.symtable.pop("open", None) - return aeval - - def render_message(role: str, body: str, style: str) -> None: from rich.panel import Panel from rich.text import Text console.print(Panel(Text(body), title=role, title_align="left", border_style=style)) -def safe_eval(aeval, expr: str): - start = time.perf_counter() - aeval.error = [] - try: - value = aeval(expr) - except Exception as exc: # asteval normally records errors instead of raising - elapsed = (time.perf_counter() - start) * 1000 - return EvalResult("asteval", False, f"{type(exc).__name__}: {exc}", elapsed) - - elapsed = (time.perf_counter() - start) * 1000 - if aeval.error: - errors = "\n".join(str(err.get_error()) for err in aeval.error) - return EvalResult("asteval", False, errors, elapsed) - return EvalResult("asteval", True, repr(value), elapsed) - - def unsafe_eval(expr: str): start = time.perf_counter() try: @@ -117,15 +90,11 @@ def render_result(result) -> None: render_message("sandbox", result.output, "green" if result.ok else "red") -def evaluate(aeval, line: str) -> None: +def evaluate(line: str) -> None: line = line.strip() if not line: return - if line.startswith("/unsafe "): - expr = line[len("/unsafe ") :].strip() - render_result(unsafe_eval(expr)) - else: - render_result(safe_eval(aeval, line)) + render_result(unsafe_eval(line)) def demo_script() -> list[str]: @@ -133,11 +102,11 @@ def demo_script() -> list[str]: "sum([i*i for i in range(1000)])", "sin(pi / 4) ** 2 + cos(pi / 4) ** 2", "open('/etc/passwd').read()", - "/unsafe open('/etc/passwd').read().splitlines()[:3]", - "/unsafe open('/etc/shadow').read()", - "/unsafe __import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True)", - "/unsafe __import__('socket').create_connection(('example.com', 80), timeout=2)", - "/unsafe __import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow')", + "open('/etc/passwd').read().splitlines()[:3]", + "open('/etc/shadow').read()", + "__import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True)", + "__import__('socket').create_connection(('example.com', 80), timeout=2)", + "__import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow')", ] @@ -146,10 +115,10 @@ def print_intro() -> None: """ Type Python expressions and get chat-style results. - Normal input uses asteval, a restricted expression evaluator. - Prefix with /unsafe to bypass asteval and use Python eval directly. - The process is still inside the GraalOS sandbox, so filesystem, - subprocess, native library, and network attempts remain contained. + This demo treats each expression as untrusted Python code, such as + code proposed by an LLM agent or pasted by a human operator. + GraalOS sandboxes that code, so filesystem, subprocess, native + library, and network attempts remain contained. Commands: /demo, /help, /quit """ @@ -164,7 +133,6 @@ def print_help() -> None: def interactive() -> int: - aeval = make_interpreter() print_intro() while True: try: @@ -179,22 +147,20 @@ def interactive() -> int: print_help() continue if command == "/demo": - run_demo(aeval) + run_demo() continue - evaluate(aeval, line) + evaluate(line) -def run_demo(aeval=None) -> None: - if aeval is None: - aeval = make_interpreter() +def run_demo() -> None: for line in demo_script(): render_message("you", line, "blue") - evaluate(aeval, line) + evaluate(line) def main(argv: list[str] | None = None) -> int: - global console, Console from rich.console import Console + global console console = Console() parser = argparse.ArgumentParser() parser.add_argument("--demo", action="store_true", help="run the prepared demo script and exit") @@ -219,13 +185,12 @@ def setUp(self): skip_unless_graalos() def test_demo_packages(self): - import asteval import rich - self.assertTrue(asteval.__version__) self.assertTrue(rich.get_console()) def test_sandbox_chat_demo(self): + from rich.console import Console global console output = io.StringIO() console = Console(file=output, force_terminal=False, color_system=None, width=120) diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index c32e1ba3e8..0ae2af7d5c 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -207,7 +207,6 @@ def _prepare_graalos_demo(standalone_home: Path, env): "--platform", "any", "--dest", str(demo_wheels), "rich", - "asteval", ], env=env) # Work around GRAALOS-8260 by installing pure-Python demo wheels from the # host. Remove this once in-sandbox ensurepip/pip subprocesses work there. @@ -220,7 +219,6 @@ def _prepare_graalos_demo(standalone_home: Path, env): "--no-compile", "--upgrade", "rich", - "asteval", ], env=env) from mx_graalpython import _python_unittest_root From a1dd8d3f78f7a4c380c108c274bc61e183af63df Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Tue, 23 Jun 2026 05:57:58 +0200 Subject: [PATCH 19/20] Update imports --- ci.jsonnet | 2 +- mx.graalpython/graalos_versions.json | 4 ++-- mx.graalpython/mx_graalpython_graalos.py | 3 ++- mx.graalpython/mx_pominit.py | 4 ++-- mx.graalpython/suite.py | 4 ++-- pom.xml | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/ci.jsonnet b/ci.jsonnet index 2d6e0b06ca..9ccdccde2a 100644 --- a/ci.jsonnet +++ b/ci.jsonnet @@ -5,7 +5,7 @@ (import "ci/python-gate.libsonnet") + (import "ci/python-bench.libsonnet") + { - overlay: "26571215e27b3c415afb8119d38a0418c14b29c9", + overlay: "12367561e6a2b54df8d3b1bd400e431de01eef0f", specVersion: "8", // Until buildbot issues around CI tiers are resolved, we cannot use them // tierConfig: self.tierConfig, diff --git a/mx.graalpython/graalos_versions.json b/mx.graalpython/graalos_versions.json index c663674ea0..6c770ec25a 100644 --- a/mx.graalpython/graalos_versions.json +++ b/mx.graalpython/graalos_versions.json @@ -1,4 +1,4 @@ { - "runtime": "graalos/graalos_prod_pkeyson_sandboxon-runtime-2026_06_23_v1.0.0_13164_g20dfaf4db006-1.el8.x86_64.tar.gz", - "toolchain": "graal/graalvm-graalos-java25-linux-amd64-25.2.4-dev-ga44f8e9.tar.gz" + "runtime": "graalos/graalos_prod_pkeyson_sandboxon-runtime-2026_06_25_v1.0.0_13202_g5af02a07e23e-1.el8.x86_64.tar.gz", + "toolchain": "graal/graalvm-graalos-java25-linux-amd64-25.2.4-dev-gbc3c7bd.tar.gz" } diff --git a/mx.graalpython/mx_graalpython_graalos.py b/mx.graalpython/mx_graalpython_graalos.py index 0ae2af7d5c..ee3375c938 100644 --- a/mx.graalpython/mx_graalpython_graalos.py +++ b/mx.graalpython/mx_graalpython_graalos.py @@ -94,7 +94,7 @@ def update_graalos_versions(): content = json.dumps(versions, indent=2, sort_keys=True) content += "\n" mx.update_file(GRAALOS_VERSIONS_PATH.as_posix(), content, showDiff=True) - SUITE.vc.git_command(SUITE.dir, ["add", GRAALOS_VERSIONS_PATH.relative_to(SUITE.dir)], abortOnError=True) + SUITE.vc.git_command(SUITE.dir, ["add", str(GRAALOS_VERSIONS_PATH.relative_to(SUITE.dir))], abortOnError=True) def resolve_latest_graalos_artifact_name(source, on_fail=mx.abort): @@ -328,6 +328,7 @@ def graalpy_graalos_standalone_build_and_test(report=None, on_fail=mx.abort): _download_graalos_standalone_artifact(versions["runtime"], runtime_tarball, on_fail=on_fail) _extract_tarball(runtime_tarball, runtime_root, on_fail=on_fail) graalos_runtime_home = _find_graalos_runtime_home(runtime_root, on_fail=on_fail) + assert graalos_runtime_home _ensure_graalos_runtime_inputs(graalos_runtime_home, on_fail=on_fail) from mx_graalpython import extend_os_env, run_mx, run_python_unittests, _graalpy_launcher diff --git a/mx.graalpython/mx_pominit.py b/mx.graalpython/mx_pominit.py index afe7727075..30270bd52d 100644 --- a/mx.graalpython/mx_pominit.py +++ b/mx.graalpython/mx_pominit.py @@ -58,8 +58,8 @@ LOCAL_GROUP_ID = "${project.groupId}" LOCAL_VERSION = "${project.version}" GRAALVM_VERSION = "${graalvm.version}" -DEFAULT_GRAALVM_VERSION = "25.0.0" -CURRENT_GRAALVM_VERSION = "25.1.3" +DEFAULT_GRAALVM_VERSION = "25.1.3" +CURRENT_GRAALVM_VERSION = "25.2.4" XML_UPL_HEADER = """