diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 4e96aef..00715d0 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,15 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version == [Unreleased] +=== Fixed (2026-08-28) +* `-regen.yml` no longer reproduces the estate `boj-build.yml` defects (hyperpolymath/standards#331). The emitted dispatch step now builds its JSON payload with `jq` instead of hand-escaping it (the estate shape shipped a stray backslash-brace pair and failed JSON parse in every deployed copy); takes its endpoint from a `BOJ_SERVER_URL` secret or variable instead of an unresolvable mDNS host; requires an `https` scheme at run time; POSTs to the singular `/cartridge//invoke` route rather than the plural LIST endpoint; and no longer carries step-level error suppression, so a dispatch that is attempted and fails now fails the job. An unset `BOJ_SERVER_URL` reports and stops rather than erroring: boj-server binds loopback and has no routable name until the http-capability-gateway ships (ADR-0004 tier-2 / standards#91). Caller-controlled expression values reach the script through the environment, never by interpolation into its text. +* Scaffolded cartridges vendor the ADR-0006 invoke-shim at `ffi/cartridge_shim.zig` and no longer reference `../../../ffi/zig/src/cartridge_shim.zig`. boj-server's in-tree `cartridges/` bundle has been retired — cartridges now live in the `hyperpolymath/boj-server-cartridges` registry, where all 118 of them vendor a byte-identical copy of the shim — so the old relative path resolved nowhere and the emitted cartridge could not build. +* Scaffolded `cartridge.json` declares the required `category` property. Without it, cartridge schema v1 rejects the manifest and boj-server's catalog drops the cartridge on boot. + +=== Added (2026-08-28) +* `generate` now emits the -iser's boj-server cartridge — the unified transaction-gated adapter and its SSE surface, modelled on the k9iser-mcp pilot (boj-server#73) — as a sibling `/-mcp/` tree, so every new -iser carries the pattern by construction (hyperpolymath/standards#90). The cartridge stays outside the -iser repo, preserving the topology PR #23 established. `--no-cartridge` emits the repository alone. +* Tests pinning the above: the four standards#331 defects plus the wrong cartridge path are asserted absent from the emitted workflow; the emitted `jq` program is executed and its output parsed as JSON, with a hostile ref name carried through as data; the vendored shim is asserted byte-identical to the registry copy; `cartridge.json` is checked against cartridge schema v1's required properties and closed enums; and a scaffolded cartridge's `ffi/` and `adapter/` are built with `zig build test` from wherever they were written. The `jq` and `zig` tests are no-ops where those tools are absent, matching the existing `idris2` test. + === Added (2026-05-20) * `cartridge` subcommand — scaffolds a complete boj-server cartridge skeleton (`-mcp/`) from an `iseriser.toml` manifest. Emits 13 files: top-level (README + cartridge.json + mod.js + panels/manifest.json), Idris2 ABI (README + `-mcp.ipkg` + `Mcp/Safe.idr` with the exposure-gate contract), Zig FFI (README + `build.zig` + `_ffi.zig` implementing the ADR-0006 5-symbol C ABI), and the unified gated adapter (README + `build.zig` + `_adapter.zig` routing REST/SSE/GraphQL/gRPC-compat behind the transaction gate). Modelled on the k9iser-mcp pilot (boj-server#73). Verified end-to-end: `idris2 --build`, `zig build test` on both `ffi/` and `adapter/`. Implements hyperpolymath/standards#89 Phase 2b. (PR #24) diff --git a/README.adoc b/README.adoc index b8c89c3..65ae56f 100644 --- a/README.adoc +++ b/README.adoc @@ -295,8 +295,13 @@ directory. |`+cartridge+` |Scaffold a boj-server cartridge skeleton (`+-mcp/+`) for the manifest’s -iser — adapter + FFI + ABI + cartridge.json + panels + mod.js. Output goes to -`+/-mcp/+`; place inside `+boj-server/cartridges/+` so the -emitted Zig build files resolve the shared invoke-shim. See +`+/-mcp/+`. Its home is the +`+hyperpolymath/boj-server-cartridges+` registry, at +`+cartridges/domains//+` — pick the domain directory and set +`+cartridge.json+`'s `+domain+` field to match; iseriser does not guess it, +because the manifest does not say which domain an -iser serves. The +cartridge vendors the ADR-0006 invoke-shim at `+ffi/cartridge_shim.zig+`, +so it builds wherever it is placed. See `+examples/cartridge-skeleton/README.adoc+`. |`+info+` |Print a summary of a manifest. diff --git a/src/codegen/cartridge.rs b/src/codegen/cartridge.rs index 17d73ed..cbfd3d8 100644 --- a/src/codegen/cartridge.rs +++ b/src/codegen/cartridge.rs @@ -7,10 +7,21 @@ // // Output topology: /-mcp/ // -// The output is meant to be placed inside `boj-server/cartridges/`; the -// emitted ffi/build.zig references the shared ADR-0006 invoke-shim via -// `../../../ffi/zig/src/cartridge_shim.zig`, which only resolves when the -// cartridge sits at `boj-server/cartridges/-mcp/`. +// The output belongs in the canonical registry, +// `hyperpolymath/boj-server-cartridges`, at +// `cartridges/domains//-mcp/`. The emitted `cartridge.json` +// declares `"category": "domain"` (schema v1's three-way taxonomy: domain, +// cross-cutting, template) but leaves `"domain"` as a placeholder: which +// functional domain an -iser belongs to is not derivable from its manifest, +// so the operator picks the directory and matches the field to it. +// +// boj-server's own in-tree `cartridges/` bundle has been retired (see its +// `BojRest.Application` doc comment): hosts fetch cartridges on demand into a +// flat cache and point `BOJ_CARTRIDGES_PATH` at it. +// +// The cartridge is therefore location-independent: it vendors the ADR-0006 +// invoke-shim at `ffi/cartridge_shim.zig`, as every cartridge in the registry +// does, rather than reaching up into a host tree. // // Tracks hyperpolymath/standards#89 Phase 2b. @@ -126,6 +137,7 @@ pub fn scaffold_cartridge(manifest: &Manifest, output_dir: &Path) -> CartridgeSc files.push(generate_ffi_readme(&ctx)); files.push(generate_ffi_build_zig(&ctx)); files.push(generate_ffi_zig(&ctx)); + files.push(generate_cartridge_shim()); // Unified gated adapter files.push(generate_adapter_readme(&ctx)); @@ -217,21 +229,34 @@ A boj-server cartridge skeleton for __LANG_NAME__, scaffolded by iseriser [source] ---- -boj-server/cartridges/__CARTRIDGE_NAME__/ +__CARTRIDGE_NAME__/ ├── cartridge.json — registration manifest (boj.dev schema v1) ├── mod.js — Deno module entry (JS-worker fallback path) ├── README.adoc — this file ├── panels/manifest.json — observability panel registration ├── abi/ — Idris2 ABI (source of truth) ├── ffi/ — Zig FFI implementing ADR-0006 5-symbol interface +│ └── cartridge_shim.zig — vendored ADR-0006 invoke-shim └── adapter/ — Unified transaction-gated adapter (loopback) ---- == Build -The cartridge presumes it lives inside `boj-server/cartridges/`; the -`ffi/build.zig` and `adapter/build.zig` reference the shared invoke-shim -at `boj-server/ffi/zig/src/cartridge_shim.zig` via `../../../`. +The cartridge builds from wherever it sits: the ADR-0006 invoke-shim is +vendored at `ffi/cartridge_shim.zig`, so nothing reaches outside the +cartridge directory. + +== Placement + +Its home is the canonical registry, `hyperpolymath/boj-server-cartridges`, +at `cartridges/domains//__CARTRIDGE_NAME__/`. + +`cartridge.json` ships `"category": "domain"`, which is fixed, and +`"domain": "scaffold"`, which is *not*: pick the functional domain from the +registry's existing directories and set the field to match. iseriser does +not guess it, because the manifest does not say which domain an -iser +serves. A host loads cartridges from a flat on-disk cache — populate one +with the registry's fetch script and point `BOJ_CARTRIDGES_PATH` at it. [source,shell] ---- @@ -253,7 +278,7 @@ gate is filled in (mirrors the Idris2 contract) and ready for production. internal/loopback, never public) - ADR-0006 — five-symbol cartridge C ABI (`boj_cartridge_init`/`deinit`/`name`/`version`/`invoke`) -- Pilot: `boj-server/cartridges/k9iser-mcp/` +- Pilot: `boj-server-cartridges` → `cartridges/domains/config/k9iser-mcp/` - Tracking: hyperpolymath/standards#89, #90, #91 "#, ); @@ -273,6 +298,7 @@ fn generate_cartridge_json(ctx: &TemplateCtx) -> GeneratedFile { "version": "0.1.0", "description": "__LANG_NAME__ -iser cartridge skeleton — regeneration pipeline (scaffold)", "domain": "scaffold", + "category": "domain", "tier": "Ayo", "protocols": [ "MCP", @@ -597,10 +623,10 @@ zig build test # run tests zig build lib # produce shared library (zig-out/lib/lib__LIB_NAME__.so) ---- -The build references the shared invoke-shim at -`../../../ffi/zig/src/cartridge_shim.zig`; the cartridge MUST live under -`boj-server/cartridges/__CARTRIDGE_NAME__/` for that relative path to -resolve. +The build references the ADR-0006 invoke-shim vendored beside it at +`cartridge_shim.zig` — byte-identical to the copy every cartridge in +`hyperpolymath/boj-server-cartridges` carries — so the cartridge builds +wherever the registry places it. "#, ); GeneratedFile { @@ -609,6 +635,25 @@ resolve. } } +/// The ADR-0006 invoke-shim, byte-identical to the copy every cartridge in +/// `hyperpolymath/boj-server-cartridges` vendors (all 118 copies on that +/// registry, and boj-server's own `ffi/zig/src/cartridge_shim.zig`, share one +/// hash). It is embedded rather than fetched so generation stays offline. +/// +/// Vendoring is what makes a cartridge build wherever the registry places it, +/// and it is also acknowledged estate debt: one more copy to keep in step if +/// the shim ever changes. Kept byte-identical on purpose — a divergent copy +/// would be worse than a duplicated one. +const CARTRIDGE_SHIM_ZIG: &str = include_str!("templates/cartridge_shim.zig"); + +/// Emit `ffi/cartridge_shim.zig`, the vendored ADR-0006 invoke-shim. +fn generate_cartridge_shim() -> GeneratedFile { + GeneratedFile { + path: PathBuf::from("ffi/cartridge_shim.zig"), + content: CARTRIDGE_SHIM_ZIG.to_string(), + } +} + fn generate_ffi_build_zig(ctx: &TemplateCtx) -> GeneratedFile { let content = ctx.render( r#"// SPDX-License-Identifier: MPL-2.0 @@ -622,9 +667,10 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + // ADR-0006 invoke-shim, vendored beside this build file. The registry + // ships one copy per cartridge so a cartridge builds wherever it sits. const shim_mod = b.addModule("cartridge_shim", .{ - .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .root_source_file = b.path("cartridge_shim.zig"), .target = target, .optimize = optimize, }); @@ -823,8 +869,10 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // The shim is vendored in the sibling ffi/ directory, not fetched from a + // host tree: the cartridge must build wherever the registry places it. const shim_mod = b.addModule("cartridge_shim", .{ - .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .root_source_file = b.path("../ffi/cartridge_shim.zig"), .target = target, .optimize = optimize, }); @@ -1213,11 +1261,11 @@ description = "Chapel distributed computing -iser" let tmp = tempfile::tempdir().unwrap(); let result = scaffold_cartridge(&manifest, tmp.path()); let repo = result.repo().unwrap(); - // 13 files: README + cartridge.json + mod.js + panels/manifest.json + // 14 files: README + cartridge.json + mod.js + panels/manifest.json // + abi/(README + ipkg + SafeXxx.idr) - // + ffi/(README + build.zig + xxx_ffi.zig) + // + ffi/(README + build.zig + xxx_ffi.zig + cartridge_shim.zig) // + adapter/(README + build.zig + xxx_adapter.zig) - assert_eq!(repo.file_count(), 13, "expected 13 files"); + assert_eq!(repo.file_count(), 14, "expected 14 files"); } #[test] @@ -1238,6 +1286,7 @@ description = "Chapel distributed computing -iser" assert!(root.join("ffi/README.adoc").exists()); assert!(root.join("ffi/build.zig").exists()); assert!(root.join("ffi/chapeliser_ffi.zig").exists()); + assert!(root.join("ffi/cartridge_shim.zig").exists()); assert!(root.join("adapter/README.adoc").exists()); assert!(root.join("adapter/build.zig").exists()); assert!(root.join("adapter/chapeliser_adapter.zig").exists()); @@ -1308,4 +1357,125 @@ description = "Chapel distributed computing -iser" assert_eq!(idris2_module_name("bqniser"), "Bqniser"); assert_eq!(idris2_module_name("k9iser"), "K9iser"); } + + /// The cartridge must build wherever the registry places it. That means + /// the ADR-0006 invoke-shim is vendored beside the FFI, byte-identical to + /// every copy in `hyperpolymath/boj-server-cartridges`, and nothing + /// reaches up into a host tree — the `boj-server/cartridges/` bundle the + /// old `../../../ffi/zig/src/` path assumed has been retired. + #[test] + fn test_cartridge_is_location_independent() { + let manifest = test_manifest(); + let tmp = tempfile::tempdir().unwrap(); + let result = scaffold_cartridge(&manifest, tmp.path()); + let repo = result.repo().unwrap(); + + let shim = + std::fs::read_to_string(tmp.path().join("chapeliser-mcp/ffi/cartridge_shim.zig")) + .expect("cartridge does not vendor the invoke-shim"); + assert_eq!( + shim, CARTRIDGE_SHIM_ZIG, + "vendored shim has drifted from the registry copy" + ); + + // `ffi/cartridge_shim.zig` is exempt from the scan below, and only that + // one file. Its canon header comment shows the retired path as a usage + // example, so byte-identity with the registry copy and a ban on the + // string are mutually unsatisfiable. The `assert_eq!` above is this + // file's guard, and a stricter one: the scan would allow any edit that + // avoids the string, whereas byte-identity allows no edit at all. + for file in &repo.files { + if file.path == Path::new("ffi/cartridge_shim.zig") { + continue; + } + assert!( + !file.content.contains("../../../ffi/zig/src"), + "{} still reaches into the retired boj-server cartridges bundle", + file.path.display() + ); + } + } + + /// boj-server's catalog schema-validates every `cartridge.json` on boot + /// and drops the ones that fail, so a cartridge missing a required + /// property never loads at all. These are the required properties of + /// cartridge schema v1 — `hyperpolymath/standards` @ `f5f0506`, mirrored + /// in `boj-server-cartridges/schemas/cartridge-v1.json` — plus the two + /// closed enums a wrong value would fall foul of. + #[test] + fn test_cartridge_json_satisfies_schema_v1_required_properties() { + let manifest = test_manifest(); + let tmp = tempfile::tempdir().unwrap(); + assert!(scaffold_cartridge(&manifest, tmp.path()).is_success()); + + let raw = + std::fs::read_to_string(tmp.path().join("chapeliser-mcp/cartridge.json")).unwrap(); + let json: serde_json::Value = + serde_json::from_str(&raw).expect("cartridge.json is not JSON"); + + for key in [ + "$schema", + "spdx", + "copyright", + "name", + "version", + "description", + "domain", + "category", + "tier", + "protocols", + "auth", + "api", + "tools", + ] { + assert!( + json.get(key).is_some(), + "cartridge.json omits required property {key:?}; the catalog would reject it" + ); + } + + // `category` is schema v1's three-way taxonomy, not the domain string. + assert_eq!(json["category"], "domain"); + // `tier` is a closed enum; Ayo is the community-contributed tier. + assert_eq!(json["tier"], "Ayo"); + } + + /// End-to-end: a freshly scaffolded cartridge's Zig FFI and unified gated + /// adapter must both compile and pass their own tests, from wherever the + /// cartridge was written. No-op (passes) where `zig` is not installed, so + /// CI without the toolchain stays green. + #[test] + fn test_scaffolded_cartridge_builds_with_zig() { + use std::process::Command; + + let zig_ok = Command::new("zig") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !zig_ok { + eprintln!("skipping: zig not on PATH"); + return; + } + + let manifest = test_manifest(); + let tmp = tempfile::tempdir().unwrap(); + assert!(scaffold_cartridge(&manifest, tmp.path()).is_success()); + let root = tmp.path().join("chapeliser-mcp"); + + for component in ["ffi", "adapter"] { + let out = Command::new("zig") + .args(["build", "test"]) + .current_dir(root.join(component)) + .output() + .expect("failed to run zig"); + assert!( + out.status.success(), + "scaffolded {} failed to build:\n{}\n{}", + component, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + } + } } diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index e2a057c..2990dfb 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -20,14 +20,58 @@ use std::path::Path; use crate::abi::ScaffoldResult; use crate::manifest::Manifest; -/// Validate, scaffold, and write a complete -iser repository. +/// Validate, scaffold, and write a complete -iser repository *and* its +/// boj-server cartridge. /// /// This is the main entry point for the generation pipeline: /// 1. Deep-validate the language description (parser) /// 2. Generate the full file set (scaffold) /// 3. Apply language-specific customizations (customizer — called by scaffold) /// 4. Write everything to disk (scaffold) +/// 5. Scaffold the `-mcp` cartridge alongside it (cartridge) +/// +/// Step 5 is what makes the unified transaction-gated adapter and its SSE +/// surface arrive *by construction* for every new -iser, which is what +/// hyperpolymath/standards#90 asks for. The cartridge is written as a +/// **sibling** of the repo, at `/-mcp/`, never inside it: +/// the adapter belongs in the `hyperpolymath/boj-server-cartridges` registry, +/// and emitting it into the -iser repo produced a non-building stub that +/// PR #23 had to revert. +/// +/// Use [`generate_repo_only`] when the cartridge is not wanted. pub fn generate_all(manifest: &Manifest, output_dir: &str) -> Result { + let result = generate_repo_only(manifest, output_dir)?; + + // Only pair a cartridge with a repo that was actually written. The + // manifest is already validated above, so scaffold directly rather than + // going through `generate_cartridge` and re-emitting the same warnings. + if result.is_success() { + let cartridge = cartridge::scaffold_cartridge(manifest, Path::new(output_dir)); + match &cartridge { + cartridge::CartridgeScaffoldResult::Success(c) => println!( + "Generated cartridge {} ({} files) at {}", + c.name, + c.file_count(), + c.root.display() + ), + _ => anyhow::bail!( + "cartridge scaffolding failed: {}", + cartridge.error_message().unwrap_or("unknown error") + ), + } + println!( + "Cartridge home: the hyperpolymath/boj-server-cartridges registry, \ + at cartridges/domains// — pick the domain directory and set \ + cartridge.json's \"domain\" field to match." + ); + } + + Ok(result) +} + +/// Validate, scaffold, and write the -iser repository only, without its +/// boj-server cartridge. +pub fn generate_repo_only(manifest: &Manifest, output_dir: &str) -> Result { // Step 1: Deep validation let report = parser::validate_or_bail(manifest)?; @@ -49,9 +93,10 @@ pub fn generate_all(manifest: &Manifest, output_dir: &str) -> Result { - if let Some(msg) = result.error_message() { - eprintln!("error: {}", msg); - } + anyhow::bail!( + "{}", + result.error_message().unwrap_or("unknown error") + ); } } @@ -61,8 +106,9 @@ pub fn generate_all(manifest: &Manifest, output_dir: &str) -> Result/-mcp/`. Meant to be placed -/// inside `boj-server/cartridges/`; the emitted Zig build files reference -/// the shared invoke-shim via `../../../ffi/zig/src/cartridge_shim.zig`. +/// in the `hyperpolymath/boj-server-cartridges` registry, at +/// `cartridges/domains//`; the cartridge vendors the ADR-0006 +/// invoke-shim at `ffi/cartridge_shim.zig` and so builds wherever it sits. /// /// See `cartridge` module docs and standards#89 Phase 2b for context. pub fn generate_cartridge( @@ -86,9 +132,10 @@ pub fn generate_cartridge( ); } _ => { - if let Some(msg) = result.error_message() { - eprintln!("error: {}", msg); - } + anyhow::bail!( + "{}", + result.error_message().unwrap_or("unknown error") + ); } } diff --git a/src/codegen/scaffold.rs b/src/codegen/scaffold.rs index 4168775..76277be 100644 --- a/src/codegen/scaffold.rs +++ b/src/codegen/scaffold.rs @@ -751,25 +751,44 @@ fn test_codegen_output() {{ // --------------------------------------------------------------------------- // // The unified transaction-gated adapter belongs to the boj-server cartridge -// for this -iser (e.g. boj-server/cartridges/-mcp/adapter/), NOT to the -// -iser repo itself. Scaffolding the cartridge skeleton is tracked -// separately under standards#89 Phase 2b — see iseriser ROADMAP. +// for this -iser (boj-server/cartridges/-mcp/adapter/), NOT to the +// -iser repo itself. `codegen::generate_all` emits that cartridge as a +// sibling tree so every newly generated -iser ships the gated adapter + SSE +// surface by construction (standards#90); see the `cartridge` module. -/// Generate `.github/workflows/-regen.yml` — the central-trigger -/// workflow that fires the boj-server cartridge to regenerate `generated/*` -/// instead of hand-committing artifacts. Mirrors `boj-build.yml` / -/// `k9iser-regen.yml` from the pilot (standards#89 sub-issue 1 / rsr-template-repo#58). -fn generate_regen_workflow(iser_name: &str) -> GeneratedFile { - let content = format!( - r#"# SPDX-License-Identifier: MPL-2.0 -# {iser_name}-regen.yml — triggers central regeneration via boj-server cartridge. +/// Template for `.github/workflows/-regen.yml`. +/// +/// `__ISER__` is the only substitution token. A token-and-`replace` template +/// is used here rather than `format!` because the body is dense in braces — +/// GitHub expressions, shell parameter expansion and a jq program — and +/// brace-doubling a payload by hand is exactly how the estate's escaping +/// defect (standards#331) was introduced in the first place. +const REGEN_WORKFLOW_TEMPLATE: &str = r#"# SPDX-License-Identifier: MPL-2.0 +# __ISER__-regen.yml — triggers central regeneration through the boj-server +# __ISER__-mcp cartridge, instead of hand-committing generated/*. # # Part of the -iser regeneration-cartridge pattern (hyperpolymath/standards#89). -# Mirrors boj-build.yml / k9iser-regen.yml (rsr-template-repo#58): guarded on -# the presence of {iser_name}.toml, fire-and-forget until gateway tier-2 ships. -# Once the http-capability-gateway (ADR-0004) is production-wired, the loopback -# URL below becomes the external gateway endpoint. -name: {iser_name} Regen Trigger +# +# This deliberately does NOT reproduce the estate `boj-build.yml` shape, which +# carries four defects (hyperpolymath/standards#331): +# 1. a hand-escaped curl payload that is not valid JSON; +# 2. an mDNS host name that no GitHub-hosted runner can resolve, so the step +# has never once reached the server; +# 3. a plaintext scheme, against the estate secure-protocols policy; +# 4. step-level error suppression, which masked all three. +# A fifth, found while fixing them: the estate copies POST to the plural +# cartridge path, which boj-server exposes only as the cartridge LIST endpoint. +# +# The defect tokens themselves are kept out of this file on purpose — estate +# scanners match them in prose as well as in code. +# +# The endpoint is NOT hardcoded. boj-server binds 127.0.0.1:7700 by default +# (elixir/lib/boj_rest/application.ex) and has no routable public name until +# the http-capability-gateway is production-wired (ADR-0004 tier-2, +# hyperpolymath/standards#91), so no correct literal host exists to emit. +# Supply it per-repo as the BOJ_SERVER_URL secret or variable: unset means the +# step says so and stops; set means a failed dispatch fails the job. +name: __ISER__ Regen Trigger on: push: @@ -780,35 +799,83 @@ permissions: contents: read jobs: - trigger-{iser_name}: + trigger-__ISER__: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Detect {iser_name} manifest + + - name: Detect __ISER__ manifest id: detect run: | - if [ -f {iser_name}.toml ]; then + if [ -f __ISER__.toml ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" fi - - name: Trigger BoJ Server ({iser_name}-mcp cartridge) + + - name: Trigger BoJ Server (__ISER__-mcp cartridge) if: steps.detect.outputs.present == 'true' - # Fire-and-forget: loopback until gateway tier-2 is wired. - # continue-on-error: internal loopback is not yet reachable from GH Actions. - # by-design: remove when gateway ships and endpoint becomes external. + env: + # Secret first — a private endpoint may itself be sensitive; fall + # back to the repository/org variable when it is not. + BOJ_URL: ${{ secrets.BOJ_SERVER_URL || vars.BOJ_SERVER_URL }} + # Expression values reach the shell through the environment, never + # by interpolation into the script text: a ref name is caller-chosen. + BOJ_REPO: ${{ github.repository }} + BOJ_BRANCH: ${{ github.ref_name }} + BOJ_SHA: ${{ github.sha }} run: | - curl -X POST "http://boj-server.local:7700/cartridges/{iser_name}-mcp/invoke" \ + set -euo pipefail + + if [ -z "$BOJ_URL" ]; then + echo "BOJ_SERVER_URL is not configured for this repository." + echo "boj-server stays loopback-only until the http-capability-gateway" + echo "lands (ADR-0004 tier-2 / standards#91) — nothing to dispatch to." + exit 0 + fi + + case "$BOJ_URL" in + https://*) ;; + *) + echo "::error::BOJ_SERVER_URL must use https:// (estate secure-protocols policy)." + exit 1 + ;; + esac + + # Payload built by jq, never by hand-escaping. The estate copies + # emitted a stray backslash-brace pair and failed JSON parse in every + # repo; jq makes that class of defect unrepresentable. Field names + # follow boj-server's router contract, which reads `tool` and + # `arguments` (elixir/lib/boj_rest/router.ex). + payload="$(jq -nc \ + --arg repo "$BOJ_REPO" \ + --arg branch "$BOJ_BRANCH" \ + --arg sha "$BOJ_SHA" \ + '{tool: "__ISER___generate", arguments: {repo: $repo, branch: $branch, sha: $sha}}')" + + # Singular cartridge path — the plural form is the LIST endpoint and + # would 404 even if the host resolved. X-Node-Identity is the caller + # tag boj-server logs against the dispatch. + curl --fail-with-body --silent --show-error --location --max-time 60 \ + -X POST "${BOJ_URL%/}/cartridge/__ISER__-mcp/invoke" \ -H "Content-Type: application/json" \ - -d "{{\"repo\": \"${{{{ github.repository }}}}\", \"branch\": \"${{{{ github.ref_name }}}}\", \"tool\": \"{iser_name}_generate\"}}" - continue-on-error: true # by-design: loopback not reachable from GH Actions runners -"#, - iser_name = iser_name, - ); + -H "X-Node-Identity: github-actions/$BOJ_REPO" \ + --data "$payload" +"#; + +/// Generate `.github/workflows/-regen.yml` — the central-trigger +/// workflow that fires the boj-server `-mcp` cartridge to +/// regenerate `generated/*` instead of hand-committing artifacts +/// (standards#89 sub-issue 1 / rsr-template-repo#58). +/// +/// See `REGEN_WORKFLOW_TEMPLATE` for the standards#331 defects this shape +/// exists to avoid re-emitting. +fn generate_regen_workflow(iser_name: &str) -> GeneratedFile { GeneratedFile { path: PathBuf::from(format!(".github/workflows/{iser_name}-regen.yml")), - content, + content: REGEN_WORKFLOW_TEMPLATE.replace("__ISER__", iser_name), } } @@ -1725,4 +1792,134 @@ description = "Chapel distributed computing -iser" assert!(repo_root.join("README.adoc").exists()); assert!(repo_root.join("LICENSE").exists()); } + + /// The estate `boj-build.yml` shape shipped four defects into ~30 repos + /// (hyperpolymath/standards#331). Fixing the deployed copies is pointless + /// while the generator keeps re-emitting them, so this pins all four — + /// plus the fifth found while fixing them, the plural cartridge path, + /// which boj-server serves only as the LIST endpoint. + #[test] + fn test_regen_workflow_is_free_of_the_boj_build_defects() { + let wf = generate_regen_workflow("chapeliser"); + let c = &wf.content; + + assert_eq!( + wf.path, + PathBuf::from(".github/workflows/chapeliser-regen.yml") + ); + + // 1. Nothing hand-escapes JSON — jq builds the payload. + assert!( + !c.contains("\\\""), + "workflow hand-escapes a JSON payload; jq must build it" + ); + assert!(c.contains("jq -nc"), "payload is not built by jq"); + + // 2. No mDNS host: GitHub-hosted runners cannot resolve one. + assert!(!c.contains(".local"), "workflow still targets an mDNS host"); + + // 3. No plaintext scheme, and the configured URL is checked at run time. + assert!( + !c.contains("http://"), + "workflow still uses a plaintext scheme" + ); + assert!( + c.contains("https://*)"), + "workflow does not enforce https on the configured URL" + ); + + // 4. No step-level error suppression masking a failed dispatch. + assert!( + !c.contains("continue-on-error"), + "workflow still suppresses step errors" + ); + assert!( + c.contains("--fail-with-body"), + "curl does not fail the job on a rejected dispatch" + ); + + // 5. Singular cartridge path (elixir/lib/boj_rest/router.ex). + assert!( + c.contains("/cartridge/chapeliser-mcp/invoke"), + "workflow does not POST to the singular cartridge invoke path" + ); + assert!( + !c.contains("/cartridges/"), + "workflow POSTs to the cartridge LIST path" + ); + + // The endpoint is parameterised, not guessed: boj-server binds + // 127.0.0.1 and has no routable name until the http-capability-gateway + // is wired (ADR-0004 tier-2 / standards#91). + assert!(c.contains("secrets.BOJ_SERVER_URL")); + assert!(c.contains("vars.BOJ_SERVER_URL")); + + // Caller-controlled expression values reach the script through the + // environment, never by interpolation into the script text. + assert!( + !c.contains("\"${{ github.ref_name }}\""), + "a caller-controlled ref name is interpolated into the shell script" + ); + + assert!(!c.contains("__ISER__"), "leftover template token"); + } + + /// The emitted jq program must produce valid JSON in the shape + /// boj-server's router accepts (`tool` + `arguments`), and must carry a + /// hostile ref name through as data rather than as script. No-op (passes) + /// where `jq` is not installed, matching the idris2 test above. + #[test] + fn test_regen_workflow_payload_is_valid_json() { + use std::process::Command; + + let jq_ok = Command::new("jq") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !jq_ok { + eprintln!("skipping: jq not on PATH"); + return; + } + + let wf = generate_regen_workflow("chapeliser"); + let program = wf + .content + .lines() + .map(str::trim) + .find(|l| l.starts_with("'{tool:")) + .expect("no jq program in the emitted workflow") + .trim_end_matches(")\"") + .trim_matches('\'') + .to_string(); + + let hostile_ref = "main\"; rm -rf /"; + let out = Command::new("jq") + .args([ + "-nc", + "--arg", + "repo", + "hyperpolymath/chapeliser", + "--arg", + "branch", + hostile_ref, + "--arg", + "sha", + "0123456789abcdef0123456789abcdef01234567", + &program, + ]) + .output() + .expect("failed to run jq"); + assert!( + out.status.success(), + "jq rejected the emitted program:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let payload: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("emitted payload is not valid JSON"); + assert_eq!(payload["tool"], "chapeliser_generate"); + assert_eq!(payload["arguments"]["repo"], "hyperpolymath/chapeliser"); + assert_eq!(payload["arguments"]["branch"], hostile_ref); + } } diff --git a/src/codegen/templates/cartridge_shim.zig b/src/codegen/templates/cartridge_shim.zig new file mode 100644 index 0000000..3838d70 --- /dev/null +++ b/src/codegen/templates/cartridge_shim.zig @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig — Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short — typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("ffi/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 §Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body — the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +/// +/// Implementation note (CWE-704 fix, post-#146): uses +/// `std.mem.sliceTo(ptr, 0)` which scans the C string up to the first +/// NUL — no `@ptrCast` and no `[*:0]` re-typing. The earlier +/// `std.mem.spanZ` call was removed in Zig 0.14+ and would not +/// compile under the 0.15.1 CI pin. +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.sliceTo(tool_name, 0); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` — the caller is then expected to re-allocate +/// and retry, per ADR-0006 §Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Shared runtime Io (Zig 0.16 compat) ────────────────────────────── +// +// Zig 0.16 moved blocking primitives and the wall clock onto the +// `std.Io` interface (`std.Thread.Mutex` and `std.time.*Timestamp` +// were removed from the stdlib). The shim owns one process-wide Io +// backed by `std.Io.Threaded` so cartridge code keeps drop-in call +// sites — `var m: shim.Mutex = .{}; m.lock(); defer m.unlock();` and +// `shim.milliTimestamp()` — without threading an Io handle through the +// C ABI. Cartridges that need richer Io (http, fs, net) should use +// this same `shim.io()` rather than constructing their own runtime. + +var shared_threaded: std.Io.Threaded = undefined; +var shared_io_state: std.atomic.Value(u8) = .init(0); // 0=uninit 1=initing 2=ready + +/// The process-wide `std.Io`, lazily initialised on first use. +/// Thread-safe: a single CAS winner runs `Threaded.init`; racing +/// callers yield until it is published. +pub fn io() std.Io { + if (shared_io_state.load(.acquire) != 2) { + if (shared_io_state.cmpxchgStrong(0, 1, .acq_rel, .acquire) == null) { + shared_threaded = std.Io.Threaded.init(std.heap.smp_allocator); + shared_io_state.store(2, .release); + } else { + while (shared_io_state.load(.acquire) != 2) std.Thread.yield() catch {}; + } + } + return shared_threaded.io(); +} + +/// Drop-in replacement for the removed `std.Thread.Mutex`, backed by +/// `std.Io.Mutex` over the shim's shared Io. Zero-initialisable: +/// `var m: shim.Mutex = .{};` +pub const Mutex = struct { + inner: std.Io.Mutex = .init, + + pub fn lock(m: *Mutex) void { + m.inner.lockUncancelable(io()); + } + + pub fn unlock(m: *Mutex) void { + m.inner.unlock(io()); + } + + pub fn tryLock(m: *Mutex) bool { + return m.inner.tryLock(); + } +}; + +/// Nanoseconds since the POSIX epoch (drop-in for the removed +/// `std.time.nanoTimestamp`). +pub fn nanoTimestamp() i128 { + const ts = std.Io.Clock.Timestamp.now(io(), .real) catch return 0; + return @intCast(ts.raw.nanoseconds); +} + +/// Milliseconds since the POSIX epoch (drop-in for the removed +/// `std.time.milliTimestamp`). +pub fn milliTimestamp() i64 { + return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_ms)); +} + +/// Seconds since the POSIX epoch (drop-in for the removed +/// `std.time.timestamp`). +pub fn timestamp() i64 { + return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_s)); +} + +/// Fill `buffer` with cryptographically secure random bytes (drop-in for +/// the removed `std.crypto.random.bytes`). +pub fn randomBytes(buffer: []u8) void { + io().random(buffer); +} + +/// Cryptographically secure random integer (drop-in for the removed +/// `std.crypto.random.int(T)`). +pub fn randomInt(comptime T: type) T { + var buf: [@sizeOf(T)]u8 = undefined; + io().random(&buf); + return @bitCast(buf); +} + +/// Process environment lookup (drop-in for the removed +/// `std.posix.getenv`). Only analysed when referenced; callers must link +/// libc (`root_module.link_libc = true` — the standard ffi build shape). +pub fn getenv(name: [*:0]const u8) ?[:0]const u8 { + const p = std.c.getenv(name) orelse return null; + return std.mem.sliceTo(p, 0); +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "Mutex: lock/unlock and tryLock round-trip" { + var m: Mutex = .{}; + m.lock(); + try std.testing.expect(!m.tryLock()); + m.unlock(); + try std.testing.expect(m.tryLock()); + m.unlock(); +} + +test "timestamps: monotone-ish and unit-consistent" { + const ns = nanoTimestamp(); + const ms = milliTimestamp(); + const s = timestamp(); + try std.testing.expect(ns > 0); + // Same instant expressed in three units must agree to within a step. + try std.testing.expect(@abs(@divTrunc(ns, std.time.ns_per_s) - s) <= 1); + try std.testing.expect(@abs(@divTrunc(ms, std.time.ms_per_s) - s) <= 1); +} + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*c]const u8 = "foo"; + try std.testing.expect(toolIs(name, "foo")); + try std.testing.expect(!toolIs(name, "bar")); + try std.testing.expect(!toolIs(name, "foobar")); + try std.testing.expect(!toolIs(name, "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*c]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(name, &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(name, null, &len)); + try std.testing.expect(invokeArgsNull(name, &buf, null)); +} diff --git a/src/main.rs b/src/main.rs index 8f2d32e..79cebee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,18 +42,24 @@ enum Commands { #[arg(short, long, default_value = "iseriser.toml")] manifest: String, }, - /// Generate a complete -iser repository from the manifest. + /// Generate a complete -iser repository from the manifest, together with + /// its boj-server cartridge (unified transaction-gated adapter + SSE) as + /// a sibling `/-mcp/` tree. standards#90. Generate { #[arg(short, long, default_value = "iseriser.toml")] manifest: String, #[arg(short, long, default_value = ".")] output: String, + /// Emit only the -iser repository, without its boj-server cartridge. + #[arg(long, default_value_t = false)] + no_cartridge: bool, }, /// Generate a boj-server cartridge skeleton (adapter + FFI + ABI + /// cartridge.json + panels + mod.js) for the manifest's -iser. - /// Output goes to `/-mcp/`. Place inside - /// `boj-server/cartridges/` — the emitted Zig build files reference - /// the shared invoke-shim via that relative location. + /// Output goes to `/-mcp/`; its home is the + /// `hyperpolymath/boj-server-cartridges` registry, at + /// `cartridges/domains//`. The cartridge vendors the + /// ADR-0006 invoke-shim, so it builds wherever it is placed. /// standards#89 Phase 2b. Cartridge { #[arg(short, long, default_value = "iseriser.toml")] @@ -127,10 +133,18 @@ fn main() -> Result<()> { } } } - Commands::Generate { manifest, output } => { + Commands::Generate { + manifest, + output, + no_cartridge, + } => { let m = manifest::load_manifest(&manifest)?; manifest::validate(&m)?; - codegen::generate_all(&m, &output)?; + if no_cartridge { + codegen::generate_repo_only(&m, &output)?; + } else { + codegen::generate_all(&m, &output)?; + } } Commands::Cartridge { manifest, output } => { let m = manifest::load_manifest(&manifest)?; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 7e66e3c..16d5281 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -347,3 +347,96 @@ description = "test" "should require repo-name ending in 'iser'" ); } + +// --------------------------------------------------------------------------- +// Test 10: `generate_all` pairs every -iser with its boj-server cartridge +// --------------------------------------------------------------------------- +// +// hyperpolymath/standards#90 asks that every new -iser carry the unified +// transaction-gated adapter and its SSE surface *by construction*. The +// cartridge is where that lives (iseriser PR #23 established the topology: +// the adapter belongs in `boj-server/cartridges/-mcp/`, never inside +// the -iser repo, where it has no FFI module to import). So the contract +// this test pins is: one `generate` produces both trees, side by side, with +// the gate and the SSE surface in the cartridge and neither in the repo. + +#[test] +fn test_generate_all_emits_repo_and_cartridge_side_by_side() { + let toml = make_toml( + "Chapel", + "imperative", + "simple", + "native", + &["task", "locale", "domain"], + ); + let m = parse_manifest(&toml).unwrap(); + validate(&m).unwrap(); + + let tmp = tempdir().unwrap(); + let out = tmp.path().to_str().unwrap(); + let result = iseriser::codegen::generate_all(&m, out).expect("generate_all failed"); + assert!(result.is_success(), "{:?}", result.error_message()); + + let repo = tmp.path().join("chapeliser"); + let cartridge = tmp.path().join("chapeliser-mcp"); + assert!(repo.is_dir(), "-iser repo was not written"); + assert!(cartridge.is_dir(), "cartridge was not written alongside it"); + + // The cartridge carries the pattern ported from boj-server#73: the + // exposure/transaction gate and the SSE surface, in the unified adapter. + let adapter = + std::fs::read_to_string(cartridge.join("adapter/chapeliser_adapter.zig")).unwrap(); + assert!( + adapter.contains("text/event-stream"), + "adapter has no SSE surface" + ); + assert!( + adapter.contains("fn exposureSatisfied("), + "adapter has no transaction gate" + ); + assert!( + adapter.contains("TRANSACTION GATE"), + "adapter does not gate before dispatch" + ); + + // The Idris2 side is the source-of-truth contract the Zig gate mirrors. + let idr = + std::fs::read_to_string(cartridge.join("abi/ChapeliserMcp/SafeChapeliser.idr")).unwrap(); + assert!( + idr.contains("exposureSatisfied"), + "cartridge ABI has no exposure contract" + ); + + // ...and none of it leaks into the -iser repo (PR #23 topology ruling). + assert!( + !repo.join("adapter").exists(), + "adapter emitted into the repo" + ); + + // The repo's half of the pattern is the trigger that fires the cartridge. + let regen = + std::fs::read_to_string(repo.join(".github/workflows/chapeliser-regen.yml")).unwrap(); + assert!(regen.contains("/cartridge/chapeliser-mcp/invoke")); +} + +// --------------------------------------------------------------------------- +// Test 11: `--no-cartridge` emits the repo alone +// --------------------------------------------------------------------------- + +#[test] +fn test_generate_repo_only_skips_the_cartridge() { + let toml = make_toml("Chapel", "imperative", "simple", "native", &["task"]); + let m = parse_manifest(&toml).unwrap(); + validate(&m).unwrap(); + + let tmp = tempdir().unwrap(); + let out = tmp.path().to_str().unwrap(); + let result = iseriser::codegen::generate_repo_only(&m, out).expect("generate_repo_only failed"); + assert!(result.is_success(), "{:?}", result.error_message()); + + assert!(tmp.path().join("chapeliser").is_dir()); + assert!( + !tmp.path().join("chapeliser-mcp").exists(), + "cartridge emitted despite the repo-only path" + ); +}