diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 289bfaee..9dee6387 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,12 +33,26 @@ jobs:
git ls-remote --exit-code https://github.com/jaiphlang/jaiph.git "refs/tags/v${VERSION}"
e2e:
- name: E2E install and CLI workflow (${{ matrix.os }})
+ name: E2E (${{ matrix.os }}, ${{ matrix.label }})
runs-on: ${{ matrix.os }}
+ env:
+ # Host/safe split applies on Ubuntu only. macOS runners do not ship Docker the same way — keep host-only there.
+ # "docker": unset JAIPH_UNSAFE so resolveDockerConfig enables the sandbox (pulls ghcr.io/jaiphlang/jaiph-runtime).
+ # "host": explicit opt-out, same as a fast local `JAIPH_UNSAFE=true npm run test:e2e`.
+ JAIPH_UNSAFE: ${{ matrix.jaiph_unsafe }}
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, macos-latest]
+ include:
+ - os: ubuntu-latest
+ label: docker
+ jaiph_unsafe: ""
+ - os: ubuntu-latest
+ label: host
+ jaiph_unsafe: "true"
+ - os: macos-latest
+ label: host
+ jaiph_unsafe: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -151,38 +165,106 @@ jobs:
id: detect_wsl
shell: pwsh
run: |
+ $ciDistro = "jaiph-ci-ubuntu"
$distros = @(wsl -l -q | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" })
if ($distros.Count -eq 0) {
- "distro=" >> $env:GITHUB_OUTPUT
- Write-Warning "No WSL distro is available on this runner. Skipping WSL E2E."
- exit 0
+ Write-Warning "No WSL distro is available on this runner. Importing Ubuntu rootfs for CI."
+ $archivePath = Join-Path $env:RUNNER_TEMP "ubuntu-base-24.04.tar.gz"
+ $installPath = Join-Path $env:RUNNER_TEMP "wsl-ubuntu"
+ $ubuntuBaseUrl = "https://cdimage.ubuntu.com/ubuntu-base/releases/24.04/release/ubuntu-base-24.04-base-amd64.tar.gz"
+
+ if (Test-Path $installPath) {
+ Remove-Item -Path $installPath -Recurse -Force
+ }
+ New-Item -ItemType Directory -Path $installPath -Force | Out-Null
+ Invoke-WebRequest -Uri $ubuntuBaseUrl -OutFile $archivePath
+ wsl --import "$ciDistro" "$installPath" "$archivePath" --version 2
+ $distros = @("$ciDistro")
}
$ubuntu = $distros | Where-Object { $_ -match "^Ubuntu" } | Select-Object -First 1
$selected = if ($ubuntu) { $ubuntu } else { $distros[0] }
+ if (-not $selected) {
+ Write-Error "Failed to provision a WSL distro for CI."
+ exit 1
+ }
"distro=$selected" >> $env:GITHUB_OUTPUT
Write-Host "Using WSL distro: $selected"
- name: Install Node and run E2E tests in WSL
- if: steps.detect_wsl.outputs.distro != ''
shell: pwsh
run: |
$workspace = "${{ github.workspace }}"
$distro = "${{ steps.detect_wsl.outputs.distro }}"
- wsl -d "$distro" -- bash -lc "set -euo pipefail
+ $env:JAIPH_WORKSPACE = $workspace
+ $bashScript = @'
+ set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
- sudo apt-get update
- sudo apt-get install -y curl ca-certificates
+ export JAIPH_UNSAFE=true
+ SUDO=
+ if [ "$(id -u)" -ne 0 ]; then
+ SUDO=sudo
+ fi
+ $SUDO apt-get update
+ $SUDO apt-get install -y curl ca-certificates
if ! command -v node >/dev/null 2>&1; then
- curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
- sudo apt-get install -y nodejs
+ curl -fsSL https://deb.nodesource.com/setup_20.x | $SUDO -E bash -
+ $SUDO apt-get install -y nodejs
fi
- cd \"\$(wslpath '$workspace')\"
+ cd "$(wslpath "$JAIPH_WORKSPACE")"
npm ci
npm run test:e2e
- "
+ '@
+ wsl -d "$distro" -- bash -lc "$bashScript"
- - name: WSL E2E skipped
- if: steps.detect_wsl.outputs.distro == ''
- shell: pwsh
+ docker-publish:
+ name: Publish Docker runtime image
+ # needs: [test, e2e, docs-local, e2e-wsl]
+ if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: jaiphlang/jaiph-runtime
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Image tags and jaiph ref
+ id: meta
+ run: |
+ if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
+ VERSION="${GITHUB_REF_NAME#v}"
+ echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
+ echo "jaiph_ref=v${VERSION}" >> "$GITHUB_OUTPUT"
+ else
+ echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly" >> "$GITHUB_OUTPUT"
+ echo "jaiph_ref=nightly" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: runtime
+ file: runtime/Dockerfile
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: ${{ steps.meta.outputs.tags }}
+ build-args: |
+ JAIPH_REPO_REF=${{ steps.meta.outputs.jaiph_ref }}
+
+ - name: Verify pushed image contains jaiph
run: |
- Write-Host "No WSL distro found on this runner image; skipping WSL E2E."
+ TAG="$(echo '${{ steps.meta.outputs.tags }}' | cut -d',' -f1)"
+ docker run --rm --entrypoint sh "${TAG}" -lc "command -v jaiph && jaiph --version"
diff --git a/.gitignore b/.gitignore
index 1673153f..b15d9eec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,12 @@ e2e/ensure_fail.sh
e2e/current_branch.sh
e2e/assign_capture.sh
-.obsidian/
\ No newline at end of file
+.obsidian/
+
+# debug / temp directories (never commit)
+docker-*/
+nested-*/
+overlay-*/
+local-*/
+.tmp*/
+QUEUE.md.tmp.*
\ No newline at end of file
diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh
index a85f59e0..22fa919b 100755
--- a/.jaiph/architect_review.jh
+++ b/.jaiph/architect_review.jh
@@ -102,7 +102,7 @@ workflow review_one_header(header) {
const verdict = run first_line_str(packed)
const updated_description = run rest_lines_str(packed)
const body_file = run jaiph_review_body_file()
- run mkdir_p_simple(run, jaiph_tmp_dir())
+ run mkdir_p_simple(run jaiph_tmp_dir())
run str_equals(verdict, "dev-ready") catch (err) {
run arg_nonempty(updated_description) catch (err) {
fail "needs-work requires a non-empty updated_description (questions for the author)."
diff --git a/.jaiph/libs/jaiphlang/artifacts.jh b/.jaiph/libs/jaiphlang/artifacts.jh
new file mode 100644
index 00000000..5181e212
--- /dev/null
+++ b/.jaiph/libs/jaiphlang/artifacts.jh
@@ -0,0 +1,36 @@
+#!/usr/bin/env jaiph
+
+#
+# Artifact publishing for Jaiph workflows.
+# Copies files from the workspace into ${JAIPH_ARTIFACTS_DIR} so they
+# survive sandbox teardown and are readable on the host at
+# .jaiph/runs//artifacts/.
+#
+# Usage:
+# import "jaiphlang/artifacts" as artifacts
+#
+# workflow default() {
+# run artifacts.save("./build/output.bin", "build-output.bin")
+# run artifacts.save_patch("snapshot.patch")
+# }
+#
+import script "./artifacts.sh" as artifacts
+
+# Copies the file at `local_path` into the artifacts directory under `name`.
+# Returns the absolute path of the saved artifact.
+export workflow save(local_path, name) {
+ return run artifacts("save", local_path, name)
+}
+
+# Runs `git diff` (working tree vs HEAD, excluding .jaiph/) and writes
+# the patch to the artifacts directory under `name`.
+# Returns the absolute path of the saved patch file.
+export workflow save_patch(name) {
+ return run artifacts("save_patch", name)
+}
+
+# Applies a patch file to the current workspace via `git apply`.
+# Useful for replaying artifacts across runs.
+export workflow apply_patch(path) {
+ run artifacts("apply_patch", path)
+}
diff --git a/.jaiph/libs/jaiphlang/artifacts.sh b/.jaiph/libs/jaiphlang/artifacts.sh
new file mode 100755
index 00000000..054a5d25
--- /dev/null
+++ b/.jaiph/libs/jaiphlang/artifacts.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+#
+# Artifacts helper for Jaiph workflows.
+# Reads JAIPH_ARTIFACTS_DIR to locate the writable artifacts directory.
+# Works identically inside the Docker sandbox and on the host.
+#
+set -euo pipefail
+
+ARTIFACTS_DIR="${JAIPH_ARTIFACTS_DIR:?JAIPH_ARTIFACTS_DIR is not set}"
+
+cmd_save() {
+ local src="$1" name="$2"
+ if [[ ! -f "${src}" ]]; then
+ printf 'artifacts save: file not found: %s\n' "${src}" >&2
+ exit 1
+ fi
+ local dest="${ARTIFACTS_DIR}/${name}"
+ mkdir -p "$(dirname "${dest}")"
+ cp -- "${src}" "${dest}"
+ printf '%s' "${dest}"
+}
+
+cmd_save_patch() {
+ local name="$1"
+ local dest="${ARTIFACTS_DIR}/${name}"
+ mkdir -p "$(dirname "${dest}")"
+ # Exclude .jaiph/ from the produced patch — the runtime writes its own
+ # state under .jaiph/ and including it would clobber state on apply.
+ local diff_out
+ diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)"
+ if [[ -z "${diff_out}" ]]; then
+ # Also check for untracked files (intent-to-add)
+ git add -N . -- ':!.jaiph/' 2>/dev/null || true
+ diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)"
+ # Reset intent-to-add to avoid side effects
+ git reset HEAD -- . 2>/dev/null || true
+ fi
+ if [[ -n "${diff_out}" ]]; then
+ printf '%s\n' "${diff_out}" > "${dest}"
+ else
+ # Empty/clean workspace — create empty file
+ : > "${dest}"
+ fi
+ printf '%s' "${dest}"
+}
+
+cmd_apply_patch() {
+ local patch_path="$1"
+ if [[ ! -f "${patch_path}" ]]; then
+ printf 'artifacts apply_patch: patch file not found: %s\n' "${patch_path}" >&2
+ exit 1
+ fi
+ if [[ ! -s "${patch_path}" ]]; then
+ printf 'artifacts apply_patch: patch file is empty: %s\n' "${patch_path}" >&2
+ exit 1
+ fi
+ git apply "${patch_path}"
+}
+
+# -- dispatch ----------------------------------------------------------------
+cmd="${1:-}"
+shift || true
+
+case "${cmd}" in
+ save) cmd_save "$@" ;;
+ save_patch) cmd_save_patch "$@" ;;
+ apply_patch) cmd_apply_patch "$@" ;;
+ *)
+ printf 'Usage: artifacts [args...]\n' >&2
+ exit 1
+ ;;
+esac
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eda20425..0360dbeb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# Unreleased
+## Summary
+
+- **Language / runtime:** Adds `Handle` for `run async`, repair-and-retry `recover` loops on `run` steps, explicit nested managed calls in arguments, and optional `module.*` fields in `config`.
+- **Docker / artifacts:** Read-only workspace with a COW sandbox layer and persistence through the run directory. Docker is on by default whenever `JAIPH_UNSAFE` is not set to `true` (including CI). Tighter capabilities plus mount and env denylists. **Breaking:** images must already include `jaiph` (default `ghcr.io/jaiphlang/jaiph-runtime`), with official builds published to GHCR. New `artifacts.jh` library and `JAIPH_ARTIFACTS_DIR` for pulling files and patches out of the sandbox.
+- **CLI / polish:** Clearer quoting in the progress tree and prompts, removal of dead isolation-export paths and stray repo debug junk, and a PTY-based E2E test for the async progress tree.
+
+## All changes
+
+- **Fix — CLI/Runtime:** Clean up double-quote rendering in step titles and log output — Parameter values displayed in the progress tree (e.g. `message="Found 3 issues in auth module"`) no longer produce backslash-escaped `\"` sequences or ambiguous nested `""` pairs. The fix touches three layers: `formatNamedParamsForDisplay` and `formatParamsForDisplay` in `src/cli/commands/format-params.ts` no longer escape inner double quotes with backslash (the surrounding `key="value"` delimiters are structural, not shell-safe); `formatStartLine` in `src/cli/run/display.ts` applies the same change for prompt previews; and `node-workflow-runtime.ts` strips outer quotes from interpolated channel-send payloads via `stripOuterQuotes` so messages like `"Found 3 issues"` are stored as `Found 3 issues` rather than carrying literal quote wrappers through dispatch. Regression tests added: `format-params-display.test.ts` asserts no `\"` in formatted output for payloads containing inner quotes; `display.test.ts` verifies prompt previews pass through quotes without escaping. E2E golden output for `agent_inbox.jh` updated to match.
+- **Cleanup — Runtime:** Remove dead per-call-isolated leftovers from Docker runtime — Deleted `exportWorkspacePatch` and `findRunArtifacts` from `src/runtime/docker.ts`, `exportPatchIfDocker` from `src/runtime/kernel/node-workflow-runtime.ts`, the `findRunArtifacts` call in `src/cli/commands/run.ts`, and ~150 LoC of dead tests in `src/runtime/docker.test.ts`. These functions were written for the abandoned per-call `isolated` keyword and have been fully replaced by the `artifacts.jh` library (`artifacts.save_patch()` for workspace patches, `JAIPH_ARTIFACTS_DIR` for artifact discovery). The automatic `workspace.patch` export during Docker teardown is removed — workflows that need a patch now request one explicitly via the artifacts library. Docs updated (`docs/sandboxing.md`, `docs/architecture.md`, `docs/artifacts.md`).
+- **Cleanup — Repo:** Delete top-level debug cruft and harden `.gitignore` — Removed 22+ leftover debug directories at the repo root (`docker-nested-arg.*`, `docker-nested-clean.*`, `overlay-warn.*`, `nested-run-arg.*`, `local-nested-arg.*`, `overlay-manual.*`, `docker-live-debug.*`, and similar) from an abandoned per-call isolation experiment. Also deleted stale tracked files: `safe_name`, `QUEUE.md.tmp.4951`, and empty top-level `lib/` and `run/` directories — none had live consumers in the source tree. Added `.gitignore` patterns (`docker-*/`, `nested-*/`, `overlay-*/`, `local-*/`, `.tmp*/`, `QUEUE.md.tmp.*`) under a `# debug / temp directories (never commit)` section so these cannot return without a deliberate `git add -f` override. No code changes; filesystem hygiene only.
+- **Test — E2E/Runtime:** PTY-based TTY test for `run async` progress rendering — New E2E test (`e2e/tests/131_tty_async_progress.sh`) exercises the live progress tree rendering path for `run async` workflows under a real PTY. The test spawns `jaiph run` with a workflow that fans out two concurrent async branches (`run async branch_a()`, `run async branch_b()`), each emitting deterministic progress events over time (log + script steps with sleep). A Python `pty.openpty()` harness captures the raw PTY stream and asserts: (1) each branch's progress events appear under the correct subscript-numbered node (₁, ₂) in the tree, (2) the final frame shows both branches completed with their resolved `Handle` return values (`result-a`, `result-b`), (3) no orphaned ANSI escape sequences survive after CSI stripping, and (4) a `RUNNING` frame was observed during live rendering. The test uses only deterministic steps (no `prompt claude` or external dependencies) and `assert_contains` checks with order-insensitive matching to tolerate async interleaving. This closes a regression-coverage gap — the existing `81_tty_progress_tree.sh` covers synchronous workflows but not the async handle/deferred-resolution render path. Docs updated (`docs/testing.md`, `docs/spec-async-handles.md`).
+- **Feature — Runtime/Library:** Artifacts — runtime mount and `artifacts.jh` library for publishing files out of the sandbox — Workflows can now publish files from inside the Docker sandbox (or host workspace) to a host-readable location at `.jaiph/runs//artifacts/`. The feature is split across two layers. **Runtime layer:** the `NodeWorkflowRuntime` creates the `artifacts/` subdirectory under the run directory before the first workflow step and exposes its path via `JAIPH_ARTIFACTS_DIR` (resolves to `/jaiph/run/artifacts` inside the Docker sandbox, `/artifacts` on the host). The existing `/jaiph/run` mount in Docker mode already maps this directory to the host — no new mount is needed. **Library layer:** a new built-in library `.jaiph/libs/jaiphlang/artifacts.jh` (paired with `artifacts.sh`) provides three `export workflow` entries: `save(local_path, name)` copies a file into the artifacts directory; `save_patch(name)` runs `git diff` (excluding `.jaiph/`) and writes the patch; `apply_patch(path)` applies a patch via `git apply`. The library mirrors the existing `queue.jh` / `queue.py` pattern — `import script "./artifacts.sh" as artifacts` with dispatch by subcommand. The `.jaiph/` exclusion in `save_patch` prevents clobbering runtime state when a patch is applied. `JAIPH_ARTIFACTS_DIR` is cleaned from inherited env in `resolveRuntimeEnv` to prevent leaking across nested runs. Runtime unit tests verify `JAIPH_ARTIFACTS_DIR` is set, writable, and exists before workflow execution. E2E test (`129_artifacts_lib.sh`) exercises `save`, `save_patch`, `apply_patch`, clean-workspace patch, and invalid-patch failure. Implementation: `node-workflow-runtime.ts` (artifacts dir creation, env var), `env.ts` (env cleanup), `.jaiph/libs/jaiphlang/artifacts.jh` and `artifacts.sh` (library). Docs updated (`docs/libraries.md`, `docs/artifacts.md`, `docs/configuration.md`, `docs/index.html`).
+- **Feature — Language/Runtime:** `Handle` value model for `run async` — `run async ref(args)` now returns a first-class `Handle` value instead of being a fire-and-forget statement. `T` is the same return type the function would have under a synchronous `run`. Capture is supported: `const h = run async ref()`. The handle resolves to the eventual return value on first non-passthrough read (string interpolation, passing as argument to `run`, comparison, conditional branching, match subject, channel send). Passthrough operations (initial capture into `const`, re-assignment) do not force resolution. Once resolved, the handle is replaced in-place by the resolved string value; subsequent reads return the cached value. Workflow exit implicitly joins all remaining unresolved handles created in that scope — this is not an error and preserves backward compatibility. `recover` composition works with `run async`: `run async foo() recover(err) { … }` — the async branch retries using the same retry-limit semantics as non-async `recover` (default 10, configurable via `run.recover_limit`). `catch` also works for single-shot recovery. The parser accepts `recover(err) { … }` and `catch(err) { … }` after `run async ref(args)` (the previous attempt silently rejected this with a "trailing content" error). There is no fire-and-forget mode — every `run async` creates a handle tracked by the runtime. No explicit `await` keyword — resolution is implicit on first read or at workflow exit. The docs-site Jaiph syntax highlighter (`docs/assets/js/main.js`) recognizes `async` as a keyword. Implementation: `Handle` registry in `NodeWorkflowRuntime` (`createHandle`, `resolveHandleResult`, `resolveHandleVar`, `resolveHandlesInInput`), `async` flag on `run_capture` const RHS in `src/types.ts`, async capture parsing in `src/parse/const-rhs.ts`, `recover`/`catch` parsing for `run async` in `src/parse/workflows.ts`, formatter round-trip in `src/format/emit.ts`. Spec: `docs/spec-async-handles.md`. Parser, formatter, runtime, and E2E tests added. Docs updated (`docs/language.md`, `docs/grammar.md`, `docs/jaiph-skill.md`, `docs/index.html`).
+- **Feature — Language/Runtime:** `recover` loop semantics for `run` steps — `recover` is a new first-class repair-and-retry primitive for `run` steps, distinct from `catch`. Syntax: `run ref() recover(err) { … }`. On failure, the binding receives merged stdout+stderr, the repair body executes, and the target is retried automatically. The loop stops when the target succeeds or the retry limit is exhausted. The default retry limit is 10; override per-module with `run.recover_limit` in a `config` block. `catch` remains unchanged (one-shot recovery). `recover` and `catch` are mutually exclusive on the same step. Supported for non-isolated, non-async `run` in workflows only. The docs-site syntax highlighter (`docs/assets/js/main.js`) recognizes `recover` as a keyword. Implementation: `recoverLoop` field on `WorkflowStepDef` in `src/types.ts`, `parseRunRecoverStep` in `src/parse/steps.ts`, retry loop in `NodeWorkflowRuntime`, `run.recover_limit` config key in `src/parse/metadata.ts`, formatter round-trip in `src/format/emit.ts`, validation in `src/transpile/validate.ts`. Parser, formatter, validation, runtime, and E2E tests added. Docs updated (`docs/language.md`, `docs/grammar.md`, `docs/configuration.md`, `docs/jaiph-skill.md`, `docs/index.html`).
+- **Feature — Docker:** Workspace immutability contract — Docker runs now enforce an explicit immutability contract: the host workspace is bind-mounted read-only and the writable `/jaiph/workspace` inside the container is a sandbox-local copy-on-write layer discarded on exit. The only persistence channel from a Docker run to the host is the run-artifacts directory (`/jaiph/run` → host `.jaiph/runs`). Non-Docker (local) runs are unaffected. *(The automatic `workspace.patch` teardown export originally shipped here has been superseded by `artifacts.save_patch()` and removed — see the cleanup entry above.)* Docs updated (`docs/sandboxing.md`, `docs/architecture.md`, `docs/artifacts.md`).
+- **Feature — Docker:** Default Docker when not CI or unsafe — Docker sandboxing is now **on by default** for local development. When neither `CI=true` nor `JAIPH_UNSAFE=true` is set in the environment, `runtime.docker_enabled` defaults to `true`. In CI environments or when `JAIPH_UNSAFE=true` is set, the default is `false`. Explicit overrides (`JAIPH_DOCKER_ENABLED` env var or in-file `runtime.docker_enabled`) always take precedence over the default rule. `JAIPH_UNSAFE=true` is the new explicit escape hatch for local development when Docker is unwanted. Implementation: `resolveDockerConfig()` in `src/runtime/docker.ts`. Unit tests for all env combinations added. Docs updated (`docs/sandboxing.md`, `docs/configuration.md`).
+- **Feature — Docker:** Harden Docker execution environment — Docker sandboxing now enforces least-privilege defaults and explicit boundary controls. Containers launch with `--cap-drop ALL --cap-add SYS_ADMIN --security-opt no-new-privileges`, dropping all Linux capabilities except the one required for fuse-overlayfs and preventing privilege escalation. A mount denylist rejects dangerous host paths (`/`, `/var/run/docker.sock`, `/run/docker.sock`, `/proc`, `/sys`, `/dev` and their subpaths) at validation time with `E_VALIDATE_MOUNT` — both in `validateMounts` and at `buildDockerArgs` time. An environment variable denylist (`SSH_*`, `GPG_*`, `AWS_*`, `GCP_*`, `AZURE_*`, `GOOGLE_*`, `DOCKER_*`, `KUBE*`, `NPM_TOKEN*`) prevents host credentials from leaking into the container; only `JAIPH_*` (except `JAIPH_DOCKER_*`) and agent prefixes (`ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`) cross the container boundary. New exports: `validateMountHostPath`, `isEnvDenied`, `ENV_DENYLIST_PREFIXES`. Documentation adds a threat-model section (what Docker does and does not protect against), a failure-modes reference table (`E_DOCKER_*` / `E_VALIDATE_MOUNT` / `E_TIMEOUT`), expanded network-mode guidance, and the env denylist specification. Implementation: `src/runtime/docker.ts` (mount denylist, env denylist, security flags), `src/runtime/docker.test.ts` (unit tests for all new paths). Docs updated (`docs/sandboxing.md`).
+- **Feature — Language:** Optional module manifest keys (`module.name`, `module.version`, `module.description`) — The module-level `config { }` block now accepts three optional descriptive metadata keys: `module.name`, `module.version`, and `module.description`. All three are strings, all optional, and purely informational — they do not affect agent, run, or runtime behavior. Values are stored on `WorkflowMetadata.module` and round-trip through `jaiph format`. No semver validation is applied to `module.version`; any quoted string is accepted. Workflow-level `config` blocks reject `module.*` keys with `E_PARSE`, consistent with the existing `runtime.*` workflow guard. Future features (e.g. MCP tool metadata) may consume these fields. Implementation: `ALLOWED_KEYS` and `assignConfigKey` in `src/parse/metadata.ts`, `WorkflowMetadata.module` in `src/types.ts`, formatter round-trip in `src/format/emit.ts`, workflow-level rejection in `src/parse/workflows.ts`. Unit tests cover happy path, partial keys, coexistence with other config keys, formatter round-trip, and workflow-level rejection. Docs updated (`docs/configuration.md`, `docs/grammar.md`).
+- **Breaking — Docker:** Strict image contract and official GHCR runtime images — Docker mode now enforces a strict contract: every Docker image used by Jaiph must already contain a working `jaiph` CLI. Jaiph no longer auto-builds derived images or bootstraps itself into containers at runtime (no `npm pack`, no `npm install -g` into arbitrary base images). If the selected image lacks `jaiph`, the run fails immediately with `E_DOCKER_NO_JAIPH` and actionable guidance. The default `runtime.docker_image` is now `ghcr.io/jaiphlang/jaiph-runtime:` (matching the installed jaiph version), replacing the previous `node:20-bookworm` default. Official runtime images are published to GHCR: `ghcr.io/jaiphlang/jaiph-runtime:` for release tags, `:nightly` for the nightly branch, and `:latest` as a convenience alias. The official image includes Node.js, jaiph, `fuse-overlayfs`, and a non-root `jaiph` user (UID 10001); it does not include agent CLIs to keep the image minimal. The `jaiph init` Dockerfile template now extends the official image (`FROM ghcr.io/jaiphlang/jaiph-runtime:nightly`) and only adds agent CLIs (Claude Code, cursor-agent), instead of building from `ubuntu:latest` with a full install chain. Removed functions: `ensureLocalRuntimeImage`, `buildRuntimeImageFromLocalPackage`, `autoRuntimeImageTag`, `imageConfiguredUser`, `imageHomeDir`. Added: `verifyImageHasJaiph`, `GHCR_IMAGE_REPO`, `resolveDefaultImageTag`. CI: new `.github/workflows/docker-publish.yml` publishes the runtime image on release tags and nightly pushes. Implementation: `src/runtime/docker.ts`, `src/cli/commands/init.ts`, `docker/Dockerfile.runtime`. Unit and E2E tests updated for the strict contract — regression test confirms images without jaiph fail with `E_DOCKER_NO_JAIPH`. Docs updated (`docs/sandboxing.md`, `docs/configuration.md`, `docs/cli.md`, `docs/architecture.md`).
+- **Feature — Language/Runtime:** Explicit nested managed calls in argument position — Call arguments can now contain nested managed calls using `run` or `ensure` keywords explicitly: `run foo(run bar())`, `run foo(ensure rule_bar())`, and `run foo(run \`echo "aaa"\`())`. The nested call executes first and its result is passed as a single argument to the outer call. Bare call-like forms in argument position are rejected at compile time: `run foo(bar())` → `E_VALIDATE` with an actionable message telling the user to add `run` or `ensure`. Bare inline script calls in argument position (`run foo(\`echo aaa\`())`) are also rejected with guidance. The explicit capture-then-pass form (`const x = run bar()` followed by `run foo(x)`) remains valid. Bare call-like forms in `const` assignments (`const x = bar()`) are also rejected — use `const x = run bar()`. The formatter round-trips explicit nested forms correctly, including the inline script variant. The runtime evaluates nested managed argument tokens (workflows, scripts, rules, and inline scripts) before passing the result to the outer call. Implementation: validator (`src/transpile/validate.ts` — `validateNestedManagedCallArgs` extended for inline script detection), runtime (`src/runtime/kernel/node-workflow-runtime.ts` — `managed_inline_script` token kind, `parseInlineScriptAt`, `resolveArgsRawSync` fast path), formatter (`src/format/emit.ts` — `parseInlineScriptArg`, inline script formatting in `formatArgs`). Regression tests added for all valid and invalid forms. Docs updated (`docs/language.md`, `docs/grammar.md`, `docs/jaiph-skill.md`).
+
# 0.9.2
## Summary
@@ -14,7 +36,7 @@
- **Breaking — Runtime:** Remove `JAIPH_LIB` — The Node runtime no longer sets `JAIPH_LIB`, and isolated script subprocesses no longer receive it (`run-step-exec.ts`). `resolveRuntimeEnv` still deletes inherited `JAIPH_LIB` so a parent shell cannot inject a stale path. Workflows that used `source "$JAIPH_LIB/…"` must use `JAIPH_WORKSPACE`-relative paths, `import script`, or inline bash. Project-scoped **`.jaiph/libs/`** (`jaiph install`) is unchanged.
- **Docs / E2E:** Documentation and tests no longer describe or assert `JAIPH_LIB` / `.jaiph/lib` (singular).
- **Feature — Runtime:** Heartbeat file in run directory — The runtime now writes a `heartbeat` file (containing epoch-ms timestamp) to the run directory (`.jaiph/runs//
+
Jaiph is under heavy development. Core features and workflow syntax are
+ stable since v0.8.0, but you may expect breaking changes before v1.0.0.
@@ -102,6 +105,17 @@
Try it out!
+
+
What is Jaiph?
+
+
Jaiph is a language and runtime for defining and orchestrating AI agent workflows.
+
+
It allows you to combine agentic workflows with strict checks and script calls. It comes with
+ build-in Docker sanboxing for agentic workflows, and a set of tooling to make your development
+ faster and more efficient.
Example failing test run output (expected string omits the trailing :( from stderr):
+
Example failing test run output (expected string omits the trailing :( from stderr):
+
➜ ./say_hello.test.jh
testing say_hello.test.jh
▸ without name, workflow fails with validation message
@@ -269,53 +284,56 @@
Samples
-
The ensure … catch pattern checks a rule and, on failure,
- runs a recovery block. The catch (failure) binding captures
- the merged stdout+stderr from the failed check.
- Recovery runs once — for retries, the workflow calls itself
- recursively (run default()).
+
The run … recover pattern is a first-class repair-and-retry loop.
+ When the target fails, the recover(err) body runs, then the target
+ is retried automatically. The loop stops on success or when the retry limit
+ (default 10, configurable via run.recover_limit) is exhausted.
#!/usr/bin/env jaiph
-# Recursive recovery: when a check fails, prompt an agent to fix it,
-# then retry via run default(). Jaiph CI uses the same pattern to
-# auto-fix failing tests — see .jaiph/ensure_ci_passes.jh
-script check_report = `test -f report.txt`
+# recover loop: when check() fails, fix() repairs the problem,
+# and Jaiph automatically retries check(). No manual recursion needed.
+script check_gate = `test -f .gate_passed`
+
+workflow check() {
+ run check_gate()
+}
+
+script do_fix = `touch .gate_passed`
-rule report_exists() {
- run check_report()
+workflow fix() {
+ run do_fix()
}
workflow default() {
- ensure report_exists() catch (failure) {
- prompt "report.txt is missing. Create it with a short dummy summary."
- run default()
+ run check() recover(err) {
+ run fix()
}
}
-
In the run below, report_exists fails once. The agent creates
- report.txt, and the recursive run default() retries
- successfully.
+
In the run below, check() fails once. The recover body runs
+ fix(), and Jaiph retries check() which now succeeds.
For one-shot failure handling without retry, use catch instead. See
+ Language — recover.
+
Samples
Running the workflow:
➜ ./agent_inbox.jh
-Jaiph: Running agent_inbox.jh
+Jaiph: Running agent_inbox.jh (Docker sandbox, fusefs)workflow default
▸workflow scanner
·ℹ Scanning for issues...
✓workflow scanner (0s)
- ▸workflow analyst (message="\"Found 3 issues in auth module\"", chan="findings", sender="scanner")
+ ▸workflow analyst (message="Found 3 issues in auth module", chan="findings", sender="scanner")·ℹ Analyzing message from scanner on channel findings...
✓workflow analyst (0s)
- ▸workflow reviewer (message="\"Summary: \"Found 3 issues in aut...", chan="report", sender="analyst")
+ ▸workflow reviewer (message="Summary: Found 3 issues in auth ...", chan="report", sender="analyst")·ℹ Reviewing message from analyst on channel report...
- ·! Critical issue: "Summary: "Found 3 issues in auth module""
+ ·! Critical issue: Summary: Found 3 issues in auth module✓workflow reviewer (0s)✓ PASSworkflow default (0s)
@@ -372,8 +390,9 @@
Samples
This sample runs two prompt workflows in parallel: one with Cursor and one with Claude.
Each workflow sets its own agent.backend, captures the prompt response, and logs it.
- The default workflow uses run async to fan out both workflows concurrently, with an
- implicit join before completion.
+ The default workflow uses run async to fan out both workflows concurrently.
+ Each run async returns a Handle<T> that resolves on first read;
+ unresolved handles are implicitly joined before the workflow completes.
#!/usr/bin/env jaiph
const prompt_text = "Say: Greetings! I am [model name]."
@@ -398,7 +417,7 @@
tags providing the runtime: ```node, ```python3, ```ruby,
```pwsh etc.
-
Async calls. For async managed work, use run async wf() — Jaiph fans
- out the workflows concurrently and implicitly joins them before the parent workflow
- completes.
+
Async calls.run async wf() returns a Handle<T>
+ that resolves on first read. Capture with const h = run async wf() and read
+ the handle when you need the value. Unresolved handles are implicitly joined
+ before the parent workflow completes. Supports recover and catch
+ composition for async error handling.
Agent inbox pattern (channels). Use inbox channels as a way to pass messages between
workflows. Declare channels at top level with channel <name> [-> workflow]
@@ -456,15 +477,19 @@
Failure recovery.ensure … catch and run … catch
- handle failures inline: when a rule or script fails, the recovery body runs once
- (like a catch clause). For retries, use explicit recursion. Both forms work in workflows
- and rules. See Grammar.
+ handle failures inline: when a rule or script fails, the recovery body runs once.
+ For automatic repair-and-retry, use run … recover — a loop that retries
+ the target after each repair attempt (configurable limit, default 10). Both catch
+ and recover work in workflows. See Grammar.
Runtime
-
Docker sandboxing. Enable isolated execution with Docker for stronger containment of
- agent and shell actions. Configure in config { runtime.* }. See Sandboxing.
+
Docker sandboxing. Workflows run inside Docker by default for local development,
+ providing
+ filesystem and process isolation for agent and shell actions. Disable with
+ JAIPH_UNSAFE=true
+ or runtime.docker_enabled = false. See Sandboxing.
+
Hooks. Attach shell automation to workflow and step lifecycle events via
~/.jaiph/hooks.json or <project>/.jaiph/hooks.json. See Hooks.
@@ -473,6 +498,12 @@
Runtime
executable — a shell script, a Python wrapper, or your own CLI tool — and Jaiph will
pipe the prompt via stdin and capture raw stdout as the response. No JSON stream
protocol required; just read stdin and print your answer.
+
Artifacts library. Publish files from inside the sandbox to a host-readable
+ location with the built-in jaiphlang/artifacts library. artifacts.save
+ copies a file, artifacts.save_patch exports a git diff, and
+ artifacts.apply_patch replays it. Works identically in Docker and on the host.
+ See Libraries.
+
Configuration. Control behavior with config { ... } blocks
at the module level or inside individual workflows for per-workflow overrides, plus environment
variables (env wins precedence). See Configuration and
@@ -503,9 +534,11 @@
Syntax
Jaiph workflows
config { ... }
-
Optional runtime options (agent backend/flags, logs, runtime). Allowed at the top level
+
Optional runtime options (agent backend/flags, logs, runtime, module metadata). Allowed at the
+ top level
(module-wide) and inside individual workflows (per-workflow overrides for agent.*
- and run.* keys). Environment variables override config values. See run.*
keys only; runtime.* and module.* are
+ module-level only). Environment variables override config values. See Configuration.
import "file.jh" as alias · const name = value /
@@ -515,10 +548,12 @@
Jaiph workflows
shared by
rules, scripts, and workflows in the same file. Values can be single-line
"..." strings, triple-quoted """...""" multiline strings,
- or bare tokens.
+ or bare tokens.
+
rule is for reusable checks (Jaiph structured steps; used with
@@ -526,14 +561,17 @@
Jaiph workflows
workflow orchestrates Jaiph steps only, and script holds bash (or any
language via a fence lang tag like ```node, ```python3, or a custom
shebang) invoked with run. Rules and workflows require parentheses
- on every definition — even when parameterless (e.g. workflow default() { … }).
+ on every definition — even when parameterless (e.g.
+ workflow default() { … }).
Named parameters go inside the parentheses; the compiler validates
- call-site arity when the callee declares params. Any fence tag is valid — it maps directly to
+ call-site arity when the callee declares params. Any fence tag is valid — it maps directly
+ to
#!/usr/bin/env <tag>. Scripts run in full isolation
— only positional arguments
and essential Jaiph variables (JAIPH_SCRIPTS,
JAIPH_WORKSPACE) are inherited; module-scoped variables are not visible.
- Reuse shell helpers with import script or small named script blocks in the same module. Scripts are emitted as
+ Reuse shell helpers with import script or small named script blocks in
+ the same module. Scripts are emitted as
separate executable files under scripts/ (within the run build output tree; see CLI reference).
@@ -551,7 +589,8 @@
Jaiph workflows
run greet(name) is equivalent to run greet("${name}").
run `body`(args) embeds a one-off shell command directly
without a named script definition — supports arguments and capture.
- Use triple backticks for multiline: run ```...```(args).
+ Use triple backticks for multiline: run ```...```(args).
+
Run a workflow or script concurrently. All async steps are implicitly joined
- before the workflow completes; failures are aggregated. Workflows only —
- capture (const x = run async ...) is not supported.
- See Grammar.
+
run async ref(args) ·
+ const h = run async ref(args)
+
+
Run a workflow or script concurrently. Returns a Handle<T>
+ that resolves on first non-passthrough read (interpolation, passing as arg to
+ run, comparison, conditional). Passthrough (capture, re-assignment)
+ does not force resolution. Unresolved handles are implicitly joined at workflow exit.
+ Supports recover (retry loop) and catch (single-shot) composition:
+ run async foo() recover(err) { … }.
+ Workflows only. See Grammar and
+ Spec: Async Handles.
fail "reason" · fail """..."""
-
fail aborts with stderr + non-zero exit. Use triple quotes for multiline messages.
+
fail aborts with stderr + non-zero exit. Use triple quotes for multiline messages.
+
Failure recovery: when the target fails, the recovery body runs once
(like a catch clause). catch requires explicit bindings
- in parentheses. Works in both workflows and rules. For retries, use explicit
- recursion in the recovery body.
+ in parentheses. Works in both workflows and rules.
+
+
+
run ref() recover (err) { … }
+
Repair-and-retry loop: when the target fails, the repair body runs and the target
+ is retried automatically. Stops on success or when the retry limit is exhausted
+ (default 10, configurable via run.recover_limit). recover
+ requires explicit bindings. Workflows only. See
+ Language.
match var { "lit" => …, /re/ => …, _ => … }
diff --git a/docs/jaiph-skill.md b/docs/jaiph-skill.md
index a7dd9c30..584561e3 100644
--- a/docs/jaiph-skill.md
+++ b/docs/jaiph-skill.md
@@ -21,16 +21,16 @@ The **JS kernel** (`src/runtime/kernel/`) handles **prompt** execution, **manage
**Test lane:** `jaiph test` runs **`*.test.jh`** in-process (`node-test-runner.ts`): **`buildScripts(workspace)`**, then **`buildRuntimeGraph(testFile)` once per file**, mocks, and assertions — same `NodeWorkflowRuntime` as `jaiph run`.
-**After `jaiph init`**, a repository gets `.jaiph/bootstrap.jh` (a triple-quoted prompt that tells the agent to read `.jaiph/SKILL.md`), `.jaiph/Dockerfile` (project sandbox image template), and a copy of this file. The bootstrap prompt explicitly asks the agent to review/update `.jaiph/Dockerfile` for the current repo and to end with a clear `WHAT CHANGED` + `WHY` summary. The expected outcome is a **minimal workflow set** for safe feature work: preflight checks, an implementation workflow, verification, and a `workflow default` entrypoint that wires them together (with an optional human-or-agent “review” step when you use a task queue).
+**After `jaiph init`**, a repository gets `.jaiph/bootstrap.jh` (a triple-quoted prompt that tells the agent to read `.jaiph/SKILL.md`) and a copy of this file. The bootstrap prompt asks the agent to scaffold workflows under `.jaiph/` and to end with a clear `WHAT CHANGED` + `WHY` summary. The expected outcome is a **minimal workflow set** for safe feature work: preflight checks, an implementation workflow, verification, and a `workflow default` entrypoint that wires them together (with an optional human-or-agent “review” step when you use a task queue). Docker-backed runs use the official `ghcr.io/jaiphlang/jaiph-runtime` image by default; see [Sandboxing](sandboxing.md) to override with `runtime.docker_image` or `JAIPH_DOCKER_IMAGE`.
**Concepts:**
- **Rules** — Structured checks: `ensure` (other **rules** only), `run` (**scripts** only — not workflows), `const`, `match`, `if`, `fail`, `log`/`logerr`, `return "…"` / `return run script()` / `return ensure rule()`, `ensure … catch`, `run … catch`. No raw shell lines, `prompt`, inbox send/route, or `run async`. Under `jaiph run`, rule bodies are executed **in-process** by the Node runtime; when a rule runs a **script**, that script is a normal managed subprocess (same as scripts from workflows) — see [Sandboxing](sandboxing.md).
-- **Workflows** — Named sequences of **Jaiph-only** steps: `ensure`, `run`, `prompt`, `const`, `fail`, `return`, `log`/`logerr`, inbox **send** (`channel_ref <- …`), `match`, `if`, `run async`, `ensure … catch`, `run … catch`. Route declarations (`->`) belong at the top level on `channel` declarations, **not** inside workflow bodies — a `->` inside a body is a parse error. Unrecognized lines are errors — put bash in **`script`** definitions and call with `run`.
+- **Workflows** — Named sequences of **Jaiph-only** steps: `ensure`, `run`, `prompt`, `const`, `fail`, `return`, `log`/`logerr`, inbox **send** (`channel_ref <- …`), `match`, `if`, `run async`, `ensure … catch`, `run … catch`, `run … recover`. Route declarations (`->`) belong at the top level on `channel` declarations, **not** inside workflow bodies — a `->` inside a body is a parse error. Unrecognized lines are errors — put bash in **`script`** definitions and call with `run`.
- **Scripts** — Top-level **`script`** definitions are **bash (or shebang interpreter) source**, not Jaiph orchestration. Defined with `` script name = `body` `` (single-line backtick) or `` script name = ```[lang] ... ``` `` (fenced block). Double-quoted string bodies (`script name = "body"`) and bare identifier bodies (`script name = varName`) are **removed** — both produce parse errors with guidance to use backtick delimiters. The compiler treats all script bodies as **opaque text**: it does not parse lines as Jaiph steps, reject keywords, strip quotes, or validate cross-script calls. This means embedded `node -e` heredocs, inline Python, `const` assignments in JS, and any other valid shell construct compile without interference. Jaiph interpolation (`${...}`) is **forbidden** in **single-line backtick** script bodies — use `$1`, `$2` positional arguments to pass data from orchestration to scripts. In **fenced** (triple-backtick) blocks, `${...}` is passed through to the shell as standard parameter expansion (`${VAR}`, `${VAR:-default}`, etc.). A single-backtick body containing a newline is a hard parse error — use a fenced block for multi-line scripts. Use `return N` / `return $?` for exit status and **stdout** (`echo` / `printf`) for string data to callers. From a **workflow** or **rule**, call with **`run fn()`**. Can be exported (`export script name = ...`) for use by importing modules. Cannot be used with `ensure`, are not valid inbox route targets, and must not be invoked through `$(...)` or as a bare shell step. **Polyglot scripts:** use a fence lang tag (`` ``` ``) to select an interpreter — the tag maps directly to `#!/usr/bin/env `. Any tag is valid (no hardcoded allowlist). For example: `` ```node ``, `` ```python3 ``, `` ```ruby ``, `` ```lua ``. Alternatively, if no fence tag is present, the first non-empty body line may start with `#!` (e.g. `#!/usr/bin/env lua`), which becomes the script's shebang and the body is emitted verbatim (you cannot combine a fence tag with a manual shebang — that is an error). Without either, `#!/usr/bin/env bash` is used and the emitter applies only lightweight bash-specific transforms (`return` normalization, `local`/`export`/`readonly` spacing, import alias resolution). Scripts are extracted to a `scripts/` directory under the run output tree (`jaiph run --target ` sets that tree; without `--target` the CLI uses a temporary directory) and executed via **`JAIPH_SCRIPTS`**. **Inline scripts:** For trivial one-off commands, use `` run `body`(args) `` or `` run ```lang...body...```(args) `` directly in a workflow or rule step instead of declaring a named `script` definition. The body (single backtick for one-liners or triple backtick for multi-line) comes before the parentheses; optional comma-separated arguments go inside the parentheses: `` run `echo $1`("hello") ``. Fenced blocks support lang tags for polyglot inline scripts: `` run ```python3 ... ```() ``. Capture forms: `` const x = run `echo val`() `` and `` const x = run ```...```() ``. The old `run script() "body"` form is **removed** — use the backtick forms instead. Inline scripts use deterministic hash-based artifact names (`__inline_`) and run with the same isolation as named scripts. `run async` with inline scripts is not supported.
- **Channels** — Top-level `channel [-> workflow, ...]` declarations with optional inline routing; **send** uses `channel_ref <- …`. Routes are declared on the channel declaration, not inside workflow bodies (see [Inbox & Dispatch](inbox.md)). Channel names share the per-module namespace with rules, workflows, scripts, and module-scoped `local` / `const` variables.
-Step semantics (`ensure`, `run`, `prompt`, `catch`, `match`, `if`, `log`, `fail`, `return`, `send`, `run async`) are detailed in the **Steps** section below.
+Step semantics (`ensure`, `run`, `prompt`, `catch`, `recover`, `match`, `if`, `log`, `fail`, `return`, `send`, `run async`) are detailed in the **Steps** section below.
**Audience:** Agents that produce or edit `.jh` files.
@@ -53,7 +53,7 @@ Use this loop whenever you add or change Jaiph workflows so failures surface bef
| `jaiph run [args...]` | Execute `workflow default` in the given file |
| `jaiph test [path]` | Run `*.test.jh` test files (workspace, directory, or single file) |
| `jaiph format [--check] ` | Reformat `.jh` files (or verify formatting without writing) |
-| `jaiph init [workspace]` | Scaffold `.jaiph/` with bootstrap workflow, Dockerfile template, and skill file |
+| `jaiph init [workspace]` | Scaffold `.jaiph/` with bootstrap workflow and skill file |
| `jaiph install [url[@version]]` | Install or restore project-scoped libraries under `.jaiph/libs/` |
| `jaiph use ` | Reinstall Jaiph at a specific version or nightly |
@@ -79,7 +79,7 @@ Ignore any outdated Markdown that contradicts the above.
A **minimal workflow set** under `.jaiph/` that matches the delivery loop above:
-1. **Sandbox baseline** — Review/update `.jaiph/Dockerfile` first so container execution matches the repository's actual build/test/runtime/tooling needs. Keep Jaiph installed via `curl -fsSL https://jaiph.org/install | bash`.
+1. **Sandbox baseline (optional)** — If the repo uses Docker sandboxing, confirm `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` match the tooling the team needs; the default is `ghcr.io/jaiphlang/jaiph-runtime` (see [Sandboxing](sandboxing.md)).
2. **Preflight** — Rules and `ensure` for repo state and required tools (e.g. clean git, required binaries). Expose a small workflow (e.g. `workflow default` in `readiness.jh`) that runs these checks.
3. **Review (optional)** — A workflow that reviews queued tasks before development starts (any filename, e.g. `ba_review.jh`). An agent prompt evaluates the next task for clarity, consistency, conflicts, and feasibility, then either marks it as ready or exits with questions. The implementation workflow gates on this marker so unreviewed tasks cannot proceed. This repository’s `.jaiph/architect_review.jh` is one concrete example; it uses `QUEUE.md` as the task queue.
4. **Implementation** — A workflow that drives coding changes (typically via `prompt`), e.g. `workflow implement` in `main.jh`. When using a task queue, the implementation workflow should check that the first task is marked as ready (e.g. via a `` marker) before proceeding.
@@ -95,15 +95,15 @@ Prefer composable modules over one large file.
- **Module-scoped variables:** `local name = value` or `const name = value` (same value forms). Prefer **`const`** for new files. Values can be single-line `"..."` strings, triple-quoted `"""..."""` multiline strings, or bare tokens. A double-quoted string that spans multiple lines is rejected — use `"""..."""` instead. Accessible as `${name}` inside orchestration strings in the same module. Names share the unified namespace with channels, rules, workflows, and scripts — duplicates are `E_PARSE`. Not exportable; module-scoped only.
- **Steps:**
- **ensure** — `ensure ref` or `ensure ref([args...])` runs a rule (local or `alias.rule_name`). **Parentheses are optional when passing zero arguments** — `ensure check` is equivalent to `ensure check()`. When arguments are present, parentheses are required with comma-separated expressions. **Bare identifier arguments** are supported and preferred: `ensure check(status)` is equivalent to `ensure check("${status}")` — the identifier must reference a known variable (`const`, capture, or named parameter); unknown names fail with `E_VALIDATE`. **Standalone `"${identifier}"` in call arguments is rejected** — use the bare form instead. Optionally `ensure ref([args]) catch () ` or `ensure ref([args]) catch (, ) `: the recovery body runs **once** on failure (like a catch clause). There is no retry loop — for retries, use explicit recursion. The first binding (e.g. `failure`) receives the full merged stdout+stderr from the failed rule execution, including output from nested scripts and rules. The optional second binding (e.g. `attempt`) receives the attempt number (always `"1"`). Full output still lives in step **`.out` / `.err`** artifacts. If the failure binding is empty for your rule, persist diagnostics before prompting or assert non-empty. Works in both workflows and rules.
- - **run** — `run ref` or `run ref([args...])` runs a workflow or script (local or `alias.name`). **Parentheses are optional when passing zero arguments** — `run setup` is equivalent to `run setup()`. When arguments are present, parentheses are required with comma-separated expressions. **`run` does not forward args by default** — pass named params explicitly (e.g. `run wf(task)`, `run util_fn(name)`). **Bare identifier arguments** are supported and preferred: `run greet(name)` is equivalent to `run greet("${name}")` — the identifier must reference a known variable (`const`, capture, or named parameter); unknown names fail with `E_VALIDATE`. **Standalone `"${identifier}"` in call arguments is rejected** — use the bare form instead (e.g. `run greet(name)` not `run greet("${name}")`). Quoted strings with additional text around the interpolation (e.g. `"prefix_${name}"`) are still allowed. Jaiph keywords cannot be used as bare identifiers. Optionally `run ref([args]) catch () `: the recovery body runs **once** on failure (same semantics as `ensure … catch`). Works in both workflows and rules. Also supports **inline scripts**: `` run `body`(args) `` or `` run ```lang...body...```(args) `` — see Scripts section above.
+ - **run** — `run ref` or `run ref([args...])` runs a workflow or script (local or `alias.name`). **Parentheses are optional when passing zero arguments** — `run setup` is equivalent to `run setup()`. When arguments are present, parentheses are required with comma-separated expressions. **`run` does not forward args by default** — pass named params explicitly (e.g. `run wf(task)`, `run util_fn(name)`). **Bare identifier arguments** are supported and preferred: `run greet(name)` is equivalent to `run greet("${name}")` — the identifier must reference a known variable (`const`, capture, or named parameter); unknown names fail with `E_VALIDATE`. **Standalone `"${identifier}"` in call arguments is rejected** — use the bare form instead (e.g. `run greet(name)` not `run greet("${name}")`). Quoted strings with additional text around the interpolation (e.g. `"prefix_${name}"`) are still allowed. Jaiph keywords cannot be used as bare identifiers. **Nested managed calls in arguments** are supported with explicit keywords: `run foo(run bar())`, `run foo(ensure check())`, `run foo(run \`echo ok\`())`. Bare call-like forms in arguments (`run foo(bar())`, `run foo(\`echo ok\`())`) are rejected — add the `run` or `ensure` keyword. Optionally `run ref([args]) catch () `: the recovery body runs **once** on failure (same semantics as `ensure … catch`). Works in both workflows and rules. Optionally `run ref([args]) recover () `: repair-and-retry loop — on failure, binds error output, runs the repair body, and retries the target. Loop stops on success or when `run.recover_limit` (default 10) is exhausted. `recover` and `catch` are mutually exclusive on the same step. Workflows only. Also supports **inline scripts**: `` run `body`(args) `` or `` run ```lang...body...```(args) `` — see Scripts section above.
- **log** — `log "message"` writes the expanded message to **stdout** and emits a **`LOG`** event; the CLI shows it in the progress tree at the current depth. Double-quoted string; `${identifier}` interpolation works at runtime. For multiline messages, use triple quotes: `log """..."""`. **Bare identifier form:** `log foo` (no quotes) expands to `log "${foo}"` — the variable's value is logged. Works with `const`, capture, and named parameters. **Inline capture interpolation** is also supported: `${run ref([args])}` and `${ensure ref([args])}` execute a managed call and inline the result (e.g. `log "Got: ${run greet()}"`). Nested inline captures are rejected. **`LOG`** events and `run_summary.jsonl` store the **same** message string (JSON-escaped for the payload). No spinner, no timing — a static annotation. See [CLI Reference](cli.md) for tree formatting. Useful for marking workflow phases (e.g. `log "Starting analysis phase"`).
- **logerr** — `logerr "message"` is identical to `log` except the message goes to **stderr** and the event type is **`LOGERR`**. In the progress tree, `logerr` lines use a red `!` instead of the dim `ℹ` used by `log`. Same quoting, interpolation, bare identifier, and triple-quote rules as `log` (e.g. `logerr err_msg`, `logerr """..."""`).
- - **Send** — After `<-`, use a **double-quoted literal**, **triple-quoted block** (`channel <- """..."""`), **`${var}`**, or **`run ref([args])`**. An explicit RHS is always required — bare `channel <-` (forward syntax) has been removed. Raw shell on the RHS is rejected — use `const x = run helper()` then `channel <- "${x}"`, or `channel <- run fmt_fn()`. Combining capture and send (`name = channel <- …`) is `E_PARSE`. See [Inbox & Dispatch](inbox.md).
+ - **Send** — After `<-`, use a **double-quoted literal**, **triple-quoted block** (`channel <- """..."""`), **`${var}`**, or **`run ref([args])`**. An explicit RHS is always required — bare `channel <-` (without a value) is invalid. Raw shell on the RHS is rejected — use `const x = run helper()` then `channel <- "${x}"`, or `channel <- run fmt_fn()`. Combining capture and send (`name = channel <- …`) is `E_PARSE`. See [Inbox & Dispatch](inbox.md).
- **Route** — Routes are declared **at the top level** on channel declarations: `channel name -> workflow_ref` or `channel name -> wf1, wf2`. A `->` inside a workflow body is a **parse error** with guidance to move it to the channel declaration. When a message arrives on the channel, the runtime calls each listed **workflow** (local or `alias.workflow`), binding the dispatch values (message, channel, sender) to the target's 3 declared parameters. Route targets must declare exactly 3 parameters. Scripts and rules are not valid route targets. The dispatch queue drains after the orchestrator completes. **`NodeWorkflowRuntime` does not cap dispatch iterations** — avoid circular sends that grow the queue without bound. See [Inbox & Dispatch](inbox.md).
- **Bindings and capture** — `const name = …` (the `const` keyword is required for all captures). For **`ensure`** / **`run` to a workflow or rule**, capture is the callee’s explicit **`return "…"`**. For **`run` to a script**, capture follows **stdout** from the script body. **`prompt`** capture is the agent answer. **`const`** RHS cannot use `$(...)` or disallowed `${...}` forms — use a **`script`** and `const x = run helper(…)`. **`const`** must not use a **bare** `ref(args…)` call shape: use **`const x = run ref(args…)`** (or **`ensure`** for rules), not **`const x = ref(args…)`** — the compiler fails with **`E_PARSE`** and suggests the **`run`** form. Do not put Jaiph symbols inside `$(...)` — use `ensure` / `run`. See [Grammar](grammar.md#step-output-contract).
- **return** — `return "value"` / `return "${var}"` / `return """..."""` sets the managed return value. Also supports **direct managed calls**: `return run ref()` or `return run ref(args)` and `return ensure ref()` or `return ensure ref(args)` — these execute the target and use its result as the return value, equivalent to `const x = run ref(args)` then `return "${x}"`. Parentheses are required on all call sites.
- **fail** — `fail "reason"` or `fail """..."""` aborts with stderr message and non-zero exit (workflows; fails the rule when used inside a rule).
- - **run async** — `run async ref([args...])` starts a workflow or script concurrently. All pending async steps are implicitly joined before the workflow completes; failures are aggregated. Capture (`const name = run async ...`) is not supported. Workflows only — rejected in rules.
+ - **run async** — `run async ref([args...])` starts a workflow or script concurrently and returns a **`Handle`**. Capture is supported: `const h = run async ref()`. The handle resolves on first non-passthrough read (string interpolation, passing as arg to `run`, comparison, conditional, match subject). Passthrough (initial capture, re-assignment) does not force resolution. Unresolved handles are implicitly joined at workflow exit. `recover` (retry loop) and `catch` (single-shot) composition work with `run async`: `run async foo() recover(err) { … }`. Workflows only — rejected in rules.
- **match** — `match var { "literal" => …, /regex/ => …, _ => … }` pattern-matches on a string value. The subject is always a bare identifier (no `$` or `${}`). Arms are tested top-to-bottom; the first match wins. Patterns: double-quoted string literal (exact match), `/regex/` (regex match), or `_` (wildcard — exactly one required). Usable as a statement, as an expression (`const x = match var { … }`), or with `return` (`return match var { … }`). Using `$var` or `${var}` as the match subject is a parse error. Allowed in both workflows and rules. See [Grammar](grammar.md#match).
- **if** — `if var == "value" { … }` or `if var =~ /pattern/ { … }`. Subject is a bare identifier. Operators: `==` (exact string equality), `!=` (inequality), `=~` (regex match), `!~` (regex non-match). Operand is a `"string"` for `==`/`!=` or `/regex/` for `=~`/`!~`. Body is a brace block of valid workflow/rule steps. No `else` branch — use `match` for exhaustive value branching. `if` is a statement (no value production; cannot use with `const` or `return`). Allowed in both workflows and rules.
- **Prompts:** Three body forms: (1) **single-line string** `prompt "..."` — double-quoted, single line only; (2) **identifier** `prompt myVar` — uses the value of an existing binding; (3) **triple-quoted block** `prompt """ ... """` — for multiline text, opening `"""` on the same line as `prompt`. Triple backticks (`` ``` ``) in prompt context are rejected with guidance — they are reserved for scripts. Multiline double-quoted strings are rejected — use a triple-quoted block instead. All forms support `${identifier}` interpolation (`${varName}`, `${paramName}`). **Inline capture interpolation** is also supported: `${run ref([args])}` and `${ensure ref([args])}` inside the prompt string or triple-quoted body (e.g. `prompt "Fix: ${ensure get_diagnostics()}"`). Nested inline captures are rejected. Bare `$varName` is not valid in orchestration strings. `$(...)` and `${var:-fallback}` are rejected. Capture: `const name = prompt "..."`, `const x = prompt myVar`, `const y = prompt """ ... """`. Optional **typed prompt:** `const name = prompt "..." returns "{ field: type, ... }"` or `const name = prompt myVar returns "..."` (flat schema; types `string`, `number`, `boolean`) validates the agent's JSON and sets `${name}` plus per-field variables accessible via **dot notation** — `${name.field}`. Dot notation is validated at compile time: the variable must be a typed prompt capture and the field must exist in the schema. **Orchestration bindings are strings:** typed fields are coerced with `String()` after JSON validation, so e.g. a numeric field is still the text `"42"` in scope. See [Grammar](grammar.md).
@@ -111,12 +111,18 @@ Prefer composable modules over one large file.
**Quick reference examples:**
```jaiph
-# catch — failure handling with retry via recursion
+# catch — one-shot failure handling
ensure ci_passes() catch (failure) {
prompt "CI failed — fix the code."
run deploy(env)
}
+# recover — repair-and-retry loop (retries until success or limit)
+run deploy(env) recover(err) {
+ log "Deploy failed: ${err}"
+ run auto_repair(env)
+}
+
# match — value branching (statement and expression forms)
const label = match status {
"ok" => "success"
@@ -155,7 +161,7 @@ Conventions:
- **Parallelism:** `run async ref([args...])` for managed async with implicit join. For concurrent **bash**, use `&` and the shell builtin `wait` inside a **`script`** and call it with `run`. Do not call Jaiph internals from background subprocesses unless you understand `run.inbox_parallel` locking.
- **Shell conditions:** Express conditionals with `run` to a **script** and handle failure with `catch`, or use `if` / `match` for value branching. Short-circuit brace groups remain valid **inside `script`** bodies: `cmd || { ... }`.
- **No shell redirection around managed calls:** `run foo() > file`, `run foo() | cmd`, `run foo() &` are all `E_PARSE` errors — shell operators (`>`, `>>`, `|`, `&`) are not supported adjacent to `run` or `ensure` steps. Move shell pipelines and redirections into a **`script`** block and call it with `run`.
-- **Script reuse:** Prefer `import script "./tool.py" as tool` (or a sibling `.jh` module) instead of maintaining ad-hoc bash outside the compiler. Do not rely on a workspace-level shared-bash directory; that mechanism is being removed from the product (see `QUEUE.md`).
+- **Script reuse:** Prefer `import script "./tool.py" as tool` (or a sibling `.jh` module) instead of maintaining ad-hoc bash outside the compiler. Avoid informal workspace-level shared-bash directories that bypass the module graph.
- **Unified namespace:** Channels, rules, workflows, scripts, script import aliases, and module-scoped `local`/`const` share a single namespace per module (`E_PARSE` on collision).
- **Calling conventions (compiler-enforced):** `ensure` must target a rule — using it on a workflow or script is `E_VALIDATE`. `run` in a **workflow** must target a workflow or script; `run` in a **rule** must target a **script** only. **Type crossing:** `string` and `script` are distinct primitive types — `prompt` rejects script names, `run` rejects string consts, assigning a script to a `const` or interpolating `${scriptName}` are all `E_VALIDATE`. See [Grammar — Types](grammar.md#types). Jaiph symbols must not appear inside `$(...)` in bash contexts the compiler still scans (principally **`script`** bodies). Script bodies cannot contain `run`, `ensure`, `config`, nested definitions, routes, or Jaiph `fail` / `const` / `log` / `logerr` / `return "…"`.
@@ -211,7 +217,6 @@ test "handles failure gracefully" {
## Suggested Starter Layout
- `.jaiph/bootstrap.jh` — Created by `jaiph init`; contains a single triple-quoted prompt (`prompt """ ... """`) that points the agent at `.jaiph/SKILL.md` (a copy of this guide).
-- `.jaiph/Dockerfile` — Created by `jaiph init`; base Docker sandbox template. Review and tailor runtime/build/test tooling to the current repository.
- `.jaiph/readiness.jh` — Preflight: rules and `workflow default` that runs readiness checks.
- `.jaiph/ba_review.jh` (or any name you choose) — (Optional) Pre-implementation review: reads tasks from a queue file, sends one to an agent for review, and marks it dev-ready or exits with questions. This repository uses `.jaiph/architect_review.jh` with `QUEUE.md`.
- `.jaiph/verification.jh` — Verification: rules and `workflow default` for lint/test/build.
diff --git a/docs/language.md b/docs/language.md
index 504b0872..94be5e82 100644
--- a/docs/language.md
+++ b/docs/language.md
@@ -192,7 +192,7 @@ workflow deploy(env, version) {
}
```
-Workflows support all step types: `run`, `ensure`, `prompt`, `const`, `log`, `logerr`, `fail`, `return`, `send`, `match`, `if`, `run async`, and `catch`.
+Workflows support all step types: `run`, `ensure`, `prompt`, `const`, `log`, `logerr`, `fail`, `return`, `send`, `match`, `if`, `run async`, `catch`, and `recover`.
### Rules
@@ -248,6 +248,36 @@ run process(task, "extra context") # mixed bare + quoted
run process("${task}") # equivalent to bare form
```
+### Nested Managed Calls in Arguments
+
+Call arguments can contain nested managed calls — but the `run` or `ensure` keyword must be explicit. This is a deliberate language rule: scripts and workflows execute only via `run`, and rules execute only via `ensure`, even when nested inside another call's arguments.
+
+**Valid — explicit nested calls:**
+
+```jaiph
+run mkdir_p_simple(run jaiph_tmp_dir())
+run do_work(ensure check_ok())
+run do_work(run `echo aaa`())
+```
+
+The nested call executes first and its result is passed as a single argument to the outer call.
+
+**Invalid — bare call-like forms:**
+
+```jaiph
+# run do_work(bar()) — E_VALIDATE: use "run bar()" or "ensure bar()"
+# run do_work(rule_bar()) — E_VALIDATE: use "ensure rule_bar()"
+# run do_work(`echo aaa`()) — E_VALIDATE: use "run `...`()"
+# const x = bar() — E_PARSE: use "const x = run bar()"
+```
+
+The explicit capture-then-pass form is also valid:
+
+```jaiph
+const x = run bar()
+run foo(x)
+```
+
### Arity Checking
When the callee declares named parameters, the compiler validates argument count:
@@ -276,19 +306,41 @@ const output = run transform()
**Capture:** For a workflow, captures the explicit `return` value. For a script, captures stdout.
-### `run async` — Concurrent Execution
+### `run async` — Concurrent Execution with Handles
-Starts a workflow or script concurrently. All pending async steps are implicitly joined before the enclosing workflow returns.
+`run async ref(args)` starts a workflow or script concurrently and returns a **`Handle`** — a value that resolves to the called function's return value on first non-passthrough read. `T` is the same type the function would return under a synchronous `run`.
```jaiph
workflow default() {
+ # Fire-and-forget style (handle created but not captured)
run async lib.task_a()
- run async lib.task_b()
- # both joined automatically before workflow returns
+
+ # Capture the handle for later use
+ const h = run async lib.task_b()
+
+ # Reading the handle forces resolution (blocks until task_b completes)
+ log "${h}"
+}
+```
+
+**Handle resolution:** The handle resolves on first non-passthrough read — string interpolation, passing as argument to `run`, comparison, conditional branching, or match subject. Passthrough operations (initial capture into `const`, re-assignment) do not force resolution.
+
+**Implicit join:** When a workflow scope exits, the runtime implicitly joins all remaining unresolved handles created in that scope. This is not an error — it preserves backward compatibility with the pre-handle `run async` model.
+
+**`recover` composition:** `recover` works with `run async` to provide retry-loop semantics on the async branch:
+
+```jaiph
+const b1 = run async foo() recover(err) {
+ log "repairing: ${err}"
+ run fix_it()
}
```
-Constraints: workflow-only (rejected in rules), capture not supported.
+The async branch retries `foo()` using the same retry-limit semantics as non-async `recover` (default 10, configurable via `run.recover_limit`). The handle resolves to the eventual success value or the final failure. `catch` also works with `run async` for single-shot recovery (no retry loop).
+
+See [Spec: Async Handles](spec-async-handles) for the full value model.
+
+Constraints: workflow-only (rejected in rules), inline scripts not supported with `run async`.
### `ensure` — Execute a Rule
@@ -325,6 +377,51 @@ workflow deploy(env) {
Bare `catch` without a binding is a parse error. All call arguments must appear inside parentheses before `catch`.
+### `recover` — Repair-and-Retry Loop
+
+`recover` is a first-class retry primitive for `run` steps. Unlike `catch` (which runs the recovery body once), `recover` implements a **loop**: try the target, and if it fails, bind the error, run the repair body, then retry. The loop stops when the target succeeds or when the retry limit is exhausted.
+
+```jaiph
+# Single-statement recovery loop
+run deploy() recover(err) run fix_deploy()
+
+# Block recovery loop
+run deploy(env) recover(err) {
+ log "Deploy failed: ${err}"
+ run auto_repair(env)
+}
+```
+
+**Semantics:**
+
+1. Execute the `run` target.
+2. If it succeeds, continue (the `recover` body never runs).
+3. If it fails, bind merged stdout+stderr to the `recover` binding (e.g. `err`), execute the repair body, then go to step 1.
+4. If the retry limit is reached and the target still fails, the step fails with the last error.
+
+**Retry limit:** The default limit is **10** attempts. Override it per-module with the `run.recover_limit` config key:
+
+```jaiph
+config {
+ run.recover_limit = 3
+}
+
+workflow default() {
+ run flaky_step() recover(err) {
+ log "Retrying after: ${err}"
+ run repair()
+ }
+}
+```
+
+**Capture:** When the target eventually succeeds, `const name = run ref() recover(err) { … }` captures the result (same rules as plain `run` — `return` value for workflows, stdout for scripts).
+
+**Constraints:**
+- `recover` requires exactly one binding: `recover(name)`. Bare `recover` without bindings is a parse error.
+- All call arguments must appear inside parentheses **before** `recover`.
+- `recover` is available on `run` steps in workflows only (not `ensure`). `recover` also works with `run async` — see [`run async`](#run-async--concurrent-execution-with-handles).
+- `recover` and `catch` are mutually exclusive on the same step — use one or the other.
+
### `prompt` — Agent Interaction
Sends text to the configured agent backend. Three body forms:
@@ -616,7 +713,7 @@ Every step produces three outputs: status, value, and logs.
| `prompt` | exit code | final assistant answer | artifacts |
| `log` / `logerr` | always 0 | — | event stream |
| `fail` | non-zero (abort) | — | stderr |
-| `run async` | aggregated | not supported | artifacts |
+| `run async` | aggregated | `Handle` — resolves to return value on read | artifacts |
| `const` | same as RHS | binds locally | — |
## Lexical Notes
diff --git a/docs/libraries.md b/docs/libraries.md
index 23f466f6..d852e9e6 100644
--- a/docs/libraries.md
+++ b/docs/libraries.md
@@ -37,3 +37,46 @@ workflow default() {
```
The import resolver tries relative paths first (same as local modules), then falls back to `.jaiph/libs/`. See [CLI — `jaiph install`](cli.md#jaiph-install) for flags, lockfile format, and edge cases.
+
+## Built-in libraries (`jaiphlang/`)
+
+The `jaiphlang/` namespace ships with Jaiph and provides standard workflow utilities. These libraries live under `.jaiph/libs/jaiphlang/` and follow the same `import` + `export workflow` pattern as user-installed libraries.
+
+### `jaiphlang/queue` — task queue management
+
+Reads and modifies a `QUEUE.md` file in the workspace root. See the source at `.jaiph/libs/jaiphlang/queue.jh` for the full API.
+
+### `jaiphlang/artifacts` — publishing files out of the sandbox
+
+Copies files from inside the workflow sandbox (or host workspace) to `.jaiph/runs//artifacts/`, a host-readable location that survives sandbox teardown.
+
+The runtime exposes `JAIPH_ARTIFACTS_DIR` pointing at the writable artifacts directory. The library reads this env var — it works identically inside the Docker sandbox and on the host.
+
+```jaiph
+import "jaiphlang/artifacts" as artifacts
+
+workflow default() {
+ # Copy a file into the artifacts directory under a chosen name.
+ # Returns the absolute path of the saved artifact.
+ const path = run artifacts.save("./build/output.bin", "build-output.bin")
+
+ # Produce a git diff (excluding .jaiph/) and save it as a patch.
+ # Returns the absolute path of the saved patch file.
+ const patch = run artifacts.save_patch("snapshot.patch")
+
+ # Apply a saved patch to the current workspace.
+ run artifacts.apply_patch(patch)
+}
+```
+
+**Exported workflows:**
+
+| Workflow | Description |
+|---|---|
+| `save(local_path, name)` | Copies the file at `local_path` into `${JAIPH_ARTIFACTS_DIR}/${name}`. Returns the host-resolved absolute path. |
+| `save_patch(name)` | Runs `git diff` (working tree vs HEAD, excluding `.jaiph/`) and writes it to `${JAIPH_ARTIFACTS_DIR}/${name}`. Returns the absolute path. |
+| `apply_patch(path)` | Applies a patch file to the current workspace via `git apply`. Fails with a clear error when the patch does not apply. |
+
+**Notes:**
+- `save_patch` excludes `.jaiph/` from the produced patch. The runtime writes its own state under `.jaiph/`; including it in a patch would clobber state on apply.
+- When the workspace is clean, `save_patch` produces an empty file.
diff --git a/docs/sandboxing.md b/docs/sandboxing.md
index 6d1be92d..c69ed0ae 100644
--- a/docs/sandboxing.md
+++ b/docs/sandboxing.md
@@ -19,27 +19,63 @@ The runtime executes rules by walking the AST in-process (`NodeWorkflowRuntime.e
`jaiph test` executes tests in-process with `NodeTestRunner` and does not use Docker or a separate rule sandbox.
+## Threat model
+
+Docker sandboxing is designed to contain damage from untrusted or semi-trusted workflow scripts. Understanding what it does and does not protect against helps you make informed decisions about when to enable it.
+
+**What Docker protects against:**
+
+- **Filesystem access** -- Scripts inside the container cannot read or write arbitrary host paths. The container's `/jaiph/workspace` is either an in-container fuse-overlayfs union over a read-only bind of the host workspace (overlay mode, writes land in a tmpfs upper layer and are discarded on exit) or a host-side clone of the workspace mounted read-write (copy mode, the clone is removed on exit). Only the run-artifacts directory (`/jaiph/run`) persists writes back to the host workspace.
+- **Process isolation** -- Container processes cannot see or signal host processes. The container runs with `--cap-drop ALL` (overlay mode re-adds `SYS_ADMIN` for fuse-overlayfs; copy mode adds nothing) and `--security-opt no-new-privileges` to prevent privilege escalation. In Linux overlay mode the workflow runs as root inside the container so fuse-overlayfs can mount reliably; copy mode and macOS remain non-root as before.
+- **Credential leakage** -- Sensitive host environment variables (`SSH_*`, `GPG_*`, `AWS_*`, `GCP_*`, `AZURE_*`, `GOOGLE_*`, `DOCKER_*`, `KUBE*`, `NPM_TOKEN*`) are never forwarded into the container. Only `JAIPH_*` (except `JAIPH_DOCKER_*`) and agent prefixes (`ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`) cross the container boundary.
+- **Mount safety** -- The host root filesystem (`/`), Docker socket (`/var/run/docker.sock`, `/run/docker.sock`), and OS internals (`/proc`, `/sys`, `/dev`) cannot be mounted into the container. Attempting to do so produces `E_VALIDATE_MOUNT`.
+
+**What Docker does NOT protect against:**
+
+- **Hooks run on the host.** Hook commands in `hooks.json` execute on the host CLI process, not inside the container. A malicious hook definition has full host access. Treat `hooks.json` as trusted configuration.
+- **Network egress by default.** Unless `runtime.docker_network` is set to `"none"`, the container has outbound network access via Docker's default bridge. Scripts can reach external services and exfiltrate data through the network.
+- **Agent credential forwarding.** `ANTHROPIC_*`, `CLAUDE_*`, and `CURSOR_*` variables are forwarded into the container so agent-backed workflows function. A malicious script can read these from its environment. When the credential-proxy feature lands, these will be replaced by proxy URLs that do not expose raw API keys.
+- **Image supply chain.** Jaiph verifies that the selected image contains `jaiph` but does not verify image signatures or provenance. Use trusted registries and pin image digests for production workloads.
+- **Container escapes.** Docker is not a security boundary against a determined attacker with kernel exploits. It raises the bar significantly for script-level mischief but is not equivalent to a VM or hardware-level isolation.
+
## Docker container isolation
> **Beta.** Docker sandboxing is functional but still under active development. Expect rough edges, breaking changes, and incomplete platform coverage. Feedback is welcome at .
Docker applies to `jaiph run` only (not `jaiph test`). When enabled, the entire workflow -- every rule and script step -- runs inside a single container. The container runs `jaiph run --raw ` using its own installed jaiph -- not the host's. The `--raw` flag makes jaiph emit `__JAIPH_EVENT__` lines to stderr without rendering a progress tree, so the host CLI can render from those events.
-The host workspace is mounted **read-only** to prevent bind-mount deadlocks with concurrent runners on macOS Docker Desktop. A `fuse-overlayfs` copy-on-write overlay makes the workspace appear writable inside the container -- reads come from the host mount, writes go to a tmpfs upper layer and are discarded on exit. Run artifacts are written to a separate rw mount at `/jaiph/run` (outside the overlay), so they persist to the host. If `fuse-overlayfs` is unavailable, the workspace stays read-only (no regression).
+The container's `/jaiph/workspace` always *looks* writable to scripts but never mutates the host checkout. The CLI picks one of two sandbox primitives at launch time:
+
+- **Overlay mode** (selected when `/dev/fuse` exists on the host -- typically Linux). The host workspace is bind-mounted read-only at `/jaiph/workspace-ro`. The runtime entrypoint (`overlay-run.sh`) sets up `fuse-overlayfs` with that read-only bind as the lower layer and a tmpfs as the upper layer, merged at `/jaiph/workspace`. Writes go to the tmpfs and are discarded on container exit. Requires `--cap-add SYS_ADMIN` and `--device /dev/fuse`.
+- **Copy mode** (selected when `/dev/fuse` is missing -- typically macOS Docker Desktop, or when forced via `JAIPH_DOCKER_NO_OVERLAY=1`). Before launching the container, the CLI clones the host workspace (excluding `.jaiph/runs`) into a fresh `/.sandbox-/` directory, then bind-mounts that clone read-write at `/jaiph/workspace`. On macOS the clone uses `cp -cR` (APFS clonefile, near-zero cost); on other platforms it falls back to `cp -pR` and emits a one-line stderr warning. The clone is removed on exit unless `JAIPH_DOCKER_KEEP_SANDBOX=1` is set. No `SYS_ADMIN`, no `/dev/fuse`, no in-container overlay script.
+
+In both modes, run artifacts are written to a separate rw mount at `/jaiph/run` (outside the workspace sandbox) so they persist to the host.
### Enabling Docker
-Docker sandboxing is opt-in. Set `runtime.docker_enabled = true` in a module-level `config` block:
+Docker sandboxing is **on by default** for both local development and CI. When `JAIPH_UNSAFE=true` is not set, `runtime.docker_enabled` defaults to `true`. CI environments (`CI=true`) deliberately exercise the same sandbox path users do — landing-page e2e tests and docs sample tests would otherwise skip the sandbox in CI and miss real regressions. The only environment-driven escape hatch is `JAIPH_UNSAFE=true`.
+
+To disable Docker for a local run without setting an environment variable, set `runtime.docker_enabled = false` in a module-level `config` block:
```jh
config {
- runtime.docker_enabled = true
+ runtime.docker_enabled = false
}
```
`runtime.*` keys belong only in module-level config. Placing them in a workflow-level `config` block is a parse error.
-The environment variable `JAIPH_DOCKER_ENABLED` overrides the in-file setting when set: only the literal string `"true"` enables Docker; any other value disables it. When unset, the in-file value (default `false`) applies.
+The environment variable `JAIPH_DOCKER_ENABLED` overrides both the in-file setting and the unsafe default when set: only the literal string `"true"` enables Docker; any other value disables it. `JAIPH_UNSAFE=true` is the explicit "run on host / skip Docker default" escape hatch for local development when Docker is unwanted.
+
+**Default rule (when no explicit `JAIPH_DOCKER_ENABLED` or in-file `runtime.docker_enabled` is set):**
+
+| Environment | Default |
+|-------------|---------|
+| Plain local (no `JAIPH_UNSAFE`) | Docker **on** |
+| `CI=true` | Docker **on** (CI exercises the same sandbox path as users) |
+| `JAIPH_UNSAFE=true` | Docker **off** |
+
+Explicit overrides (`JAIPH_DOCKER_ENABLED` env or in-file `runtime.docker_enabled`) always take precedence over the default rule.
If Docker is enabled but `docker info` fails, the run exits with `E_DOCKER_NOT_FOUND` -- there is no silent fallback to local execution.
@@ -49,8 +85,8 @@ All Docker-related keys live under `runtime.*` in module-level config:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
-| `runtime.docker_enabled` | boolean | `false` | Enable Docker sandbox for the run. |
-| `runtime.docker_image` | string | `"node:20-bookworm"` | Base container image. If it lacks `jaiph`, Jaiph builds a thin derived image and installs the current package into it. |
+| `runtime.docker_enabled` | boolean | `true` by default (incl. CI); `false` only when `JAIPH_UNSAFE=true` | Enable Docker sandbox for the run. |
+| `runtime.docker_image` | string | `"ghcr.io/jaiphlang/jaiph-runtime:"` | Container image. Must already contain `jaiph`. Defaults to the official GHCR runtime image matching the installed jaiph version. |
| `runtime.docker_network` | string | `"default"` | Docker network mode. |
| `runtime.docker_timeout` | integer | `300` | Max execution time in seconds. `0` disables the timeout. |
| `runtime.workspace` | string array | `[".:/jaiph/workspace:rw"]` | Mount specifications (see below). |
@@ -59,71 +95,144 @@ Each key is type-checked at parse time. Unknown keys produce `E_PARSE`.
#### Environment variable overrides
-Following the `JAIPH_*` convention: `JAIPH_DOCKER_ENABLED`, `JAIPH_DOCKER_IMAGE`, `JAIPH_DOCKER_NETWORK`, `JAIPH_DOCKER_TIMEOUT`. Workspace mounts are not overridable via environment.
+Following the `JAIPH_*` convention: `JAIPH_DOCKER_ENABLED`, `JAIPH_DOCKER_IMAGE`, `JAIPH_DOCKER_NETWORK`, `JAIPH_DOCKER_TIMEOUT`. Additionally, `JAIPH_UNSAFE` affects the default for `runtime.docker_enabled` (see [Enabling Docker](#enabling-docker)). `CI=true` does **not** affect the default — CI runs use the same sandbox path users do. Workspace mounts are not overridable via environment.
-Precedence: environment variable > in-file config > default.
+Precedence: `JAIPH_DOCKER_ENABLED` env > in-file config > unsafe default rule.
If `JAIPH_DOCKER_TIMEOUT` is set but not a valid integer, the default (`300`) is used.
### Mount specifications
-Mount strings in `runtime.workspace` define which host paths are visible inside the container. All mounts are **forced to read-only** regardless of the specified mode to prevent bind-mount deadlocks on macOS Docker Desktop. The overlay wrapper makes the workspace writable via fuse-overlayfs.
+Mount strings in `runtime.workspace` define which host paths are visible inside the container. The mount targeting `/jaiph/workspace` selects the workspace source; additional sub-mounts pin parts of the tree to a particular mode (e.g. `"config:ro"` to make a subdir read-only inside the container).
| Form | Segments | Example | Result |
|------|----------|---------|--------|
-| Full | 3 | `".:/jaiph/workspace:rw"` | Mount `.` at `/jaiph/workspace` and `/jaiph/workspace-ro` (both read-only; overlay makes workspace writable) |
-| Shorthand | 2 | `"config:ro"` | Mount `config` at `/jaiph/workspace/config` and `/jaiph/workspace-ro/config` (read-only) |
+| Full | 3 | `".:/jaiph/workspace:rw"` | Workspace source. In overlay mode this becomes the read-only lower layer at `/jaiph/workspace-ro`; in copy mode the clone is mounted rw at `/jaiph/workspace`. |
+| Shorthand | 2 | `"config:ro"` | Mount `config` under `/jaiph/workspace/config`. In overlay mode the path is duplicated at `/jaiph/workspace-ro/config`; in copy mode the cloned subdirectory is bound at the requested mode. |
| Too few | 1 | `"data"` | `E_PARSE` |
| Too many | 4+ | `"a:b:c:d"` | `E_PARSE` |
Mode must be `ro` or `rw` (otherwise `E_PARSE`). Exactly one mount must target `/jaiph/workspace` -- zero or more than one produces `E_VALIDATE`. The default `[".:/jaiph/workspace:rw"]` satisfies this requirement.
-Host paths are resolved relative to the workspace root. Each mount is duplicated at the overlay lower-layer path (`/jaiph/workspace-ro/...`) so the overlay wrapper can use it as the read-only source.
+Host paths are resolved relative to the workspace root. In overlay mode each mount is duplicated at the overlay lower-layer path (`/jaiph/workspace-ro/...`) so the overlay wrapper can use it as the read-only source. In copy mode, sub-mounts under `/jaiph/workspace` are bound from the cloned workspace directory.
+
+The following host paths are rejected at mount validation time with `E_VALIDATE_MOUNT`:
+
+- `/` (host root filesystem)
+- `/var/run/docker.sock`, `/run/docker.sock` (Docker daemon socket)
+- `/proc`, `/sys`, `/dev` (OS internals, including subpaths like `/proc/1/root`)
### Container layout
+Overlay mode:
+
```
/jaiph/
workspace-ro/ # read-only bind mount of host workspace (overlay lower layer)
workspace/ # fuse-overlayfs merged view (reads from -ro, writes to tmpfs)
*.jh # source files
.jaiph/ # project config
- run/ # writable bind mount for this run's artifacts (host temp dir)
+ run/ # writable bind mount for this run's artifacts (host runs root)
overlay-run.sh # runtime-generated entrypoint mounted ro from host temp file
```
-The working directory is `/jaiph/workspace`. The host CLI generates `overlay-run.sh` (a ~10 line bash script) to a temp file and mounts it read-only at `/jaiph/overlay-run.sh`. The container runs `/jaiph/overlay-run.sh jaiph run --raw `. The overlay wrapper sets up fuse-overlayfs, then execs the jaiph command. When the selected image does not already contain `jaiph`, the host first builds a thin derived image from that base and installs the current Jaiph package into it, so the runtime path stays generic. No `COPY` in the project Dockerfile is needed -- `overlay-run.sh` is a jaiph runtime artifact.
+Copy mode:
+
+```
+/jaiph/
+ workspace/ # rw bind mount of /.sandbox-/ on the host
+ *.jh # cloned source files (writes are local to the clone)
+ .jaiph/ # cloned config (.jaiph/runs is excluded from the clone)
+ run/ # writable bind mount for this run's artifacts (host runs root)
+```
+
+The working directory is `/jaiph/workspace`. In overlay mode the host CLI generates `overlay-run.sh` (a short bash script) to a temp file and mounts it read-only at `/jaiph/overlay-run.sh`; the container runs `/jaiph/overlay-run.sh jaiph run --raw `. In copy mode the container runs `jaiph run --raw ` directly -- no entrypoint script. The image must already contain `jaiph` — Jaiph does not install itself into the container at runtime.
### Runtime behavior
-**Container lifecycle** -- `docker run --rm` launches the container and auto-removes it on exit. `--device /dev/fuse` exposes the FUSE device for the overlay. The pseudo-TTY flag (`-t`) is intentionally omitted: Docker's `-t` merges stderr into stdout, which would break the `__JAIPH_EVENT__` stderr-only live contract. On Linux, `--user :` maps the container user to the host user.
+**Container lifecycle** -- `docker run --rm` launches the container and auto-removes it on exit. `--cap-drop ALL` drops all Linux capabilities; overlay mode re-adds only `SYS_ADMIN` (fuse-overlayfs mount). Copy mode adds nothing. `--security-opt no-new-privileges` prevents any process inside the container from gaining additional privileges. The pseudo-TTY flag (`-t`) is intentionally omitted: Docker's `-t` merges stderr into stdout, which would break the `__JAIPH_EVENT__` stderr-only live contract.
+
+**UID/GID handling on Linux:**
+
+- **Copy mode** -- the container runs directly as `--user :` so writes to the cloned workspace and `/jaiph/run` land owned by the host user.
+- **Overlay mode** -- the container runs as `--user 0:0` and executes the workflow as root inside the container. This keeps the overlay path simple and robust on Linux runners where `fusermount3` enforces strict mountpoint checks.
+
+On macOS Docker Desktop the VM transparently translates UIDs across the bind-mount boundary, so no `--user` override is applied.
**stdin** -- The `docker run` process is spawned with stdin set to `ignore` to prevent the Docker CLI from blocking on stdin EOF.
**Events** -- The container's jaiph runs in `--raw` mode: it spawns the runtime with inherited stdio, so `__JAIPH_EVENT__` JSON flows directly to the container's stderr. The host CLI reads Docker's stderr pipe and renders the progress tree. stdout carries plain script output. `STEP_END` events embed `out_content` (and `err_content` on failure) so consumers do not need host paths to step artifact files.
-**Overlay** -- The `overlay-run.sh` wrapper (generated by the host CLI and mounted read-only) sets up `fuse-overlayfs` with the ro bind mount (`/jaiph/workspace-ro`) as the lower layer and a tmpfs as the upper layer, merged at `/jaiph/workspace`. All workspace writes go to the tmpfs and are discarded on container exit. If fuse-overlayfs is unavailable (e.g. the image doesn't include it), the overlay step is skipped and the workspace remains read-only.
+**Sandbox primitive (overlay vs. copy)** -- Selected at launch time. If `/dev/fuse` exists on the host, the CLI uses **overlay mode**: the `overlay-run.sh` wrapper (generated by the host CLI and mounted read-only) sets up `fuse-overlayfs` with the ro bind mount (`/jaiph/workspace-ro`) as the lower layer and a tmpfs as the upper layer, merged at `/jaiph/workspace`. All workspace writes go to the tmpfs and are discarded on container exit. On Linux hosts, the overlay container is also launched with `--security-opt apparmor=unconfined` because the default Docker AppArmor profile (active on Ubuntu 22.04+, GitHub Actions runners, and similar) denies fuse mounts even when `SYS_ADMIN` and `/dev/fuse` are present. If `fuse-overlayfs` is missing from the image or the mount still fails at runtime, the entrypoint exits with `E_DOCKER_OVERLAY` -- there is no in-container fallback. Set `JAIPH_DOCKER_NO_OVERLAY=1` on the host to opt into copy mode instead. Custom images used in overlay mode must ensure `/jaiph/workspace` is mountable by root (the official image keeps this path root-owned).
+
+If `/dev/fuse` is missing on the host, the CLI uses **copy mode**: before launching the container it clones the workspace into `/.sandbox-/` (excluding `.jaiph/runs`) using `cp -cR` on macOS (APFS clonefile, O(1) per file) or `cp -pR` elsewhere (a real copy; a single stderr warning is printed when the fast path is unavailable). The clone is bind-mounted rw at `/jaiph/workspace`. After the container exits the clone is removed unless `JAIPH_DOCKER_KEEP_SANDBOX=1` is set, in which case the path is left in place and printed to stderr for debugging.
**Run artifacts** -- The host CLI mounts the resolved host runs root at `/jaiph/run:rw` inside the container. By default this is `.jaiph/runs` under the workspace; a relative `JAIPH_RUNS_DIR` is resolved under the workspace; an absolute `JAIPH_RUNS_DIR` must stay within the workspace or the run fails with `E_DOCKER_RUNS_DIR`. `JAIPH_RUNS_DIR` is set to `/jaiph/run` inside the container, so the runtime writes artifacts directly into the requested host path.
-**Network** -- `"default"` omits `--network` (Docker's default bridge). `"none"` passes `--network none`. Any other value is passed through as-is.
+**Workspace immutability contract** -- Docker runs cannot directly modify the host workspace. In overlay mode the host checkout is bind-mounted read-only and writes land in a tmpfs upper layer that is discarded on container exit. In copy mode the container writes to a separate host-side clone of the workspace (`/.sandbox-/`), which is removed on container exit unless explicitly kept for debugging. In both modes the only persistence channel from a Docker run to the host is the run-artifacts directory (`/jaiph/run` → host `.jaiph/runs`). Non-Docker (local) runs are unaffected by this contract.
+
+**Workspace patch export** -- To capture workspace changes as a patch, call `artifacts.save_patch(name)` from `jaiphlang/artifacts`; it writes a named `git diff` into the `artifacts/` subdirectory under the run tree. Callers choose when and what to record; output lives alongside other artifacts. See [Libraries — `jaiphlang/artifacts`](libraries.md#jaiphlangartifacts--publishing-files-out-of-the-sandbox). The published GHCR runtime image includes `git`.
+
+**Network** -- `"default"` omits `--network`, which uses Docker's default bridge network (outbound access allowed). `"none"` passes `--network none` and fully disables networking -- use this for workflows that should not make external calls. Any other value (e.g. a custom Docker network name) is passed through as-is. Set `runtime.docker_network` in config or `JAIPH_DOCKER_NETWORK` in the environment.
**Timeout** -- When `runtime.docker_timeout` is greater than zero, the CLI sends `SIGTERM` to the container process on overrun, followed by `SIGKILL` after a 5-second grace period. The failure message includes `E_TIMEOUT container execution exceeded timeout`.
**Image pull** -- If the image is not present locally, `docker pull` runs automatically. Pull failure produces `E_DOCKER_PULL`.
-### Dockerfile-based image detection
+### Failure modes
+
+Docker-related errors use `E_DOCKER_*` codes for programmatic detection:
+
+| Error code | Trigger | Behavior |
+|------------|---------|----------|
+| `E_DOCKER_NOT_FOUND` | `docker info` fails (Docker not installed or daemon not running) | Run exits immediately. No fallback to local execution. |
+| `E_DOCKER_PULL` | `docker pull` fails (network error, image not found, auth failure) | Run exits. Check registry access and image name. |
+| `E_DOCKER_NO_JAIPH` | Selected image does not contain a `jaiph` CLI | Run exits with guidance to use the official image or install jaiph. |
+| `E_DOCKER_RUNS_DIR` | Absolute `JAIPH_RUNS_DIR` points outside the workspace | Run exits. Use a relative path or an absolute path within the workspace. |
+| `E_DOCKER_OVERLAY` | Overlay mode selected but `fuse-overlayfs` is missing from the image or the mount fails inside the container | Container exits with code 78. Use the official runtime image, install `fuse-overlayfs` in your custom image, or set `JAIPH_DOCKER_NO_OVERLAY=1` on the host to switch to copy mode. The CLI already passes `--security-opt apparmor=unconfined` on Linux to defeat the default AppArmor fuse-deny; remaining failures usually mean the host kernel itself blocks fuse mounts (rootless docker without the right user-namespace setup, locked-down kernel, etc.). |
+| `E_DOCKER_SANDBOX_COPY` | Copy mode failed to clone the host workspace (`cp` returned non-zero) | Run exits before container launch. Inspect the path printed in the error. |
+| `E_VALIDATE_MOUNT` | Mount targets a denied host path (`/`, `/proc`, docker socket, etc.) | Run exits before container launch. |
+| `E_TIMEOUT` | Container exceeds `runtime.docker_timeout` seconds | Container receives SIGTERM, then SIGKILL after 5s grace period. |
+
+All failures are deterministic and produce non-zero exit codes. There is no silent fallback from Docker to local execution.
+
+### Image contract
+
+**Every Docker image used by Jaiph must already contain a working `jaiph` CLI.** Jaiph does not auto-install itself into containers at runtime — no derived image builds, no `npm pack` bootstrap. If the selected image lacks `jaiph`, the run fails immediately with `E_DOCKER_NO_JAIPH` and guidance to use the official image or install jaiph in a custom image.
+
+### Official runtime image
-The runtime considers the image explicitly configured when either `runtime.docker_image` appears in the file or `JAIPH_DOCKER_IMAGE` is set in the environment. In that case, `.jaiph/Dockerfile` is not consulted.
+Jaiph publishes official runtime images to GHCR:
-When the image is not explicit:
+| Tag | Built from | Use case |
+|-----|-----------|----------|
+| `ghcr.io/jaiphlang/jaiph-runtime:` | Release tags (`v*`) | Production / pinned versions |
+| `ghcr.io/jaiphlang/jaiph-runtime:nightly` | `nightly` branch | Contributors and CI |
+| `ghcr.io/jaiphlang/jaiph-runtime:latest` | Latest release tag | Convenience alias |
-1. If `.jaiph/Dockerfile` exists in the workspace root, the runtime builds it, tags the result `jaiph-runtime:latest`, and uses that image. Build failure produces `E_DOCKER_BUILD`.
-2. Otherwise, the default image (`node:20-bookworm`) is pulled if needed.
+The default `runtime.docker_image` is `ghcr.io/jaiphlang/jaiph-runtime:` where `` matches the installed jaiph package version. Published tags (`:`, `:nightly`, `:latest`) are built from the `runtime/Dockerfile` in the jaiph repository (see the `docker-publish` job in `.github/workflows/ci.yml`). The image includes Node.js, jaiph, `fuse-overlayfs`, agent CLIs where that Dockerfile installs them, and a non-root `jaiph` user (UID 10001).
-If the selected base image does not already contain `jaiph`, Jaiph builds a thin derived runtime image from it and installs the current local package with `npm install -g`, then runs the workflow in that derived image.
+### Custom images and `jaiph run`
-The repository's example `.jaiph/Dockerfile` includes `ubuntu:latest` as a base, Node.js LTS from NodeSource, `fuse-overlayfs`, Claude Code CLI, cursor-agent, and jaiph (installed via the official installer). The image creates a non-root `jaiph` user (UID 10001) and sets `USER jaiph`. Including `fuse-overlayfs` and `jaiph` in the image is still the best path for full sandbox parity and faster startup, but Jaiph can also auto-build a thin derived runtime image when the base image lacks `jaiph`. The Dockerfile does not need to copy any jaiph runtime files -- `overlay-run.sh` is generated by the host CLI and mounted into the container at runtime.
+`jaiph run` **always** uses the configured image (`runtime.docker_image`, `JAIPH_DOCKER_IMAGE`, or the default GHCR tag above). It does not run `docker build` for you. Build and publish (or `docker build` + `docker tag`) your own image, then set `runtime.docker_image` / `JAIPH_DOCKER_IMAGE`.
+
+After the image is pulled or found locally, Jaiph verifies that `jaiph` is available inside the container. If the check fails, the run exits with `E_DOCKER_NO_JAIPH`.
+
+`overlay-run.sh` is generated by the host CLI and mounted into the container at runtime.
+
+### Extending the official image
+
+To add project-specific tools or agent CLIs, extend the published image in your own Dockerfile (build locally or in CI), then point `runtime.docker_image` at the result:
+
+```dockerfile
+FROM ghcr.io/jaiphlang/jaiph-runtime:nightly
+
+USER root
+RUN npm install -g @anthropic-ai/claude-code
+USER jaiph
+
+# Add project-specific package managers/build tools below.
+```
### Environment variable forwarding
@@ -133,6 +242,16 @@ All `JAIPH_*` variables from the host are forwarded into the container, **except
- `ANTHROPIC_*`
- `CLAUDE_*`
+The following prefixes are **never** forwarded, even if present on the host:
+
+- `SSH_*`, `GPG_*` -- authentication agent sockets and signing keys
+- `AWS_*`, `GCP_*`, `AZURE_*`, `GOOGLE_*` -- cloud provider credentials
+- `DOCKER_*` -- Docker daemon configuration (prevents container-in-container)
+- `KUBE*` -- Kubernetes configuration
+- `NPM_TOKEN*` -- package registry credentials
+
+This denylist is enforced in `buildDockerArgs` and cannot be overridden. If a workflow needs cloud credentials inside the container, pass them explicitly through `JAIPH_*`-prefixed variables or use a credential proxy.
+
### Example
A workflow with Docker sandboxing enabled and an extra read-only mount for a `config` directory (using the shorthand form):
diff --git a/docs/setup.md b/docs/setup.md
index d0c3221b..501c8fc0 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -91,8 +91,7 @@ jaiph init
This creates a `.jaiph/` directory in your project root with:
- `.jaiph/.gitignore` — ignores ephemeral `runs/` and `tmp/` under `.jaiph/` (workflows and libraries stay tracked)
-- `.jaiph/bootstrap.jh` — an interactive workflow that asks an agent to scaffold recommended workflows for your project. The generated template uses a triple-quoted multiline prompt (`prompt """ ... """`), explicitly asks the agent to review/update `.jaiph/Dockerfile` for this repository's sandbox needs, and logs a final summary of what changed and why
-- `.jaiph/Dockerfile` — generated project sandbox image template (`ubuntu:latest`, common utilities, Node.js LTS, Claude Code CLI, cursor-agent). It installs Jaiph with the default installer path: `curl -fsSL https://jaiph.org/install | bash`
+- `.jaiph/bootstrap.jh` — an interactive workflow that asks an agent to scaffold recommended workflows for your project. The generated template uses a triple-quoted multiline prompt (`prompt """ ... """`) and logs a final summary of what changed and why. Docker-backed runs use the official `ghcr.io/jaiphlang/jaiph-runtime` image by default (see [Sandboxing](sandboxing.md)).
- `.jaiph/SKILL.md` — the agent skill file for AI assistants authoring `.jh` workflows (from your Jaiph installation, or `JAIPH_SKILL_PATH`)
Run the bootstrap workflow to get started:
diff --git a/docs/spec-async-handles.md b/docs/spec-async-handles.md
new file mode 100644
index 00000000..b5571b86
--- /dev/null
+++ b/docs/spec-async-handles.md
@@ -0,0 +1,113 @@
+---
+title: "Spec: Async Handles"
+---
+
+# Async Handles — `Handle` Value Model
+
+This document specifies the `Handle` value model for `run async` in Jaiph.
+
+## Overview
+
+`run async ref(args)` returns a **`Handle`** immediately. `T` is the same type the called function would return under a synchronous `run`. The handle resolves to the eventual return value on first non-passthrough read.
+
+## Handle creation
+
+```jaiph
+const h = run async foo()
+run async bar()
+```
+
+- `const h = run async foo()` — `h` receives a handle. The async execution starts immediately; the workflow continues without waiting.
+- `run async bar()` — a handle is created internally but not captured. The workflow proceeds without waiting.
+
+There is **no fire-and-forget mode**. Every `run async` creates a handle tracked by the runtime, whether captured or not.
+
+## Resolution semantics
+
+A handle resolves to the value of the called function (its `return` value or trimmed stdout). Resolution is triggered by the **first non-passthrough read**.
+
+### Reads that force resolution
+
+| Access pattern | Example | Forces resolution? |
+|---|---|---|
+| String interpolation | `log "${h}"` | Yes |
+| Passing as argument to `run` | `run other(h)` | Yes |
+| Comparison / conditional | `if h == "ok" { ... }` | Yes |
+| Match subject | `match h { ... }` | Yes |
+| Any other value access | `channel <- $h` | Yes |
+
+### Passthrough (does NOT force resolution)
+
+| Access pattern | Example | Forces resolution? |
+|---|---|---|
+| Initial capture | `const h = run async foo()` | No |
+| Re-assignment | (internal scope passing) | No |
+
+Once resolved, the handle is replaced in-place by the resolved string value. Subsequent reads return the cached value without re-executing.
+
+## Workflow exit — implicit join
+
+When a workflow scope exits (the last step completes), the runtime **implicitly joins all remaining unresolved handles** created in that scope. This is not an error condition.
+
+- If all handles resolve successfully, the workflow returns normally.
+- If any handle resolved (or resolves during join) with a non-zero status, the workflow fails with an aggregated error message listing all failed async refs.
+
+This preserves backward compatibility with the pre-handle `run async` behavior where all async steps were awaited at workflow exit.
+
+## `recover` composition
+
+`recover` works with `run async` to provide retry-loop semantics on the async branch:
+
+```jaiph
+const b1 = run async foo() recover(err) {
+ log "repairing: ${err}"
+ run fix_it()
+}
+```
+
+### Semantics
+
+1. The async branch executes `foo()`.
+2. If `foo()` succeeds, the handle resolves to its return value.
+3. If `foo()` fails, the recover body runs with `err` bound to the merged stdout+stderr of the failure.
+4. If the recover body completes successfully (status 0, no early return), `foo()` is retried.
+5. Steps 3–4 repeat until `foo()` succeeds or the retry limit is reached.
+6. If the retry limit is exhausted, the handle resolves to the final failure result.
+
+### Retry limit
+
+The retry limit is shared with non-async `recover`:
+
+- Default: **10** attempts.
+- Configurable per module/workflow via `config { run.recover_limit = N }`.
+
+### `catch` composition
+
+`catch` also works with `run async` for single-shot recovery (no retry loop):
+
+```jaiph
+run async foo() catch(err) {
+ log "caught: ${err}"
+}
+```
+
+If `foo()` fails, the catch body runs once. No retry.
+
+## Interaction with progress/events
+
+Async handles preserve the existing async progress/event visibility model:
+
+- Each async branch gets a unique branch index (subscript numbering: ₁, ₂, …).
+- Step events (`STEP_START`, `STEP_END`) and log events carry `async_indices` for the branch.
+- The CLI progress tree renders async branches at the appropriate indent level.
+
+Handle resolution does not emit additional events beyond what the async branch already emits.
+
+The live TTY rendering path for async branches is covered by a dedicated PTY-based E2E test (`e2e/tests/131_tty_async_progress.sh`). It spawns `jaiph run` under a real pseudo-terminal with two concurrent async branches and asserts that per-branch events render under the correct subscript node, that resolved return values appear in the final frame, and that no ANSI escape corruption occurs. See [Testing — PTY-based TTY tests](testing.md#pty-based-tty-tests) for details.
+
+## Constraints
+
+- `run async` is only allowed in workflows, not in rules.
+- `run async` is not supported with inline scripts (`` run async `body`(args) ``).
+- There is no explicit `await` keyword. Resolution is implicit on first read or at workflow exit.
+- There is no fire-and-forget. All handles are joined.
diff --git a/docs/testing.md b/docs/testing.md
index 474aa9a9..a85ec98b 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -123,7 +123,7 @@ mock script w.helper() {
}
```
-The former `mock function` syntax is no longer accepted — the parser emits an error with migration guidance.
+Test stubs use `mock script`, not `mock function`; the latter is a parse error with a fix hint.
### Workflow run (with capture)
@@ -360,6 +360,19 @@ For concurrency-sensitive behavior (for example parallel inbox dispatch), the re
See `e2e/tests/91_inbox_dispatch.sh`, `e2e/tests/93_inbox_stress.sh`, and `e2e/tests/94_parallel_shell_steps.sh` for examples.
+## PTY-based TTY tests
+
+Some CLI behavior only activates when stdout is a real TTY — the live progress tree with ANSI redraws, for example. These tests use Python's `pty.openpty()` to spawn `jaiph run` under a pseudo-terminal, capture the raw byte stream, and assert on the rendered output.
+
+Two PTY tests exist today:
+
+| Test file | What it covers |
+|-----------|----------------|
+| `e2e/tests/81_tty_progress_tree.sh` | Synchronous workflow progress rendering — verifies the tree structure, step timing, and PASS/FAIL markers under a real TTY. |
+| `e2e/tests/131_tty_async_progress.sh` | Async workflow progress rendering — verifies that `run async` branches (with `Handle` deferred resolution) render per-branch progress events under subscript-numbered nodes (₁, ₂), that both branches show resolved return values in the final frame, and that no orphaned ANSI escape sequences appear. |
+
+Both tests require Python 3 and use only deterministic, non-LLM steps (sleep loops, `log`, scripts) so results are reproducible. Assertions use `assert_contains` with order-insensitive matching because async interleaving and PTY redraws make exact full-output comparison infeasible.
+
## E2E testing
Shell harnesses and CI expectations for the full repo are described in [Contributing — E2E testing](contributing.md#e2e-testing).
diff --git a/e2e/lib/common.sh b/e2e/lib/common.sh
index 14febd71..5bf7f581 100644
--- a/e2e/lib/common.sh
+++ b/e2e/lib/common.sh
@@ -74,8 +74,15 @@ e2e::assert_equals() {
e2e::normalize_output() {
local input="$1"
# Strip ANSI and normalize timing values for stable assertions.
+ # Final perl step canonicalizes the order of contiguous "async-progress"
+ # lines (lines starting with one or more spaces followed by a subscript
+ # marker ₁..₉, UTF-8 bytes E2 82 81..89). Async branches that run in
+ # parallel complete in non-deterministic order; sorting both actual and
+ # expected with the same stable order makes strict equality usable while
+ # still asserting that the same set of progress lines was emitted.
printf "%s" "${input}" \
| sed -E $'s/\x1B\\[[0-9;]*[A-Za-z]//g' \
+ | sed -E 's/^(Jaiph: Running [^ ]+) \(.+\)$/\1/' \
| sed -E 's/\(([0-9]+(\.[0-9]+)?s|[0-9]+m [0-9]+s)\)/(