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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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")),
Expand Down
24 changes: 24 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
# 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

- The exported slice-2 effect-session surface (pkgops slice 2 complete), over
Expand Down
6 changes: 5 additions & 1 deletion R/effect_session_api.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions inst/tinytest/test_effect_session.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}
}
21 changes: 21 additions & 0 deletions inst/tinytest/test_effect_session_api.R
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion man/effect_session_open.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -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}}).}
Expand Down
18 changes: 15 additions & 3 deletions src/effect_session.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -548,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();
Expand All @@ -562,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));
Expand Down
Loading