From e8ca0590a30af4ff67a9d09f5af6437d39074dd1 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 28 May 2026 19:26:34 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m1:=20Step=207d-pure=20=E2=80=94=20sandb?= =?UTF-8?q?ox:eval=5Fpure/2,/3=20+=2013=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next/kernel/sandbox.erl | 41 ++++++++++++ next/tests/sandbox_eval.sh | 130 ++++++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-1.md | 3 +- 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 next/kernel/sandbox.erl create mode 100755 next/tests/sandbox_eval.sh diff --git a/next/kernel/sandbox.erl b/next/kernel/sandbox.erl new file mode 100644 index 00000000..1300e259 --- /dev/null +++ b/next/kernel/sandbox.erl @@ -0,0 +1,41 @@ +-module(sandbox). +-export([eval_pure/2, eval_pure/3]). + +%% Sandboxed evaluation of an Erlang fun. +%% +%% eval_pure/2(Fun, Arg) -> {ok, Result} | {error, Reason} +%% eval_pure/3(Fun, Arg1, Arg2) -> {ok, Result} | {error, Reason} +%% +%% The 3-arity variant matches the (Activity, State) -> NewState +%% shape of projection folds. The projection scheduler can wrap +%% every fold call in `sandbox:eval_pure(Fun, Act, State)` to +%% ensure a misbehaving fold body can't crash the projection +%% gen_server. +%% +%% v1 sandboxing is just the try/catch envelope: no gas budget, +%% no IO denial, no environment stripping. Real sandboxing lands +%% with SX-source eval (the fold body would then be an SX form +%% evaluated under the spec/harness platform). The API shape is +%% stable — callers don't need to change when that arrives. + +%% Port note: this Erlang implementation catches by explicit +%% class names (throw, error, exit) rather than the open +%% `Class:Reason` pattern. The wrappers below enumerate the three. + +eval_pure(Fun, Arg) -> + try Fun(Arg) of + Result -> {ok, Result} + catch + throw:Reason -> {error, {throw, Reason}}; + error:Reason -> {error, {error, Reason}}; + exit:Reason -> {error, {exit, Reason}} + end. + +eval_pure(Fun, Arg1, Arg2) -> + try Fun(Arg1, Arg2) of + Result -> {ok, Result} + catch + throw:Reason -> {error, {throw, Reason}}; + error:Reason -> {error, {error, Reason}}; + exit:Reason -> {error, {exit, Reason}} + end. diff --git a/next/tests/sandbox_eval.sh b/next/tests/sandbox_eval.sh new file mode 100755 index 00000000..c9abc21d --- /dev/null +++ b/next/tests/sandbox_eval.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# next/tests/sandbox_eval.sh — Step 7d-pure test. +# +# Exercises sandbox:eval_pure/2 and eval_pure/3. Catches all +# three exception classes (throw / error / exit) and returns +# them tagged. Successful fold-shaped (Activity, State) calls +# pass through unchanged. 13 cases. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" +PASS=0; FAIL=0; ERRORS="" +TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT + +cat > "$TMPFILE" <<'EPOCHS' +(epoch 1) +(load "lib/erlang/tokenizer.sx") +(load "lib/erlang/parser.sx") +(load "lib/erlang/parser-core.sx") +(load "lib/erlang/parser-expr.sx") +(load "lib/erlang/parser-module.sx") +(load "lib/erlang/transpile.sx") +(load "lib/erlang/runtime.sx") +(load "lib/erlang/vm/dispatcher.sx") + +(epoch 2) +(eval "(get (erlang-load-module (file-read \"next/kernel/sandbox.erl\")) :name)") + +;; eval_pure/2 normal return +(epoch 10) +(eval "(get (erlang-eval-ast \"sandbox:eval_pure(fun (X) -> X + 1 end, 41) =:= {ok, 42}\") :name)") + +;; eval_pure/2 throw caught +(epoch 11) +(eval "(get (erlang-eval-ast \"case sandbox:eval_pure(fun (_) -> throw(boom) end, 1) of {error, {throw, boom}} -> ok; _ -> bad end\") :name)") + +;; eval_pure/2 error caught +(epoch 12) +(eval "(get (erlang-eval-ast \"case sandbox:eval_pure(fun (_) -> erlang:error(crash) end, 1) of {error, {error, crash}} -> ok; _ -> bad end\") :name)") + +;; eval_pure/2 exit caught +(epoch 13) +(eval "(get (erlang-eval-ast \"case sandbox:eval_pure(fun (_) -> erlang:exit(bye) end, 1) of {error, {exit, bye}} -> ok; _ -> bad end\") :name)") + +;; eval_pure/2 carries the original argument through +(epoch 14) +(eval "(get (erlang-eval-ast \"sandbox:eval_pure(fun (X) -> X end, marker) =:= {ok, marker}\") :name)") + +;; eval_pure/2 returning a tuple is wrapped in {ok, _} +(epoch 15) +(eval "(get (erlang-eval-ast \"sandbox:eval_pure(fun (_) -> {a, b} end, 0) =:= {ok, {a, b}}\") :name)") + +;; eval_pure/3 normal return (Activity, State) shape +(epoch 16) +(eval "(get (erlang-eval-ast \"sandbox:eval_pure(fun (A, S) -> S + A end, 10, 5) =:= {ok, 15}\") :name)") + +;; eval_pure/3 throw caught +(epoch 17) +(eval "(get (erlang-eval-ast \"case sandbox:eval_pure(fun (_, _) -> throw(stop) end, x, y) of {error, {throw, stop}} -> ok; _ -> bad end\") :name)") + +;; eval_pure/3 error caught +(epoch 18) +(eval "(get (erlang-eval-ast \"case sandbox:eval_pure(fun (_, _) -> erlang:error(badarith) end, 1, 2) of {error, {error, badarith}} -> ok; _ -> bad end\") :name)") + +;; eval_pure/3 fold-style fun: tag activities into state +(epoch 19) +(eval "(get (erlang-eval-ast \"Fold = fun ({tag, T}, S) -> [T | S]; (_, S) -> S end, sandbox:eval_pure(Fold, {tag, foo}, []) =:= {ok, [foo]}\") :name)") + +;; Successful eval_pure does not catch silently — distinguishes ok+nil from error +(epoch 20) +(eval "(get (erlang-eval-ast \"sandbox:eval_pure(fun (_) -> nil end, 0) =:= {ok, nil}\") :name)") + +;; Tuple reason inside the caught exception is preserved +(epoch 21) +(eval "(get (erlang-eval-ast \"case sandbox:eval_pure(fun (_) -> throw({bad_input, {field, x}}) end, 0) of {error, {throw, {bad_input, {field, x}}}} -> ok; _ -> bad end\") :name)") +EPOCHS + +OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) + +check() { + local epoch="$1" desc="$2" expected="$3" + local actual + actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' + $0 ~ "^\\(ok-len " e " " { getline; print; exit } + $0 ~ "^\\(ok " e " " { print; exit } + $0 ~ "^\\(error " e " " { print; exit } + ') + [ -z "$actual" ] && actual="" + if echo "$actual" | grep -qF -- "$expected"; then + PASS=$((PASS+1)) + [ "$VERBOSE" = "-v" ] && echo " ok $desc" + else + FAIL=$((FAIL+1)) + ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual +" + fi +} + +check 2 "module load name" "sandbox" +check 10 "eval_pure/2 normal return" "true" +check 11 "eval_pure/2 throw caught" "ok" +check 12 "eval_pure/2 error caught" "ok" +check 13 "eval_pure/2 exit caught" "ok" +check 14 "eval_pure/2 arg passthrough" "true" +check 15 "eval_pure/2 tuple wrapped in ok" "true" +check 16 "eval_pure/3 fold-shape success" "true" +check 17 "eval_pure/3 throw caught" "ok" +check 18 "eval_pure/3 error caught" "ok" +check 19 "eval_pure/3 tag-fold body" "true" +check 20 "ok+nil distinct from error" "true" +check 21 "tuple reason preserved" "ok" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/sandbox_eval.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-1.md b/plans/fed-sx-milestone-1.md index 565fedb9..de78a0ec 100644 --- a/plans/fed-sx-milestone-1.md +++ b/plans/fed-sx-milestone-1.md @@ -459,7 +459,7 @@ publish(ActorId, ActivityRequest) -> - [x] **7a** — Pure-functional `next/kernel/projection.erl`: `new/2,3`, `fold_activity/2`, `replay/2`, `name/1`, `state/1`, `fold_fn/1`. Projection record is `[{name, _}, {state, _}, {fold, fun}]`; fold body is an Erlang fun in v1 (SX-source eval bridge deferred). `next/tests/projection_pure.sh` (12 cases). - [x] **7b** — gen_server-per-projection: `start_link/3(Name, InitialState, FoldFn)` + `async_fold/2(Name, Activity)` (cast) + `query/1(Name)` (call) + `stop/1`. Each projection registered under its own Name atom. `next/tests/projection_server.sh` (11 cases). Snapshot persistence deferred (needs SX-source eval + on-disk state). - [x] **7c** — `outbox:publish` broadcast hook: after `log:append`, fans out the signed activity to every projection listed under `Context`'s `:projections` entry via `projection:async_fold`. Stage halts (replay, sig failure) skip broadcast. `next/tests/outbox_broadcast.sh` (14 cases). -- [ ] **7d** — `sandbox:eval_pure/2` (Erlang sandbox-mode caller — gas budget + IO denial) once an SX-source eval bridge exists. +- [x] **7d-pure** — `next/kernel/sandbox.erl` with `eval_pure/2` and `eval_pure/3` — try/catch wrappers over Erlang funs. Catches throw, error, exit; returns `{ok, Result}` on success, `{error, {Class, Reason}}` on exception. Gas/IO sandboxing lands with SX-source eval; API shape is stable. `next/tests/sandbox_eval.sh` (13 cases). **Deliverables:** @@ -1002,6 +1002,7 @@ A few things still under-specified; resolve as work begins. Newest first. One line per sub-deliverable commit. Erlang conformance gate (`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry. +- **2026-05-28** — Step 7d-pure: `next/kernel/sandbox.erl` — `eval_pure/2(Fun, Arg)` and `eval_pure/3(Fun, Activity, State)`. try/catch envelope returns `{ok, Result}` on success and `{error, {Class, Reason}}` for each of the three exception classes (throw, error, exit). The 3-arity variant matches the projection-fold shape so the scheduler can wrap fold bodies. Port note: this Erlang implementation catches by explicit class names rather than the open `Class:Reason` pattern — wrappers enumerate `throw:Reason / error:Reason / exit:Reason` explicitly. Real gas budget + IO denial + env-stripping lands with SX-source eval; the wrapper API doesn't change. `next/tests/sandbox_eval.sh` 13/13. Erlang conformance 729/729. - **2026-05-28** — Step 9b-pure: **reactive application extensibility, proven end-to-end.** Mirrors §Step 9b structurally without TCP/curl/JSON. A trigger projection (Erlang-fun fold over `{Captured, Count}` state) matches Note activities whose `:object :tags` contains `smoketest`, constructs a derived `TestEcho` activity with `:object :echoes` pointing at the Note's `:id`, and captures it into projection state. Order-independent; non-Note + non-smoketest + Note-without-tags + sig-failed publishes all suppressed correctly. Multi-tag (e.g. `[smoketest, foo, bar]`) still matches. Cascade publish (the trigger actually publishing the derived activity back through outbox) is deferred — the gen_server reentrancy that introduces is a v2 concern; the projection-state capture is sufficient proof of the match-then-derive mechanism. `next/tests/smoke_app_pure.sh` 12/12. Erlang conformance 729/729. - **2026-05-28** — Step 9a-pure: **the first verb-extensibility smoke test, proven end-to-end.** Mirrors §Step 9a structurally without TCP/curl/JSON. Two projections wired into `nx_kernel:with_projections([define_reg, pin_state])` — `define_reg` uses `define_registry:fold_fn/0` (Step 5d-pure), `pin_state` uses an Erlang-fun fold that records `{Path, Cid}` from Pin activities. Publish `Create{DefineActivity{name: pin}}` → registry update visible via `registry:lookup(activity_types, pin, projection:query(define_reg))`; publish `Pin{path: docs_intro, cid: qm_cid_1}` → `projection:query(pin_state) =:= [{docs_intro, qm_cid_1}]`. Order-independent (DefineActivity-then-Pin and Pin-then-DefineActivity both succeed); Note + non-Define types are pass-throughs in both projections. The TCP/curl variant (Step 9a-tcp) layers on Step 8b-start. `next/tests/smoke_pin_pure.sh` 13/13. Erlang conformance 729/729. - **2026-05-28** — Step 5d-pure: `next/kernel/define_registry.erl` — the meta-projection fold body, in pure Erlang. State shape mirrors `registry:new()` exactly; `fold/2` dispatches Create{Define*} to `registry:register/4` keyed by `define_kind/1` (define_activity → activity_types, define_object → object_types, …). Non-Create + Create{non-Define} + Define{no :name} are all pass-throughs. Override re-registration preserves a single entry per name. `fold_fn/0` plugs the fold into `projection:start_link/3` — verified end-to-end: activity → projection async_fold → query state → registry:lookup returns the registered Object. The SX `define-registry.sx` body will replace this once an SX-source eval bridge exists; the Erlang shape proves the wiring is correct. `next/tests/define_registry_pure.sh` 16/16. Erlang conformance 729/729.