From a0c587b467d4cc300f2fc9643f37e16c1adbf31c Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 10:06:10 -0500 Subject: [PATCH 1/3] effect-session: allow an empty resource for whole-system verbs effect_session_open() rejected any empty resource as bad_request:resource, but whole-system verbs (update/upgrade/dist_upgrade/configure) carry an empty resource by contract -- the planner returns resource:"", the broker accepts it, and the durable record stores "". The flat rejection failed every whole-system commit at the effect-session open step, before any intent opened. The native wrapper was stricter than the broker it fronts. Scope the check with the existing runix_verb_takes_packages(): an empty resource is a bad_request only for targeted verbs (install/remove/purge/hold/unhold); an over-length resource is still always rejected. pkgops writes resource:"" to match the reference boundary (rab-exercise), so the fix belongs here, not a pkgops sentinel that would diverge the durable record. Regression tests (test_effect_session_api.R): install with "" still bad_request:resource; update with "" is accepted and proceeds to the transport (runix_broker_unavailable on a dead socket). Found by the Part B disposable-VM gate -- invisible to the hermetic pkgops tests, whose fake open accepts any resource. 358 checks pass in both the production and -DRUNIX_TESTING builds. --- R/effect_session_api.R | 6 +++++- inst/tinytest/test_effect_session_api.R | 21 +++++++++++++++++++++ man/effect_session_open.Rd | 6 +++++- src/effect_session.c | 8 +++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/R/effect_session_api.R b/R/effect_session_api.R index 85ad19c..9dab29f 100644 --- a/R/effect_session_api.R +++ b/R/effect_session_api.R @@ -62,7 +62,11 @@ #' @param operation A contracted apt operation, e.g. \code{"apt.install"} (the #' runix-owned closed set; the C side maps it to the immutable entrypoint, so #' no path ever crosses from R). -#' @param resource The intent's bound resource string (non-empty). +#' @param resource The intent's bound resource string. Non-empty for a targeted +#' verb (\code{apt.install}/\code{remove}/\code{purge}/\code{hold}/\code{unhold}); +#' the empty string \code{""} for a whole-system verb +#' (\code{apt.update}/\code{upgrade}/\code{dist_upgrade}/\code{configure}), which +#' binds no package. #' @param plan_schema The plan-digest schema (a positive integer; matches the #' value negotiated by \code{\link{effect_capability}}). #' @param plan_hash The 64-hex SHA-256 plan digest bound into the receipt. diff --git a/inst/tinytest/test_effect_session_api.R b/inst/tinytest/test_effect_session_api.R index 0bc0349..475e6bc 100644 --- a/inst/tinytest/test_effect_session_api.R +++ b/inst/tinytest/test_effect_session_api.R @@ -37,6 +37,27 @@ if (is_linux) { expect_true(inherits(e2, "runix_broker_bad_request")) expect_equal(e2$detail, "plan_hash") + ## resource validation is VERB-AWARE. A targeted verb (install/remove/...) with + ## an empty resource is a bad_request; a whole-system verb (update/upgrade/ + ## dist_upgrade/configure) binds no package, carries "" by contract, and must be + ## ACCEPTED -- it then proceeds to the transport (a dead socket -> unavailable). + ## Regression: the flat `rlen == 0` rejection wrongly failed every whole-system + ## commit at open with bad_request:resource (caught by the Part B VM gate). + e_res <- tryCatch( + effect_session_open(tempfile(fileext = ".sock"), "apt.install", "", + 1L, plan_hash, connect_ms = 200L), + condition = function(c) c) + expect_true(inherits(e_res, "runix_broker_bad_request")) # targeted: still rejected + expect_equal(e_res$detail, "resource") + + e_whole <- tryCatch( + effect_session_open(tempfile(fileext = ".sock"), "apt.update", "", + 1L, plan_hash, connect_ms = 200L), + condition = function(c) c) + expect_false(inherits(e_whole, "runix_broker_bad_request")) # "" ACCEPTED for update + expect_true(inherits(e_whole, "runix_broker_unavailable")) # proceeded to connect + expect_equal(e_whole$operation, "apt.update") + ## an unknown apt verb is a hard error from the C closed enum expect_error( effect_session_open(tempfile(fileext = ".sock"), "apt.frobnicate", diff --git a/man/effect_session_open.Rd b/man/effect_session_open.Rd index 5d4ec3e..b25ce8a 100644 --- a/man/effect_session_open.Rd +++ b/man/effect_session_open.Rd @@ -21,7 +21,11 @@ effect_session_open( runix-owned closed set; the C side maps it to the immutable entrypoint, so no path ever crosses from R).} -\item{resource}{The intent's bound resource string (non-empty).} +\item{resource}{The intent's bound resource string. Non-empty for a targeted +verb (\code{apt.install}/\code{remove}/\code{purge}/\code{hold}/\code{unhold}); +the empty string \code{""} for a whole-system verb +(\code{apt.update}/\code{upgrade}/\code{dist_upgrade}/\code{configure}), which +binds no package.} \item{plan_schema}{The plan-digest schema (a positive integer; matches the value negotiated by \code{\link{effect_capability}}).} diff --git a/src/effect_session.c b/src/effect_session.c index b0c1ddc..351f4a0 100644 --- a/src/effect_session.c +++ b/src/effect_session.c @@ -532,7 +532,13 @@ SEXP effect_session_open(SEXP socket_path_, SEXP operation_, SEXP resource_, return es_open_result(NULL, NULL, "bad_request", "plan_schema"); } size_t rlen = strlen(resource); - if (rlen == 0 || rlen >= RUNIX_APT_RES_CAP) { + /* Whole-system verbs (update/upgrade/dist_upgrade/configure) carry an EMPTY + * resource by contract -- the broker accepts it and the durable record stores + * "". Only targeted verbs (install/remove/purge/hold/unhold) must name a + * package, so an empty resource is a bad_request for those alone. An + * over-length resource is always rejected. */ + if (rlen >= RUNIX_APT_RES_CAP || + (rlen == 0 && runix_verb_takes_packages(verb))) { return es_open_result(NULL, NULL, "bad_request", "resource"); } if (strlen(socket_path) >= RUNIX_SOCKPATH_CAP) { From d311630d9a8516fdf11582cc2418f3bb4f86fb29 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 10:07:03 -0500 Subject: [PATCH 2/3] Bump version to 0.0.1.13 --- DESCRIPTION | 4 ++-- NEWS.md | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 808b560..5baab80 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: runix Type: Package Title: Common Core for the Runix System-Administration Framework -Version: 0.0.1.12 -Date: 2026-08-17 +Version: 0.0.1.13 +Date: 2026-08-20 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", comment = c(ORCID = "0009-0005-4248-604X")), diff --git a/NEWS.md b/NEWS.md index 59d1820..6ddff03 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,12 @@ +# runix 0.0.1.13 + +- `effect_session_open()` now accepts an empty `resource` for the whole-system + verbs (`apt.update`/`upgrade`/`dist_upgrade`/`configure`), which bind no package + and carry `resource = ""` by contract. An empty resource is still a + `bad_request` for the targeted verbs (`apt.install`/`remove`/`purge`/`hold`/ + `unhold`). Previously every whole-system commit failed at the session-open step; + the native wrapper had been stricter than the broker it fronts. + # runix 0.0.1.12 - The exported slice-2 effect-session surface (pkgops slice 2 complete), over From 91161f6faf0adade9eed006603408974d1c3c44d Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 11:09:12 -0500 Subject: [PATCH 3/3] effect-session: native open_intent carries the broker-required outcome The native open_intent frame omitted the broker-required outcome and effect_issued fields, so every native open was schema_invalid at a real broker -- durable-audit-contract.md (two-phase write) has always specified an intent record carries effect_issued=false, outcome="intent". The schemaless fake responder the hermetic suite uses validated nothing and passed the malformed frame; the disposable-VM (Part B) run caught it. Add an at-home regression driving native open + write_outcome against the REAL broker daemon (a must-match negative asserts the daemon rejects a record missing outcome: broker_error/schema_invalid), plus a dedicated CI job (effect-session-native) that builds runix with the testing seam and a post-v0.0.1 broker so the native path is exercised for real. The linux job now runs at_home= TRUE -- test_package() defaults it FALSE, which had been silently skipping the uid-0 pinning regression and the live-broker round-trips its own comment claims. --- .github/workflows/ci.yaml | 76 ++++++++++++++++++++++++++++- NEWS.md | 15 ++++++ inst/tinytest/test_effect_session.R | 70 ++++++++++++++++++++++++++ src/effect_session.c | 10 +++- 4 files changed, 168 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fb299eb..b666fdd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -69,13 +69,87 @@ jobs: - name: Install runix (compiles the AF_UNIX client) run: R CMD INSTALL . + # at_home = TRUE is REQUIRED: tinytest::test_package() defaults it to FALSE, + # which silently skips every at_home()-gated block -- the uid-0 pinning + # regression and the R-level live-broker round-trips among them. Without it + # this step is green while proving neither (an instrument that cannot fail). - name: Tests (fake-responder battery + live broker when armed) run: | - Rscript -e 'r <- tinytest::test_package("runix"); n <- sum(!vapply(r, isTRUE, logical(1))); cat(length(r), "checks,", n, "failures\n"); quit(status = as.integer(n > 0))' + Rscript -e 'r <- tinytest::test_package("runix", at_home = TRUE); n <- sum(!vapply(r, isTRUE, logical(1))); cat(length(r), "checks,", n, "failures\n"); quit(status = as.integer(n > 0))' - name: R CMD check (CRAN cleanliness) run: ./run.sh run_tests + # The NATIVE effect-session path against a REAL broker. The production `linux` + # job builds runix normally, so its testing-only seam (.effect_session_testing()) + # is off and the native open/commit/write_outcome frames are never exercised + # end to end -- exactly the blind spot the disposable-VM (Part B) run caught (a + # native open_intent missing the broker-required `outcome` field passed every + # hermetic test because the fake responder validates no schema). This job builds + # runix WITH the seam (RUNIX_TESTING=1) and drives the real broker daemon so the + # C-built frames are validated for real. The broker is pinned to the SHA that + # first carries the effect-session protocol (open_intent effect.required + the + # receipt/binding); the v0.0.1 tag predates it and cannot serve these frames. + effect-session-native: + runs-on: ubuntu-24.04 + env: + # 5752166 (on origin/master): effect-receipt capability (#3) + rab-exercise + # (#4). The immutable v0.0.1 tag has NO effect-session support. + RAB_SHA: 57521661ef55be65499348bc92ff81489939550f + steps: + - uses: actions/checkout@v6 + + - name: Setup r-ci + uses: eddelbuettel/github-actions/r-ci@master + with: + backend: RAPT + + - name: System build deps (for the broker daemon) + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libjansson-dev pkg-config build-essential + + - name: Install janssonr from the cornball apt repository + run: | + sudo tee /etc/apt/sources.list.d/janssonr.sources > /dev/null <<'EOF' + Types: deb + URIs: https://cornball-ai.github.io/janssonr + Suites: noble + Components: main + Trusted: yes + Enabled: yes + EOF + sudo apt-get update + sudo apt-get install -y r-cornball-janssonr + + - name: R dependencies + run: ./run.sh install_deps + + # Pinned to a SHA (not the v0.0.1 tag), so a full clone + checkout rather + # than --branch. The repo is small; a shallow clone is not worth the fetch + # dance for an arbitrary object. + - name: Build the broker daemon at the effect-session SHA + run: | + git clone https://github.com/cornball-ai/runix-audit-broker.git "${RUNNER_TEMP}/rab" + git -C "${RUNNER_TEMP}/rab" checkout "${RAB_SHA}" + make -C "${RUNNER_TEMP}/rab" + echo "RUNIX_TEST_BROKER_BIN=${RUNNER_TEMP}/rab/audit-broker" >> "$GITHUB_ENV" + + # RUNIX_TESTING=1 compiles the commit seam and makes .effect_session_testing() + # TRUE, and lets the transport accept a non-root broker via RUNIX_TEST_PEER_UID + # (the test sets it). A clean object tree first: a stale src/*.o from a prior + # non-testing build would silently defeat the -DRUNIX_TESTING flag. + - name: Install runix with the testing seam + run: | + rm -f src/*.o src/*.so + RUNIX_TESTING=1 R CMD INSTALL . + + # at_home = TRUE (test_package defaults it FALSE) so the seam block AND the + # real-broker block run; the native frames are validated by the daemon, not a + # schemaless fake. A negative case asserts the daemon REJECTS a record missing + # `outcome` (broker_error/schema_invalid), so the guard cannot pass vacuously. + - name: Native effect-session round-trips against the real broker + run: | + Rscript -e 'r <- tinytest::test_package("runix", at_home = TRUE); n <- sum(!vapply(r, isTRUE, logical(1))); cat(length(r), "checks,", n, "failures\n"); quit(status = as.integer(n > 0))' + # Non-Linux Unix (macOS): runix is a Unix package, but the broker is # Linux-only. On other Unix the package must still install, load, and keep its # portable sinks working while every broker call returns a typed diff --git a/NEWS.md b/NEWS.md index 6ddff03..ed2ff00 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,11 +1,26 @@ # runix 0.0.1.13 +Two native effect-session conformance fixes the disposable-VM (Part B) run caught +against a real broker -- both invisible to the schemaless fake responder the +hermetic suite uses. + - `effect_session_open()` now accepts an empty `resource` for the whole-system verbs (`apt.update`/`upgrade`/`dist_upgrade`/`configure`), which bind no package and carry `resource = ""` by contract. An empty resource is still a `bad_request` for the targeted verbs (`apt.install`/`remove`/`purge`/`hold`/ `unhold`). Previously every whole-system commit failed at the session-open step; the native wrapper had been stricter than the broker it fronts. +- `effect_session_open()`'s `open_intent` frame now carries the broker-required + `outcome = "intent"` and `effect_issued = false`, as + `durable-audit-contract.md` (two-phase write) has always specified for an intent + record. `RECORD_SCHEMA` marks `outcome` required, so the native frame was + `schema_invalid` at the broker; the fake responder validated nothing and passed + it. A new at-home regression drives the native open + `write_outcome` against the + real broker daemon (and asserts the daemon rejects a record missing `outcome`), + and a dedicated CI job (`effect-session-native`) builds runix with the testing + seam and a post-v0.0.1 broker so this path is exercised for real; the `linux` + job now runs its suite `at_home = TRUE` (it was silently skipping the pinning and + live-broker blocks). # runix 0.0.1.12 diff --git a/inst/tinytest/test_effect_session.R b/inst/tinytest/test_effect_session.R index 8967750..95913dc 100644 --- a/inst/tinytest/test_effect_session.R +++ b/inst/tinytest/test_effect_session.R @@ -518,5 +518,75 @@ if (is_linux && tinytest::at_home() && "takes no packages") parallel::mccollect(cbroker) + + ## ---- REAL broker: the native open_intent + write_outcome FRAMES ---------- + ## The fake broker above validates NO schema, so a malformed native frame (a + ## missing broker-required field, a rejected empty resource) slips through -- + ## exactly what the Part B VM gate caught. Drive the native C open + + ## write_outcome against the ACTUAL broker daemon (the binary CI builds at + ## RUNIX_TEST_BROKER_BIN) so those frames are validated for real. The peer uid + ## is already unpinned to myuid above; skip when the binary is absent. + rbin <- Sys.getenv("RUNIX_TEST_BROKER_BIN") + if (nzchar(rbin) && file.exists(rbin)) { + rdir <- tempfile("es-realbroker-") + dir.create(rdir) + rsock <- file.path(rdir, "sock") + rsink <- file.path(rdir, "audit.jsonl") + rpid <- suppressWarnings(system(sprintf( + "%s --listen %s --sink %s >/dev/null 2>&1 & echo $!", + shQuote(rbin), shQuote(rsock), shQuote(rsink)), intern = TRUE)) + rpid <- rpid[grepl("^[0-9]+$", rpid)][1] + for (i in 1:200) { + if (file.exists(rsock)) break + Sys.sleep(0.02) + } + on.exit({ + if (length(rpid) == 1L && !is.na(rpid)) { + system(paste("kill", rpid, "2>/dev/null")) + } + unlink(rdir, recursive = TRUE, force = TRUE) + }, add = TRUE) + expect_true(file.exists(rsock)) + rph <- strrep("e", 64) + + ## a WHOLE-SYSTEM verb carries an EMPTY resource -> ACCEPTED. Regresses both + ## Part B open findings: the empty-resource rejection AND the missing + ## broker-required `outcome` field in open_intent. A malformed frame comes + ## back broker_error / bad_response, never status "ok" with a handle. + ru <- runix:::.effect_session_open(rsock, "apt.update", "", 1L, rph) + expect_equal(ru$status, "ok") + expect_equal(typeof(ru$handle), "externalptr") + expect_true(grepl("^[0-9]{20}-[0-9a-f]{16}$", ru$correlation_id)) + + ## native write_outcome FRAME accepted end to end: a well-formed record + ## (operation + the REQUIRED outcome) closes the intent. effect_issued=FALSE + ## so the broker's effect-receipt gate needs no redemption. + wu <- runix:::.effect_session_write_outcome(ru$handle, + list(operation = "apt.update", outcome = "ok", effect_issued = FALSE)) + expect_equal(wu$status, "ok") + + ## a targeted verb still round-trips with a non-empty resource + ri <- runix:::.effect_session_open(rsock, "apt.install", "nginx", 1L, rph) + expect_equal(ri$status, "ok") + + ## NEGATIVE: a record OMITTING the broker-required `outcome` is REJECTED by + ## the real broker -- proving the enforcement is real and this test actually + ## guards the frame, not merely that the client did not error. A must-MATCH + ## (the exact broker_error/schema_invalid pair) rather than a must-differ, so + ## a transport slip cannot read as a false proof. The fake broker would have + ## accepted it. + rbad <- runix:::.effect_session_open(rsock, "apt.update", "", 1L, rph) + expect_equal(rbad$status, "ok") + wbad <- runix:::.effect_session_write_outcome(rbad$handle, + list(operation = "apt.update", effect_issued = FALSE)) + expect_equal(wbad$status, "broker_error") + expect_equal(wbad$detail, "schema_invalid") + + ## both phases of the good update landed durably in the real sink + Sys.sleep(0.1) + raud <- readLines(rsink, warn = FALSE) + expect_true(any(grepl("\"phase\":\"intent\"", raud, fixed = TRUE))) + expect_true(any(grepl("\"phase\":\"outcome\"", raud, fixed = TRUE))) + } } } diff --git a/src/effect_session.c b/src/effect_session.c index 351f4a0..0a3e7d3 100644 --- a/src/effect_session.c +++ b/src/effect_session.c @@ -554,8 +554,12 @@ SEXP effect_session_open(SEXP socket_path_, SEXP operation_, SEXP resource_, } } - /* build {type, record:{operation,resource}, effect:{required,plan_schema, - * plan_hash}} with jansson so `resource` is properly escaped. */ + /* build {type, record:{operation,resource,outcome,effect_issued}, + * effect:{required,plan_schema,plan_hash}} with jansson so `resource` is + * properly escaped. The broker's RECORD_SCHEMA requires BOTH `operation` and + * `outcome`; an open_intent is the intent checkpoint, so `outcome` is the fixed + * "intent" and `effect_issued` is false (nothing is issued yet). This matches + * the reference boundary (runix-audit-broker tools/rab-exercise.c). */ json_t *root = json_object(); json_t *record = json_object(); json_t *effect = json_object(); @@ -568,6 +572,8 @@ SEXP effect_session_open(SEXP socket_path_, SEXP operation_, SEXP resource_, json_object_set_new(record, "operation", json_string(RUNIX_VERBS[verb].operation)); json_object_set_new(record, "resource", json_string(resource)); + json_object_set_new(record, "outcome", json_string("intent")); + json_object_set_new(record, "effect_issued", json_false()); json_object_set_new(effect, "required", json_true()); json_object_set_new(effect, "plan_schema", json_integer(plan_schema)); json_object_set_new(effect, "plan_hash", json_string(plan_hash));