From 775ab93d133640a35be20f8492eef89fa58df98f Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:00:00 +0200
Subject: [PATCH 01/85] Fix: cross-platform process-tree termination via
killProcessTree
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
jaiph run spawns the workflow leader detached and previously stopped it
with process.kill(-pid, signal) — a POSIX-only negative-PID group kill.
On win32 that throws, the child.kill() fallback terminated only the
leader, and the agent backends / script children it spawned were
orphaned.
Add src/runtime/kernel/portability.ts exporting killProcessTree(pid,
signal) as the single sanctioned home for group kills. On POSIX it
preserves prior behavior (negative-PID group kill with a per-process
ESRCH fallback); on win32 it force-kills the whole tree with
`taskkill /pid /T /F` (spawned, not shelled) via an injectable
seam, degrading to a per-process kill if taskkill cannot launch.
Because taskkill /F is already forceful, the SIGTERM->SIGKILL
escalation is a documented no-op on win32.
Repoint every group-kill call site at the helper: run teardown
(lifecycle.ts), the prompt watchdog (prompt.ts), and the Docker
run-timeout kill (docker.ts). New unit tests stub process.platform to
cover both branches (negative-PID kill on POSIX plus ESRCH fallback;
taskkill /T argv on win32) and prove win32 never calls process.kill
with a negative PID; a src/-wide lint test asserts no production file
outside portability.ts matches `process.kill(-`. Docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +
docs/architecture.md | 2 +-
docs/configuration.md | 2 +-
src/cli/run/lifecycle.ts | 11 +-
src/runtime/docker.ts | 18 +--
src/runtime/kernel/portability.test.ts | 204 +++++++++++++++++++++++++
src/runtime/kernel/portability.ts | 78 ++++++++++
src/runtime/kernel/prompt.test.ts | 26 +++-
src/runtime/kernel/prompt.ts | 18 +--
9 files changed, 332 insertions(+), 31 deletions(-)
create mode 100644 src/runtime/kernel/portability.test.ts
create mode 100644 src/runtime/kernel/portability.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c09f9e5..1de9058f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Unreleased
+## All changes
+
+- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
+
# 0.10.0
## Summary
diff --git a/docs/architecture.md b/docs/architecture.md
index 6dc87d33..d672076b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -123,7 +123,7 @@ User-visible contracts (banner, hooks, run artifacts, `run_summary.jsonl`, `retu
### CLI responsibilities
- Parse, validate, and launch workflows/tests.
-- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation).
+- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation). Terminating a run means terminating the whole tree — the detached leader plus the agent backends and script children it spawned — routed through **`killProcessTree(pid, signal)`** (`src/runtime/kernel/portability.ts`), the single sanctioned home for group kills. On POSIX it signals the leader's process group with **`process.kill(-pid, signal)`**, falling back to a per-process kill if the group no longer exists (`ESRCH`). On **`win32`** a negative-PID group kill throws and a per-process kill would orphan the children, so it force-kills the tree with **`taskkill /pid /T /F`** (spawned, not shelled), degrading to a per-process kill if `taskkill` cannot be launched. Because `taskkill /F` is already forceful, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32`. All group-kill call sites route through this helper: run teardown (`src/cli/run/lifecycle.ts`), the prompt watchdog (`src/runtime/kernel/prompt.ts`), and the Docker run-timeout kill (`src/runtime/docker.ts`).
- Parse live runtime events; render terminal progress; trigger hooks — skipped in **`jaiph run --raw`** (child stdio inherited; see [CLI](cli.md#jaiph-run)).
## Contracts
diff --git a/docs/configuration.md b/docs/configuration.md
index d1ba8de1..83bdb949 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -236,7 +236,7 @@ The retry backoff above handles a backend that *fails*. A separate set of watchd
Set any variable to `0` to disable that layer. The idle timer resets on every chunk of backend output, so a slow-but-active run is bounded only by the absolute cap.
-The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it sends `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop for the whole container.
+The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it terminates the backend's whole process tree (via `killProcessTree`; see [Architecture](architecture.md)) with `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. On Windows the tree is force-killed with `taskkill /T` on the first signal, so the `SIGKILL` escalation is a no-op. Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop for the whole container.
## Custom agent commands
diff --git a/src/cli/run/lifecycle.ts b/src/cli/run/lifecycle.ts
index 55f93206..392e90c1 100644
--- a/src/cli/run/lifecycle.ts
+++ b/src/cli/run/lifecycle.ts
@@ -1,6 +1,7 @@
import { ChildProcess } from "node:child_process";
import { spawnJaiphWorkflowProcess } from "../../runtime/kernel/workflow-launch";
+import { killProcessTree } from "../../runtime/kernel/portability";
export function spawnRunProcess(
args: string[],
@@ -17,15 +18,7 @@ export function terminateRunProcessGroup(
if (!pid) {
return;
}
- try {
- process.kill(-pid, signal);
- } catch {
- try {
- child.kill(signal);
- } catch {
- // no-op
- }
- }
+ killProcessTree(pid, signal);
}
export function setupRunSignalHandlers(
diff --git a/src/runtime/docker.ts b/src/runtime/docker.ts
index de91f7c8..b281f050 100644
--- a/src/runtime/docker.ts
+++ b/src/runtime/docker.ts
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join, resolve, dirname, relative } from "node:path";
import type { RuntimeConfig } from "../types";
import { OVERLAY_RUN_SH_BASE64, decodeEmbeddedAsset } from "./embedded-assets";
+import { killProcessTree } from "./kernel/portability";
/** Resolved Docker runtime config with defaults applied and env overrides merged. */
export interface DockerRunConfig {
@@ -812,17 +813,16 @@ export function spawnDockerProcess(opts: DockerSpawnOptions): DockerSpawnResult
let timeoutTimer: NodeJS.Timeout | undefined;
if (opts.config.timeoutSeconds > 0) {
timeoutTimer = setTimeout(() => {
- try {
- child.kill("SIGTERM");
- } catch {
- // no-op
+ const pid = child.pid;
+ if (!pid) {
+ return;
}
+ // Terminate the `docker run` child and its descendants. On win32 the
+ // taskkill /T force-kills the tree, so the SIGKILL escalation below is a
+ // documented no-op there (see killProcessTree).
+ killProcessTree(pid, "SIGTERM");
setTimeout(() => {
- try {
- child.kill("SIGKILL");
- } catch {
- // no-op
- }
+ killProcessTree(pid, "SIGKILL");
}, 5000);
}, opts.config.timeoutSeconds * 1000);
}
diff --git a/src/runtime/kernel/portability.test.ts b/src/runtime/kernel/portability.test.ts
new file mode 100644
index 00000000..6ee257c6
--- /dev/null
+++ b/src/runtime/kernel/portability.test.ts
@@ -0,0 +1,204 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import { readFileSync, readdirSync } from "node:fs";
+import { resolve, join } from "node:path";
+import { killProcessTree, _portability } from "./portability";
+
+// Precedent for platform stubbing: src/runtime/docker.test.ts.
+function withPlatform(platform: string, fn: () => void): void {
+ const orig = Object.getOwnPropertyDescriptor(process, "platform");
+ Object.defineProperty(process, "platform", { value: platform, configurable: true });
+ try {
+ fn();
+ } finally {
+ if (orig) Object.defineProperty(process, "platform", orig);
+ }
+}
+
+/** Capture every `process.kill(pid, signal)` call while `fn` runs. */
+function withKillSpy(
+ fn: (calls: Array<{ pid: number; signal: NodeJS.Signals | undefined }>) => void,
+ behavior?: (pid: number, signal: NodeJS.Signals | undefined) => void,
+): void {
+ const calls: Array<{ pid: number; signal: NodeJS.Signals | undefined }> = [];
+ const orig = process.kill;
+ (process as { kill: typeof process.kill }).kill = ((pid: number, signal?: NodeJS.Signals) => {
+ calls.push({ pid, signal });
+ if (behavior) behavior(pid, signal);
+ return true;
+ }) as typeof process.kill;
+ try {
+ fn(calls);
+ } finally {
+ (process as { kill: typeof process.kill }).kill = orig;
+ }
+}
+
+/** Capture the `_portability.spawn` invocation and return a fake child. */
+function withSpawnSpy(
+ fn: (calls: Array<{ command: string; args: string[] }>, fakeChild: EventEmitter) => void,
+): void {
+ const calls: Array<{ command: string; args: string[] }> = [];
+ const fakeChild = new EventEmitter() as EventEmitter & { unref?: () => void };
+ fakeChild.unref = () => {};
+ const orig = _portability.spawn;
+ _portability.spawn = ((command: string, args: string[]) => {
+ calls.push({ command, args });
+ return fakeChild as unknown as ReturnType;
+ }) as typeof _portability.spawn;
+ try {
+ fn(calls, fakeChild);
+ } finally {
+ _portability.spawn = orig;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// POSIX branch
+// ---------------------------------------------------------------------------
+
+test("POSIX: killProcessTree sends signal to the negative PID (process group)", () => {
+ withPlatform("linux", () => {
+ withKillSpy((calls) => {
+ killProcessTree(1234, "SIGTERM");
+ assert.equal(calls.length, 1, "exactly one process.kill call on the happy path");
+ assert.deepEqual(calls[0], { pid: -1234, signal: "SIGTERM" });
+ });
+ });
+});
+
+test("POSIX: falls back to per-process kill when the group kill throws (ESRCH)", () => {
+ withPlatform("linux", () => {
+ withKillSpy(
+ (calls) => {
+ killProcessTree(1234, "SIGINT");
+ assert.deepEqual(calls, [
+ { pid: -1234, signal: "SIGINT" }, // group kill attempted first
+ { pid: 1234, signal: "SIGINT" }, // then per-process fallback
+ ]);
+ },
+ (pid) => {
+ if (pid < 0) throw new Error("ESRCH");
+ },
+ );
+ });
+});
+
+test("POSIX: SIGKILL escalation also uses the negative PID group kill", () => {
+ withPlatform("linux", () => {
+ withKillSpy((calls) => {
+ killProcessTree(999, "SIGKILL");
+ assert.deepEqual(calls[0], { pid: -999, signal: "SIGKILL" });
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// win32 branch
+// ---------------------------------------------------------------------------
+
+test("win32: killProcessTree spawns `taskkill /pid /T /F`", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((spawnCalls) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(4321, "SIGTERM");
+ assert.equal(spawnCalls.length, 1, "taskkill spawned once");
+ assert.equal(spawnCalls[0].command, "taskkill");
+ assert.deepEqual(spawnCalls[0].args, ["/pid", "4321", "/T", "/F"]);
+ assert.equal(killCalls.length, 0, "no process.kill on the happy win32 path");
+ });
+ });
+ });
+});
+
+test("win32: SIGINT also spawns taskkill /T (Ctrl-C path)", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((spawnCalls) => {
+ killProcessTree(7, "SIGINT");
+ assert.deepEqual(spawnCalls[0].args, ["/pid", "7", "/T", "/F"]);
+ });
+ });
+});
+
+test("win32: SIGKILL escalation is a documented no-op (tree already force-killed)", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((spawnCalls) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(4321, "SIGKILL");
+ assert.equal(spawnCalls.length, 0, "no second taskkill on SIGKILL escalation");
+ assert.equal(killCalls.length, 0, "no process.kill on SIGKILL escalation");
+ });
+ });
+ });
+});
+
+test("win32: degrades to per-process kill when taskkill cannot be spawned", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((_spawnCalls, fakeChild) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(555, "SIGTERM");
+ // spawn returns a child that reports failure asynchronously via "error".
+ fakeChild.emit("error", new Error("spawn taskkill ENOENT"));
+ assert.deepEqual(killCalls, [{ pid: 555, signal: "SIGTERM" }]);
+ });
+ });
+ });
+});
+
+test("win32: NEVER calls process.kill with a negative PID (SIGTERM, SIGINT, SIGKILL)", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((_spawnCalls, fakeChild) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(1234, "SIGTERM");
+ fakeChild.emit("error", new Error("no taskkill")); // force the fallback path too
+ killProcessTree(1234, "SIGINT");
+ fakeChild.emit("error", new Error("no taskkill"));
+ killProcessTree(1234, "SIGKILL");
+ for (const call of killCalls) {
+ assert.ok(call.pid > 0, `win32 must never signal a negative PID (saw ${call.pid})`);
+ }
+ });
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Lint contract: only the portability module may group-kill via negative PID.
+// ---------------------------------------------------------------------------
+
+// Tests run from dist/src/runtime/kernel/, so repo root is five levels up.
+const REPO_ROOT = resolve(__dirname, "../../../..");
+const SRC_ROOT = join(REPO_ROOT, "src");
+
+/** Non-test production source files (excludes *.test.ts / *.acceptance.test.ts). */
+function walkProductionTsFiles(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ out.push(...walkProductionTsFiles(full));
+ } else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+test("no production source file outside portability.ts invokes process.kill with a negative PID", () => {
+ // Match `process.kill(-` allowing whitespace, e.g. `process.kill( -pid`.
+ const negativeGroupKill = /process\.kill\(\s*-/;
+ const offenders: string[] = [];
+ for (const file of walkProductionTsFiles(SRC_ROOT)) {
+ const rel = file.slice(REPO_ROOT.length + 1);
+ // The helper is the one sanctioned home for the negative-PID group kill.
+ if (rel === join("src", "runtime", "kernel", "portability.ts")) continue;
+ const content = readFileSync(file, "utf8");
+ if (negativeGroupKill.test(content)) offenders.push(rel);
+ }
+ assert.deepEqual(
+ offenders,
+ [],
+ `negative-PID group kill must be confined to portability.ts; offenders: ${offenders.join(", ")}`,
+ );
+});
diff --git a/src/runtime/kernel/portability.ts b/src/runtime/kernel/portability.ts
new file mode 100644
index 00000000..a012ef90
--- /dev/null
+++ b/src/runtime/kernel/portability.ts
@@ -0,0 +1,78 @@
+// Cross-platform process-tree termination.
+//
+// `jaiph run` launches the workflow leader detached (`workflow-launch.ts`), and
+// the leader in turn spawns agent backends (`prompt.ts`) and script children,
+// or a `docker run` child (`docker.ts`). Terminating a run cleanly means
+// terminating that whole tree, not just the leader — otherwise backends and
+// script children are orphaned.
+//
+// POSIX has a single primitive for this: a detached child is its own process
+// group leader (pgid == pid), so `process.kill(-pid, signal)` delivers `signal`
+// to every process in the group. Windows has no such primitive — a negative pid
+// throws, and `child.kill()` / `process.kill(pid)` terminate only the leader.
+// `killProcessTree` hides that difference behind one call site.
+
+import { spawn, type ChildProcess } from "node:child_process";
+
+/**
+ * Test seam for the `taskkill` spawn. Swapped out in unit tests so the win32
+ * branch can be exercised on any host without a real `taskkill` binary.
+ */
+export const _portability = {
+ spawn(command: string, args: string[]): ChildProcess {
+ return spawn(command, args, { stdio: "ignore" });
+ },
+};
+
+/**
+ * Terminate `pid` and its descendants with `signal`, portably.
+ *
+ * POSIX: `process.kill(-pid, signal)` signals the whole process group. If the
+ * target is not a group leader the group does not exist (ESRCH) and we fall
+ * back to signaling the single process, matching the pre-portability behavior.
+ *
+ * win32: negative-pid group kill is unsupported (it throws) and per-process
+ * kill orphans children, so we spawn `taskkill /pid /T /F`, which force-
+ * terminates the entire tree. If `taskkill` cannot be launched we degrade to a
+ * per-process `process.kill(pid, signal)`. Because `taskkill /F` is already a
+ * forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after
+ * a `SIGTERM`/`SIGINT` is a **documented no-op** — the tree is already gone.
+ */
+export function killProcessTree(pid: number, signal: NodeJS.Signals): void {
+ if (process.platform === "win32") {
+ killProcessTreeWin32(pid, signal);
+ return;
+ }
+ try {
+ process.kill(-pid, signal);
+ } catch {
+ killSingleProcess(pid, signal);
+ }
+}
+
+function killProcessTreeWin32(pid: number, signal: NodeJS.Signals): void {
+ // `taskkill /F` already force-killed the tree on the first (SIGTERM/SIGINT)
+ // call, so the SIGKILL escalation has nothing left to terminate.
+ if (signal === "SIGKILL") {
+ return;
+ }
+ let child: ChildProcess;
+ try {
+ child = _portability.spawn("taskkill", ["/pid", String(pid), "/T", "/F"]);
+ } catch {
+ killSingleProcess(pid, signal);
+ return;
+ }
+ // `spawn` reports a missing/unspawnable `taskkill` asynchronously via "error",
+ // not by throwing — degrade to a per-process kill when that fires.
+ child.once?.("error", () => killSingleProcess(pid, signal));
+ child.unref?.();
+}
+
+function killSingleProcess(pid: number, signal: NodeJS.Signals): void {
+ try {
+ process.kill(pid, signal);
+ } catch {
+ // no-op: process already gone or not signalable.
+ }
+}
diff --git a/src/runtime/kernel/prompt.test.ts b/src/runtime/kernel/prompt.test.ts
index fa10db5c..1e042d7c 100644
--- a/src/runtime/kernel/prompt.test.ts
+++ b/src/runtime/kernel/prompt.test.ts
@@ -1,4 +1,4 @@
-import { describe, it } from "node:test";
+import { describe, it, afterEach } from "node:test";
import * as assert from "node:assert/strict";
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -235,18 +235,40 @@ describe("executePrompt", () => {
});
/** Minimal ChildProcess stand-in: an EventEmitter with a recording `kill`. */
+const FAKE_CHILD_PID = 4242;
+let origProcessKill: typeof process.kill | undefined;
+
+// The watchdog terminates via killProcessTree(pid, signal) (portability.ts),
+// which on POSIX calls process.kill(-pid|pid, signal). Spy on process.kill and
+// record the signals aimed at the fake child's pid (either sign). Restored per
+// test by the afterEach below so the spy never leaks into other suites.
function makeFakeChild(): { child: ChildProcess; killSignals: string[] } {
const emitter = new EventEmitter() as EventEmitter & { pid: number; kill: (s?: string) => boolean };
const killSignals: string[] = [];
- emitter.pid = 4242;
+ emitter.pid = FAKE_CHILD_PID;
emitter.kill = (signal?: string) => {
killSignals.push(signal ?? "SIGTERM");
return true;
};
+ origProcessKill = process.kill;
+ (process as { kill: typeof process.kill }).kill = ((pid: number, signal?: NodeJS.Signals) => {
+ if (Math.abs(pid) === FAKE_CHILD_PID) {
+ killSignals.push(signal ?? "SIGTERM");
+ return true;
+ }
+ return origProcessKill!(pid, signal);
+ }) as typeof process.kill;
return { child: emitter as unknown as ChildProcess, killSignals };
}
describe("installPromptWatchdog", () => {
+ afterEach(() => {
+ if (origProcessKill) {
+ (process as { kill: typeof process.kill }).kill = origProcessKill;
+ origProcessKill = undefined;
+ }
+ });
+
it("layer 2: terminates and fails when output stalls past the idle timeout", async () => {
const { child, killSignals } = makeFakeChild();
const events: Array<{ status: number; reason: string; final: string }> = [];
diff --git a/src/runtime/kernel/prompt.ts b/src/runtime/kernel/prompt.ts
index c4b26f05..d09c4977 100644
--- a/src/runtime/kernel/prompt.ts
+++ b/src/runtime/kernel/prompt.ts
@@ -5,6 +5,7 @@ import { writeFileSync, readFileSync, existsSync, accessSync, mkdirSync, cpSync,
import { basename, delimiter, join } from "node:path";
import { parseStream, type StreamWriter } from "./stream-parser";
import { consumeNextMockResponse, dispatchMockArms, type MockPromptArm } from "./mock";
+import { killProcessTree } from "./portability";
export type PromptConfig = {
backend: string;
@@ -452,17 +453,16 @@ export function installPromptWatchdog(
};
const killChild = (): void => {
- try {
- child.kill("SIGTERM");
- } catch {
- // no-op
+ const pid = child.pid;
+ if (!pid) {
+ return;
}
+ // Terminate the backend and any descendants it spawned. On win32 this
+ // taskkill /T already force-kills the tree, so the SIGKILL escalation
+ // below is a documented no-op there (see killProcessTree).
+ killProcessTree(pid, "SIGTERM");
const escalate = setTimeout(() => {
- try {
- child.kill("SIGKILL");
- } catch {
- // no-op
- }
+ killProcessTree(pid, "SIGKILL");
}, 5000);
escalate.unref?.();
};
From afef44b85a14d860743b35b12c3d1fb1bd2f567d Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:17:58 +0200
Subject: [PATCH 02/85] Fix: execute script steps via explicit interpreter, not
shebang
Script steps previously ran by spawning the emitted file directly,
relying on the `#!/usr/bin/env ` shebang and the 0o755 exec bit.
Windows honors neither, and `noexec` mounts on Linux strip the exec bit,
both breaking script execution. The runtime already knows the
interpreter since it writes the shebang itself, so `executeScript` now
reads the emitted script's shebang, resolves the interpreter through the
new `resolveInterpreterFromShebang` (`src/parse/script-bash.ts`), and
spawns `` explicitly. The shebang
line is still written into every emitted script so they remain directly
executable by hand on POSIX, but the runtime no longer depends on it or
on the exec bit being honored. A spawn ENOENT from a missing interpreter
now surfaces as a diagnosable Jaiph error naming the interpreter instead
of a raw `spawn ENOENT`. New unit tests assert the resolved
interpreter and argv on an injectable `_scriptSpawn` seam, prove a script
with the exec bit stripped (0o644) still runs on POSIX, and prove a
missing-interpreter shebang produces the diagnosable error.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 104 ++++++++++
docs/architecture.md | 1 +
docs/setup.md | 2 +-
src/parse/script-bash.test.ts | 62 ++++++
src/parse/script-bash.ts | 39 ++++
.../node-workflow-runtime.script-exec.test.ts | 183 ++++++++++++++++++
src/runtime/kernel/node-workflow-runtime.ts | 63 +++++-
8 files changed, 449 insertions(+), 6 deletions(-)
create mode 100644 src/parse/script-bash.test.ts
create mode 100644 src/runtime/kernel/node-workflow-runtime.script-exec.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1de9058f..e6418d99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
# 0.10.0
diff --git a/QUEUE.md b/QUEUE.md
index 64f890c3..d41cd418 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -13,3 +13,107 @@ Process rules:
7. **Acceptance criteria are non-negotiable.** A task is not done until every acceptance bullet is verified by a test that fails when the contract is violated. "It works on my machine" or "the existing tests pass" is not acceptance.
***
+
+## Portability: single `resolveShell()` seam for inline shell lines and hooks #dev-ready
+
+Inline workflow shell lines run via hardcoded `spawn("sh", ["-c", ...])` (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hooks do the same (`src/cli/run/hooks.ts`). Jaiph's language semantics require POSIX `sh` on all platforms — inline lines must NOT be translated to cmd/PowerShell, or workflows stop being portable.
+
+Add `resolveShell(): string` to the portability module:
+
+* On POSIX: `sh`.
+* On `win32`: locate `sh.exe` on `PATH`, then in the standard Git for Windows locations (`/bin/sh.exe`, `/usr/bin/sh.exe`). If none found, throw a Jaiph error with a stable code (e.g. `E_NO_POSIX_SHELL`) telling the user to install Git for Windows.
+* Resolution is memoized per process.
+
+Both call sites go through `resolveShell()`. No other `spawn("sh", ...)` remains in `src/`.
+
+Acceptance:
+
+* Unit tests stub `process.platform` and `PATH` lookup: POSIX returns `sh`; win32 returns a discovered `sh.exe` path; win32 with no shell available throws `E_NO_POSIX_SHELL` and the message names Git for Windows.
+* A grep-style test asserts no literal `spawn("sh"` / `spawn('sh'` call sites exist in `src/` outside the portability module.
+* Inline shell-line semantics on POSIX are unchanged (existing e2e passes).
+
+## Portability: home directory, Docker gating, and ANSI on win32 #dev-ready
+
+Remaining small POSIX assumptions for a host-only Windows runtime:
+
+1. `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) reads `execEnv.HOME || process.env.HOME`. Use `os.homedir()` as the final fallback so `USERPROFILE`-only environments resolve; an explicit `HOME` in `execEnv` still wins.
+2. Docker sandboxing (`src/runtime/docker.ts`) hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches. On `win32`, the Docker sandbox is out of scope: `resolveDockerConfig` must resolve to host-only mode with a one-line notice (same UX as an explicit `JAIPH_UNSAFE=true`), never attempt `docker` probing, and never hard-fail because Docker is missing.
+3. Live status rendering already gates SGR colors on `isTTY` + `NO_COLOR` and uses a single erase/cursor-up sequence (`src/cli/run/stderr-handler.ts`). Node enables VT processing on Windows 10+; no rendering change required. Add a `canUseAnsi()` helper to the portability module and route the existing gates through it so the policy lives in one place.
+
+Acceptance:
+
+* Unit test: with `HOME` unset and `os.homedir()` stubbed, `prepareClaudeEnv` resolves the config dir from `os.homedir()`; with `execEnv.HOME` set, it wins.
+* Unit test: with `process.platform` stubbed to `win32`, Docker resolution returns host-only mode, emits the notice once, and performs zero `docker` invocations (spawn spied).
+* Unit test: `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set; all color/erase emission sites consume it (grep test: no direct `isTTY && NO_COLOR` gating outside the portability module).
+
+## Distro: build and release `jaiph-windows-x64.exe` #dev-ready
+
+The release workflow (`.github/workflows/release.yml`) cross-compiles four assets (`jaiph-{darwin,linux}-{arm64,x64}`) via `bun build --compile` and publishes them with `SHA256SUMS`. Add Windows:
+
+* New matrix entry: `--target=bun-windows-x64`, asset name `jaiph-windows-x64.exe` (Bun has no Windows arm64 target — do not add one).
+* Include the `.exe` in `SHA256SUMS` generation and in both the stable and nightly `gh release` upload lists.
+* Add a Windows sanity gate alongside the existing linux-x64 one: run `jaiph-windows-x64.exe --version` on a `windows-latest` job and, for stable releases, assert the output matches the tag.
+* Update the "Release asset naming contract" in `docs/contributing.md` to include the new asset name.
+
+Acceptance:
+
+* A nightly release run publishes five binaries + `SHA256SUMS`, and the `.exe` checksum entry verifies against the downloaded asset.
+* The Windows sanity gate fails the release when `--version` output mismatches the tag (verified by test or a deliberate dry-run with a wrong version).
+* `docs/contributing.md` naming contract lists `jaiph-windows-x64.exe`; the e2e installer test's asset-name mapping and the contract stay in sync (grep/parity check).
+
+## Distro: PowerShell installer at `jaiph.org/install.ps1` #dev-ready
+
+The bash installer (`docs/install`) downloads a per-platform binary from the GitHub Release for a pinned ref and verifies it against `SHA256SUMS`. Windows users need a native equivalent — the bash script must keep rejecting Windows and point at the PowerShell one.
+
+Add `docs/install.ps1` (served as `https://jaiph.org/install.ps1`, `irm https://jaiph.org/install.ps1 | iex`):
+
+* Downloads `jaiph-windows-x64.exe` and `SHA256SUMS` from the pinned release ref (default: current stable tag; overridable via `JAIPH_REPO_REF` / first argument, mirroring the bash installer).
+* Verifies the SHA-256 (`Get-FileHash`) against `SHA256SUMS`; mismatch aborts and installs nothing.
+* Installs to `%LOCALAPPDATA%\jaiph\bin\jaiph.exe` (overridable via `JAIPH_BIN_DIR`), adds the directory to the user `PATH` if absent, and prints the same try-it hints as the bash installer.
+* Non-x64 / ARM Windows: exit with a documented unsupported-platform message.
+* Update `docs/install`'s unsupported-platform message to mention the PowerShell installer for Windows, and document the Windows install path in `docs/setup.md` and the main page install tabs.
+
+Acceptance:
+
+* Pester (or equivalent) tests run on `windows-latest` in CI, pointing the installer at a `file://`-style local release directory (same technique as `e2e/tests/07_installer_binary.sh`): happy path installs and `jaiph --version` works; checksum mismatch exits non-zero and leaves no binary; unsupported arch exits with the documented message.
+* The installed binary runs from a shell with no Node/npm/Bun on `PATH`.
+* `docs/setup.md` and the main page reference the PowerShell one-liner (docs parity check passes).
+
+## Distro: main-page install tabs — Windows variant with platform auto-detect #dev-ready
+
+The main page (`docs/index.html`) hero has three install tabs (run sample / init project / just install), all showing bash `curl … | bash` one-liners. Tab switching in `docs/assets/js/main.js` is click-only; there is no platform detection. Windows visitors currently see commands that cannot run natively.
+
+Given a published PowerShell installer at `https://jaiph.org/install.ps1` (`irm https://jaiph.org/install.ps1 | iex`):
+
+* Add a Windows variant to the install section: at minimum the "Just install" panel must offer the PowerShell one-liner alongside the curl one (separate tab, sub-toggle, or swapped command — implementer's choice, but the PowerShell command must be copy-able via the existing copy button).
+* Auto-detect the visitor's platform on load (`navigator.userAgentData?.platform` with `navigator.platform` fallback) and default to the Windows variant for Windows visitors; macOS/Linux visitors see exactly today's default. Manual switching always remains possible; no layout shift for non-Windows users.
+* Tabs whose commands have no PowerShell equivalent (run sample / init project) must not show a bash-only command as the Windows default — show the install one-liner plus a short "then run:" `jaiph` command instead, or an equivalent honest fallback.
+* Keep the static-render constraint: the page must remain correct with JS disabled (bash commands shown, Windows variant reachable via tab markup).
+
+Acceptance:
+
+* Playwright docs tests (same suite as the "Try it out" test) assert: with a Windows platform emulated, the page defaults to the PowerShell command; with macOS/Linux emulated, the default is unchanged from today; manual tab switching works in both.
+* The copy button on the Windows variant copies the exact `irm … | iex` line (asserted via clipboard stub).
+* With JS disabled, all bash commands render and no panel is blank.
+* Docs parity check (`.jaiph/docs_parity.jh`) passes with the new content.
+
+## Distro: native Windows smoke job in CI #dev-ready
+
+CI's only Windows coverage runs the e2e suite inside WSL (`e2e-wsl` in `.github/workflows/ci.yml`), which exercises the Linux binary. Developing Jaiph on Windows is out of scope — this job proves *running* Jaiph natively works.
+
+Add a `windows-native-smoke` job on `windows-latest`:
+
+* Build the standalone Windows binary from the checkout (`bun build --compile --target=bun-windows-x64`).
+* With Git for Windows' `sh.exe` available (preinstalled on the runner), run a sample workflow host-only (`JAIPH_UNSAFE=true`) that covers: an inline shell line, a `script` step with a non-bash lang tag (e.g. ` ```node `), string interpolation, and `log` output.
+* Assert the process tree is cleaned up after a mid-run cancellation (spawn `jaiph run`, terminate it, assert no orphaned child processes remain).
+* No agent-backend credentials in this job: `prompt`-step coverage is limited to the credential pre-flight failing with the documented error, not a hang.
+* Keep `e2e-wsl` as-is; do not gate its removal on this task.
+
+Acceptance:
+
+* The smoke job is required for merge (listed in the CI gate alongside `test`/`e2e`/`e2e-wsl`).
+* Workflow output assertions run against actual `jaiph.exe` stdout (exit code + expected `log` lines).
+* The cancellation assertion fails if any child of the workflow leader survives termination.
+* The job completes with no WSL usage (fails if `wsl` is invoked).
+
+***
diff --git a/docs/architecture.md b/docs/architecture.md
index d672076b..b3fd5fa7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -72,6 +72,7 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)**
- `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts.
+ - **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ``. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`.
- One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `ensure_call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST.
- **Prompt transport-failure retry.** `runPromptStep` wraps each `executePrompt` invocation in a retry loop driven by the schedule resolved through `src/runtime/kernel/prompt-retry.ts` (default `15s → 1m → 10m → 30m → 2h`, six total attempts; configurable via `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS`). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return `{ ok: false }` on the first attempt. Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STEP_END`; each failure (and the final termination) logs a `LOGERR` through `RuntimeEventEmitter.emitLog`. The backoff sleep is injectable (`sleep` constructor option) and interruptible via `runtime.abort()` / an internal `AbortController` so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes **below** `recover` / `catch` — backoff is exhausted before the failure reaches the recover loop. See [Configuration — Prompt retry on transport failure](configuration.md#prompt-retry-on-transport-failure).
- Three sibling modules under `src/runtime/kernel/` carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back.
diff --git a/docs/setup.md b/docs/setup.md
index e9c1a9e7..e64585bc 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -15,7 +15,7 @@ The curl installer downloads a per-platform standalone binary from the current s
## Prerequisites
-- A POSIX `sh` (the runtime uses `sh -c` for inline shell lines inside workflows; emitted `script` steps follow their own shebang).
+- A POSIX `sh` (the runtime uses `sh -c` for inline shell lines inside workflows). Each emitted `script` step runs under the interpreter named by its shebang (`bash` by default), so that interpreter must be on `PATH`; the runtime spawns it explicitly and does not rely on the file's exec bit, so scripts also work under `noexec` mounts.
- For the curl installer (step 1): `curl` and either `shasum` or `sha256sum` on `PATH`.
- For the npm alternative (step 1): Node.js and npm on the host.
diff --git a/src/parse/script-bash.test.ts b/src/parse/script-bash.test.ts
new file mode 100644
index 00000000..03f920fd
--- /dev/null
+++ b/src/parse/script-bash.test.ts
@@ -0,0 +1,62 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { resolveInterpreterFromShebang } from "./script-bash";
+
+test("resolveInterpreterFromShebang: #!/usr/bin/env bash resolves to bash", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env bash"), {
+ command: "bash",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: #!/usr/bin/env node resolves to node", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env node"), {
+ command: "node",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: #!/usr/bin/env python3 resolves to python3", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env python3"), {
+ command: "python3",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: absolute-path shebang spawns that path", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/bin/bash"), {
+ command: "/bin/bash",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: custom interpreter shebang resolves to the named interpreter", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env my-lang"), {
+ command: "my-lang",
+ prefixArgs: [],
+ });
+ assert.deepEqual(resolveInterpreterFromShebang("#!/opt/tools/customlang"), {
+ command: "/opt/tools/customlang",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: interpreter flags after the interpreter are preserved", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env node --experimental-vm-modules"), {
+ command: "node",
+ prefixArgs: ["--experimental-vm-modules"],
+ });
+});
+
+test("resolveInterpreterFromShebang: env -S split flag is skipped", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env -S deno run"), {
+ command: "deno",
+ prefixArgs: ["run"],
+ });
+});
+
+test("resolveInterpreterFromShebang: non-shebang line returns null", () => {
+ assert.equal(resolveInterpreterFromShebang("echo hi"), null);
+ assert.equal(resolveInterpreterFromShebang(""), null);
+ assert.equal(resolveInterpreterFromShebang("#!"), null);
+});
diff --git a/src/parse/script-bash.ts b/src/parse/script-bash.ts
index c4d69251..70d25329 100644
--- a/src/parse/script-bash.ts
+++ b/src/parse/script-bash.ts
@@ -9,3 +9,42 @@ export function scriptShebangIsBash(shebang?: string): boolean {
if (/^#!\/usr\/bin\/env\s+bash(?:\s|$)/.test(t)) return true;
return false;
}
+
+/** The interpreter to spawn for a script, plus any leading interpreter flags. */
+export type ScriptInterpreter = { command: string; prefixArgs: string[] };
+
+/**
+ * Resolve the interpreter a script should be executed with from its shebang
+ * line, so the runtime can spawn ``
+ * explicitly rather than relying on the OS honoring the shebang or the file's
+ * exec bit (Windows honors neither; `noexec` mounts break the exec bit too).
+ *
+ * Supported forms:
+ * `#!/usr/bin/env bash` -> { command: "bash", prefixArgs: [] }
+ * `#!/usr/bin/env python3` -> { command: "python3", prefixArgs: [] }
+ * `#!/bin/bash` -> { command: "/bin/bash", prefixArgs: [] }
+ * `#!/usr/bin/env node --foo` -> { command: "node", prefixArgs: ["--foo"] }
+ * `#!/usr/bin/env -S deno run` -> { command: "deno", prefixArgs: ["run"] }
+ *
+ * The interpreter is spawned by name so Node resolves it on PATH; a bare name
+ * that names a missing interpreter surfaces as an ENOENT the caller can turn
+ * into a diagnosable error. Returns null when the line is not a shebang.
+ */
+export function resolveInterpreterFromShebang(shebangLine: string): ScriptInterpreter | null {
+ const t = shebangLine.trim();
+ if (!t.startsWith("#!")) return null;
+ const rest = t.slice(2).trim();
+ if (rest === "") return null;
+ const tokens = rest.split(/\s+/);
+ const first = tokens[0]!;
+ // `#!/usr/bin/env [args...]`: the real interpreter is the token
+ // after `env` (skipping a leading `-S` split flag). Spawn it directly.
+ if (first === "env" || first.endsWith("/env")) {
+ let idx = 1;
+ if (tokens[idx] === "-S") idx += 1;
+ if (idx >= tokens.length) return null;
+ return { command: tokens[idx]!, prefixArgs: tokens.slice(idx + 1) };
+ }
+ // `#!/absolute/path/interp [args...]`: spawn that path directly.
+ return { command: first, prefixArgs: tokens.slice(1) };
+}
diff --git a/src/runtime/kernel/node-workflow-runtime.script-exec.test.ts b/src/runtime/kernel/node-workflow-runtime.script-exec.test.ts
new file mode 100644
index 00000000..21a44455
--- /dev/null
+++ b/src/runtime/kernel/node-workflow-runtime.script-exec.test.ts
@@ -0,0 +1,183 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildRuntimeGraph } from "./graph";
+import { NodeWorkflowRuntime, _scriptSpawn } from "./node-workflow-runtime";
+
+/** Minimal fake ChildProcess that emits `close(exitCode)` on the next tick. */
+function fakeChild(exitCode: number): EventEmitter {
+ const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
+ const makeStream = (): EventEmitter & { setEncoding: () => void } => {
+ const s = new EventEmitter() as EventEmitter & { setEncoding: () => void };
+ s.setEncoding = () => {};
+ return s;
+ };
+ child.stdout = makeStream();
+ child.stderr = makeStream();
+ setImmediate(() => child.emit("close", exitCode));
+ return child;
+}
+
+/** Swap `_scriptSpawn.spawn` for a recording stub while `fn` runs. */
+async function withSpawnSpy(
+ fn: (calls: Array<{ command: string; args: string[] }>) => Promise,
+): Promise {
+ const calls: Array<{ command: string; args: string[] }> = [];
+ const orig = _scriptSpawn.spawn;
+ _scriptSpawn.spawn = ((command: string, args: string[]) => {
+ calls.push({ command, args });
+ return fakeChild(0) as unknown as ReturnType;
+ }) as typeof _scriptSpawn.spawn;
+ try {
+ await fn(calls);
+ } finally {
+ _scriptSpawn.spawn = orig;
+ }
+}
+
+function makeRuntime(root: string): { runtime: NodeWorkflowRuntime; env: NodeJS.ProcessEnv; scriptsDir: string } {
+ const jh = join(root, "flow.jh");
+ writeFileSync(jh, ["workflow default() {", ' log "noop"', "}", ""].join("\n"));
+ const scriptsDir = join(root, "scripts");
+ mkdirSync(scriptsDir, { recursive: true });
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ JAIPH_SCRIPTS: scriptsDir,
+ JAIPH_WORKSPACE: root,
+ };
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ return { runtime, env, scriptsDir };
+}
+
+// AC1: executeScript spawns the resolved interpreter with the script path as
+// argv[1] (spawn args[0]) — asserted on the spawn call, not on side effects.
+const SHEBANG_CASES: Array<{ label: string; shebang: string; expected: string }> = [
+ { label: "bash", shebang: "#!/usr/bin/env bash", expected: "bash" },
+ { label: "node", shebang: "#!/usr/bin/env node", expected: "node" },
+ { label: "python3", shebang: "#!/usr/bin/env python3", expected: "python3" },
+ { label: "custom", shebang: "#!/usr/bin/env my-custom-lang", expected: "my-custom-lang" },
+];
+
+for (const c of SHEBANG_CASES) {
+ test(`executeScript: spawns resolved interpreter "${c.expected}" with the script path as argv[1] (${c.label} shebang)`, async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-script-exec-"));
+ try {
+ const { runtime, env, scriptsDir } = makeRuntime(root);
+ const scriptName = `run_${c.label}`;
+ const scriptPath = join(scriptsDir, scriptName);
+ writeFileSync(scriptPath, `${c.shebang}\necho hi\n`);
+ await withSpawnSpy(async (calls) => {
+ const result = await (runtime as unknown as {
+ executeScript: (
+ filePath: string,
+ scriptName: string,
+ args: string[],
+ env: NodeJS.ProcessEnv,
+ ) => Promise<{ status: number }>;
+ }).executeScript(join(root, "flow.jh"), scriptName, ["a1", "a2"], env);
+ assert.equal(result.status, 0);
+ assert.equal(calls.length, 1, "expected exactly one spawn call");
+ assert.equal(calls[0]!.command, c.expected, "spawned command is the resolved interpreter");
+ assert.equal(calls[0]!.args[0], scriptPath, "script path is argv[1] (spawn args[0])");
+ assert.deepEqual(calls[0]!.args, [scriptPath, "a1", "a2"], "script args follow the script path");
+ });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+}
+
+// AC2: a script with the exec bit stripped (0o644) still executes on POSIX,
+// because the runtime spawns the interpreter explicitly rather than the file.
+test("executeScript: script with exec bit stripped (0o644) still executes through the runtime", { skip: process.platform === "win32" }, async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-script-noexec-"));
+ try {
+ const jh = join(root, "flow.jh");
+ writeFileSync(
+ jh,
+ [
+ "script write_marker = ```",
+ 'printf "ran-ok" > marker.txt',
+ "```",
+ "",
+ "workflow default() {",
+ " run write_marker()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const scriptsDir = join(root, "scripts");
+ mkdirSync(scriptsDir, { recursive: true });
+ const scriptPath = join(scriptsDir, "write_marker");
+ writeFileSync(
+ scriptPath,
+ ["#!/usr/bin/env bash", "set -euo pipefail", 'printf "ran-ok" > marker.txt', ""].join("\n"),
+ );
+ // Strip the exec bit: with shebang+exec-bit execution this would fail EACCES.
+ chmodSync(scriptPath, 0o644);
+
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ JAIPH_SCRIPTS: scriptsDir,
+ JAIPH_WORKSPACE: root,
+ };
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ const status = await runtime.runDefault([]);
+ assert.equal(status, 0, "workflow succeeded despite stripped exec bit");
+ assert.equal(readFileSync(join(root, "marker.txt"), "utf8"), "ran-ok");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// AC3: a shebang naming a missing interpreter fails with a diagnosable Jaiph
+// error naming the interpreter, not a raw ENOENT.
+test("executeScript: missing interpreter fails with a diagnosable error naming the interpreter (not raw ENOENT)", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-script-badinterp-"));
+ try {
+ const missing = "jaiph-nonexistent-interp-xyz";
+ const jh = join(root, "flow.jh");
+ writeFileSync(
+ jh,
+ [
+ "script run_bad = ```",
+ 'echo "unreachable"',
+ "```",
+ "",
+ "workflow default() {",
+ " run run_bad()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const scriptsDir = join(root, "scripts");
+ mkdirSync(scriptsDir, { recursive: true });
+ writeFileSync(join(scriptsDir, "run_bad"), `#!/usr/bin/env ${missing}\necho unreachable\n`);
+
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ JAIPH_SCRIPTS: scriptsDir,
+ JAIPH_WORKSPACE: root,
+ };
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ const result = await runtime.runNamedWorkflow("default", []);
+ assert.equal(result.status, 1, "workflow failed on missing interpreter");
+ const message = `${result.output ?? ""}${result.error ?? ""}`;
+ assert.match(message, new RegExp(missing), "error names the missing interpreter");
+ assert.doesNotMatch(message, /ENOENT/, "error is not a raw ENOENT");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/src/runtime/kernel/node-workflow-runtime.ts b/src/runtime/kernel/node-workflow-runtime.ts
index c2012aa6..42b3305c 100644
--- a/src/runtime/kernel/node-workflow-runtime.ts
+++ b/src/runtime/kernel/node-workflow-runtime.ts
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
-import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
+import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve as resolvePath } from "node:path";
import { PassThrough } from "node:stream";
import { randomUUID } from "node:crypto";
@@ -27,6 +27,7 @@ import {
stripOuterQuotes,
type PromptSchemaField,
} from "./runtime-arg-parser";
+import { resolveInterpreterFromShebang } from "../../parse/script-bash";
import { RuntimeEventEmitter, type Frame } from "./runtime-event-emitter";
import { executeMockBodyDef, type MockBodyDef, type StepResult } from "./runtime-mock";
import { linesOfDelimitedString } from "../string-lines";
@@ -42,6 +43,13 @@ export type { MockBodyDef } from "./runtime-mock";
const HANDLE_PREFIX = "__JAIPH_HANDLE__";
+/**
+ * Test seam for the script/shell subprocess spawn. Swapped out in unit tests so
+ * the resolved interpreter + argv can be asserted on the spawn call itself
+ * without side effects (mirrors `_portability.spawn` in `portability.ts`).
+ */
+export const _scriptSpawn = { spawn };
+
export function formatInvalidAsyncHandleError(handleId: string): string {
return `invalid async handle "${handleId}" — the handle was never created or was already consumed`;
}
@@ -1479,16 +1487,22 @@ export class NodeWorkflowRuntime {
return { status: 0, output: res.output, error: "" };
}
- /** Spawn a child process, stream stdout/stderr into io and collect them into the StepResult. */
+ /**
+ * Spawn a child process, stream stdout/stderr into io and collect them into
+ * the StepResult. When `interpreter` is set, a spawn ENOENT (the interpreter
+ * binary is missing on PATH) is turned into a diagnosable Jaiph error naming
+ * the interpreter instead of a raw `spawn ENOENT`.
+ */
private spawnAndCapture(
command: string,
args: string[],
env: NodeJS.ProcessEnv,
cwd: string,
io: StepIO | undefined,
+ interpreter?: string,
): Promise {
return new Promise((resolve) => {
- const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
+ const child = _scriptSpawn.spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
let error = "";
child.stdout?.setEncoding("utf8");
@@ -1502,7 +1516,10 @@ export class NodeWorkflowRuntime {
io?.appendErr(chunk);
});
child.on("error", (err) => {
- const msg = err instanceof Error ? err.message : String(err);
+ const code = (err as NodeJS.ErrnoException).code;
+ const msg = code === "ENOENT" && interpreter
+ ? `script interpreter "${interpreter}" not found — install it or fix the script shebang`
+ : err instanceof Error ? err.message : String(err);
error += msg;
io?.appendErr(msg);
resolve({ status: 1, output, error });
@@ -1536,7 +1553,43 @@ export class NodeWorkflowRuntime {
if (!scriptsDir) {
return { status: 1, output: "", error: "JAIPH_SCRIPTS not set for script execution" };
}
- return this.spawnAndCapture(join(scriptsDir, scriptName), args, env, this.scriptCwd(env, filePath), io);
+ const scriptPath = join(scriptsDir, scriptName);
+ const interp = this.resolveScriptInterpreter(scriptPath);
+ if (!interp.ok) return { status: 1, output: "", error: interp.error };
+ // Spawn `` explicitly. This does not
+ // depend on the OS honoring the shebang line (Windows) or on the file's
+ // exec bit (stripped bit / `noexec` mounts); the shebang is still written
+ // into the file so it stays directly executable by hand on POSIX.
+ return this.spawnAndCapture(
+ interp.command,
+ [...interp.prefixArgs, scriptPath, ...args],
+ env,
+ this.scriptCwd(env, filePath),
+ io,
+ interp.command,
+ );
+ }
+
+ /**
+ * Resolve the interpreter to spawn for an emitted script from its shebang
+ * line. Emitted scripts always carry a shebang (`buildScriptFiles`); a script
+ * without one falls back to bash (Jaiph's default script language) rather
+ * than depending on the OS exec bit.
+ */
+ private resolveScriptInterpreter(
+ scriptPath: string,
+ ): { ok: true; command: string; prefixArgs: string[] } | { ok: false; error: string } {
+ let firstLine: string;
+ try {
+ const content = readFileSync(scriptPath, "utf8");
+ const nl = content.indexOf("\n");
+ firstLine = nl === -1 ? content : content.slice(0, nl);
+ } catch {
+ return { ok: false, error: `script file not found or unreadable: ${scriptPath}` };
+ }
+ const interp = resolveInterpreterFromShebang(firstLine);
+ if (!interp) return { ok: true, command: "bash", prefixArgs: [] };
+ return { ok: true, command: interp.command, prefixArgs: interp.prefixArgs };
}
/**
From 7a0f2e4d64953ba9e32ec9d252e2cd89391b2ccb Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:35:22 +0200
Subject: [PATCH 03/85] Fix: route inline shell lines and hooks through
resolveShell() seam
Inline workflow shell lines (executeShLine) and CLI hook commands used a
hardcoded spawn("sh", ["-c", ...]), which fails on win32 where there is no
sh on the default PATH. Add resolveShell() to the portability module as the
single seam both call sites go through: on POSIX it returns bare sh; on
win32 it locates Git for Windows' bundled sh.exe, first on PATH and then in
the standard install layouts (/bin/sh.exe, /usr/bin/sh.exe) under
each known root, throwing a diagnosable E_NO_POSIX_SHELL error naming Git
for Windows if none is found. Resolution is memoized per process. Inline
lines are never translated to cmd/PowerShell, so POSIX shell semantics are
unchanged. Unit tests stub process.platform and the PATH/existence lookup
across all branches, and a src/-wide lint test asserts no spawn("sh") call
site remains outside portability.ts.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 18 ---
docs/architecture.md | 1 +
src/cli/run/hooks.ts | 3 +-
src/runtime/kernel/node-workflow-runtime.ts | 3 +-
src/runtime/kernel/portability.test.ts | 140 +++++++++++++++++++-
src/runtime/kernel/portability.ts | 63 ++++++++-
7 files changed, 206 insertions(+), 23 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6418d99..875ce769 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
diff --git a/QUEUE.md b/QUEUE.md
index d41cd418..c02bca1e 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,24 +14,6 @@ Process rules:
***
-## Portability: single `resolveShell()` seam for inline shell lines and hooks #dev-ready
-
-Inline workflow shell lines run via hardcoded `spawn("sh", ["-c", ...])` (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hooks do the same (`src/cli/run/hooks.ts`). Jaiph's language semantics require POSIX `sh` on all platforms — inline lines must NOT be translated to cmd/PowerShell, or workflows stop being portable.
-
-Add `resolveShell(): string` to the portability module:
-
-* On POSIX: `sh`.
-* On `win32`: locate `sh.exe` on `PATH`, then in the standard Git for Windows locations (`/bin/sh.exe`, `/usr/bin/sh.exe`). If none found, throw a Jaiph error with a stable code (e.g. `E_NO_POSIX_SHELL`) telling the user to install Git for Windows.
-* Resolution is memoized per process.
-
-Both call sites go through `resolveShell()`. No other `spawn("sh", ...)` remains in `src/`.
-
-Acceptance:
-
-* Unit tests stub `process.platform` and `PATH` lookup: POSIX returns `sh`; win32 returns a discovered `sh.exe` path; win32 with no shell available throws `E_NO_POSIX_SHELL` and the message names Git for Windows.
-* A grep-style test asserts no literal `spawn("sh"` / `spawn('sh'` call sites exist in `src/` outside the portability module.
-* Inline shell-line semantics on POSIX are unchanged (existing e2e passes).
-
## Portability: home directory, Docker gating, and ANSI on win32 #dev-ready
Remaining small POSIX assumptions for a host-only Windows runtime:
diff --git a/docs/architecture.md b/docs/architecture.md
index b3fd5fa7..c6f5c6a0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -73,6 +73,7 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)**
- `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts.
- **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ``. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`.
+ - **Inline shell lines resolve their shell through one portable seam.** A single-line shell step (`executeShLine`) and CLI hook commands (`src/cli/run/hooks.ts`) both run under POSIX `sh -c`, but the shell itself is resolved through **`resolveShell()`** (`src/runtime/kernel/portability.ts`) rather than a hardcoded `spawn("sh", …)`. On POSIX this is bare `sh`; on **`win32`**, where there is no `sh` on the default `PATH`, it discovers Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root — memoizes the result for the process, and throws a diagnosable **`E_NO_POSIX_SHELL`** error naming Git for Windows if none is found. Inline lines are **never** translated to `cmd`/PowerShell: Jaiph's shell semantics are POSIX `sh` on every platform, so the seam only ever chooses *which* `sh` to invoke, never rewrites the command — otherwise workflows would stop being portable. `resolveShell()` is the single call site for the POSIX shell; no other `spawn("sh", …)` remains in `src/`.
- One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `ensure_call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST.
- **Prompt transport-failure retry.** `runPromptStep` wraps each `executePrompt` invocation in a retry loop driven by the schedule resolved through `src/runtime/kernel/prompt-retry.ts` (default `15s → 1m → 10m → 30m → 2h`, six total attempts; configurable via `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS`). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return `{ ok: false }` on the first attempt. Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STEP_END`; each failure (and the final termination) logs a `LOGERR` through `RuntimeEventEmitter.emitLog`. The backoff sleep is injectable (`sleep` constructor option) and interruptible via `runtime.abort()` / an internal `AbortController` so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes **below** `recover` / `catch` — backoff is exhausted before the failure reaches the recover loop. See [Configuration — Prompt retry on transport failure](configuration.md#prompt-retry-on-transport-failure).
- Three sibling modules under `src/runtime/kernel/` carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back.
diff --git a/src/cli/run/hooks.ts b/src/cli/run/hooks.ts
index 5cc2a95e..e3af0305 100644
--- a/src/cli/run/hooks.ts
+++ b/src/cli/run/hooks.ts
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
+import { resolveShell } from "../../runtime/kernel/portability";
import type { HookConfig, HookEventName, HookPayload } from "../../types";
import type { RunEmitter } from "./emitter";
@@ -133,7 +134,7 @@ export function runHooksForEvent(
for (const cmd of commands) {
try {
- const child = spawn("sh", ["-c", cmd], {
+ const child = spawn(resolveShell(), ["-c", cmd], {
stdio: ["pipe", "ignore", "pipe"],
env: { ...process.env },
});
diff --git a/src/runtime/kernel/node-workflow-runtime.ts b/src/runtime/kernel/node-workflow-runtime.ts
index 42b3305c..4e8b923e 100644
--- a/src/runtime/kernel/node-workflow-runtime.ts
+++ b/src/runtime/kernel/node-workflow-runtime.ts
@@ -28,6 +28,7 @@ import {
type PromptSchemaField,
} from "./runtime-arg-parser";
import { resolveInterpreterFromShebang } from "../../parse/script-bash";
+import { resolveShell } from "./portability";
import { RuntimeEventEmitter, type Frame } from "./runtime-event-emitter";
import { executeMockBodyDef, type MockBodyDef, type StepResult } from "./runtime-mock";
import { linesOfDelimitedString } from "../string-lines";
@@ -1597,7 +1598,7 @@ export class NodeWorkflowRuntime {
* the workspace, matching script cwd semantics.
*/
private executeShLine(scope: Scope, command: string, io: StepIO): Promise {
- return this.spawnAndCapture("sh", ["-c", command], scope.env, this.scriptCwd(scope.env, scope.filePath), io);
+ return this.spawnAndCapture(resolveShell(), ["-c", command], scope.env, this.scriptCwd(scope.env, scope.filePath), io);
}
private async executeInlineScript(
diff --git a/src/runtime/kernel/portability.test.ts b/src/runtime/kernel/portability.test.ts
index 6ee257c6..f95a3476 100644
--- a/src/runtime/kernel/portability.test.ts
+++ b/src/runtime/kernel/portability.test.ts
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
-import { killProcessTree, _portability } from "./portability";
+import { killProcessTree, resolveShell, _portability } from "./portability";
// Precedent for platform stubbing: src/runtime/docker.test.ts.
function withPlatform(platform: string, fn: () => void): void {
@@ -163,6 +163,144 @@ test("win32: NEVER calls process.kill with a negative PID (SIGTERM, SIGINT, SIGK
});
});
+// ---------------------------------------------------------------------------
+// resolveShell(): single POSIX-sh seam for inline shell lines and hooks.
+// ---------------------------------------------------------------------------
+
+/** Stub `_portability.fileExists` and `process.env` while `fn` runs. */
+function withShellEnv(
+ opts: { exists: (path: string) => boolean; env: Record },
+ fn: () => void,
+): void {
+ const origExists = _portability.fileExists;
+ const savedEnv: Record = {};
+ for (const key of Object.keys(opts.env)) {
+ savedEnv[key] = process.env[key];
+ if (opts.env[key] === undefined) delete process.env[key];
+ else process.env[key] = opts.env[key];
+ }
+ _portability.fileExists = opts.exists;
+ _portability.resetShellCache();
+ try {
+ fn();
+ } finally {
+ _portability.fileExists = origExists;
+ for (const key of Object.keys(savedEnv)) {
+ if (savedEnv[key] === undefined) delete process.env[key];
+ else process.env[key] = savedEnv[key];
+ }
+ _portability.resetShellCache();
+ }
+}
+
+test("POSIX: resolveShell returns bare `sh`", () => {
+ withPlatform("linux", () => {
+ _portability.resetShellCache();
+ try {
+ assert.equal(resolveShell(), "sh");
+ } finally {
+ _portability.resetShellCache();
+ }
+ });
+});
+
+test("win32: resolveShell returns an sh.exe discovered on PATH", () => {
+ withPlatform("win32", () => {
+ // Compute the expected path with the same `join` production uses so the
+ // assertion is host-separator-independent (POSIX CI vs a real win32 host).
+ const onPath = join("C:\\git\\bin", "sh.exe");
+ withShellEnv(
+ {
+ env: { PATH: "C:\\tools;C:\\git\\bin", ProgramFiles: undefined, "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: (p) => p === onPath,
+ },
+ () => {
+ assert.equal(resolveShell(), onPath);
+ },
+ );
+ });
+});
+
+test("win32: resolveShell falls back to the standard Git for Windows location", () => {
+ withPlatform("win32", () => {
+ // Only the Git usr/bin layout exists; nothing on PATH.
+ const gitUsrBin = join("C:\\Program Files", "Git", "usr", "bin", "sh.exe");
+ withShellEnv(
+ {
+ env: { PATH: "C:\\tools", ProgramFiles: "C:\\Program Files", "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: (p) => p === gitUsrBin,
+ },
+ () => {
+ assert.equal(resolveShell(), gitUsrBin);
+ },
+ );
+ });
+});
+
+test("win32: resolveShell throws E_NO_POSIX_SHELL naming Git for Windows when no sh.exe exists", () => {
+ withPlatform("win32", () => {
+ withShellEnv(
+ {
+ env: { PATH: "C:\\tools", ProgramFiles: "C:\\Program Files", "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: () => false,
+ },
+ () => {
+ assert.throws(
+ () => resolveShell(),
+ (err: Error) => {
+ assert.match(err.message, /E_NO_POSIX_SHELL/);
+ assert.match(err.message, /Git for Windows/);
+ return true;
+ },
+ );
+ },
+ );
+ });
+});
+
+test("resolveShell memoizes: fileExists is not consulted on the second call", () => {
+ withPlatform("win32", () => {
+ const onPath = join("C:\\git\\bin", "sh.exe");
+ let calls = 0;
+ withShellEnv(
+ {
+ env: { PATH: "C:\\git\\bin", ProgramFiles: undefined, "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: (p) => {
+ calls++;
+ return p === onPath;
+ },
+ },
+ () => {
+ assert.equal(resolveShell(), onPath);
+ const after = calls;
+ assert.equal(resolveShell(), onPath);
+ assert.equal(calls, after, "second call is served from the memoized cache");
+ },
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Lint contract: no `spawn("sh", …)` outside the portability module.
+// ---------------------------------------------------------------------------
+
+test("no production source file invokes spawn(\"sh\", …) directly — all go through resolveShell", () => {
+ // Match spawn("sh" / spawn('sh' with optional whitespace after the paren.
+ const rawShSpawn = /spawn\(\s*['"]sh['"]/;
+ const offenders: string[] = [];
+ for (const file of walkProductionTsFiles(SRC_ROOT)) {
+ const rel = file.slice(REPO_ROOT.length + 1);
+ if (rel === join("src", "runtime", "kernel", "portability.ts")) continue;
+ const content = readFileSync(file, "utf8");
+ if (rawShSpawn.test(content)) offenders.push(rel);
+ }
+ assert.deepEqual(
+ offenders,
+ [],
+ `inline shell must resolve through resolveShell(); offenders: ${offenders.join(", ")}`,
+ );
+});
+
// ---------------------------------------------------------------------------
// Lint contract: only the portability module may group-kill via negative PID.
// ---------------------------------------------------------------------------
diff --git a/src/runtime/kernel/portability.ts b/src/runtime/kernel/portability.ts
index a012ef90..d8e8be76 100644
--- a/src/runtime/kernel/portability.ts
+++ b/src/runtime/kernel/portability.ts
@@ -13,17 +13,76 @@
// `killProcessTree` hides that difference behind one call site.
import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
/**
- * Test seam for the `taskkill` spawn. Swapped out in unit tests so the win32
- * branch can be exercised on any host without a real `taskkill` binary.
+ * Test seams. Swapped out in unit tests so the win32 branches can be exercised
+ * on any host without a real `taskkill` binary or a real `sh.exe` on disk.
*/
export const _portability = {
spawn(command: string, args: string[]): ChildProcess {
return spawn(command, args, { stdio: "ignore" });
},
+ /** Existence probe for `resolveShell` PATH / Git-for-Windows lookups. */
+ fileExists(path: string): boolean {
+ return existsSync(path);
+ },
+ /** Reset the memoized shell so a test can re-run resolution under a new platform. */
+ resetShellCache(): void {
+ cachedShell = undefined;
+ },
};
+let cachedShell: string | undefined;
+
+/**
+ * Resolve the POSIX shell used to run inline workflow shell lines and hooks.
+ *
+ * Jaiph's language semantics require POSIX `sh` on every platform — inline
+ * lines are never translated to cmd/PowerShell, or workflows stop being
+ * portable. On POSIX the answer is simply `sh`. On win32 there is no `sh` on
+ * the default PATH, so we locate Git for Windows' bundled `sh.exe`, first on
+ * PATH and then in the standard install locations. The result is memoized for
+ * the lifetime of the process.
+ */
+export function resolveShell(): string {
+ if (cachedShell !== undefined) return cachedShell;
+ cachedShell = process.platform === "win32" ? resolveWindowsPosixShell() : "sh";
+ return cachedShell;
+}
+
+function resolveWindowsPosixShell(): string {
+ // PATH first: honor an sh.exe the user already put on their PATH.
+ const path = process.env.PATH ?? "";
+ for (const dir of path.split(";")) {
+ if (!dir) continue;
+ const candidate = join(dir, "sh.exe");
+ if (_portability.fileExists(candidate)) return candidate;
+ }
+ // Then the standard Git for Windows layouts under each known install root.
+ const roots = [
+ process.env.ProgramFiles,
+ process.env["ProgramFiles(x86)"],
+ process.env.ProgramW6432,
+ "C:\\Program Files",
+ "C:\\Program Files (x86)",
+ ];
+ for (const root of roots) {
+ if (!root) continue;
+ for (const rel of [join("Git", "bin", "sh.exe"), join("Git", "usr", "bin", "sh.exe")]) {
+ const candidate = join(root, rel);
+ if (_portability.fileExists(candidate)) return candidate;
+ }
+ }
+ throw new Error(
+ "E_NO_POSIX_SHELL Jaiph requires a POSIX `sh` to run inline shell lines and hooks, " +
+ "but `sh.exe` was not found on PATH or in the standard Git for Windows install " +
+ "locations. Install Git for Windows (https://git-scm.com/download/win), which " +
+ "bundles `sh.exe`.",
+ );
+}
+
/**
* Terminate `pid` and its descendants with `signal`, portably.
*
From 2b1922df3f659fd0cd20c9f3a31fec96590d5c5c Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:55:59 +0200
Subject: [PATCH 04/85] Fix: portability home-dir fallback, Docker gating on
win32, ANSI policy
Close three remaining POSIX assumptions that broke a host-only Windows
runtime. prepareClaudeEnv now falls back to os.homedir() when neither
execEnv.HOME nor process.env.HOME is set, so USERPROFILE-only
environments resolve the .claude config dir; an explicit execEnv.HOME
still wins. resolveDockerConfig forces host-only mode on win32 with a
one-line notice (same UX as JAIPH_UNSAFE=true), so the CLI never probes
docker and never hard-fails on a missing daemon, and
JAIPH_DOCKER_ENABLED=true cannot override it. A new canUseAnsi() helper
in the portability module centralizes the isTTY + NO_COLOR gate; every
color/erase emission site routes through it so the policy lives in one
place. Adds unit tests for each and a src-wide lint test asserting no
production file outside portability.ts gates directly on isTTY &&
NO_COLOR. Docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 14 ------
docs/architecture.md | 4 +-
docs/configuration.md | 5 +-
docs/env-vars.md | 4 +-
docs/sandboxing.md | 4 ++
src/cli/commands/run.ts | 3 +-
src/cli/run/progress.ts | 9 ++--
src/cli/shared/errors.ts | 3 +-
src/runtime/docker.test.ts | 38 ++++++++++++++++
src/runtime/docker.ts | 34 ++++++++++++--
src/runtime/kernel/portability.test.ts | 63 +++++++++++++++++++++++++-
src/runtime/kernel/portability.ts | 16 +++++++
src/runtime/kernel/prompt.test.ts | 46 +++++++++++++++++++
src/runtime/kernel/prompt.ts | 5 +-
15 files changed, 219 insertions(+), 30 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 875ce769..102f19dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
diff --git a/QUEUE.md b/QUEUE.md
index c02bca1e..b55ccf29 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,20 +14,6 @@ Process rules:
***
-## Portability: home directory, Docker gating, and ANSI on win32 #dev-ready
-
-Remaining small POSIX assumptions for a host-only Windows runtime:
-
-1. `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) reads `execEnv.HOME || process.env.HOME`. Use `os.homedir()` as the final fallback so `USERPROFILE`-only environments resolve; an explicit `HOME` in `execEnv` still wins.
-2. Docker sandboxing (`src/runtime/docker.ts`) hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches. On `win32`, the Docker sandbox is out of scope: `resolveDockerConfig` must resolve to host-only mode with a one-line notice (same UX as an explicit `JAIPH_UNSAFE=true`), never attempt `docker` probing, and never hard-fail because Docker is missing.
-3. Live status rendering already gates SGR colors on `isTTY` + `NO_COLOR` and uses a single erase/cursor-up sequence (`src/cli/run/stderr-handler.ts`). Node enables VT processing on Windows 10+; no rendering change required. Add a `canUseAnsi()` helper to the portability module and route the existing gates through it so the policy lives in one place.
-
-Acceptance:
-
-* Unit test: with `HOME` unset and `os.homedir()` stubbed, `prepareClaudeEnv` resolves the config dir from `os.homedir()`; with `execEnv.HOME` set, it wins.
-* Unit test: with `process.platform` stubbed to `win32`, Docker resolution returns host-only mode, emits the notice once, and performs zero `docker` invocations (spawn spied).
-* Unit test: `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set; all color/erase emission sites consume it (grep test: no direct `isTTY && NO_COLOR` gating outside the portability module).
-
## Distro: build and release `jaiph-windows-x64.exe` #dev-ready
The release workflow (`.github/workflows/release.yml`) cross-compiles four assets (`jaiph-{darwin,linux}-{arm64,x64}`) via `bun build --compile` and publishes them with `SHA256SUMS`. Add Windows:
diff --git a/docs/architecture.md b/docs/architecture.md
index c6f5c6a0..d72c38de 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -92,7 +92,7 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. `emitModule(ast, trivia, opts?)` reads the semantic AST together with the parallel **`Trivia`** store ([Trivia (CST layer)](#trivia-cst-layer)) to round-trip leading comments, top-level order, `config` body sequence, `"""..."""` and `bareSource` forms, the original quotedness of top-level `const` values (`EnvDeclDef.wasQuoted` — `true` for `"…"` / `"""…"""` sources, `undefined` for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on `WorkflowStepDef.type` (8 variants) and an `emitExpr` helper switches on `Expr.kind` (8 kinds) — there are no dual code paths for "managed sidecar vs literal value" because that branch was removed from the AST. Call arguments render straight off the typed `Arg[]` — `var` → bare name, `literal` → raw — so the formatter no longer re-parses any args string or consults a `bareIdentifierArgs` shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` — pinned by `src/format/roundtrip.test.ts`, which asserts `parse → format → parse → format` converges in one step on every fixture.
- **Docker runtime helper (`src/runtime/docker.ts`)**
- - Parses mount specs, resolves Docker config (image, network, timeout), and builds the `docker run` invocation when the CLI enables **Docker sandboxing** for `jaiph run` (environment-driven; there is no `jaiph run --docker` flag — see [Sandboxing](sandboxing.md)). The container runs the same **`jaiph run --raw`** / **`__workflow-runner`** entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image; every selected image must already contain `jaiph` (no auto-install or derived-image build at runtime). Image preparation (`prepareImage`) runs before the CLI banner: it checks whether the image is local, pulls with `--quiet` if needed (short status lines on stderr instead of Docker’s default pull UI), and verifies that `jaiph` exists in the image. `spawnDockerProcess` does not pull or verify — it receives a pre-resolved image. The spawn call uses `stdio: ["ignore", "pipe", "pipe"]` — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits.
+ - Parses mount specs, resolves Docker config (image, network, timeout), and builds the `docker run` invocation when the CLI enables **Docker sandboxing** for `jaiph run` (environment-driven; there is no `jaiph run --docker` flag — see [Sandboxing](sandboxing.md)). On **`win32`** the Docker sandbox is out of scope: **`resolveDockerConfig`** forces host-only mode (same UX as an explicit **`JAIPH_UNSAFE=true`**) with a one-line notice, so the CLI never probes `docker` and never hard-fails on a missing daemon (`JAIPH_DOCKER_ENABLED=true` cannot override this). The container runs the same **`jaiph run --raw`** / **`__workflow-runner`** entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image; every selected image must already contain `jaiph` (no auto-install or derived-image build at runtime). Image preparation (`prepareImage`) runs before the CLI banner: it checks whether the image is local, pulls with `--quiet` if needed (short status lines on stderr instead of Docker’s default pull UI), and verifies that `jaiph` exists in the image. `spawnDockerProcess` does not pull or verify — it receives a pre-resolved image. The spawn call uses `stdio: ["ignore", "pipe", "pipe"]` — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits.
- **Workspace immutability:** By default Docker runs cannot modify the host workspace. The host checkout is mounted read-only (overlay) or as a disposable clone (copy); `/jaiph/workspace` is sandbox-local and discarded on exit. The only host-writable path is `/jaiph/run` (run artifacts). Workflows that need to capture workspace changes should write files (for example a `git diff` into a temp path) and publish them with `artifacts.save()`. The explicit opt-in **inplace** mode (truthy **`JAIPH_INPLACE`** — `1` or `true`, or `jaiph run --inplace`) breaks this contract on purpose — the host workspace itself is bind-mounted read-write so the run's edits persist live on the host, with the rest of the sandbox (caps, env allowlist, mount set) unchanged. See [Sandboxing](sandboxing.md) for the full contract and [Save artifacts](artifacts.md).
## Local module graph
@@ -170,7 +170,7 @@ Authoring rules, fixtures, and mock syntax for `*.test.jh` are documented in [Te
## CLI progress reporting pipeline
-The progress UI combines a **static** step tree derived from the workflow AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` / `ensure` arms; matched targets appear as child items in the step tree (for example `▸ workflow my_flow` or `▸ rule my_rule` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**.
+The progress UI combines a **static** step tree derived from the workflow AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — **`canUseAnsi()`** (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR` unset — and every color emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it rather than re-deriving `isTTY && NO_COLOR` locally. On Windows 10+ Node enables console VT processing automatically, so `isTTY` is a sufficient ANSI proxy with no extra win32 branch. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` / `ensure` arms; matched targets appear as child items in the step tree (for example `▸ workflow my_flow` or `▸ rule my_rule` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**.
## Distribution: Node vs Bun standalone
diff --git a/docs/configuration.md b/docs/configuration.md
index 83bdb949..d7dc7492 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,14 +83,17 @@ In-file `runtime.docker_enabled` is not supported (`E_PARSE`); use the env-only
## Docker enablement
+Checks are applied top to bottom; the first match wins.
+
| Check | Result |
|---|---|
+| Platform is Windows (`win32`) | Docker off (host-only mode, with a one-line notice). Overrides everything below, including `JAIPH_DOCKER_ENABLED=true`. |
| `JAIPH_DOCKER_ENABLED` is set to exact `true` | Docker on. |
| `JAIPH_DOCKER_ENABLED` is set to any other value | Docker off. |
| `JAIPH_DOCKER_ENABLED` is unset and `JAIPH_UNSAFE=true` | Docker off. |
| Default (no env) | Docker on. |
-`CI=true` does not change this default. Host `jaiph run --raw` never consults this branch — the workflow runner is local in that path. See [Sandboxing](sandboxing.md) for the full model.
+`CI=true` does not change this default. Host `jaiph run --raw` never consults this branch — the workflow runner is local in that path. On Windows the Docker sandbox is out of scope, so `jaiph run` resolves to host-only mode automatically without probing `docker` or failing on a missing daemon — see [Sandboxing — Windows runs host-only](sandboxing.md#windows-runs-host-only) for the full model.
## Precedence
{: #precedence}
diff --git a/docs/env-vars.md b/docs/env-vars.md
index 5f232352..42927c27 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -43,7 +43,7 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_CODEX_API_URL` | runtime | string | `https://api.openai.com/v1/chat/completions` | — | Chat-completions endpoint for the `codex` backend. |
| `JAIPH_DEBUG` | host, runtime | bool (exact `"true"`) | `false` | `run.debug` | Enable debug tracing for the run. |
| `JAIPH_DEBUG_LOCKED` | internal | bool | — | — | Lock flag for `JAIPH_DEBUG`. |
-| `JAIPH_DOCKER_ENABLED` | host | bool (exact `true`) | — | — | Force Docker on (`true`) or off (any other value). When unset, Docker is on unless `JAIPH_UNSAFE=true`. |
+| `JAIPH_DOCKER_ENABLED` | host | bool (exact `true`) | — | — | Force Docker on (`true`) or off (any other value). When unset, Docker is on unless `JAIPH_UNSAFE=true`. Ignored on Windows (`win32`), where the sandbox is out of scope and runs are always host-only. |
| `JAIPH_DOCKER_IMAGE` | host | string | `ghcr.io/jaiphlang/jaiph-runtime:` | `runtime.docker_image` | Container image. Must already contain `jaiph`. |
| `JAIPH_DOCKER_KEEP_SANDBOX` | host | bool (`1` / `true`) | `false` | — | Copy mode only — when enabled, leave the host-side `.sandbox-/` clone on disk after exit for debugging. |
| `JAIPH_DOCKER_NETWORK` | host | string (`default`, `none`, or named network) | `default` | `runtime.docker_network` | `docker run --network` value. `none` disables egress. |
@@ -115,7 +115,7 @@ These error codes surface during Docker-backed `jaiph run` invocations. They are
| Code | Trigger | Behaviour |
|---|---|---|
-| `E_DOCKER_NOT_FOUND` | `docker info` fails (Docker not installed or daemon not running). | Run exits before launch. No fallback to local execution. |
+| `E_DOCKER_NOT_FOUND` | `docker info` fails (Docker not installed or daemon not running). | Run exits before launch. No fallback to local execution. Not reachable on Windows, where the CLI resolves to host-only mode without probing `docker`. |
| `E_DOCKER_PULL` | `docker pull` fails (network error, image not found, auth failure). | Run exits before launch. |
| `E_DOCKER_NO_JAIPH` | Selected image does not contain a `jaiph` CLI. | Run exits before launch. |
| `E_DOCKER_RUNS_DIR` | Absolute `JAIPH_RUNS_DIR` points outside the workspace. | Run exits before launch. |
diff --git a/docs/sandboxing.md b/docs/sandboxing.md
index 054626f2..c1523b4a 100644
--- a/docs/sandboxing.md
+++ b/docs/sandboxing.md
@@ -75,6 +75,10 @@ A second, equally deliberate choice: **enablement lives entirely in environment
The escape hatch — `JAIPH_UNSAFE=true` or `jaiph run --unsafe` — exists because some environments genuinely cannot run Docker (a sandboxed CI without nested virtualization, a developer iterating on the runtime itself). The choice to take that hatch should be visible and ergonomic, which is why it is a single explicit host-side switch rather than an in-file `config` knob.
+## Windows runs host-only
+
+On Windows (`win32`) the Docker sandbox is out of scope: the sandbox modes rely on POSIX socket paths and Linux/macOS-specific workspace presentation, so Jaiph does not attempt them there. `jaiph run` on Windows resolves to **host-only mode automatically** — the same posture as an explicit `JAIPH_UNSAFE=true` — and prints a one-line notice that the run is host-only. The CLI never probes for `docker` and never fails just because a Docker daemon is absent, and `JAIPH_DOCKER_ENABLED=true` cannot force the sandbox back on. Windows workflows therefore run with no OS sandbox; keep the [not-protected-against](#what-docker-does-not-protect-against) list in mind, or run under WSL, where the Linux path (and the full sandbox) applies.
+
## Why `jaiph test` does not use Docker
The test runner runs in-process on the host. This is intentional: tests are a development feedback loop, they typically mock prompts and replace external calls, and Docker spawn overhead would harm the iteration cycle. Tests already get isolation from the things they care about (prompts, network) through the runtime's mock infrastructure. The Docker boundary is for `jaiph run`, where the workflow is executing real scripts against real resources.
diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts
index 5f143e54..aa2b73c7 100644
--- a/src/cli/commands/run.ts
+++ b/src/cli/commands/run.ts
@@ -12,6 +12,7 @@ import { basename } from "node:path";
import { parsejaiph } from "../../parser";
import { buildScripts, buildScriptsFromGraph } from "../../transpiler";
import { loadModuleGraph, writeModuleGraph } from "../../transpile/module-graph";
+import { canUseAnsi } from "../../runtime/kernel/portability";
import { metadataToConfig } from "../../config";
import { buildStepDisplayParamPairs, formatNamedParamsForDisplay } from "./format-params.js";
import {
@@ -130,7 +131,7 @@ export async function runWorkflow(rest: string[]): Promise {
const outDir = target ? resolve(target) : mkdtempSync(join(tmpdir(), "jaiph-run-"));
const shouldCleanup = !target;
try {
- const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const colorEnabled = canUseAnsi();
const isTTY = !!process.stdout.isTTY;
const startedAt = Date.now();
diff --git a/src/cli/run/progress.ts b/src/cli/run/progress.ts
index 546c7aac..f3b9b9b8 100644
--- a/src/cli/run/progress.ts
+++ b/src/cli/run/progress.ts
@@ -1,6 +1,7 @@
import { resolve } from "node:path";
import { jaiphModule, type Expr, type WorkflowStepDef } from "../../types";
import { workflowSymbolForFile } from "../../transpiler";
+import { canUseAnsi } from "../../runtime/kernel/portability";
export type TreeRow = {
rawLabel: string;
@@ -282,7 +283,7 @@ export function parseLabel(rawLabel: string): { kind: string; name: string } {
export function styleKeywordLabel(rawLabel: string): string {
const { kind, name } = parseLabel(rawLabel);
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return `${kind} ${name}`;
}
@@ -290,7 +291,7 @@ export function styleKeywordLabel(rawLabel: string): string {
}
export function styleDim(text: string): string {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return text;
}
@@ -298,7 +299,7 @@ export function styleDim(text: string): string {
}
export function styleYellow(text: string): string {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return text;
}
@@ -306,7 +307,7 @@ export function styleYellow(text: string): string {
}
export function styleBold(text: string): string {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return text;
}
diff --git a/src/cli/shared/errors.ts b/src/cli/shared/errors.ts
index 91582395..f7c13888 100644
--- a/src/cli/shared/errors.ts
+++ b/src/cli/shared/errors.ts
@@ -1,9 +1,10 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { CONTAINER_RUN_DIR } from "../../runtime/docker";
+import { canUseAnsi } from "../../runtime/kernel/portability";
export function colorPalette(): { green: string; red: string; dim: string; reset: string } {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return { green: "", red: "", dim: "", reset: "" };
}
diff --git a/src/runtime/docker.test.ts b/src/runtime/docker.test.ts
index a227fac4..ffbdfcb8 100644
--- a/src/runtime/docker.test.ts
+++ b/src/runtime/docker.test.ts
@@ -22,7 +22,9 @@ import {
cleanupDocker,
withDockerExitGuard,
_dockerExec,
+ _dockerSpawn,
_uidDetect,
+ _win32Notice,
type DockerRunConfig,
type DockerSpawnOptions,
type DockerSpawnResult,
@@ -220,6 +222,42 @@ test("resolveDockerConfig: in-file dockerEnabled is ignored (field removed from
assert.equal(cfg.enabled, false, "JAIPH_UNSAFE disables Docker regardless of in-file");
});
+test("resolveDockerConfig: win32 forces host-only mode, notice once, zero docker invocations", () => {
+ const origPlatform = Object.getOwnPropertyDescriptor(process, "platform");
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
+ // Spy every docker entry point so we can assert win32 never probes.
+ const origExec = _dockerExec.run;
+ const origSpawn = _dockerSpawn.run;
+ const origWrite = _win32Notice.write;
+ const origEmitted = _win32Notice.emitted;
+ let execCalls = 0;
+ let spawnCalls = 0;
+ const notices: string[] = [];
+ _dockerExec.run = () => { execCalls += 1; };
+ _dockerSpawn.run = () => { spawnCalls += 1; return {} as any; };
+ _win32Notice.write = (msg) => { notices.push(msg); };
+ _win32Notice.emitted = false;
+ try {
+ // Even an explicit JAIPH_DOCKER_ENABLED=true cannot re-enable Docker on win32.
+ const first = resolveDockerConfig(undefined, { JAIPH_DOCKER_ENABLED: "true" });
+ const second = resolveDockerConfig(undefined, {});
+ assert.equal(first.enabled, false, "win32 resolves to host-only mode");
+ assert.equal(second.enabled, false, "win32 resolves to host-only mode on every call");
+ assert.equal(notices.length, 1, "notice is emitted exactly once across calls");
+ assert.match(notices[0], /Windows/, "notice mentions Windows");
+ assert.match(notices[0], /host-only|no sandbox/, "notice describes host-only mode");
+ assert.equal(execCalls, 0, "no docker exec probing on win32");
+ assert.equal(spawnCalls, 0, "no docker spawn on win32");
+ } finally {
+ _dockerExec.run = origExec;
+ _dockerSpawn.run = origSpawn;
+ _win32Notice.write = origWrite;
+ _win32Notice.emitted = origEmitted;
+ if (origPlatform) Object.defineProperty(process, "platform", origPlatform);
+ else Object.defineProperty(process, "platform", { value: process.platform, configurable: true });
+ }
+});
+
test("checkDockerAvailable: E_DOCKER_NOT_FOUND message mentions JAIPH_UNSAFE", () => {
const src = readFileSync(join(__dirname, "docker.ts"), "utf8");
assert.ok(
diff --git a/src/runtime/docker.ts b/src/runtime/docker.ts
index b281f050..b1e49c04 100644
--- a/src/runtime/docker.ts
+++ b/src/runtime/docker.ts
@@ -89,9 +89,34 @@ const DEFAULTS: DockerRunConfig = {
timeoutSeconds: 14400,
};
+/**
+ * Test seam for the one-time win32 host-only notice. Tests reset `emitted`
+ * between runs and can spy `write` to assert the notice fires exactly once.
+ */
+export const _win32Notice = {
+ emitted: false,
+ write(message: string): void {
+ process.stderr.write(message);
+ },
+};
+
+/** Emit the win32 host-only notice at most once per process. */
+function emitWin32HostOnlyNotice(): void {
+ if (_win32Notice.emitted) return;
+ _win32Notice.emitted = true;
+ _win32Notice.write(
+ "jaiph: Docker sandbox is not supported on Windows; running host-only (no sandbox).\n",
+ );
+}
+
/**
* Resolve effective Docker config.
- * Precedence: env vars (`JAIPH_DOCKER_*`) > unsafe default rule.
+ * Precedence: platform > env vars (`JAIPH_DOCKER_*`) > unsafe default rule.
+ *
+ * On win32 the Docker sandbox is out of scope: resolution is forced to
+ * host-only mode (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line
+ * notice, so the CLI never probes `docker` and never hard-fails on a missing
+ * daemon. `JAIPH_DOCKER_ENABLED=true` cannot override this.
*
* Default rule (when no explicit `JAIPH_DOCKER_ENABLED` is set):
* - `JAIPH_UNSAFE=true` → Docker off (explicit "run on host" escape hatch)
@@ -101,9 +126,12 @@ export function resolveDockerConfig(
inFile: RuntimeConfig | undefined,
env: Record,
): DockerRunConfig {
- // enabled: env JAIPH_DOCKER_ENABLED > unsafe default rule
+ // enabled: win32 host-only override > env JAIPH_DOCKER_ENABLED > unsafe default rule
let enabled: boolean;
- if (env.JAIPH_DOCKER_ENABLED !== undefined) {
+ if (process.platform === "win32") {
+ emitWin32HostOnlyNotice();
+ enabled = false;
+ } else if (env.JAIPH_DOCKER_ENABLED !== undefined) {
enabled = env.JAIPH_DOCKER_ENABLED === "true";
} else {
// Default: Docker on unless the user explicitly opts out via JAIPH_UNSAFE.
diff --git a/src/runtime/kernel/portability.test.ts b/src/runtime/kernel/portability.test.ts
index f95a3476..173d42a2 100644
--- a/src/runtime/kernel/portability.test.ts
+++ b/src/runtime/kernel/portability.test.ts
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
-import { killProcessTree, resolveShell, _portability } from "./portability";
+import { killProcessTree, resolveShell, canUseAnsi, _portability } from "./portability";
// Precedent for platform stubbing: src/runtime/docker.test.ts.
function withPlatform(platform: string, fn: () => void): void {
@@ -280,6 +280,67 @@ test("resolveShell memoizes: fileExists is not consulted on the second call", ()
});
});
+// ---------------------------------------------------------------------------
+// canUseAnsi(): single ANSI color/erase policy for the CLI.
+// ---------------------------------------------------------------------------
+
+/** Run `fn` with NO_COLOR forced to a given presence. */
+function withNoColor(value: string | undefined, fn: () => void): void {
+ const saved = process.env.NO_COLOR;
+ if (value === undefined) delete process.env.NO_COLOR;
+ else process.env.NO_COLOR = value;
+ try {
+ fn();
+ } finally {
+ if (saved === undefined) delete process.env.NO_COLOR;
+ else process.env.NO_COLOR = saved;
+ }
+}
+
+test("canUseAnsi: true only when isTTY and NO_COLOR is unset", () => {
+ withNoColor(undefined, () => {
+ assert.equal(canUseAnsi({ isTTY: true }), true);
+ });
+});
+
+test("canUseAnsi: false when isTTY is false", () => {
+ withNoColor(undefined, () => {
+ assert.equal(canUseAnsi({ isTTY: false }), false);
+ assert.equal(canUseAnsi({}), false);
+ });
+});
+
+test("canUseAnsi: false when NO_COLOR is set, even on a TTY", () => {
+ withNoColor("1", () => {
+ assert.equal(canUseAnsi({ isTTY: true }), false);
+ });
+ // NO_COLOR honored even when empty (https://no-color.org: any value).
+ withNoColor("", () => {
+ assert.equal(canUseAnsi({ isTTY: true }), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Lint contract: the `isTTY && NO_COLOR` policy lives only in portability.ts.
+// ---------------------------------------------------------------------------
+
+test("no production source outside portability.ts gates directly on isTTY && NO_COLOR", () => {
+ // Match either ordering of the combined gate on a single line.
+ const combinedGate = /(isTTY[^\n]*NO_COLOR)|(NO_COLOR[^\n]*isTTY)/;
+ const offenders: string[] = [];
+ for (const file of walkProductionTsFiles(SRC_ROOT)) {
+ const rel = file.slice(REPO_ROOT.length + 1);
+ if (rel === join("src", "runtime", "kernel", "portability.ts")) continue;
+ const content = readFileSync(file, "utf8");
+ if (combinedGate.test(content)) offenders.push(rel);
+ }
+ assert.deepEqual(
+ offenders,
+ [],
+ `ANSI gating must route through canUseAnsi(); offenders: ${offenders.join(", ")}`,
+ );
+});
+
// ---------------------------------------------------------------------------
// Lint contract: no `spawn("sh", …)` outside the portability module.
// ---------------------------------------------------------------------------
diff --git a/src/runtime/kernel/portability.ts b/src/runtime/kernel/portability.ts
index d8e8be76..4f4c6263 100644
--- a/src/runtime/kernel/portability.ts
+++ b/src/runtime/kernel/portability.ts
@@ -34,6 +34,22 @@ export const _portability = {
},
};
+/**
+ * Whether ANSI SGR colors and cursor/erase sequences may be emitted.
+ *
+ * Single policy for the whole CLI: ANSI is on only when writing to a TTY and
+ * `NO_COLOR` is unset (https://no-color.org). Live status rendering, the
+ * progress tree, and error formatting route their `isTTY && NO_COLOR` gate
+ * through here so the rule lives in one place rather than being re-derived at
+ * each emission site.
+ *
+ * On Windows 10+ Node enables VT processing on the console automatically, so
+ * `isTTY` is a sufficient proxy for ANSI support — no extra win32 branch.
+ */
+export function canUseAnsi(stream: { isTTY?: boolean } = process.stdout): boolean {
+ return Boolean(stream.isTTY) && process.env.NO_COLOR === undefined;
+}
+
let cachedShell: string | undefined;
/**
diff --git a/src/runtime/kernel/prompt.test.ts b/src/runtime/kernel/prompt.test.ts
index 1e042d7c..7d875a72 100644
--- a/src/runtime/kernel/prompt.test.ts
+++ b/src/runtime/kernel/prompt.test.ts
@@ -467,6 +467,52 @@ describe("prepareClaudeEnv", () => {
}
});
+ it("resolves the config dir from os.homedir() when HOME is unset", () => {
+ // The named `import { homedir }` in prompt.ts reads node:os's raw (mutable)
+ // require object at call time, so stubbing homedir on that same cached
+ // object changes what prepareClaudeEnv sees. Also clear process.env.HOME so
+ // the os.homedir() fallback (not the ambient HOME) is what resolves.
+ const osRaw = require("node:os") as { homedir: () => string };
+ const root = mkdtempSync(join(tmpdir(), "jaiph-claude-env-homedir-"));
+ const origHomedir = osRaw.homedir;
+ const savedHome = process.env.HOME;
+ delete process.env.HOME;
+ osRaw.homedir = () => root;
+ try {
+ const prepared = prepareClaudeEnv({}, join(root, "workspace"));
+ assert.equal(prepared.error, undefined);
+ assert.equal(prepared.warning, undefined);
+ // A writable /.claude means execEnv is returned unchanged and
+ // session-env is created there — proving os.homedir() was the source.
+ assert.equal(prepared.env.CLAUDE_CONFIG_DIR, undefined);
+ assert.ok(existsSync(join(root, ".claude", "session-env")));
+ } finally {
+ osRaw.homedir = origHomedir;
+ if (savedHome === undefined) delete process.env.HOME;
+ else process.env.HOME = savedHome;
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ it("prefers execEnv.HOME over os.homedir()", () => {
+ const osRaw = require("node:os") as { homedir: () => string };
+ const homeRoot = mkdtempSync(join(tmpdir(), "jaiph-claude-env-home-wins-"));
+ const homedirRoot = mkdtempSync(join(tmpdir(), "jaiph-claude-env-homedir-unused-"));
+ const origHomedir = osRaw.homedir;
+ osRaw.homedir = () => homedirRoot;
+ try {
+ const prepared = prepareClaudeEnv({ HOME: homeRoot }, join(homeRoot, "workspace"));
+ assert.equal(prepared.error, undefined);
+ // Config resolves under execEnv.HOME, not the os.homedir() stub.
+ assert.ok(existsSync(join(homeRoot, ".claude", "session-env")));
+ assert.ok(!existsSync(join(homedirRoot, ".claude")));
+ } finally {
+ osRaw.homedir = origHomedir;
+ rmSync(homeRoot, { recursive: true, force: true });
+ rmSync(homedirRoot, { recursive: true, force: true });
+ }
+ });
+
it("falls back to workspace-local claude config when default is not writable", () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-claude-env-fallback-"));
try {
diff --git a/src/runtime/kernel/prompt.ts b/src/runtime/kernel/prompt.ts
index d09c4977..b124cb79 100644
--- a/src/runtime/kernel/prompt.ts
+++ b/src/runtime/kernel/prompt.ts
@@ -3,6 +3,7 @@
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import { writeFileSync, readFileSync, existsSync, accessSync, mkdirSync, cpSync, constants as fsConstants } from "node:fs";
import { basename, delimiter, join } from "node:path";
+import { homedir } from "node:os";
import { parseStream, type StreamWriter } from "./stream-parser";
import { consumeNextMockResponse, dispatchMockArms, type MockPromptArm } from "./mock";
import { killProcessTree } from "./portability";
@@ -229,7 +230,9 @@ type ClaudeEnvPreparation = {
* Falls back to workspace-local `.jaiph/claude-config` when home config is not writable.
*/
export function prepareClaudeEnv(execEnv: NodeJS.ProcessEnv, workspaceRoot: string): ClaudeEnvPreparation {
- const home = execEnv.HOME || process.env.HOME || "";
+ // Final fallback to os.homedir() so USERPROFILE-only environments (Windows)
+ // resolve; an explicit HOME in execEnv still wins.
+ const home = execEnv.HOME || process.env.HOME || homedir() || "";
const defaultConfigDir = home ? join(home, ".claude") : "";
const configuredDir = execEnv.CLAUDE_CONFIG_DIR || defaultConfigDir;
From 07fb4e2c2576f91e96b33741e62e07bd72083d57 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 14:16:01 +0200
Subject: [PATCH 05/85] Feat: build and release jaiph-windows-x64.exe
Add a Windows x64 target to the release workflow so every release ships
a fifth standalone binary alongside darwin/linux x arm64/x64. A new
bun-windows-x64 matrix entry (with an ext field so the compiled outfile
and uploaded artifact carry the .exe suffix) produces jaiph-windows-x64.exe;
Bun has no windows-arm64 target so Windows is x64 only. The .exe is
included in SHA256SUMS generation and in both the stable and nightly
gh release upload lists.
A new sanity-windows job runs on windows-latest, downloads the .exe
artifact, and runs jaiph-windows-x64.exe --version through a version
gate; the publish job now needs [build, sanity-windows], so a Windows
version mismatch fails the whole release. The linux-x64 gate's inline
comparison is extracted into a shared, testable
scripts/release-version-check.sh that both gates delegate to.
Update the release asset naming contract in docs/contributing.md and
docs/architecture.md, and add integration/release-workflow.test.ts
asserting the five-binary matrix, SHA256SUMS and upload-list coverage,
the shared gate behavior, and naming-contract/installer parity.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/release.yml | 83 ++++++++---
CHANGELOG.md | 1 +
QUEUE.md | 15 --
docs/architecture.md | 2 +-
docs/contributing.md | 7 +-
integration/release-workflow.test.ts | 201 +++++++++++++++++++++++++++
scripts/release-version-check.sh | 34 +++++
7 files changed, 304 insertions(+), 39 deletions(-)
create mode 100644 integration/release-workflow.test.ts
create mode 100755 scripts/release-version-check.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ab8d24d6..af3850a1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -57,15 +57,24 @@ jobs:
- target: bun-darwin-arm64
os: darwin
arch: arm64
+ ext: ""
- target: bun-darwin-x64
os: darwin
arch: x64
+ ext: ""
- target: bun-linux-x64
os: linux
arch: x64
+ ext: ""
- target: bun-linux-arm64
os: linux
arch: arm64
+ ext: ""
+ # Bun has no bun-windows-arm64 target; windows ships x64 only.
+ - target: bun-windows-x64
+ os: windows
+ arch: x64
+ ext: ".exe"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -88,20 +97,61 @@ jobs:
- name: Cross-compile standalone binary for ${{ matrix.target }}
run: |
set -euo pipefail
- bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "jaiph-${{ matrix.os }}-${{ matrix.arch }}"
- ls -la "jaiph-${{ matrix.os }}-${{ matrix.arch }}"
+ out="jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}"
+ bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "${out}"
+ ls -la "${out}"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
- name: jaiph-${{ matrix.os }}-${{ matrix.arch }}
- path: jaiph-${{ matrix.os }}-${{ matrix.arch }}
+ name: jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}
+ path: jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}
if-no-files-found: error
retention-days: 7
+ sanity-windows:
+ name: Sanity gate (windows-x64 --version)
+ needs: build
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Resolve tag and channel
+ id: meta
+ shell: bash
+ run: |
+ set -euo pipefail
+ case "${GITHUB_REF}" in
+ refs/tags/v*)
+ tag="${GITHUB_REF_NAME}"; channel="stable" ;;
+ refs/heads/nightly|refs/tags/nightly)
+ tag="nightly"; channel="nightly" ;;
+ *)
+ echo "Unsupported ref for release: ${GITHUB_REF}" >&2; exit 1 ;;
+ esac
+ echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
+ echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
+
+ - name: Download windows binary artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: jaiph-windows-x64.exe
+ path: release-assets
+
+ - name: Sanity gate (windows-x64 --version)
+ working-directory: release-assets
+ shell: bash
+ run: |
+ set -euo pipefail
+ got="$(./jaiph-windows-x64.exe --version)"
+ echo "got: ${got}"
+ bash ../scripts/release-version-check.sh \
+ "${{ steps.meta.outputs.channel }}" "${{ steps.meta.outputs.tag }}" "${got}"
+
release:
name: Publish release assets
- needs: build
+ needs: [build, sanity-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -138,7 +188,7 @@ jobs:
set -euo pipefail
ls -la
rm -f SHA256SUMS
- sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 > SHA256SUMS
+ sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 jaiph-windows-x64.exe > SHA256SUMS
cat SHA256SUMS
- name: Sanity gate (linux-x64 --version)
@@ -148,19 +198,8 @@ jobs:
chmod +x jaiph-linux-x64
got="$(./jaiph-linux-x64 --version)"
echo "got: ${got}"
- if [ "${{ steps.meta.outputs.channel }}" = "stable" ]; then
- tag="${{ steps.meta.outputs.tag }}"
- expected="jaiph ${tag#v}"
- if [ "${got}" != "${expected}" ]; then
- echo "Version sanity check failed: expected '${expected}', got '${got}'" >&2
- exit 1
- fi
- else
- if ! printf '%s\n' "${got}" | grep -Eq '^jaiph [0-9]+\.[0-9]+\.[0-9]+'; then
- echo "Version sanity check failed: '${got}' does not look like a jaiph version" >&2
- exit 1
- fi
- fi
+ bash ../scripts/release-version-check.sh \
+ "${{ steps.meta.outputs.channel }}" "${{ steps.meta.outputs.tag }}" "${got}"
- name: Publish stable release ${{ steps.meta.outputs.tag }}
if: steps.meta.outputs.channel == 'stable'
@@ -172,13 +211,15 @@ jobs:
gh release upload "${tag}" --clobber \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
else
gh release create "${tag}" \
--title "${tag}" \
- --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64) plus SHA256SUMS." \
+ --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64, windows x64) plus SHA256SUMS." \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
fi
@@ -191,6 +232,7 @@ jobs:
gh release upload nightly --clobber \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
else
gh release create nightly \
@@ -200,5 +242,6 @@ jobs:
--target "${GITHUB_SHA}" \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 102f19dc..32dfc3b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
diff --git a/QUEUE.md b/QUEUE.md
index b55ccf29..6f022f58 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,21 +14,6 @@ Process rules:
***
-## Distro: build and release `jaiph-windows-x64.exe` #dev-ready
-
-The release workflow (`.github/workflows/release.yml`) cross-compiles four assets (`jaiph-{darwin,linux}-{arm64,x64}`) via `bun build --compile` and publishes them with `SHA256SUMS`. Add Windows:
-
-* New matrix entry: `--target=bun-windows-x64`, asset name `jaiph-windows-x64.exe` (Bun has no Windows arm64 target — do not add one).
-* Include the `.exe` in `SHA256SUMS` generation and in both the stable and nightly `gh release` upload lists.
-* Add a Windows sanity gate alongside the existing linux-x64 one: run `jaiph-windows-x64.exe --version` on a `windows-latest` job and, for stable releases, assert the output matches the tag.
-* Update the "Release asset naming contract" in `docs/contributing.md` to include the new asset name.
-
-Acceptance:
-
-* A nightly release run publishes five binaries + `SHA256SUMS`, and the `.exe` checksum entry verifies against the downloaded asset.
-* The Windows sanity gate fails the release when `--version` output mismatches the tag (verified by test or a deliberate dry-run with a wrong version).
-* `docs/contributing.md` naming contract lists `jaiph-windows-x64.exe`; the e2e installer test's asset-name mapping and the contract stay in sync (grep/parity check).
-
## Distro: PowerShell installer at `jaiph.org/install.ps1` #dev-ready
The bash installer (`docs/install`) downloads a per-platform binary from the GitHub Release for a pinned ref and verifies it against `SHA256SUMS`. Windows users need a native equivalent — the bash script must keep rejecting Windows and point at the PowerShell one.
diff --git a/docs/architecture.md b/docs/architecture.md
index d72c38de..4e8a7ce8 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -176,7 +176,7 @@ The progress UI combines a **static** step tree derived from the workflow AST (`
- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `runtime/overlay-run.sh` and `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.), and copies **`runtime/overlay-run.sh`** from the repo root into **`dist/src/runtime/overlay-run.sh`**. The published `jaiph` bin is **`node dist/src/cli.js`**.
- **Standalone:** `npm run build:standalone` runs the same build, copies **`dist/src/runtime`** to **`dist/runtime`** beside the binary, then `bun build --compile ./src/cli.ts --outfile dist/jaiph`. Workflow launch self-spawns via **`process.execPath`** using the internal **`__workflow-runner`** argv marker (`src/runtime/kernel/workflow-launch.ts` + `src/cli/index.ts`): the node build invokes `node dist/src/cli.js __workflow-runner …`; the bun-compiled binary invokes itself, `jaiph __workflow-runner …`. The reserved marker is excluded from `--help`/usage and the file-shorthand path. `overlay-run.sh` and `docs/jaiph-skill.md` are also embedded base64 inside the executable via **`src/runtime/embedded-assets.ts`**, so the standalone artifact is **fully self-contained** — no sibling `runtime/` or `docs/` files required. The displayed `jaiph --version` string is sourced from the generated **`src/version.ts`** (codegen'd from `package.json` by `embed-assets`), so the literal is statically baked into both the `tsc` and the `bun build --compile` outputs without a runtime read of `package.json`. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** alone, or with **`dist/runtime`** alongside it for parity with the npm layout (table in [Contributing](contributing.md)).
-- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the four binaries, runs a `--version` sanity gate on the linux-x64 output, and uploads the five assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract).
+- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the six assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract).
## Mermaid architecture diagram
diff --git a/docs/contributing.md b/docs/contributing.md
index 05119b24..91e422fe 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -212,7 +212,7 @@ The workflow refuses to start when the git tree is dirty or when `v` al
Pushing a **`v*`** tag triggers two things in this repo:
1. **Docker image publish** — the `docker-publish` job in `ci.yml` pushes `ghcr.io/jaiphlang/jaiph-runtime:` and `:latest` after the other CI jobs succeed.
-2. **Standalone-binary release** — `.github/workflows/release.yml` cross-compiles the Bun-compiled standalone binary for four targets via `oven-sh/setup-bun` and `bun build --compile --target=…`, generates a `SHA256SUMS` file, runs a Linux x64 sanity gate (`./jaiph-linux-x64 --version` must equal `jaiph `), and uploads all five assets to the GitHub Release for the tag (creating it if needed). The release job waits for the `CI` workflow on the same SHA to succeed before publishing. Re-runs are available via `workflow_dispatch`.
+2. **Standalone-binary release** — `.github/workflows/release.yml` cross-compiles the Bun-compiled standalone binary for five targets via `oven-sh/setup-bun` and `bun build --compile --target=…`, generates a `SHA256SUMS` file, runs a Linux x64 sanity gate and a `windows-latest` Windows x64 sanity gate (`--version` must equal `jaiph ` for stable tags — both delegate to `scripts/release-version-check.sh`), and uploads all six assets to the GitHub Release for the tag (creating it if needed). The Windows gate is a required dependency of the publish job, so a version mismatch there fails the whole release. The release job waits for the `CI` workflow on the same SHA to succeed before publishing. Re-runs are available via `workflow_dispatch`.
Pushes to the **`nightly`** branch follow the same matrix and upload to a **rolling prerelease** tagged `nightly` (`gh release upload nightly --clobber`), so `jaiph use nightly` keeps working under the binary installer.
@@ -228,9 +228,10 @@ The installer (`docs/install`) downloads these exact filenames from the release
| `bun-darwin-x64` | `jaiph-darwin-x64` |
| `bun-linux-x64` | `jaiph-linux-x64` |
| `bun-linux-arm64` | `jaiph-linux-arm64` |
-| — | `SHA256SUMS` (covers all four binaries) |
+| `bun-windows-x64` | `jaiph-windows-x64.exe` |
+| — | `SHA256SUMS` (covers all five binaries) |
-Every release (stable `v*` and rolling `nightly`) ships exactly these five assets.
+Bun has no `bun-windows-arm64` target, so Windows ships x64 only. Every release (stable `v*` and rolling `nightly`) ships exactly these six assets.
### Local docs site (Jekyll)
diff --git a/integration/release-workflow.test.ts b/integration/release-workflow.test.ts
new file mode 100644
index 00000000..522b3bf5
--- /dev/null
+++ b/integration/release-workflow.test.ts
@@ -0,0 +1,201 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { createHash, randomBytes } from "node:crypto";
+import { spawnSync } from "node:child_process";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Acceptance for "Distro: build and release jaiph-windows-x64.exe".
+//
+// These guards turn each acceptance bullet into a check that fails when the
+// contract is violated:
+// 1. The release workflow builds/ships five binaries + SHA256SUMS, and a
+// checksum entry for the .exe verifies against the asset (round-trip via
+// the installer's own lookup).
+// 2. The shared version-check gate (scripts/release-version-check.sh, invoked
+// by both the linux-x64 and the windows-latest gate) fails on a
+// tag/version mismatch and passes on a match.
+// 3. The docs/contributing.md naming contract, the release matrix, the
+// installer (docs/install), and the e2e installer test all agree on the
+// asset names (grep/parity check).
+
+const REPO_ROOT = process.cwd();
+const RELEASE_YML = readFileSync(join(REPO_ROOT, ".github/workflows/release.yml"), "utf8");
+const CONTRIBUTING = readFileSync(join(REPO_ROOT, "docs/contributing.md"), "utf8");
+const INSTALLER = readFileSync(join(REPO_ROOT, "docs/install"), "utf8");
+const INSTALLER_TEST = readFileSync(join(REPO_ROOT, "e2e/tests/07_installer_binary.sh"), "utf8");
+const VERSION_CHECK = join(REPO_ROOT, "scripts/release-version-check.sh");
+
+// Single source of truth for the assets a release must ship.
+const BINARY_ASSETS = [
+ "jaiph-darwin-arm64",
+ "jaiph-darwin-x64",
+ "jaiph-linux-x64",
+ "jaiph-linux-arm64",
+ "jaiph-windows-x64.exe",
+];
+// Names the bash installer (and its e2e test) can construct from {os}×{arch}.
+const INSTALLER_ASSETS = [
+ "jaiph-darwin-arm64",
+ "jaiph-darwin-x64",
+ "jaiph-linux-x64",
+ "jaiph-linux-arm64",
+];
+
+// Slice a workflow's job/step body out of the YAML by a stable anchor so
+// per-section assertions don't accidentally match text from another job.
+function sliceBetween(text: string, start: string, end: string | null): string {
+ const from = text.indexOf(start);
+ assert.notEqual(from, -1, `expected to find "${start}" in workflow`);
+ const to = end === null ? text.length : text.indexOf(end, from + start.length);
+ assert.notEqual(to, -1, `expected to find "${end}" after "${start}"`);
+ return text.slice(from, to === text.length ? text.length : to);
+}
+
+// ── Acceptance 1: five binaries + SHA256SUMS are built and uploaded ───────────
+
+test("release matrix cross-compiles the windows-x64 target (x64 only)", () => {
+ assert.match(RELEASE_YML, /target:\s*bun-windows-x64/, "windows-x64 target present");
+ const winEntry = sliceBetween(RELEASE_YML, "target: bun-windows-x64", "steps:");
+ assert.match(winEntry, /os:\s*windows/);
+ assert.match(winEntry, /arch:\s*x64/);
+ assert.match(winEntry, /ext:\s*"\.exe"/);
+ // Bun has no windows arm64 target — do not add one.
+ assert.doesNotMatch(RELEASE_YML, /target:\s*bun-windows-arm64/, "no windows arm64 target");
+ // The four original targets are still built.
+ for (const target of ["bun-darwin-arm64", "bun-darwin-x64", "bun-linux-x64", "bun-linux-arm64"]) {
+ assert.match(RELEASE_YML, new RegExp(`target:\\s*${target}\\b`), `${target} still built`);
+ }
+});
+
+test("SHA256SUMS generation covers all five binaries including the .exe", () => {
+ const shaLine = RELEASE_YML.split("\n").find((l) => l.includes("sha256sum ") && l.includes("SHA256SUMS"));
+ assert.ok(shaLine, "found the sha256sum generation line");
+ for (const asset of BINARY_ASSETS) {
+ assert.ok(shaLine!.includes(asset), `SHA256SUMS covers ${asset}`);
+ }
+});
+
+test("both stable and nightly release uploads include the .exe and SHA256SUMS", () => {
+ const stable = sliceBetween(RELEASE_YML, "Publish stable release", "Publish nightly prerelease");
+ const nightly = sliceBetween(RELEASE_YML, "Publish nightly prerelease", null);
+ for (const section of [stable, nightly]) {
+ for (const asset of [...BINARY_ASSETS, "SHA256SUMS"]) {
+ assert.ok(section.includes(asset), `upload list includes ${asset}`);
+ }
+ }
+});
+
+test("a SHA256SUMS entry for the .exe verifies against the asset via the installer's lookup", () => {
+ // Mirror the release: hash a windows binary, write the SHA256SUMS line, then
+ // resolve it back with the exact awk lookup docs/install uses. A mismatch
+ // between "generation" and "verification" would fail here.
+ const dir = mkdtempSync(join(tmpdir(), "jaiph-sha-"));
+ try {
+ const asset = "jaiph-windows-x64.exe";
+ const binPath = join(dir, asset);
+ const bytes = randomBytes(4096);
+ writeFileSync(binPath, bytes);
+ const expected = createHash("sha256").update(bytes).digest("hex");
+ const sumsPath = join(dir, "SHA256SUMS");
+ writeFileSync(sumsPath, `${expected} ${asset}\n`);
+
+ // The installer resolves a checksum with this awk expression.
+ assert.match(INSTALLER, /awk -v name="\$\{BIN_NAME\}" '\$2 == name \|\| \$2 == "\*"name \{ print \$1 \}'/);
+ const looked = spawnSync(
+ "awk",
+ ["-v", `name=${asset}`, '$2 == name || $2 == "*"name { print $1 }', sumsPath],
+ { encoding: "utf8" },
+ );
+ assert.equal(looked.status, 0, looked.stderr);
+ assert.equal(looked.stdout.trim(), expected, "installer lookup returns the asset's checksum");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// ── Acceptance 2: the version sanity gate fails on a tag/version mismatch ──────
+
+function runVersionCheck(channel: string, tag: string, got: string) {
+ return spawnSync("bash", [VERSION_CHECK, channel, tag, got], { encoding: "utf8" });
+}
+
+test("version sanity gate fails when a stable --version mismatches the tag", () => {
+ const bad = runVersionCheck("stable", "v9.9.9", "jaiph 1.2.3");
+ assert.equal(bad.status, 1, "mismatch exits non-zero");
+ assert.match(bad.stderr, /Version sanity check failed/);
+
+ const good = runVersionCheck("stable", "v1.2.3", "jaiph 1.2.3");
+ assert.equal(good.status, 0, good.stderr);
+});
+
+test("version sanity gate only requires a version-shaped banner for nightly", () => {
+ const good = runVersionCheck("nightly", "nightly", "jaiph 0.10.0");
+ assert.equal(good.status, 0, good.stderr);
+
+ const bad = runVersionCheck("nightly", "nightly", "not-a-version");
+ assert.equal(bad.status, 1, "garbage banner fails even on nightly");
+ assert.match(bad.stderr, /Version sanity check failed/);
+});
+
+test("a windows-latest job runs the .exe --version through the shared gate and blocks publish", () => {
+ const job = sliceBetween(RELEASE_YML, "sanity-windows:", "\n release:");
+ assert.match(job, /runs-on:\s*windows-latest/);
+ assert.match(job, /jaiph-windows-x64\.exe --version/);
+ assert.match(job, /release-version-check\.sh/, "windows gate delegates to the shared script");
+ // The linux gate uses the same shared script (no duplicated comparison logic).
+ const linux = sliceBetween(RELEASE_YML, "Sanity gate (linux-x64 --version)", "Publish stable release");
+ assert.match(linux, /release-version-check\.sh/);
+ // A windows gate failure must fail the release: publish depends on it.
+ assert.match(RELEASE_YML, /needs:\s*\[build, sanity-windows\]/, "release job needs sanity-windows");
+});
+
+// ── Acceptance 3: contract ↔ matrix ↔ installer parity ────────────────────────
+
+function contractAssets(): string[] {
+ const section = sliceBetween(CONTRIBUTING, "#### Release asset naming contract", "### Local docs site");
+ const names = new Set();
+ for (const m of section.matchAll(/`(jaiph-[A-Za-z0-9.\-]+|SHA256SUMS)`/g)) {
+ names.add(m[1]);
+ }
+ return [...names];
+}
+
+test("the naming contract lists exactly the five binaries plus SHA256SUMS", () => {
+ const listed = contractAssets().sort();
+ const expected = [...BINARY_ASSETS, "SHA256SUMS"].sort();
+ assert.deepEqual(listed, expected, "contract asset set matches the release");
+ // The prose count stays in sync with the table.
+ assert.match(CONTRIBUTING, /exactly these six assets/);
+});
+
+test("release matrix builds exactly the binaries named in the contract", () => {
+ // Every contract binary maps to a matrix target of the same os/arch, and the
+ // matrix builds nothing the contract omits.
+ const matrixTargets = [...RELEASE_YML.matchAll(/target:\s*(bun-[a-z0-9-]+)/g)].map((m) => m[1]);
+ const built = matrixTargets
+ .map((t) => t.replace(/^bun-/, "jaiph-"))
+ .map((n) => (n === "jaiph-windows-x64" ? "jaiph-windows-x64.exe" : n))
+ .sort();
+ assert.deepEqual(built, [...BINARY_ASSETS].sort(), "matrix binaries == contract binaries");
+});
+
+test("installer and its e2e test can only produce asset names the contract lists", () => {
+ // Installer + e2e test construct names from {os}×{arch}; pin the construction
+ // so a rename in the contract that isn't mirrored here fails the parity check.
+ assert.match(INSTALLER, /BIN_NAME="jaiph-\$\{os\}-\$\{arch\}"/);
+ assert.match(INSTALLER_TEST, /HOST_BIN_NAME="jaiph-\$\{HOST_OS\}-\$\{HOST_ARCH\}"/);
+ const listed = new Set(contractAssets());
+ for (const asset of INSTALLER_ASSETS) {
+ assert.ok(listed.has(asset), `contract lists installer asset ${asset}`);
+ // Both bash sources still support the os/arch that produces this name.
+ const [, os, arch] = asset.split("-");
+ for (const src of [INSTALLER, INSTALLER_TEST]) {
+ assert.ok(src.includes(os), `bash source supports os ${os}`);
+ assert.ok(src.includes(arch), `bash source supports arch ${arch}`);
+ }
+ }
+ // The .exe is release-only; the bash installer does not download it.
+ assert.ok(listed.has("jaiph-windows-x64.exe"));
+});
diff --git a/scripts/release-version-check.sh b/scripts/release-version-check.sh
new file mode 100755
index 00000000..0cd0c5c2
--- /dev/null
+++ b/scripts/release-version-check.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+#
+# Sanity-check a compiled jaiph binary's `--version` output against the release
+# channel/tag. Shared by the linux-x64 and windows-x64 gates in
+# `.github/workflows/release.yml` so the comparison lives in one place and is
+# unit-testable (see integration/release-workflow.test.ts).
+#
+# Usage: release-version-check.sh
+# channel: "stable" | "nightly"
+# tag: release tag (e.g. "v0.10.0" for stable, "nightly" otherwise)
+# version-output: the string printed by ` --version`
+#
+# For a stable release the output must equal "jaiph " exactly.
+# For any other channel it only has to look like a jaiph version banner.
+set -euo pipefail
+
+channel="${1:?channel required}"
+tag="${2:?tag required}"
+got="${3?version output required}"
+
+if [ "${channel}" = "stable" ]; then
+ expected="jaiph ${tag#v}"
+ if [ "${got}" != "${expected}" ]; then
+ echo "Version sanity check failed: expected '${expected}', got '${got}'" >&2
+ exit 1
+ fi
+else
+ if ! printf '%s\n' "${got}" | grep -Eq '^jaiph [0-9]+\.[0-9]+\.[0-9]+'; then
+ echo "Version sanity check failed: '${got}' does not look like a jaiph version" >&2
+ exit 1
+ fi
+fi
+
+echo "Version sanity check passed: ${got}"
From dca4ea52c01a93111ac9ed590285b66d2255773f Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 14:44:42 +0200
Subject: [PATCH 06/85] Feat: PowerShell installer at jaiph.org/install.ps1
Add docs/install.ps1 as the Windows counterpart to the POSIX docs/install
(run with irm https://jaiph.org/install.ps1 | iex). It downloads
jaiph-windows-x64.exe and SHA256SUMS from the pinned release ref (default
the current stable tag, overridable via JAIPH_REPO_REF / first arg plus
JAIPH_RELEASE_BASE_URL for local file:// dirs used by tests), verifies the
SHA-256 with Get-FileHash and installs nothing on mismatch, then installs
to %LOCALAPPDATA%\jaiph\bin\jaiph.exe (overridable via JAIPH_BIN_DIR), adds
it to the user PATH if absent, and prints the same try-it hints as the bash
installer. Non-x64 / ARM Windows exits non-zero with a documented
unsupported-platform message.
The bash installer now rejects Windows-like uname values and points at the
PowerShell one-liner, and prepare_release.jh rewrites the pinned ref in both
installers in lockstep. CI gains an installer-powershell job on
windows-latest that cross-compiles the real .exe and runs
e2e/tests/installer_powershell.ps1 (checksum mismatch, unsupported arch,
happy-path install with no Node/npm/Bun on PATH); the Docker publish job now
needs it. Host-portable guards live in integration/installer-powershell.test.ts.
Docs updated (setup.md, index.html, architecture.md, contributing.md, README).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/ci.yml | 39 ++++++-
.jaiph/prepare_release.jh | 26 +++--
CHANGELOG.md | 1 +
QUEUE.md | 18 ---
README.md | 6 +
docs/architecture.md | 2 +-
docs/contributing.md | 5 +-
docs/index.html | 4 +
docs/install | 7 ++
docs/install.ps1 | 136 ++++++++++++++++++++++
docs/setup.md | 9 ++
e2e/tests/installer_powershell.ps1 | 141 +++++++++++++++++++++++
integration/installer-powershell.test.ts | 111 ++++++++++++++++++
13 files changed, 472 insertions(+), 33 deletions(-)
create mode 100644 docs/install.ps1
create mode 100644 e2e/tests/installer_powershell.ps1
create mode 100644 integration/installer-powershell.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c911083d..dfa0a737 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -275,9 +275,46 @@ jobs:
$bashScript = $bashScript -replace "`r", ""
wsl -d "$distro" -- bash -lc "$bashScript"
+ installer-powershell:
+ name: PowerShell installer (windows-x64)
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build TypeScript + embed assets
+ run: npm run build
+
+ # Same build as release.yml's windows-x64 leg, so the acceptance runs the
+ # real self-contained binary the release ships.
+ - name: Cross-compile windows-x64 standalone binary
+ shell: bash
+ run: |
+ set -euo pipefail
+ bun build --compile --target=bun-windows-x64 ./src/cli.ts --outfile jaiph-windows-x64.exe
+ ls -la jaiph-windows-x64.exe
+
+ - name: Run PowerShell installer acceptance (docs/install.ps1)
+ shell: pwsh
+ run: |
+ $env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe"
+ ./e2e/tests/installer_powershell.ps1
+
docker-publish:
name: Publish Docker runtime image
- needs: [test, e2e, docs-local, e2e-wsl]
+ needs: [test, e2e, docs-local, e2e-wsl, installer-powershell]
if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
diff --git a/.jaiph/prepare_release.jh b/.jaiph/prepare_release.jh
index a110429c..a57c7089 100755
--- a/.jaiph/prepare_release.jh
+++ b/.jaiph/prepare_release.jh
@@ -58,18 +58,20 @@ script npm_version_no_tag = `npm version "$1" --no-git-tag-version --allow-same-
script update_install_release_ref = ```python3
import sys
old, new = sys.argv[1], sys.argv[2]
-path = "docs/install"
-with open(path, "r", encoding="utf-8") as f:
- src = f.read()
needle = f"v{old}"
-count = src.count(needle)
-if count == 0:
- sys.stderr.write(f"docs/install: hardcoded ref v{old} not found\n")
- sys.exit(1)
-new_src = src.replace(needle, f"v{new}")
-with open(path, "w", encoding="utf-8") as f:
- f.write(new_src)
-print(count)
+total = 0
+# Both installers pin the same hardcoded ref; keep them in lockstep.
+for path in ("docs/install", "docs/install.ps1"):
+ with open(path, "r", encoding="utf-8") as f:
+ src = f.read()
+ count = src.count(needle)
+ if count == 0:
+ sys.stderr.write(f"{path}: hardcoded ref v{old} not found\n")
+ sys.exit(1)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(src.replace(needle, f"v{new}"))
+ total += count
+print(total)
```
script run_npm_build = `npm run build >&2`
@@ -124,7 +126,7 @@ workflow default(arg) {
log """
prepare_release: staged release v${version}
- package.json + package-lock.json (npm version ${version})
- - docs/install (release ref v${old_version} -> v${version})
+ - docs/install + docs/install.ps1 (release ref v${old_version} -> v${version})
- docs/registry (regenerated)
- dist/ (rebuilt; jaiph --version == jaiph ${version})
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32dfc3b2..3a6a2f11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`).
- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
diff --git a/QUEUE.md b/QUEUE.md
index 6f022f58..5028cbc8 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,24 +14,6 @@ Process rules:
***
-## Distro: PowerShell installer at `jaiph.org/install.ps1` #dev-ready
-
-The bash installer (`docs/install`) downloads a per-platform binary from the GitHub Release for a pinned ref and verifies it against `SHA256SUMS`. Windows users need a native equivalent — the bash script must keep rejecting Windows and point at the PowerShell one.
-
-Add `docs/install.ps1` (served as `https://jaiph.org/install.ps1`, `irm https://jaiph.org/install.ps1 | iex`):
-
-* Downloads `jaiph-windows-x64.exe` and `SHA256SUMS` from the pinned release ref (default: current stable tag; overridable via `JAIPH_REPO_REF` / first argument, mirroring the bash installer).
-* Verifies the SHA-256 (`Get-FileHash`) against `SHA256SUMS`; mismatch aborts and installs nothing.
-* Installs to `%LOCALAPPDATA%\jaiph\bin\jaiph.exe` (overridable via `JAIPH_BIN_DIR`), adds the directory to the user `PATH` if absent, and prints the same try-it hints as the bash installer.
-* Non-x64 / ARM Windows: exit with a documented unsupported-platform message.
-* Update `docs/install`'s unsupported-platform message to mention the PowerShell installer for Windows, and document the Windows install path in `docs/setup.md` and the main page install tabs.
-
-Acceptance:
-
-* Pester (or equivalent) tests run on `windows-latest` in CI, pointing the installer at a `file://`-style local release directory (same technique as `e2e/tests/07_installer_binary.sh`): happy path installs and `jaiph --version` works; checksum mismatch exits non-zero and leaves no binary; unsupported arch exits with the documented message.
-* The installed binary runs from a shell with no Node/npm/Bun on `PATH`.
-* `docs/setup.md` and the main page reference the PowerShell one-liner (docs parity check passes).
-
## Distro: main-page install tabs — Windows variant with platform auto-detect #dev-ready
The main page (`docs/index.html`) hero has three install tabs (run sample / init project / just install), all showing bash `curl … | bash` one-liners. Tab switching in `docs/assets/js/main.js` is click-only; there is no platform detection. Windows visitors currently see commands that cannot run natively.
diff --git a/README.md b/README.md
index 82fb3d97..bd6dbbe6 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,12 @@ Requires `node` and `curl`. The script installs Jaiph automatically if needed.
curl -fsSL https://jaiph.org/install | bash
```
+On Windows, install with PowerShell instead (installs `jaiph-windows-x64.exe` to `%LOCALAPPDATA%\jaiph\bin`):
+
+```powershell
+irm https://jaiph.org/install.ps1 | iex
+```
+
Or install from npm:
```bash
diff --git a/docs/architecture.md b/docs/architecture.md
index 4e8a7ce8..e9bd19e9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -176,7 +176,7 @@ The progress UI combines a **static** step tree derived from the workflow AST (`
- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `runtime/overlay-run.sh` and `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.), and copies **`runtime/overlay-run.sh`** from the repo root into **`dist/src/runtime/overlay-run.sh`**. The published `jaiph` bin is **`node dist/src/cli.js`**.
- **Standalone:** `npm run build:standalone` runs the same build, copies **`dist/src/runtime`** to **`dist/runtime`** beside the binary, then `bun build --compile ./src/cli.ts --outfile dist/jaiph`. Workflow launch self-spawns via **`process.execPath`** using the internal **`__workflow-runner`** argv marker (`src/runtime/kernel/workflow-launch.ts` + `src/cli/index.ts`): the node build invokes `node dist/src/cli.js __workflow-runner …`; the bun-compiled binary invokes itself, `jaiph __workflow-runner …`. The reserved marker is excluded from `--help`/usage and the file-shorthand path. `overlay-run.sh` and `docs/jaiph-skill.md` are also embedded base64 inside the executable via **`src/runtime/embedded-assets.ts`**, so the standalone artifact is **fully self-contained** — no sibling `runtime/` or `docs/` files required. The displayed `jaiph --version` string is sourced from the generated **`src/version.ts`** (codegen'd from `package.json` by `embed-assets`), so the literal is statically baked into both the `tsc` and the `bun build --compile` outputs without a runtime read of `package.json`. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** alone, or with **`dist/runtime`** alongside it for parity with the npm layout (table in [Contributing](contributing.md)).
-- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the six assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract).
+- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the six assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract). Two installers consume these assets: the POSIX **`docs/install`** (`curl … | bash`, darwin/linux; rejects Windows and points at the PowerShell one) and **`docs/install.ps1`** (`irm https://jaiph.org/install.ps1 | iex`, Windows x64), which downloads `jaiph-windows-x64.exe`, verifies it against `SHA256SUMS` with `Get-FileHash`, and installs to `%LOCALAPPDATA%\jaiph\bin`.
## Mermaid architecture diagram
diff --git a/docs/contributing.md b/docs/contributing.md
index 91e422fe..0c9751f2 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -171,6 +171,8 @@ Tests that span multiple modules, require subprocess/PTY harnesses, exercise pro
| `integration/docs-reference-task5.test.ts` | Integration | Reference quadrant — permalinks, nav placement, `env-vars.md` source parity against `src/`, anti-tutorial shape guards |
| `integration/docs-tutorials-task6.test.ts` | Integration | Tutorial quadrant — permalinks, `/getting-started` redirect absorption, runnable `first-workflow` snippet with documented output |
| `integration/docs-nav-structure-task7.test.ts` | Integration | Nav spine — five Diátaxis section headings in documented order; every published page under its quadrant exactly once |
+| `integration/release-workflow.test.ts` | Integration | Release matrix / asset-naming contract — five-binary matrix (no windows-arm64), `SHA256SUMS` + upload lists include `jaiph-windows-x64.exe`, shared version-gate script, naming contract ↔ matrix ↔ installer parity |
+| `integration/installer-powershell.test.ts` | Integration | Windows PowerShell installer (`docs/install.ps1`) contract — download/verify/install steps, bash↔PowerShell lockstep release ref, and `docs/setup.md` / main-page one-liner parity |
| `integration/sample-build/build.test.ts` | Integration | Build/transpile behavior — `buildScripts`, script extraction |
| `integration/sample-build/cli-tree.test.ts` | Integration | CLI tree output rendering for sample workflows |
| `integration/sample-build/run-core.test.ts` | Integration | Core runtime execution — workflow runs, step sequencing, artifacts |
@@ -187,7 +189,7 @@ The `integration/sample-build/` directory also has a shared `helpers.ts` module
## CI pipeline
-The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **six** jobs; on a typical feature-branch push, **five** of them run. The sixth — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, and WSL jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
+The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **seven** jobs; on a typical feature-branch push, **six** of them run. The seventh — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, WSL, and PowerShell-installer jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
| Job | Runner | Purpose |
|-----|--------|---------|
@@ -196,6 +198,7 @@ The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defin
| **E2E** | Matrix: **`ubuntu-latest` twice** + **`macos-latest`** | Job id `e2e`; in the Actions UI each leg appears as **`E2E (,
+
On Windows, install with PowerShell instead:
+
irm https://jaiph.org/install.ps1 | iex
+
This installs jaiph-windows-x64.exe to
+ %LOCALAPPDATA%\jaiph\bin and adds it to your user PATH.
Or install from npm: npm install -g jaiph
diff --git a/docs/install b/docs/install
index cd692382..716c178e 100755
--- a/docs/install
+++ b/docs/install
@@ -104,6 +104,13 @@ else
case "${uname_s}" in
Darwin) os="darwin" ;;
Linux) os="linux" ;;
+ MINGW*|MSYS*|CYGWIN*|Windows_NT)
+ print_error "Unsupported platform: ${uname_s} ${uname_m}"
+ echo "On Windows, install with PowerShell instead:"
+ echo " irm https://jaiph.org/install.ps1 | iex"
+ echo "Or build from source per https://jaiph.org/contributing#installing-from-source"
+ exit 1
+ ;;
*)
print_error "Unsupported platform: ${uname_s} ${uname_m}"
echo "Build from source per https://jaiph.org/contributing#installing-from-source"
diff --git a/docs/install.ps1 b/docs/install.ps1
new file mode 100644
index 00000000..b6899d4b
--- /dev/null
+++ b/docs/install.ps1
@@ -0,0 +1,136 @@
+#!/usr/bin/env pwsh
+#
+# Jaiph Windows installer — the native counterpart to docs/install (the POSIX
+# curl installer, which rejects Windows and points here). Downloads the pinned
+# release's jaiph-windows-x64.exe + SHA256SUMS, verifies the checksum, installs
+# to %LOCALAPPDATA%\jaiph\bin\jaiph.exe, and adds that dir to the user PATH.
+#
+# irm https://jaiph.org/install.ps1 | iex
+#
+# Overrides mirror docs/install (env, or first argument for the ref):
+# JAIPH_REPO_REF release ref (default: current stable tag)
+# JAIPH_BIN_DIR install dir (default: %LOCALAPPDATA%\jaiph\bin)
+# JAIPH_RELEASE_BASE_URL base URL for the release assets; a local directory
+# or file:// URL installs offline (used by the tests,
+# same technique as e2e/tests/07_installer_binary.sh).
+
+param([string]$RepoRef)
+
+$ErrorActionPreference = "Stop"
+
+# ── Output helpers (honour NO_COLOR, same as docs/install) ────────────────────
+$UseColor = -not (Test-Path Env:\NO_COLOR)
+function Write-Line { param([string]$Text, [string]$Color)
+ if ($UseColor -and $Color) { Write-Host $Text -ForegroundColor $Color } else { Write-Host $Text }
+}
+function Print-Step { param([string]$m) Write-Line "> $m" "DarkGray" }
+function Print-Success { param([string]$m) Write-Line "+ $m" "Green" }
+function Print-Warning { param([string]$m) Write-Line "! $m" "Yellow" }
+function Print-Error { param([string]$m) Write-Line "x $m" "Red" }
+
+# A local directory or file:// URL resolves to a filesystem path we copy from;
+# anything else is fetched over the network. Mirrors curl's native file:// support.
+function Resolve-LocalBase {
+ param([string]$BaseUrl)
+ if ($BaseUrl -like "file://*") { return ([uri]$BaseUrl).LocalPath }
+ if (Test-Path -LiteralPath $BaseUrl -PathType Container) { return (Resolve-Path -LiteralPath $BaseUrl).Path }
+ return $null
+}
+
+function Get-ReleaseAsset {
+ param([string]$BaseUrl, [string]$Name, [string]$OutFile)
+ $localBase = Resolve-LocalBase $BaseUrl
+ if ($localBase) {
+ $src = Join-Path $localBase $Name
+ if (-not (Test-Path -LiteralPath $src)) { throw "Failed to download $BaseUrl/$Name" }
+ Copy-Item -LiteralPath $src -Destination $OutFile -Force
+ } else {
+ Invoke-WebRequest -Uri "$BaseUrl/$Name" -OutFile $OutFile -UseBasicParsing
+ }
+}
+
+# Resolve the expected hash for $Name from a sha256sum-format SHA256SUMS file
+# (mirrors the installer's awk lookup: match the name, tolerate a `*` prefix).
+function Get-ExpectedSum {
+ param([string]$SumsFile, [string]$Name)
+ foreach ($line in Get-Content -LiteralPath $SumsFile) {
+ $parts = $line -split "\s+", 2
+ if ($parts.Count -lt 2) { continue }
+ $entry = $parts[1].Trim().TrimStart("*")
+ if ($entry -eq $Name) { return $parts[0].Trim() }
+ }
+ return $null
+}
+
+Write-Host ""
+Write-Line "Jaiph installer (Windows)" "White"
+Write-Host ""
+
+# ── Platform gate: Windows ships x64 only (Bun has no windows-arm64 target) ───
+$rawArch = if ($env:PROCESSOR_ARCHITECTURE) { $env:PROCESSOR_ARCHITECTURE } else { "" }
+if ($rawArch.ToUpper() -ne "AMD64" -and $rawArch.ToUpper() -ne "X64") {
+ Print-Error "Unsupported platform: windows $rawArch"
+ Write-Host "jaiph ships a Windows binary for x64 only."
+ Write-Host "Build from source per https://jaiph.org/contributing#installing-from-source"
+ exit 1
+}
+
+$RepoRef = if ($RepoRef) { $RepoRef } elseif ($env:JAIPH_REPO_REF) { $env:JAIPH_REPO_REF } else { "v0.10.0" }
+$BinName = "jaiph-windows-x64.exe"
+$BaseUrl = if ($env:JAIPH_RELEASE_BASE_URL) { $env:JAIPH_RELEASE_BASE_URL } else { "https://github.com/jaiphlang/jaiph/releases/download/$RepoRef" }
+$BinDir = if ($env:JAIPH_BIN_DIR) { $env:JAIPH_BIN_DIR } else { Join-Path $env:LOCALAPPDATA "jaiph\bin" }
+$Target = Join-Path $BinDir "jaiph.exe"
+
+$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("jaiph-install-" + [System.IO.Path]::GetRandomFileName())
+New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
+try {
+ Print-Step "Downloading $BinName ($RepoRef)..."
+ try { Get-ReleaseAsset $BaseUrl $BinName (Join-Path $tmpDir $BinName) }
+ catch { Print-Error "Failed to download $BaseUrl/$BinName"; exit 1 }
+
+ Print-Step "Downloading SHA256SUMS..."
+ try { Get-ReleaseAsset $BaseUrl "SHA256SUMS" (Join-Path $tmpDir "SHA256SUMS") }
+ catch { Print-Error "Failed to download $BaseUrl/SHA256SUMS"; exit 1 }
+
+ Print-Step "Verifying checksum..."
+ $expected = Get-ExpectedSum (Join-Path $tmpDir "SHA256SUMS") $BinName
+ if (-not $expected) { Print-Error "No checksum entry for $BinName in SHA256SUMS"; exit 1 }
+ $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $tmpDir $BinName)).Hash
+ if ($actual -ine $expected) {
+ Print-Error "Checksum mismatch for $BinName"
+ Write-Host " expected: $expected"
+ Write-Host " got: $actual"
+ exit 1
+ }
+
+ Print-Step "Installing binary to $Target..."
+ New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
+ Copy-Item -LiteralPath (Join-Path $tmpDir $BinName) -Destination $Target -Force
+} finally {
+ Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+$version = try { & $Target --version } catch { "jaiph ($RepoRef)" }
+Write-Host ""
+Print-Success "Installed $version to $Target"
+
+# Add the install dir to the user PATH if it is not already there.
+$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+$segments = if ($userPath) { $userPath -split ";" | Where-Object { $_ -ne "" } } else { @() }
+if ($segments -contains $BinDir) {
+ Print-Success "$BinDir is already on PATH"
+ Write-Host ""
+ Write-Host "Try:"
+ Write-Host " jaiph --version"
+ Write-Host " jaiph --help"
+} else {
+ $newPath = if ($userPath) { "$userPath;$BinDir" } else { $BinDir }
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
+ $env:Path = "$env:Path;$BinDir"
+ Print-Warning "Added $BinDir to your user PATH"
+ Write-Host ""
+ Write-Host "Open a new terminal, then try:"
+ Write-Host " jaiph --version"
+ Write-Host " jaiph --help"
+}
+Write-Host ""
diff --git a/docs/setup.md b/docs/setup.md
index e64585bc..55929f2b 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -17,6 +17,7 @@ The curl installer downloads a per-platform standalone binary from the current s
- A POSIX `sh` (the runtime uses `sh -c` for inline shell lines inside workflows). Each emitted `script` step runs under the interpreter named by its shebang (`bash` by default), so that interpreter must be on `PATH`; the runtime spawns it explicitly and does not rely on the file's exec bit, so scripts also work under `noexec` mounts.
- For the curl installer (step 1): `curl` and either `shasum` or `sha256sum` on `PATH`.
+- For the PowerShell installer (step 1, Windows): PowerShell (`irm`/`Invoke-WebRequest` and `Get-FileHash` are built in).
- For the npm alternative (step 1): Node.js and npm on the host.
## 1. Install the binary
@@ -29,6 +30,14 @@ curl -fsSL https://jaiph.org/install | bash
This downloads `jaiph-{darwin|linux}-{arm64|x64}` and `SHA256SUMS` from the current stable Release, verifies the checksum, and installs the binary to `~/.local/bin/jaiph`. Override the install location with `JAIPH_BIN_DIR`.
+**Windows (PowerShell):** the curl installer rejects Windows and points you here. Use the PowerShell one-liner instead:
+
+```powershell
+irm https://jaiph.org/install.ps1 | iex
+```
+
+This downloads `jaiph-windows-x64.exe` and `SHA256SUMS` from the current stable Release, verifies the checksum with `Get-FileHash`, installs the binary to `%LOCALAPPDATA%\jaiph\bin\jaiph.exe`, and adds that directory to your user `PATH` (open a new terminal to pick it up). Override the ref with `JAIPH_REPO_REF` (or the first argument) and the install location with `JAIPH_BIN_DIR`. Windows ships an x64 binary only — Bun has no Windows arm64 target, so ARM Windows exits with an unsupported-platform message.
+
(Alternative) Install via npm when you already have Node on the host and want package-manager-tracked installs:
```bash
diff --git a/e2e/tests/installer_powershell.ps1 b/e2e/tests/installer_powershell.ps1
new file mode 100644
index 00000000..be3d6fef
--- /dev/null
+++ b/e2e/tests/installer_powershell.ps1
@@ -0,0 +1,141 @@
+#!/usr/bin/env pwsh
+#
+# Acceptance for docs/install.ps1 (the Windows PowerShell installer), the native
+# counterpart to e2e/tests/07_installer_binary.sh. Network-free: it points the
+# installer at a local release directory via JAIPH_RELEASE_BASE_URL and shims the
+# architecture via PROCESSOR_ARCHITECTURE (same technique as the bash test's
+# file:// base URL and fake `uname`). It covers each acceptance bullet with a
+# check that fails when the contract is violated:
+# - checksum mismatch -> non-zero exit, nothing installed
+# - unsupported arch -> non-zero exit with the documented message
+# - happy path -> installs and `jaiph --version` works with no
+# Node/npm/Bun on PATH (self-contained binary)
+#
+# The happy-path case needs a real jaiph-windows-x64.exe; set
+# JAIPH_TEST_WINDOWS_EXE to a prebuilt binary to run it. Without it that case is
+# skipped, mirroring the bun-gated parity section of the bash test.
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
+$InstallScript = Join-Path $RepoRoot "docs\install.ps1"
+
+$script:Failures = 0
+function Report-Pass { param([string]$m) Write-Host "PASS: $m" -ForegroundColor Green }
+function Report-Fail { param([string]$m) Write-Host "FAIL: $m" -ForegroundColor Red; $script:Failures++ }
+function Assert-Equal {
+ param($Actual, $Expected, [string]$Message)
+ if ($Actual -eq $Expected) { Report-Pass $Message }
+ else { Report-Fail "$Message (expected '$Expected', got '$Actual')" }
+}
+function Assert-Contains {
+ param([string]$Haystack, [string]$Needle, [string]$Message)
+ if ($Haystack -like "*$Needle*") { Report-Pass $Message }
+ else { Report-Fail "$Message (missing '$Needle')`n---`n$Haystack`n---" }
+}
+
+# Run docs/install.ps1 as a child process so its `exit` does not stop this
+# runner. Env overrides are inherited from the current process.
+function Invoke-Installer {
+ $out = & pwsh -NoProfile -ExecutionPolicy Bypass -File $InstallScript 2>&1 | Out-String
+ return [pscustomobject]@{ Code = $LASTEXITCODE; Out = $out }
+}
+
+$OrigArch = $env:PROCESSOR_ARCHITECTURE
+$Work = Join-Path ([System.IO.Path]::GetTempPath()) ("jaiph-ps-test-" + [System.IO.Path]::GetRandomFileName())
+New-Item -ItemType Directory -Path $Work -Force | Out-Null
+
+try {
+ $BinName = "jaiph-windows-x64.exe"
+
+ # ── Checksum mismatch ───────────────────────────────────────────────────────
+ Write-Host "`n== Checksum mismatch fails and installs nothing =="
+ $relBad = Join-Path $Work "release-mismatch"
+ $binBad = Join-Path $Work "bin-mismatch"
+ New-Item -ItemType Directory -Path $relBad, $binBad -Force | Out-Null
+ Set-Content -Path (Join-Path $relBad $BinName) -Value "real-binary-bytes" -NoNewline
+ # Wrong hash so the installer reaches verify and fails (not a download error).
+ Set-Content -Path (Join-Path $relBad "SHA256SUMS") `
+ -Value ("0000000000000000000000000000000000000000000000000000000000000000 $BinName")
+
+ $env:PROCESSOR_ARCHITECTURE = "AMD64"
+ $env:JAIPH_RELEASE_BASE_URL = $relBad
+ $env:JAIPH_BIN_DIR = $binBad
+ $bad = Invoke-Installer
+ Assert-Equal $bad.Code 1 "checksum mismatch exits non-zero"
+ Assert-Contains $bad.Out "Checksum mismatch" "checksum mismatch is reported"
+ if (Test-Path (Join-Path $binBad "jaiph.exe")) {
+ Report-Fail "installer left a binary on checksum failure"
+ } else {
+ Report-Pass "checksum mismatch leaves no binary"
+ }
+
+ # ── Unsupported arch ────────────────────────────────────────────────────────
+ Write-Host "`n== Unsupported arch exits with the documented message =="
+ $binArm = Join-Path $Work "bin-arm"
+ New-Item -ItemType Directory -Path $binArm -Force | Out-Null
+ $env:PROCESSOR_ARCHITECTURE = "ARM64"
+ $env:JAIPH_BIN_DIR = $binArm
+ $arm = Invoke-Installer
+ Assert-Equal $arm.Code 1 "unsupported arch exits non-zero"
+ Assert-Contains $arm.Out "Unsupported platform: windows ARM64" "error names the detected arch"
+ Assert-Contains $arm.Out "contributing" "error points at the from-source instructions"
+ if (Test-Path (Join-Path $binArm "jaiph.exe")) {
+ Report-Fail "installer left a binary on unsupported arch"
+ } else {
+ Report-Pass "unsupported arch leaves no binary"
+ }
+ $env:PROCESSOR_ARCHITECTURE = "AMD64"
+
+ # ── Happy path (needs a real windows binary) ────────────────────────────────
+ Write-Host "`n== Happy path installs and --version works with no Node/npm/Bun =="
+ $exe = $env:JAIPH_TEST_WINDOWS_EXE
+ if (-not $exe -or -not (Test-Path $exe)) {
+ Write-Host "SKIP: JAIPH_TEST_WINDOWS_EXE not set — skipping happy-path install"
+ } else {
+ $relOk = Join-Path $Work "release-ok"
+ $binOk = Join-Path $Work "bin-ok"
+ New-Item -ItemType Directory -Path $relOk, $binOk -Force | Out-Null
+ Copy-Item -LiteralPath $exe -Destination (Join-Path $relOk $BinName) -Force
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $relOk $BinName)).Hash.ToLower()
+ Set-Content -Path (Join-Path $relOk "SHA256SUMS") -Value "$hash $BinName"
+
+ $env:JAIPH_RELEASE_BASE_URL = $relOk
+ $env:JAIPH_BIN_DIR = $binOk
+ $ok = Invoke-Installer
+ Assert-Equal $ok.Code 0 "happy path exits zero"
+
+ $Target = Join-Path $binOk "jaiph.exe"
+ if (Test-Path $Target) { Report-Pass "installed $Target" } else { Report-Fail "no binary installed at $Target" }
+ Assert-Contains $ok.Out "Added $binOk to your user PATH" "adds the install dir to PATH"
+
+ # Run the installed binary with only the Windows system dirs on PATH: it must
+ # work with no Node/npm/Bun visible (self-contained, like the bash parity check).
+ # (`$env:SystemRoot` is Windows-only; off-Windows local runs skip the PATH
+ # strip and just confirm the binary runs, since the separator/system dirs differ.)
+ $cleanPath = if ($env:SystemRoot) { "$env:SystemRoot\System32;$env:SystemRoot" } else { $env:Path }
+ if ($env:SystemRoot) {
+ foreach ($tool in "node", "npm", "bun") {
+ $visible = & pwsh -NoProfile -Command "`$env:Path = '$cleanPath'; [bool](Get-Command $tool -ErrorAction SilentlyContinue)"
+ if ($visible -eq "True") { Report-Fail "$tool unexpectedly visible on stripped PATH" }
+ }
+ }
+ $verOut = (& pwsh -NoProfile -Command "`$env:Path = '$cleanPath'; & '$Target' --version" | Out-String).Trim()
+ $pkgVersion = (Get-Content (Join-Path $RepoRoot "package.json") -Raw | ConvertFrom-Json).version
+ Assert-Equal $verOut "jaiph $pkgVersion" "jaiph --version works without Node/npm/Bun on PATH"
+ }
+} finally {
+ $env:PROCESSOR_ARCHITECTURE = $OrigArch
+ Remove-Item Env:\JAIPH_RELEASE_BASE_URL -ErrorAction SilentlyContinue
+ Remove-Item Env:\JAIPH_BIN_DIR -ErrorAction SilentlyContinue
+ Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Host ""
+if ($script:Failures -gt 0) {
+ Write-Host "$($script:Failures) check(s) failed" -ForegroundColor Red
+ exit 1
+}
+Write-Host "All PowerShell installer checks passed" -ForegroundColor Green
+exit 0
diff --git a/integration/installer-powershell.test.ts b/integration/installer-powershell.test.ts
new file mode 100644
index 00000000..56fcace1
--- /dev/null
+++ b/integration/installer-powershell.test.ts
@@ -0,0 +1,111 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+
+// Acceptance for "Distro: PowerShell installer at jaiph.org/install.ps1".
+//
+// The Pester-equivalent runtime behaviour (happy path / checksum mismatch /
+// unsupported arch) is exercised on windows-latest by
+// e2e/tests/installer_powershell.ps1. These host-portable guards cover the
+// contract that must hold everywhere and fail when it is violated:
+// 1. docs/install.ps1 exists and implements the download → verify → install
+// contract with the documented overrides and unsupported-arch message.
+// 2. The bash installer keeps rejecting Windows and points at the PowerShell
+// one; both installers pin the same release ref (prepare_release keeps
+// them in lockstep).
+// 3. docs/setup.md and the main page reference the PowerShell one-liner
+// (docs parity).
+
+const REPO_ROOT = process.cwd();
+const read = (p: string) => readFileSync(join(REPO_ROOT, p), "utf8");
+
+const PS_INSTALLER = read("docs/install.ps1");
+const BASH_INSTALLER = read("docs/install");
+const SETUP = read("docs/setup.md");
+const INDEX = read("docs/index.html");
+const PREPARE_RELEASE = read(".jaiph/prepare_release.jh");
+
+const ONE_LINER = "irm https://jaiph.org/install.ps1 | iex";
+
+// ── Acceptance 1: install.ps1 implements the contract ─────────────────────────
+
+test("docs/install.ps1 exists", () => {
+ assert.ok(existsSync(join(REPO_ROOT, "docs/install.ps1")), "docs/install.ps1 present");
+});
+
+test("install.ps1 downloads the windows-x64 asset and SHA256SUMS", () => {
+ assert.match(PS_INSTALLER, /jaiph-windows-x64\.exe/, "downloads the .exe asset");
+ assert.match(PS_INSTALLER, /SHA256SUMS/, "downloads SHA256SUMS");
+});
+
+test("install.ps1 verifies the checksum with Get-FileHash and aborts on mismatch", () => {
+ assert.match(PS_INSTALLER, /Get-FileHash\s+-Algorithm\s+SHA256/, "hashes with Get-FileHash SHA256");
+ assert.match(PS_INSTALLER, /Checksum mismatch/, "reports a checksum mismatch");
+ // The install copy must come after the verify (nothing installed on mismatch):
+ // the mismatch branch exits before the "Installing binary" step.
+ const mismatchIdx = PS_INSTALLER.indexOf("Checksum mismatch");
+ const installIdx = PS_INSTALLER.indexOf("Installing binary to");
+ assert.ok(mismatchIdx !== -1 && installIdx !== -1 && mismatchIdx < installIdx, "verify precedes install");
+});
+
+test("install.ps1 installs to %LOCALAPPDATA%\\jaiph\\bin\\jaiph.exe, overridable via JAIPH_BIN_DIR", () => {
+ assert.match(PS_INSTALLER, /LOCALAPPDATA/, "defaults under LOCALAPPDATA");
+ assert.match(PS_INSTALLER, /jaiph\\bin/, "installs into jaiph\\bin");
+ assert.match(PS_INSTALLER, /jaiph\.exe/, "target is jaiph.exe");
+ assert.match(PS_INSTALLER, /env:JAIPH_BIN_DIR/, "JAIPH_BIN_DIR override honoured");
+});
+
+test("install.ps1 supports JAIPH_REPO_REF / first-arg override and a base-url override", () => {
+ assert.match(PS_INSTALLER, /param\(\[string\]\$RepoRef\)/, "accepts a ref as the first argument");
+ assert.match(PS_INSTALLER, /env:JAIPH_REPO_REF/, "JAIPH_REPO_REF override honoured");
+ assert.match(PS_INSTALLER, /env:JAIPH_RELEASE_BASE_URL/, "release base URL is overridable (for tests)");
+});
+
+test("install.ps1 adds the install dir to the user PATH and prints the try-it hints", () => {
+ assert.match(PS_INSTALLER, /SetEnvironmentVariable\("Path",\s*\$newPath,\s*"User"\)/, "updates the user PATH");
+ assert.match(PS_INSTALLER, /jaiph --version/, "prints try-it hint: --version");
+ assert.match(PS_INSTALLER, /jaiph --help/, "prints try-it hint: --help");
+});
+
+test("install.ps1 rejects non-x64 Windows with a documented unsupported message", () => {
+ assert.match(PS_INSTALLER, /PROCESSOR_ARCHITECTURE/, "detects the arch");
+ assert.match(PS_INSTALLER, /Unsupported platform: windows/, "documented unsupported-platform message");
+ assert.match(PS_INSTALLER, /contributing#installing-from-source/, "points at the from-source instructions");
+ // x64 is accepted; there is no windows-arm64 asset to install.
+ assert.match(PS_INSTALLER, /"AMD64"|"X64"/, "x64 is the supported arch");
+});
+
+// ── Acceptance 2: bash installer rejects Windows and refs stay in lockstep ────
+
+test("bash installer rejects Windows and points at the PowerShell installer", () => {
+ assert.match(BASH_INSTALLER, /MINGW\*\|MSYS\*\|CYGWIN\*\|Windows_NT/, "detects Windows-like uname");
+ assert.match(BASH_INSTALLER, /irm https:\/\/jaiph\.org\/install\.ps1 \| iex/, "points at the PowerShell one-liner");
+ // The generic unsupported message (AIX etc.) is preserved for the e2e test.
+ assert.match(BASH_INSTALLER, /Unsupported platform: \$\{uname_s\} \$\{uname_m\}/);
+});
+
+test("both installers pin the same release ref", () => {
+ const bashRef = BASH_INSTALLER.match(/JAIPH_REPO_REF:-(v\d+\.\d+\.\d+)/);
+ const psRef = PS_INSTALLER.match(/else\s*\{\s*"(v\d+\.\d+\.\d+)"\s*\}/);
+ assert.ok(bashRef, "bash installer pins a vX.Y.Z ref");
+ assert.ok(psRef, "PowerShell installer pins a vX.Y.Z ref");
+ assert.equal(psRef![1], bashRef![1], "PowerShell ref matches the bash ref");
+});
+
+test("prepare_release refreshes both installers' pinned ref in lockstep", () => {
+ // The release-prep workflow must rewrite the ref in both files, or the
+ // PowerShell installer would drift to a stale release on the next bump.
+ assert.match(PREPARE_RELEASE, /"docs\/install",\s*"docs\/install\.ps1"/, "ref refresh covers both files");
+});
+
+// ── Acceptance 3: docs reference the PowerShell one-liner ─────────────────────
+
+test("docs/setup.md references the PowerShell one-liner and Windows install path", () => {
+ assert.ok(SETUP.includes(ONE_LINER), "setup.md includes the irm | iex one-liner");
+ assert.match(SETUP, /%LOCALAPPDATA%\\jaiph\\bin/, "setup.md documents the Windows install path");
+});
+
+test("the main page references the PowerShell one-liner", () => {
+ assert.ok(INDEX.includes(ONE_LINER), "index.html includes the irm | iex one-liner");
+});
From d75834468a08966be7d61b0fad79cda35bb4a754 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 15:01:54 +0200
Subject: [PATCH 07/85] Feat: main-page install tabs Windows variant w/
auto-detect
The landing page hero install card now offers a Windows PowerShell path
alongside the POSIX curl | bash one-liners. A new .os-switch sub-toggle
(macOS/Linux vs Windows) sits above the run-sample / init-project /
just-install tabs, and each panel wraps its content in .os-variant blocks.
On load, attachOsSwitch() (docs/assets/js/main.js) auto-selects the
Windows variant for Windows visitors via isWindowsPlatform(), which
prefers navigator.userAgentData?.platform and falls back to
navigator.platform. macOS/Linux visitors keep today's POSIX default with
no layout shift; manual switching stays available and is remembered
card-wide. The 'Just install' Windows variant exposes the copy-able
irm https://jaiph.org/install.ps1 | iex line; the run-sample and
init-project tabs (no PowerShell pipe equivalent) show the install
one-liner plus a short 'then run:' jaiph command instead of a bash-only
default. The static-render constraint holds: the POSIX variant carries
is-active in the markup and CSS hides inactive variants, so with JS
disabled all bash one-liners render and no panel is blank.
New Playwright cases in the docs suite assert the Windows default, the
unchanged macOS/Linux default, manual platform + tab switching, the
clipboard contents of the Windows copy button, and the JS-disabled
static render.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 18 -----
docs/assets/css/style.css | 35 +++++++++
docs/assets/js/main.js | 47 ++++++++++++
docs/index.html | 78 +++++++++++++------
e2e/playwright/landing-page.spec.ts | 114 +++++++++++++++++++++++++++-
6 files changed, 251 insertions(+), 42 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a6a2f11..d231ab29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: main-page install tabs — Windows variant with platform auto-detect:** The landing page (`docs/index.html`) hero install card now offers a Windows PowerShell path alongside the POSIX `curl … | bash` one-liners, and defaults to the right one for the visitor's platform. A new **`.os-switch`** sub-toggle (two buttons, `data-os="posix"` "macOS / Linux" and `data-os="windows"` "Windows", `role="group"`) sits above the existing run-sample / init-project / just-install tabs, and every tab panel now wraps its content in two **`.os-variant`** blocks (`data-os="posix"` / `data-os="windows"`). On load, **`attachOsSwitch()`** (`docs/assets/js/main.js`, scoped to `section.try-it-out .card`) auto-selects the Windows variant for Windows visitors via **`isWindowsPlatform()`** — which prefers `navigator.userAgentData?.platform` and falls back to `navigator.platform`, matching `/win/i` — while macOS/Linux visitors keep exactly today's POSIX default with no layout shift; **`setOsVariant()`** flips the `is-active` class on both the switch buttons and the variants across all three panels, so a manual platform choice (or a tab switch) is remembered card-wide. The **"Just install"** Windows variant is the copy-able `irm https://jaiph.org/install.ps1 | iex` line (via the existing copy button) plus the `npm install -g jaiph` alternative. The **"Run sample"** and **"Init project"** tabs have no PowerShell equivalent for their `curl … | bash -s` / `curl … | bash` pipes, so instead of showing a bash-only command as the Windows default they show the install one-liner followed by a short "then run:" `jaiph run say_hello.jh Adam` / `jaiph init` step (installing to `%LOCALAPPDATA%\jaiph\bin`). The static-render constraint holds: the POSIX variant carries `is-active` in the shipped markup and CSS keeps `.os-variant { display: none }` / `.os-variant.is-active { display: block }` (`docs/assets/css/style.css`), so with JS disabled all three bash one-liners render, no panel is blank, and the Windows variant stays reachable in the tab markup — JS only reveals it on demand (and by default for Windows). New Playwright cases in the docs suite (`e2e/playwright/landing-page.spec.ts`, alongside the "Try it out" test) emulate the platform before page scripts run and assert: a Windows visitor defaults to the `irm … | iex` command, a macOS/Linux visitor's default is unchanged from today, manual platform + tab switching works in both directions, the copy button on the Windows variant copies the exact `irm … | iex` line (asserted through a clipboard stub), and with JavaScript disabled all bash commands render with no blank panel. No `docs/*.md` or `README.md` change is needed — the Windows install path is already documented in `docs/setup.md` (`/how-to/install`) and the README install section; this task is landing-page presentation only, with no runtime, CLI, or installer behavior change.
- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`).
- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
diff --git a/QUEUE.md b/QUEUE.md
index 5028cbc8..c1ad77a5 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,24 +14,6 @@ Process rules:
***
-## Distro: main-page install tabs — Windows variant with platform auto-detect #dev-ready
-
-The main page (`docs/index.html`) hero has three install tabs (run sample / init project / just install), all showing bash `curl … | bash` one-liners. Tab switching in `docs/assets/js/main.js` is click-only; there is no platform detection. Windows visitors currently see commands that cannot run natively.
-
-Given a published PowerShell installer at `https://jaiph.org/install.ps1` (`irm https://jaiph.org/install.ps1 | iex`):
-
-* Add a Windows variant to the install section: at minimum the "Just install" panel must offer the PowerShell one-liner alongside the curl one (separate tab, sub-toggle, or swapped command — implementer's choice, but the PowerShell command must be copy-able via the existing copy button).
-* Auto-detect the visitor's platform on load (`navigator.userAgentData?.platform` with `navigator.platform` fallback) and default to the Windows variant for Windows visitors; macOS/Linux visitors see exactly today's default. Manual switching always remains possible; no layout shift for non-Windows users.
-* Tabs whose commands have no PowerShell equivalent (run sample / init project) must not show a bash-only command as the Windows default — show the install one-liner plus a short "then run:" `jaiph` command instead, or an equivalent honest fallback.
-* Keep the static-render constraint: the page must remain correct with JS disabled (bash commands shown, Windows variant reachable via tab markup).
-
-Acceptance:
-
-* Playwright docs tests (same suite as the "Try it out" test) assert: with a Windows platform emulated, the page defaults to the PowerShell command; with macOS/Linux emulated, the default is unchanged from today; manual tab switching works in both.
-* The copy button on the Windows variant copies the exact `irm … | iex` line (asserted via clipboard stub).
-* With JS disabled, all bash commands render and no panel is blank.
-* Docs parity check (`.jaiph/docs_parity.jh`) passes with the new content.
-
## Distro: native Windows smoke job in CI #dev-ready
CI's only Windows coverage runs the e2e suite inside WSL (`e2e-wsl` in `.github/workflows/ci.yml`), which exercises the Linux binary. Developing Jaiph on Windows is out of scope — this job proves *running* Jaiph natively works.
diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css
index 2b3cc45b..2543e3e0 100644
--- a/docs/assets/css/style.css
+++ b/docs/assets/css/style.css
@@ -704,6 +704,41 @@ pre code .code-line::before {
display: block;
}
+.os-switch {
+ display: flex;
+ gap: 0.4rem;
+ margin-bottom: 0.75rem;
+}
+
+.os-switch-button {
+ border: 1px solid var(--tab-border);
+ border-radius: 8px;
+ background: var(--tab-bg);
+ color: var(--muted);
+ font-size: 0.8rem;
+ font-weight: 600;
+ font-family: "Fira Code", monospace;
+ padding: 0.3rem 0.7rem;
+ cursor: pointer;
+}
+
+.os-switch-button.is-active {
+ background: var(--tab-active-bg);
+ border-color: var(--tab-active-border);
+ color: var(--tab-active-color);
+}
+
+/* Platform variants: static markup shows the POSIX (bash) variant; JS reveals
+ the Windows variant on demand (and by default for Windows visitors). Without
+ JS the POSIX variant stays visible and the Windows one remains in the markup. */
+.os-variant {
+ display: none;
+}
+
+.os-variant.is-active {
+ display: block;
+}
+
.primitive-list {
margin: 0.5rem 0 0;
}
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js
index 6c9e91ef..8521742e 100644
--- a/docs/assets/js/main.js
+++ b/docs/assets/js/main.js
@@ -732,6 +732,51 @@
});
}
+ /**
+ * True when the visitor is on Windows. Prefers the modern
+ * navigator.userAgentData.platform, falling back to navigator.platform.
+ */
+ function isWindowsPlatform() {
+ var platform = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
+ return /win/i.test(platform);
+ }
+
+ /**
+ * Toggles the visible platform variant (posix / windows) across every
+ * panel in the install card, and reflects the choice on the switch buttons.
+ */
+ function setOsVariant(root, os) {
+ root.querySelectorAll(".os-switch-button").forEach(function (button) {
+ button.classList.toggle("is-active", button.getAttribute("data-os") === os);
+ });
+ root.querySelectorAll(".os-variant").forEach(function (variant) {
+ variant.classList.toggle("is-active", variant.getAttribute("data-os") === os);
+ });
+ }
+
+ /**
+ * Wires the platform sub-toggle in the install card and auto-selects the
+ * Windows variant for Windows visitors. Manual switching stays available;
+ * non-Windows visitors keep the static (POSIX) default with no layout shift.
+ */
+ function attachOsSwitch() {
+ var root = document.querySelector("section.try-it-out .card");
+ if (!root || root.querySelectorAll(".os-variant").length === 0) {
+ return;
+ }
+ root.querySelectorAll(".os-switch-button").forEach(function (button) {
+ button.addEventListener("click", function () {
+ var os = button.getAttribute("data-os");
+ if (os) {
+ setOsVariant(root, os);
+ }
+ });
+ });
+ if (isWindowsPlatform()) {
+ setOsVariant(root, "windows");
+ }
+ }
+
function attachCodeTabs() {
const buttons = document.querySelectorAll(".code-tab-button");
buttons.forEach(function (button) {
@@ -979,6 +1024,7 @@
highlightAll();
attachCopyButtons();
attachCodeTabs();
+ attachOsSwitch();
attachDocsNavToggle();
attachThemeToggle();
});
@@ -989,6 +1035,7 @@
highlightAll();
attachCopyButtons();
attachCodeTabs();
+ attachOsSwitch();
attachDocsNavToggle();
attachThemeToggle();
}
diff --git a/docs/index.html b/docs/index.html
index 565e3622..8af4a1dd 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -75,36 +75,68 @@
Try it out!
+
+
+
+
+
-
curl -fsSL https://jaiph.org/run | bash -s 'workflowdefault() {constresponse=prompt"Say: Hello, I am [model name]!"log response}'
-
Installs Jaiph v0.10.0 to ~/.local/bin (if not
- already
- installed), and runs the sample workflow with Cursor CLI agent backend (the default one).
- See more samples!
-
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.
+
+
curl -fsSL https://jaiph.org/run | bash -s 'workflowdefault() {constresponse=prompt"Say: Hello, I am [model name]!"log response}'
+
Installs Jaiph v0.10.0 to ~/.local/bin (if not
+ already
+ installed), and runs the sample workflow with Cursor CLI agent backend (the default one).
+ See more samples!
+
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.
+
+
+
On Windows, install with PowerShell:
+
irm https://jaiph.org/install.ps1 | iex
+
then run a sample workflow:
+
jaiph run say_hello.jh Adam
+
Installs Jaiph v0.10.0 to %LOCALAPPDATA%\jaiph\bin.
+ Grab say_hello.jh from the samples below, then run it. See
+ more samples!
+
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.
+
-
Run the script below from the project directory:
-
curl -fsSL https://jaiph.org/init | bash
-
Installs Jaiph v0.10.0 to ~/.local/bin (if not
- already installed), and runs jaiph init to initialize the Jaiph workspace in the
- current directory.
+
+
Run the script below from the project directory:
+
curl -fsSL https://jaiph.org/init | bash
+
Installs Jaiph v0.10.0 to ~/.local/bin (if not
+ already installed), and runs jaiph init to initialize the Jaiph workspace in the
+ current directory.
+
+
+
On Windows, install with PowerShell:
+
irm https://jaiph.org/install.ps1 | iex
+
then initialize the Jaiph workspace in the current directory:
+
jaiph init
+
Installs Jaiph v0.10.0 to %LOCALAPPDATA%\jaiph\bin
+ (if not already installed).
+
-
curl -fsSL https://jaiph.org/install | bash
-
The installer will install the version 0.10.0 of Jaiph to
- ~/.local/bin. To switch versions, use jaiph use nightly
- or jaiph use <version> to switch.
-
-
On Windows, install with PowerShell instead:
-
irm https://jaiph.org/install.ps1 | iex
-
This installs jaiph-windows-x64.exe to
- %LOCALAPPDATA%\jaiph\bin and adds it to your user PATH.
-
Or install from npm: npm install -g jaiph
+
+
curl -fsSL https://jaiph.org/install | bash
+
The installer will install the version 0.10.0 of Jaiph to
+ ~/.local/bin. To switch versions, use jaiph use nightly
+ or jaiph use <version> to switch.
+
+
Or install from npm: npm install -g jaiph
+
+
+
irm https://jaiph.org/install.ps1 | iex
+
This installs jaiph-windows-x64.exe to
+ %LOCALAPPDATA%\jaiph\bin and adds it to your user PATH.
+
Or install from npm: npm install -g jaiph
+
diff --git a/e2e/playwright/landing-page.spec.ts b/e2e/playwright/landing-page.spec.ts
index 4802e2f6..afaf34cb 100644
--- a/e2e/playwright/landing-page.spec.ts
+++ b/e2e/playwright/landing-page.spec.ts
@@ -109,7 +109,11 @@ test.describe.serial('docs landing page', () => {
await page.goto('/');
- const codeEl = page.locator('section.try-it-out [data-panel="try-run-sample"] pre code');
+ // Scope to the active platform variant: the run-sample panel now holds a
+ // POSIX (bash) and a Windows variant. On CI (Linux) the POSIX one is active.
+ const codeEl = page.locator(
+ 'section.try-it-out [data-panel="try-run-sample"] .os-variant.is-active pre code',
+ );
await expect(codeEl).toBeVisible();
let script = (await codeEl.innerText()).trim();
script = script.replace(/https:\/\/jaiph\.org/g, LOCAL_DOCS_SITE);
@@ -146,6 +150,114 @@ test.describe.serial('docs landing page', () => {
});
});
+ test.describe('install tabs — platform variants', () => {
+ const PS_INSTALL = 'irm https://jaiph.org/install.ps1 | iex';
+
+ /** Override the platform Jaiph auto-detects, before any page script runs. */
+ async function emulatePlatform(page: import('@playwright/test').Page, platform: string) {
+ await page.addInitScript((p) => {
+ Object.defineProperty(navigator, 'platform', { configurable: true, get: () => p });
+ Object.defineProperty(navigator, 'userAgentData', {
+ configurable: true,
+ get: () => ({ platform: /win/i.test(p) ? 'Windows' : 'Linux' }),
+ });
+ }, platform);
+ }
+
+ const activeRunSampleVariant = (page: import('@playwright/test').Page) =>
+ page.locator('section.try-it-out [data-panel="try-run-sample"] .os-variant.is-active');
+
+ test('Windows visitor defaults to the PowerShell install command', async ({ page }) => {
+ await emulatePlatform(page, 'Win32');
+ await page.goto('/');
+
+ const active = activeRunSampleVariant(page);
+ await expect(active).toHaveAttribute('data-os', 'windows');
+ await expect(active.locator('pre code').first()).toHaveText(PS_INSTALL);
+ });
+
+ test('macOS/Linux visitor keeps the bash default unchanged', async ({ page }) => {
+ await emulatePlatform(page, 'MacIntel');
+ await page.goto('/');
+
+ const active = activeRunSampleVariant(page);
+ await expect(active).toHaveAttribute('data-os', 'posix');
+ await expect(active.locator('pre code')).toContainText('curl -fsSL https://jaiph.org/run | bash');
+ });
+
+ test('manual platform + tab switching works in both directions', async ({ page }) => {
+ await emulatePlatform(page, 'MacIntel');
+ await page.goto('/');
+
+ // Starts on POSIX; manual switch to Windows reveals the PowerShell command.
+ await expect(activeRunSampleVariant(page)).toHaveAttribute('data-os', 'posix');
+ await page.locator('section.try-it-out .os-switch-button[data-os="windows"]').click();
+ await expect(activeRunSampleVariant(page)).toHaveAttribute('data-os', 'windows');
+
+ // Tab switch keeps the chosen platform: the install tab shows PowerShell.
+ await page.locator('[data-target="try-install-only"]').click();
+ const installActive = page.locator(
+ 'section.try-it-out [data-panel="try-install-only"] .os-variant.is-active',
+ );
+ await expect(installActive).toHaveAttribute('data-os', 'windows');
+ await expect(installActive.locator('pre code').first()).toHaveText(PS_INSTALL);
+
+ // Switch back to POSIX; the install tab shows the bash one-liner.
+ await page.locator('section.try-it-out .os-switch-button[data-os="posix"]').click();
+ await expect(installActive).toHaveAttribute('data-os', 'posix');
+ await expect(installActive.locator('pre code').first()).toHaveText(
+ 'curl -fsSL https://jaiph.org/install | bash',
+ );
+ });
+
+ test('copy button on the Windows install variant copies the exact irm line', async ({ page }) => {
+ await page.addInitScript(() => {
+ (window as unknown as { __copied: string[] }).__copied = [];
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: {
+ writeText: (t: string) => {
+ (window as unknown as { __copied: string[] }).__copied.push(t);
+ return Promise.resolve();
+ },
+ },
+ });
+ });
+ await page.goto('/');
+
+ await page.locator('section.try-it-out .os-switch-button[data-os="windows"]').click();
+ await page.locator('[data-target="try-install-only"]').click();
+
+ const winInstall = page.locator(
+ 'section.try-it-out [data-panel="try-install-only"] .os-variant[data-os="windows"]',
+ );
+ await winInstall.locator('.copy-code-button').first().click();
+
+ const copied = await page.evaluate(() => (window as unknown as { __copied: string[] }).__copied);
+ expect(copied).toContain(PS_INSTALL);
+ });
+
+ test('with JS disabled, bash commands render and no panel is blank', async ({ browser }) => {
+ const context = await browser.newContext({ javaScriptEnabled: false });
+ const page = await context.newPage();
+ try {
+ await page.goto('/');
+ const card = page.locator('section.try-it-out .card');
+ // All three bash one-liners are statically present (not JS-injected).
+ await expect(card).toContainText('curl -fsSL https://jaiph.org/run | bash');
+ await expect(card).toContainText('curl -fsSL https://jaiph.org/init | bash');
+ await expect(card).toContainText('curl -fsSL https://jaiph.org/install | bash');
+ // The Windows variant is reachable via static tab markup.
+ await expect(card).toContainText(PS_INSTALL);
+ // The active panel renders its bash command (not blank).
+ const activePanel = page.locator('section.try-it-out .code-tab-panel.is-active');
+ await expect(activePanel).toContainText('curl -fsSL https://jaiph.org/run | bash');
+ } finally {
+ await context.close();
+ }
+ });
+ });
+
test.describe('landing page samples', () => {
test('each tab: source from page runs and output matches (normalized)', async ({ page }) => {
await page.goto('/#samples');
From d4bed974399fa479c5e90efb831d391ffd44d574 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 16:05:39 +0200
Subject: [PATCH 08/85] Queue: add native Windows support tasks (portability,
then distro)
Co-Authored-By: Claude Fable 5
---
QUEUE.md | 20 --------------------
1 file changed, 20 deletions(-)
diff --git a/QUEUE.md b/QUEUE.md
index c1ad77a5..72c078fc 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,23 +14,3 @@ Process rules:
***
-## Distro: native Windows smoke job in CI #dev-ready
-
-CI's only Windows coverage runs the e2e suite inside WSL (`e2e-wsl` in `.github/workflows/ci.yml`), which exercises the Linux binary. Developing Jaiph on Windows is out of scope — this job proves *running* Jaiph natively works.
-
-Add a `windows-native-smoke` job on `windows-latest`:
-
-* Build the standalone Windows binary from the checkout (`bun build --compile --target=bun-windows-x64`).
-* With Git for Windows' `sh.exe` available (preinstalled on the runner), run a sample workflow host-only (`JAIPH_UNSAFE=true`) that covers: an inline shell line, a `script` step with a non-bash lang tag (e.g. ` ```node `), string interpolation, and `log` output.
-* Assert the process tree is cleaned up after a mid-run cancellation (spawn `jaiph run`, terminate it, assert no orphaned child processes remain).
-* No agent-backend credentials in this job: `prompt`-step coverage is limited to the credential pre-flight failing with the documented error, not a hang.
-* Keep `e2e-wsl` as-is; do not gate its removal on this task.
-
-Acceptance:
-
-* The smoke job is required for merge (listed in the CI gate alongside `test`/`e2e`/`e2e-wsl`).
-* Workflow output assertions run against actual `jaiph.exe` stdout (exit code + expected `log` lines).
-* The cancellation assertion fails if any child of the workflow leader survives termination.
-* The job completes with no WSL usage (fails if `wsl` is invoked).
-
-***
From 1383e762800474ec727797b9e3955b37d15596a7 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 16:11:12 +0200
Subject: [PATCH 09/85] Feat: native Windows smoke job in CI (windows-latest,
no WSL)
Co-Authored-By: Claude Fable 5
---
.github/workflows/ci.yml | 42 +++-
CHANGELOG.md | 1 +
docs/contributing.md | 4 +-
e2e/tests/windows_native_smoke.ps1 | 275 +++++++++++++++++++++++
integration/windows-native-smoke.test.ts | 124 ++++++++++
5 files changed, 444 insertions(+), 2 deletions(-)
create mode 100644 e2e/tests/windows_native_smoke.ps1
create mode 100644 integration/windows-native-smoke.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dfa0a737..8e908b3d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -312,9 +312,49 @@ jobs:
$env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe"
./e2e/tests/installer_powershell.ps1
+ windows-native-smoke:
+ name: Native Windows smoke (windows-latest, no WSL)
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build TypeScript + embed assets
+ run: npm run build
+
+ # Same build as release.yml's windows-x64 leg / the installer-powershell
+ # job, so the smoke test runs the real self-contained binary we ship.
+ - name: Cross-compile windows-x64 standalone binary
+ shell: bash
+ run: |
+ set -euo pipefail
+ bun build --compile --target=bun-windows-x64 ./src/cli.ts --outfile jaiph-windows-x64.exe
+ ls -la jaiph-windows-x64.exe
+
+ # Native run only — no `wsl`. Git for Windows' sh.exe (preinstalled on the
+ # runner) is the POSIX shell for inline lines; the harness shadows `wsl` so
+ # any accidental invocation fails the job.
+ - name: Run native Windows smoke (e2e/tests/windows_native_smoke.ps1)
+ shell: pwsh
+ run: |
+ $env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe"
+ ./e2e/tests/windows_native_smoke.ps1
+
docker-publish:
name: Publish Docker runtime image
- needs: [test, e2e, docs-local, e2e-wsl, installer-powershell]
+ needs: [test, e2e, docs-local, e2e-wsl, installer-powershell, windows-native-smoke]
if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d231ab29..f06d178c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: native Windows smoke job in CI:** CI's only Windows coverage was `e2e-wsl`, which runs the suite inside WSL against the *Linux* binary. A new **`windows-native-smoke`** job (`.github/workflows/ci.yml`, `windows-latest`) proves the *native* `jaiph.exe` runs. It cross-compiles the standalone binary from the checkout (`bun build --compile --target=bun-windows-x64`, the same build as the release / `installer-powershell` legs) and runs **`e2e/tests/windows_native_smoke.ps1`** against it. The harness (a PowerShell acceptance test, same convention as `installer_powershell.ps1`, pointed at the binary via `JAIPH_TEST_WINDOWS_EXE`) covers three contracts, each failing when violated: (1) a **sample workflow** runs host-only (`JAIPH_UNSAFE=true`) exercising an inline shell line (through Git for Windows' `sh.exe`), a `script` step with a non-bash lang tag (` ```node `), `${…}` string interpolation, and `log` output — with assertions against the real `jaiph.exe` **stdout** (exit code `0` plus the interpolated `log` line and the `PASS` footer); (2) a **mid-run cancellation** records the workflow leader's descendant PID tree (`Win32_Process` parent/child links), delivers a real **Ctrl-C** isolated to the leader's own console (`AttachConsole` + `GenerateConsoleCtrlEvent`, so the CI runner shell is untouched), and fails if **any** descendant survives — exercising the win32 `taskkill /T /F` teardown path in `killProcessTree`; (3) a **`prompt`-step credential pre-flight** (`agent.backend = "codex"`, `OPENAI_API_KEY` unset, and `JAIPH_UNSAFE` dropped so the pre-flight runs — win32 forces host-only regardless) fails fast with the documented `E_AGENT_CREDENTIALS` error, bounded by a 30s `WaitForExit` so a hang is a failure, rather than calling any backend. The job never touches WSL — it shadows `wsl` so an accidental call throws — and now gates merge alongside `test`/`e2e`/`e2e-wsl` (added to `docker-publish`'s `needs`); `e2e-wsl` is left in place. Host-portable guards in `integration/windows-native-smoke.test.ts` pin the CI job shape and the harness contract (build command, stdout assertions, cancellation orphan check, pre-flight error, no-WSL, gate membership) so a regression fails `npm test` on any platform.
- **Feature — Distro: main-page install tabs — Windows variant with platform auto-detect:** The landing page (`docs/index.html`) hero install card now offers a Windows PowerShell path alongside the POSIX `curl … | bash` one-liners, and defaults to the right one for the visitor's platform. A new **`.os-switch`** sub-toggle (two buttons, `data-os="posix"` "macOS / Linux" and `data-os="windows"` "Windows", `role="group"`) sits above the existing run-sample / init-project / just-install tabs, and every tab panel now wraps its content in two **`.os-variant`** blocks (`data-os="posix"` / `data-os="windows"`). On load, **`attachOsSwitch()`** (`docs/assets/js/main.js`, scoped to `section.try-it-out .card`) auto-selects the Windows variant for Windows visitors via **`isWindowsPlatform()`** — which prefers `navigator.userAgentData?.platform` and falls back to `navigator.platform`, matching `/win/i` — while macOS/Linux visitors keep exactly today's POSIX default with no layout shift; **`setOsVariant()`** flips the `is-active` class on both the switch buttons and the variants across all three panels, so a manual platform choice (or a tab switch) is remembered card-wide. The **"Just install"** Windows variant is the copy-able `irm https://jaiph.org/install.ps1 | iex` line (via the existing copy button) plus the `npm install -g jaiph` alternative. The **"Run sample"** and **"Init project"** tabs have no PowerShell equivalent for their `curl … | bash -s` / `curl … | bash` pipes, so instead of showing a bash-only command as the Windows default they show the install one-liner followed by a short "then run:" `jaiph run say_hello.jh Adam` / `jaiph init` step (installing to `%LOCALAPPDATA%\jaiph\bin`). The static-render constraint holds: the POSIX variant carries `is-active` in the shipped markup and CSS keeps `.os-variant { display: none }` / `.os-variant.is-active { display: block }` (`docs/assets/css/style.css`), so with JS disabled all three bash one-liners render, no panel is blank, and the Windows variant stays reachable in the tab markup — JS only reveals it on demand (and by default for Windows). New Playwright cases in the docs suite (`e2e/playwright/landing-page.spec.ts`, alongside the "Try it out" test) emulate the platform before page scripts run and assert: a Windows visitor defaults to the `irm … | iex` command, a macOS/Linux visitor's default is unchanged from today, manual platform + tab switching works in both directions, the copy button on the Windows variant copies the exact `irm … | iex` line (asserted through a clipboard stub), and with JavaScript disabled all bash commands render with no blank panel. No `docs/*.md` or `README.md` change is needed — the Windows install path is already documented in `docs/setup.md` (`/how-to/install`) and the README install section; this task is landing-page presentation only, with no runtime, CLI, or installer behavior change.
- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`).
- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
diff --git a/docs/contributing.md b/docs/contributing.md
index 0c9751f2..cc911d3a 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -173,6 +173,7 @@ Tests that span multiple modules, require subprocess/PTY harnesses, exercise pro
| `integration/docs-nav-structure-task7.test.ts` | Integration | Nav spine — five Diátaxis section headings in documented order; every published page under its quadrant exactly once |
| `integration/release-workflow.test.ts` | Integration | Release matrix / asset-naming contract — five-binary matrix (no windows-arm64), `SHA256SUMS` + upload lists include `jaiph-windows-x64.exe`, shared version-gate script, naming contract ↔ matrix ↔ installer parity |
| `integration/installer-powershell.test.ts` | Integration | Windows PowerShell installer (`docs/install.ps1`) contract — download/verify/install steps, bash↔PowerShell lockstep release ref, and `docs/setup.md` / main-page one-liner parity |
+| `integration/windows-native-smoke.test.ts` | Integration | Host-portable guards for the `windows-native-smoke` CI job and its `e2e/tests/windows_native_smoke.ps1` harness — job shape (windows-latest, `bun --compile` build, gate membership alongside `test`/`e2e`/`e2e-wsl`), stdout/exit-code assertions, cancellation orphan check, `prompt` pre-flight error, and no-WSL enforcement |
| `integration/sample-build/build.test.ts` | Integration | Build/transpile behavior — `buildScripts`, script extraction |
| `integration/sample-build/cli-tree.test.ts` | Integration | CLI tree output rendering for sample workflows |
| `integration/sample-build/run-core.test.ts` | Integration | Core runtime execution — workflow runs, step sequencing, artifacts |
@@ -189,7 +190,7 @@ The `integration/sample-build/` directory also has a shared `helpers.ts` module
## CI pipeline
-The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **seven** jobs; on a typical feature-branch push, **six** of them run. The seventh — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, WSL, and PowerShell-installer jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
+The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **eight** jobs; on a typical feature-branch push, **seven** of them run. The eighth — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, WSL, PowerShell-installer, and native-Windows-smoke jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
| Job | Runner | Purpose |
|-----|--------|---------|
@@ -199,6 +200,7 @@ The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defin
| **Getting started (local)** | `ubuntu-latest` | Serves the Jekyll site from `docs/` on `127.0.0.1:4000`, smoke-checks key routes with `curl`, builds the same local runtime image as E2E for any Docker-backed sample paths, installs Playwright (Chromium), and runs `npx playwright test` for landing-page samples. |
| **E2E install and CLI workflow (windows-latest + wsl)** | `windows-latest` | Provisions or selects a WSL distro, installs Node inside it, and runs `npm run test:e2e` under WSL with **`JAIPH_UNSAFE=true`**. |
| **PowerShell installer (windows-x64)** | `windows-latest` | Cross-compiles `jaiph-windows-x64.exe` (same as the release leg), then runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1`: checksum mismatch, unsupported arch, and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`. |
+| **Native Windows smoke (windows-latest, no WSL)** | `windows-latest` | Cross-compiles `jaiph-windows-x64.exe` (same as the release leg) and runs `e2e/tests/windows_native_smoke.ps1` against it — proving the *native* binary runs, where `e2e-wsl` exercises the *Linux* binary under WSL. Uses Git for Windows' `sh.exe` (preinstalled) as the POSIX shell and touches **no WSL** (an accidental `wsl` call fails the job). Covers: a host-only sample workflow (`JAIPH_UNSAFE=true`) exercising an inline shell line, a `script` step with a non-bash lang tag (` ```node `), string interpolation, and `log` output, asserted against real `jaiph.exe` stdout (exit code + `log` lines); a mid-run cancellation that fails if any child of the workflow leader survives termination; and a `prompt`-step credential pre-flight that fails fast with the documented `E_AGENT_CREDENTIALS` error (bounded so a hang is a failure), not a backend call. |
| **Publish Docker runtime image** | `ubuntu-latest` | *Conditional (see above).* Multi-arch push to GHCR. |
### Version tags, releases, and npm
diff --git a/e2e/tests/windows_native_smoke.ps1 b/e2e/tests/windows_native_smoke.ps1
new file mode 100644
index 00000000..09262bf7
--- /dev/null
+++ b/e2e/tests/windows_native_smoke.ps1
@@ -0,0 +1,275 @@
+#!/usr/bin/env pwsh
+#
+# Native Windows smoke test for the standalone jaiph.exe (the `windows-native-smoke`
+# CI job runs this on windows-latest). Developing Jaiph on Windows is out of scope;
+# this proves that *running* a workflow natively — with Git for Windows' sh.exe as
+# the POSIX shell and no WSL — works. It covers each acceptance bullet with a check
+# that fails when the contract is violated:
+#
+# 1. A sample workflow runs host-only (JAIPH_UNSAFE=true) covering an inline
+# shell line, a `script` step with a non-bash lang tag (```node), string
+# interpolation, and `log` output. Assertions run against the real jaiph.exe
+# stdout (exit code + expected `log` lines).
+# 2. A mid-run cancellation cleans up the process tree: we start `jaiph run`,
+# record the workflow leader's descendant PIDs, deliver a real Ctrl-C, and
+# fail if any descendant survives termination (the win32 taskkill /T /F path).
+# 3. A `prompt`-step workflow with a configured backend but no credentials fails
+# fast with the documented E_AGENT_CREDENTIALS error rather than hanging.
+#
+# The binary under test is provided via JAIPH_TEST_WINDOWS_EXE (same convention as
+# installer_powershell.ps1). Without it the test cannot run and exits non-zero.
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+# Hard ban: this smoke test must never touch WSL — exercising the Linux binary is
+# e2e-wsl's job. Shadow `wsl` so any accidental invocation in this session throws
+# loudly instead of silently succeeding.
+function wsl {
+ throw "WSL must not be used in the native Windows smoke test (e2e-wsl covers WSL)"
+}
+
+$script:Failures = 0
+function Report-Pass { param([string]$m) Write-Host "PASS: $m" -ForegroundColor Green }
+function Report-Fail { param([string]$m) Write-Host "FAIL: $m" -ForegroundColor Red; $script:Failures++ }
+function Assert-Equal {
+ param($Actual, $Expected, [string]$Message)
+ if ($Actual -eq $Expected) { Report-Pass $Message }
+ else { Report-Fail "$Message (expected '$Expected', got '$Actual')" }
+}
+function Assert-True {
+ param([bool]$Condition, [string]$Message)
+ if ($Condition) { Report-Pass $Message } else { Report-Fail $Message }
+}
+function Assert-Contains {
+ param([string]$Haystack, [string]$Needle, [string]$Message)
+ if ($Haystack -like "*$Needle*") { Report-Pass $Message }
+ else { Report-Fail "$Message (missing '$Needle')`n---`n$Haystack`n---" }
+}
+
+$Exe = $env:JAIPH_TEST_WINDOWS_EXE
+if (-not $Exe -or -not (Test-Path $Exe)) {
+ Write-Host "FAIL: JAIPH_TEST_WINDOWS_EXE is not set to an existing jaiph.exe" -ForegroundColor Red
+ exit 1
+}
+$Exe = (Resolve-Path $Exe).Path
+
+# Host-only: the Docker sandbox is out of scope on win32 (resolveDockerConfig
+# forces host mode), but be explicit so this never probes for a daemon.
+$env:JAIPH_UNSAFE = "true"
+$env:JAIPH_DOCKER_ENABLED = "false"
+
+$Work = Join-Path ([System.IO.Path]::GetTempPath()) ("jaiph-winsmoke-" + [System.IO.Path]::GetRandomFileName())
+New-Item -ItemType Directory -Path $Work -Force | Out-Null
+
+# Enumerate the full descendant tree of a PID via the parent/child links in
+# Win32_Process (procps-free, unlike `pgrep`, and portable across runner images).
+function Get-DescendantPids {
+ param([int]$RootPid)
+ $all = Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId
+ $result = New-Object System.Collections.Generic.List[int]
+ $frontier = @($RootPid)
+ while ($frontier.Count -gt 0) {
+ $next = New-Object System.Collections.Generic.List[int]
+ foreach ($proc in $all) {
+ if ($frontier -contains [int]$proc.ParentProcessId) {
+ $childPid = [int]$proc.ProcessId
+ if (-not $result.Contains($childPid)) {
+ $result.Add($childPid)
+ $next.Add($childPid)
+ }
+ }
+ }
+ $frontier = $next.ToArray()
+ }
+ return $result.ToArray()
+}
+
+function Test-PidAlive {
+ param([int]$TargetPid)
+ return [bool](Get-Process -Id $TargetPid -ErrorAction SilentlyContinue)
+}
+
+# Ctrl-C delivery. To signal only the jaiph leader (never the CI runner shell),
+# we detach from our own console, attach to the leader's own console, make the
+# attached process ignore Ctrl-C, then GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)
+# — which hits every process in that (isolated) console — and finally reattach to
+# our parent console so the rest of the script can still write output.
+Add-Type -Namespace JaiphSmoke -Name Native -MemberDefinition @'
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool SetConsoleCtrlHandler(System.IntPtr HandlerRoutine, bool Add);
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool AttachConsole(uint dwProcessId);
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool FreeConsole();
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool AllocConsole();
+'@
+
+$CTRL_C_EVENT = 0
+$ATTACH_PARENT_PROCESS = [uint32]"0xFFFFFFFF"
+$leaderPid = $null # referenced in the outer finally under Set-StrictMode
+
+# Send a real Ctrl-C to $TargetPid's console only, sparing this process' console.
+function Send-CtrlC {
+ param([int]$TargetPid)
+ [JaiphSmoke.Native]::FreeConsole() | Out-Null
+ $attached = [JaiphSmoke.Native]::AttachConsole([uint32]$TargetPid)
+ if (-not $attached) {
+ # Nothing to attach to (child already gone / no console); restore and bail.
+ [JaiphSmoke.Native]::AttachConsole($ATTACH_PARENT_PROCESS) | Out-Null
+ return $false
+ }
+ [JaiphSmoke.Native]::SetConsoleCtrlHandler([System.IntPtr]::Zero, $true) | Out-Null
+ $sent = [JaiphSmoke.Native]::GenerateConsoleCtrlEvent($CTRL_C_EVENT, 0)
+ # Restore our own console so Write-Host works again after this call.
+ [JaiphSmoke.Native]::FreeConsole() | Out-Null
+ if (-not [JaiphSmoke.Native]::AttachConsole($ATTACH_PARENT_PROCESS)) {
+ [JaiphSmoke.Native]::AllocConsole() | Out-Null
+ }
+ [JaiphSmoke.Native]::SetConsoleCtrlHandler([System.IntPtr]::Zero, $false) | Out-Null
+ return [bool]$sent
+}
+
+try {
+ # ── 1. Sample workflow: inline shell + node script + interpolation + log ─────
+ Write-Host "`n== Sample workflow runs host-only against jaiph.exe =="
+
+ $sampleWf = Join-Path $Work "sample.jh"
+ Set-Content -LiteralPath $sampleWf -Encoding utf8 -Value @'
+script node_step = ```node
+process.stdout.write("node-step-output\n");
+```
+
+workflow default() {
+ const who = "Windows"
+ echo "inline shell for ${who}"
+ run node_step()
+ log "smoke greeting for ${who}"
+}
+'@
+
+ $sampleOut = Join-Path $Work "sample.out"
+ $sampleErr = Join-Path $Work "sample.err"
+ $sample = Start-Process -FilePath $Exe -ArgumentList @("run", $sampleWf) `
+ -NoNewWindow -PassThru -Wait -WorkingDirectory $Work `
+ -RedirectStandardOutput $sampleOut -RedirectStandardError $sampleErr
+ $sampleStdout = Get-Content -LiteralPath $sampleOut -Raw
+ $sampleStderr = Get-Content -LiteralPath $sampleErr -Raw
+
+ Assert-Equal $sample.ExitCode 0 "sample workflow exits 0"
+ # Assert against stdout: the interpolated `log` line and the success footer.
+ Assert-Contains $sampleStdout "smoke greeting for Windows" "log line (with interpolation) is on stdout"
+ Assert-Contains $sampleStdout "PASS workflow default" "success footer is on stdout"
+ if ($script:Failures -gt 0) {
+ Write-Host "sample stderr was:`n$sampleStderr"
+ }
+
+ # ── 2. Mid-run cancellation cleans up the process tree ───────────────────────
+ Write-Host "`n== Mid-run cancellation leaves no orphaned child processes =="
+
+ $cancelWf = Join-Path $Work "cancel.jh"
+ Set-Content -LiteralPath $cancelWf -Encoding utf8 -Value @'
+script long_sleep = ```node
+setInterval(() => {}, 1000);
+```
+
+workflow default() {
+ run long_sleep()
+}
+'@
+
+ # Launch the leader in its OWN (hidden) console so the Ctrl-C we send later is
+ # isolated to its process group and never reaches the CI runner shell. We do
+ # not redirect its stdio here — the cancellation contract is about the process
+ # tree, not output — because redirection would force a shared console.
+ $leader = Start-Process -FilePath $Exe -ArgumentList @("run", $cancelWf) `
+ -WindowStyle Hidden -PassThru -WorkingDirectory $Work
+ $leaderPid = $leader.Id
+
+ # Wait for the workflow to spawn its child tree (detached runner -> node child).
+ $descendants = @()
+ for ($i = 0; $i -lt 100; $i++) {
+ Start-Sleep -Milliseconds 200
+ $descendants = Get-DescendantPids -RootPid $leaderPid
+ if ($descendants.Count -ge 1) { break }
+ }
+ Assert-True ($descendants.Count -ge 1) "workflow leader spawned a child process tree"
+
+ # Deliver a real Ctrl-C to the leader's console only, then wait for it to exit.
+ Send-CtrlC -TargetPid $leaderPid | Out-Null
+ $exited = $leader.WaitForExit(15000)
+ Assert-True $exited "workflow leader exits after Ctrl-C (no hang)"
+
+ # Give the win32 teardown (taskkill /T /F) a moment to reap the tree.
+ Start-Sleep -Milliseconds 1500
+ $survivors = @($descendants | Where-Object { Test-PidAlive -TargetPid $_ })
+ Assert-True ($survivors.Count -eq 0) "no child of the workflow leader survives termination"
+ if ($survivors.Count -gt 0) {
+ Write-Host "surviving PIDs: $($survivors -join ', ')"
+ # Best-effort reap so the CI runner is not left with orphans.
+ foreach ($p in $survivors) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }
+ }
+
+ # ── 3. prompt-step credential pre-flight fails fast (no hang) ─────────────────
+ Write-Host "`n== prompt-step pre-flight fails with the documented error, not a hang =="
+
+ $promptWf = Join-Path $Work "prompt.jh"
+ Set-Content -LiteralPath $promptWf -Encoding utf8 -Value @'
+config {
+ agent.backend = "codex"
+}
+
+workflow default() {
+ const answer = prompt "Say hello"
+ log answer
+}
+'@
+
+ # The credential pre-flight is skipped under JAIPH_UNSAFE (that flag means
+ # "trust my host env"), so drop it here — on win32 resolveDockerConfig already
+ # forces host-only mode, so the run stays host-only without it. codex has no
+ # login fallback: with OPENAI_API_KEY unset the pre-flight hard-fails before any
+ # backend call, so this fails fast with E_AGENT_CREDENTIALS instead of hanging.
+ $origOpenAi = $env:OPENAI_API_KEY
+ $origUnsafe = $env:JAIPH_UNSAFE
+ Remove-Item Env:\OPENAI_API_KEY -ErrorAction SilentlyContinue
+ Remove-Item Env:\JAIPH_UNSAFE -ErrorAction SilentlyContinue
+ try {
+ $promptOut = Join-Path $Work "prompt.out"
+ $promptErr = Join-Path $Work "prompt.err"
+ $prompt = Start-Process -FilePath $Exe -ArgumentList @("run", $promptWf) `
+ -NoNewWindow -PassThru -WorkingDirectory $Work `
+ -RedirectStandardOutput $promptOut -RedirectStandardError $promptErr
+ $promptExited = $prompt.WaitForExit(30000)
+ if (-not $promptExited) {
+ $prompt.Kill($true)
+ Report-Fail "prompt pre-flight hung (did not exit within 30s)"
+ } else {
+ $promptStderr = Get-Content -LiteralPath $promptErr -Raw
+ Assert-True ($prompt.ExitCode -ne 0) "prompt pre-flight exits non-zero"
+ Assert-Contains $promptStderr "E_AGENT_CREDENTIALS" "pre-flight names the documented error code"
+ Assert-Contains $promptStderr "OPENAI_API_KEY" "pre-flight names the missing credential"
+ }
+ } finally {
+ if ($null -ne $origOpenAi) { $env:OPENAI_API_KEY = $origOpenAi }
+ if ($null -ne $origUnsafe) { $env:JAIPH_UNSAFE = $origUnsafe }
+ }
+} finally {
+ # Defensive: if the cancellation contract regressed (or the harness threw mid-run),
+ # force-kill the leader's tree so the CI runner is never left with orphans.
+ if ($null -ne $leaderPid) {
+ & taskkill.exe /PID $leaderPid /T /F 2>&1 | Out-Null
+ }
+ Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Host ""
+if ($script:Failures -gt 0) {
+ Write-Host "$($script:Failures) check(s) failed" -ForegroundColor Red
+ exit 1
+}
+Write-Host "All native Windows smoke checks passed" -ForegroundColor Green
+exit 0
diff --git a/integration/windows-native-smoke.test.ts b/integration/windows-native-smoke.test.ts
new file mode 100644
index 00000000..8ceed4cc
--- /dev/null
+++ b/integration/windows-native-smoke.test.ts
@@ -0,0 +1,124 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+
+// Acceptance for "Distro: native Windows smoke job in CI".
+//
+// The runtime behaviour (running the real jaiph.exe, cancelling mid-run, the
+// prompt pre-flight) is exercised on windows-latest by
+// e2e/tests/windows_native_smoke.ps1. These host-portable guards pin the
+// contract that must hold everywhere and fail when it is violated:
+// 1. The windows-native-smoke job exists on windows-latest, builds the
+// standalone .exe, and runs the smoke harness — and it is required for
+// merge (the docker-publish gate needs it, alongside test/e2e/e2e-wsl).
+// 2. Output assertions run against actual jaiph.exe stdout (exit code +
+// expected log lines); the cancellation check fails if any child survives.
+// 3. The job uses no WSL (e2e-wsl is left as-is and is the only WSL lane).
+
+const REPO_ROOT = process.cwd();
+const CI_YML = readFileSync(join(REPO_ROOT, ".github/workflows/ci.yml"), "utf8");
+const HARNESS_PATH = "e2e/tests/windows_native_smoke.ps1";
+const HARNESS = readFileSync(join(REPO_ROOT, HARNESS_PATH), "utf8");
+
+// Slice a job body out of the YAML by stable anchors so per-job assertions do
+// not accidentally match text from another job (e.g. the WSL job).
+function sliceBetween(text: string, start: string, end: string | null): string {
+ const from = text.indexOf(start);
+ assert.notEqual(from, -1, `expected to find "${start}" in workflow`);
+ const to = end === null ? text.length : text.indexOf(end, from + start.length);
+ assert.notEqual(to, -1, `expected to find "${end}" after "${start}"`);
+ return text.slice(from, to === text.length ? text.length : to);
+}
+
+const SMOKE_JOB = sliceBetween(CI_YML, "\n windows-native-smoke:", "\n docker-publish:");
+
+// ── Acceptance 1: the job exists, builds the .exe, runs the harness, gates merge ─
+
+test("windows-native-smoke runs natively on windows-latest", () => {
+ assert.match(SMOKE_JOB, /runs-on:\s*windows-latest/, "job runs on windows-latest");
+});
+
+test("windows-native-smoke builds the standalone windows-x64 binary from the checkout", () => {
+ assert.match(
+ SMOKE_JOB,
+ /bun build --compile --target=bun-windows-x64 \.\/src\/cli\.ts --outfile jaiph-windows-x64\.exe/,
+ "compiles jaiph-windows-x64.exe with bun --compile",
+ );
+});
+
+test("windows-native-smoke runs the smoke harness against the built exe", () => {
+ assert.match(SMOKE_JOB, /windows_native_smoke\.ps1/, "invokes the smoke harness");
+ assert.match(
+ SMOKE_JOB,
+ /JAIPH_TEST_WINDOWS_EXE\s*=\s*Join-Path \$env:GITHUB_WORKSPACE "jaiph-windows-x64\.exe"/,
+ "points the harness at the freshly built exe",
+ );
+});
+
+test("windows-native-smoke is required for merge in the CI gate", () => {
+ // docker-publish is the CI gate (release-workflow uses the same pattern): its
+ // needs list is what must be green. The smoke job joins test/e2e/e2e-wsl there.
+ const needs = sliceBetween(CI_YML, "\n docker-publish:", "\n if:");
+ assert.match(needs, /needs:\s*\[[^\]]*\bwindows-native-smoke\b[^\]]*\]/, "gate needs windows-native-smoke");
+ for (const gate of ["test", "e2e", "e2e-wsl"]) {
+ assert.match(needs, new RegExp(`\\b${gate}\\b`), `gate still needs ${gate}`);
+ }
+});
+
+// ── Acceptance 2: assertions run against real jaiph.exe stdout / cancellation ──
+
+test("harness exists and runs the built exe (not a Node fallback)", () => {
+ assert.ok(existsSync(join(REPO_ROOT, HARNESS_PATH)), "windows_native_smoke.ps1 present");
+ assert.match(HARNESS, /JAIPH_TEST_WINDOWS_EXE/, "runs the binary provided by CI");
+});
+
+test("sample run asserts exit code and expected log lines against stdout", () => {
+ // The sample workflow covers every required construct.
+ assert.match(HARNESS, /script node_step = ```node/, "script step with a non-bash lang tag");
+ assert.match(HARNESS, /echo "inline shell for \$\{who\}"/, "inline shell line with interpolation");
+ assert.match(HARNESS, /log "smoke greeting for \$\{who\}"/, "log output with interpolation");
+ // Assertions read stdout (redirected to a file), not merged output, and check
+ // both the exit code and the interpolated log line.
+ assert.match(HARNESS, /-RedirectStandardOutput \$sampleOut/, "captures stdout separately");
+ assert.match(HARNESS, /Assert-Equal \$sample\.ExitCode 0/, "asserts the exit code");
+ assert.match(
+ HARNESS,
+ /Assert-Contains \$sampleStdout "smoke greeting for Windows"/,
+ "asserts the interpolated log line on stdout",
+ );
+});
+
+test("cancellation check fails if any child of the workflow leader survives", () => {
+ // Record the descendant tree, deliver a real Ctrl-C, then assert none survive.
+ assert.match(HARNESS, /Get-DescendantPids -RootPid \$leaderPid/, "captures the leader's descendant tree");
+ assert.match(HARNESS, /GenerateConsoleCtrlEvent/, "delivers a real Ctrl-C (not a plain kill)");
+ assert.match(
+ HARNESS,
+ /Assert-True \(\$survivors\.Count -eq 0\) "no child of the workflow leader survives termination"/,
+ "fails when a child survives",
+ );
+});
+
+test("prompt-step pre-flight fails fast with the documented error (no hang)", () => {
+ assert.match(HARNESS, /agent\.backend = "codex"/, "configures a backend with no login path");
+ assert.match(HARNESS, /Remove-Item Env:\\OPENAI_API_KEY/, "runs with the credential unset");
+ assert.match(HARNESS, /WaitForExit\(30000\)/, "bounds the run so a hang is a failure");
+ assert.match(HARNESS, /prompt pre-flight hung/, "reports a hang as a failure");
+ assert.match(HARNESS, /Assert-Contains \$promptStderr "E_AGENT_CREDENTIALS"/, "asserts the documented error");
+});
+
+// ── Acceptance 3: no WSL usage ────────────────────────────────────────────────
+
+test("windows-native-smoke never invokes wsl", () => {
+ // e2e-wsl is the only WSL lane; the smoke job must not shell out to `wsl`.
+ assert.doesNotMatch(SMOKE_JOB, /\bwsl\s+-/, "no wsl flag invocation in the CI job");
+ assert.doesNotMatch(HARNESS, /(^|[^a-zA-Z])wsl\s+-/m, "no wsl flag invocation in the harness");
+ // The harness actively guards against it by shadowing `wsl`.
+ assert.match(HARNESS, /function wsl \{/, "harness shadows wsl so any call throws");
+});
+
+test("e2e-wsl is left in place (its removal is not gated on this task)", () => {
+ assert.match(CI_YML, /\n e2e-wsl:/, "the WSL job still exists");
+ assert.match(CI_YML, /wsl -d "\$distro"/, "e2e-wsl still runs the suite inside WSL");
+});
From 3a0861363cd048dd3d192e76b301faf9b45ebf25 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Tue, 14 Jul 2026 14:49:32 +0200
Subject: [PATCH 10/85] Feat: interpolate workflow parameters in config string
values.
Config blocks now resolve `${identifier}` and bare-identifier sugar at runtime so workflows can parameterize agent settings like default_model.
Co-authored-by: Cursor
---
CHANGELOG.md | 1 +
docs/configuration.md | 14 ++-
docs/grammar.md | 4 +-
e2e/tests/87_workflow_config.sh | 30 +++++
src/cli/commands/run.ts | 13 +-
src/config.ts | 97 ++++++++++++++-
src/format/emit.ts | 55 +++++----
src/parse/metadata.ts | 36 +++++-
src/parse/parse-metadata.test.ts | 34 +++++-
.../node-workflow-runtime.artifacts.test.ts | 57 +++++++++
src/runtime/kernel/node-workflow-runtime.ts | 53 +++++----
src/transpile/compiler-golden.test.ts | 4 +-
src/transpile/validate-config.test.ts | 112 ++++++++++++++++++
src/transpile/validate-config.ts | 69 +++++++++++
src/transpile/validate.ts | 3 +
.../compiler-txtar/parse-errors-snapshot.json | 2 +-
test-fixtures/compiler-txtar/parse-errors.txt | 4 +-
17 files changed, 521 insertions(+), 67 deletions(-)
create mode 100644 src/transpile/validate-config.test.ts
create mode 100644 src/transpile/validate-config.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f06d178c..a2940602 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feat — Config interpolation: `${identifier}` and bare-identifier sugar in `config { }` string values:** Workflow and module `config` blocks previously accepted only static string literals, booleans, and integers — `agent.default_model = model` was a parse error and `"${model}"` was stored literally with no runtime expansion. String config values now support the same interpolation model as orchestration strings: a **bare identifier** on the RHS is sugar for a single `${identifier}` reference (`agent.default_model = model` ≡ `agent.default_model = "${model}"`), and quoted strings may embed `${identifier}` anywhere in the value. **Module-level** config resolves references from module `const` values and environment variables at CLI startup (`resolveModuleMetadata` in `src/config.ts`, wired through `jaiph run` in `src/cli/commands/run.ts`). **Workflow-level** config additionally resolves that workflow's parameters; the runtime now binds workflow params before applying workflow metadata (`applyMetadataScope` in `src/runtime/kernel/node-workflow-runtime.ts` calls `interpolateWorkflowMetadata`). Compile-time validation (`src/transpile/validate-config.ts`) rejects unknown identifiers in interpolated config values. `agent.backend` enum checking is deferred when the value contains interpolation. Formatter round-trip preserves bare-identifier sugar (`src/format/emit.ts`). Tests: `src/parse/parse-metadata.test.ts`, `src/transpile/validate-config.test.ts`, `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts`, `e2e/tests/87_workflow_config.sh`. Docs: `docs/configuration.md`, `docs/grammar.md`.
- **Feature — Distro: native Windows smoke job in CI:** CI's only Windows coverage was `e2e-wsl`, which runs the suite inside WSL against the *Linux* binary. A new **`windows-native-smoke`** job (`.github/workflows/ci.yml`, `windows-latest`) proves the *native* `jaiph.exe` runs. It cross-compiles the standalone binary from the checkout (`bun build --compile --target=bun-windows-x64`, the same build as the release / `installer-powershell` legs) and runs **`e2e/tests/windows_native_smoke.ps1`** against it. The harness (a PowerShell acceptance test, same convention as `installer_powershell.ps1`, pointed at the binary via `JAIPH_TEST_WINDOWS_EXE`) covers three contracts, each failing when violated: (1) a **sample workflow** runs host-only (`JAIPH_UNSAFE=true`) exercising an inline shell line (through Git for Windows' `sh.exe`), a `script` step with a non-bash lang tag (` ```node `), `${…}` string interpolation, and `log` output — with assertions against the real `jaiph.exe` **stdout** (exit code `0` plus the interpolated `log` line and the `PASS` footer); (2) a **mid-run cancellation** records the workflow leader's descendant PID tree (`Win32_Process` parent/child links), delivers a real **Ctrl-C** isolated to the leader's own console (`AttachConsole` + `GenerateConsoleCtrlEvent`, so the CI runner shell is untouched), and fails if **any** descendant survives — exercising the win32 `taskkill /T /F` teardown path in `killProcessTree`; (3) a **`prompt`-step credential pre-flight** (`agent.backend = "codex"`, `OPENAI_API_KEY` unset, and `JAIPH_UNSAFE` dropped so the pre-flight runs — win32 forces host-only regardless) fails fast with the documented `E_AGENT_CREDENTIALS` error, bounded by a 30s `WaitForExit` so a hang is a failure, rather than calling any backend. The job never touches WSL — it shadows `wsl` so an accidental call throws — and now gates merge alongside `test`/`e2e`/`e2e-wsl` (added to `docker-publish`'s `needs`); `e2e-wsl` is left in place. Host-portable guards in `integration/windows-native-smoke.test.ts` pin the CI job shape and the harness contract (build command, stdout assertions, cancellation orphan check, pre-flight error, no-WSL, gate membership) so a regression fails `npm test` on any platform.
- **Feature — Distro: main-page install tabs — Windows variant with platform auto-detect:** The landing page (`docs/index.html`) hero install card now offers a Windows PowerShell path alongside the POSIX `curl … | bash` one-liners, and defaults to the right one for the visitor's platform. A new **`.os-switch`** sub-toggle (two buttons, `data-os="posix"` "macOS / Linux" and `data-os="windows"` "Windows", `role="group"`) sits above the existing run-sample / init-project / just-install tabs, and every tab panel now wraps its content in two **`.os-variant`** blocks (`data-os="posix"` / `data-os="windows"`). On load, **`attachOsSwitch()`** (`docs/assets/js/main.js`, scoped to `section.try-it-out .card`) auto-selects the Windows variant for Windows visitors via **`isWindowsPlatform()`** — which prefers `navigator.userAgentData?.platform` and falls back to `navigator.platform`, matching `/win/i` — while macOS/Linux visitors keep exactly today's POSIX default with no layout shift; **`setOsVariant()`** flips the `is-active` class on both the switch buttons and the variants across all three panels, so a manual platform choice (or a tab switch) is remembered card-wide. The **"Just install"** Windows variant is the copy-able `irm https://jaiph.org/install.ps1 | iex` line (via the existing copy button) plus the `npm install -g jaiph` alternative. The **"Run sample"** and **"Init project"** tabs have no PowerShell equivalent for their `curl … | bash -s` / `curl … | bash` pipes, so instead of showing a bash-only command as the Windows default they show the install one-liner followed by a short "then run:" `jaiph run say_hello.jh Adam` / `jaiph init` step (installing to `%LOCALAPPDATA%\jaiph\bin`). The static-render constraint holds: the POSIX variant carries `is-active` in the shipped markup and CSS keeps `.os-variant { display: none }` / `.os-variant.is-active { display: block }` (`docs/assets/css/style.css`), so with JS disabled all three bash one-liners render, no panel is blank, and the Windows variant stays reachable in the tab markup — JS only reveals it on demand (and by default for Windows). New Playwright cases in the docs suite (`e2e/playwright/landing-page.spec.ts`, alongside the "Try it out" test) emulate the platform before page scripts run and assert: a Windows visitor defaults to the `irm … | iex` command, a macOS/Linux visitor's default is unchanged from today, manual platform + tab switching works in both directions, the copy button on the Windows variant copies the exact `irm … | iex` line (asserted through a clipboard stub), and with JavaScript disabled all bash commands render with no blank panel. No `docs/*.md` or `README.md` change is needed — the Windows install path is already documented in `docs/setup.md` (`/how-to/install`) and the README install section; this task is landing-page presentation only, with no runtime, CLI, or installer behavior change.
- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`).
diff --git a/docs/configuration.md b/docs/configuration.md
index d7dc7492..8dfc7b84 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -36,10 +36,22 @@ Docker enablement uses a separate, env-only resolution; see [Docker enablement](
| Type | Format | Example |
|---|---|---|
-| String | Double-quoted; supports `\\`, `\n`, `\t`, `\"` | `"gpt-4"` |
+| String | Double-quoted; supports `\\`, `\n`, `\t`, `\"`, and `${name}` interpolation | `"gpt-4"`, `"${model}"` |
+| String (interpolation sugar) | Bare identifier — stored as `${name}` and resolved at runtime | `model` |
| Boolean | Bare `true` / `false` | `true` |
| Integer | Unsigned decimal digits | `300` |
+String config values support the same `${identifier}` interpolation as orchestration strings (`log`, `prompt`, `return`, etc.). A bare identifier on the RHS is sugar for a single `${identifier}` reference (for example `agent.default_model = model` is equivalent to `agent.default_model = "${model}"`).
+
+**Interpolation scope:**
+
+| Config level | Available identifiers |
+|---|---|
+| Module-level | Module `const` values and environment variables |
+| Workflow-level | Module `const` values, environment variables, and that workflow's parameters |
+
+Interpolation runs when the config scope is applied (workflow entry for workflow-level keys; CLI startup for module-level keys). Environment variables still win over in-file config when locked.
+
## Agent keys
| Key | Type | Default | Env equivalent | Notes |
diff --git a/docs/grammar.md b/docs/grammar.md
index 9e06cb7d..2d042056 100644
--- a/docs/grammar.md
+++ b/docs/grammar.md
@@ -90,9 +90,11 @@ One channel per line. A `->` route declaration inside a workflow body is `E_PARS
```ebnf
config_block = "config" "{" { config_line } "}" ;
config_line = config_key "=" config_value ;
-config_value = string | "true" | "false" | integer ;
+config_value = string | identifier | "true" | "false" | integer ;
```
+A bare `identifier` on the RHS is sugar for a quoted string containing a single `${identifier}` reference. String values may also embed `${identifier}` interpolation directly (same rules as orchestration strings). See [Configuration](configuration.md#value-syntax).
+
Allowed keys: `agent.default_model`, `agent.command`, `agent.backend`, `agent.trusted_workspace`, `agent.cursor_flags`, `agent.claude_flags`, `run.logs_dir`, `run.debug`, `run.recover_limit`, `runtime.docker_image`, `runtime.docker_network`, `runtime.docker_timeout_seconds`, `module.name`, `module.version`, `module.description`. See [Configuration](configuration.md). Workflow-level `config` permits only `agent.*` and `run.*` (`runtime.*` / `module.*` are `E_PARSE`).
The opening line is `config` followed by `{` with optional whitespace between them. Duplicate blocks are `E_PARSE` (`duplicate config block …`). Unknown keys are `E_PARSE` listing the allowed keys. Wrong value types are `E_PARSE`.
diff --git a/e2e/tests/87_workflow_config.sh b/e2e/tests/87_workflow_config.sh
index 5154073c..25c53003 100755
--- a/e2e/tests/87_workflow_config.sh
+++ b/e2e/tests/87_workflow_config.sh
@@ -229,3 +229,33 @@ actual="$(cat "${SIBLING_LOG}")"
expected="$(printf '%s\n' 'alpha:model=alpha-model,backend=claude' 'beta:model=beta-model,backend=cursor')"
e2e::assert_equals "${actual}" "${expected}" \
"alpha sees its own model+backend; beta sees its own model and module default backend"
+
+# ---------------------------------------------------------------------------
+# Section 6: Workflow config interpolation from workflow parameters
+# ---------------------------------------------------------------------------
+e2e::section "workflow config interpolates workflow parameters"
+
+PARAM_LOG="${TEST_DIR}/param.log"
+export JAIPH_PARAM_LOG="${PARAM_LOG}"
+
+e2e::file "param_config.jh" <<'EOF'
+script log_model = `printf 'model:%s\n' "$JAIPH_AGENT_MODEL" >> "$JAIPH_PARAM_LOG"`
+
+workflow implement(model) {
+ config {
+ agent.default_model = model
+ }
+ run log_model()
+}
+
+workflow default() {
+ run implement("param-model")
+}
+EOF
+
+unset JAIPH_AGENT_MODEL 2>/dev/null || true
+jaiph run "${TEST_DIR}/param_config.jh" >/dev/null
+
+actual="$(cat "${PARAM_LOG}")"
+e2e::assert_equals "${actual}" "model:param-model" \
+ "workflow config resolves agent.default_model from workflow parameter"
diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts
index aa2b73c7..32cbfe09 100644
--- a/src/cli/commands/run.ts
+++ b/src/cli/commands/run.ts
@@ -13,7 +13,7 @@ import { parsejaiph } from "../../parser";
import { buildScripts, buildScriptsFromGraph } from "../../transpiler";
import { loadModuleGraph, writeModuleGraph } from "../../transpile/module-graph";
import { canUseAnsi } from "../../runtime/kernel/portability";
-import { metadataToConfig } from "../../config";
+import { resolveModuleMetadata, metadataToConfig } from "../../config";
import { buildStepDisplayParamPairs, formatNamedParamsForDisplay } from "./format-params.js";
import {
colorPalette,
@@ -126,7 +126,8 @@ export async function runWorkflow(rest: string[]): Promise {
const hooksConfig = loadMergedHooks(workspaceRoot);
const graph = loadModuleGraph(inputAbs, workspaceRoot);
const mod = graph.modules.get(inputAbs)!.ast;
- const effectiveConfig = metadataToConfig(mod.metadata);
+ const resolvedModuleMetadata = resolveModuleMetadata(mod, process.env);
+ const effectiveConfig = metadataToConfig(resolvedModuleMetadata);
const outDir = target ? resolve(target) : mkdtempSync(join(tmpdir(), "jaiph-run-"));
const shouldCleanup = !target;
@@ -145,7 +146,7 @@ export async function runWorkflow(rest: string[]): Promise {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
return 1;
}
- const dockerConfigForBanner = resolveDockerConfig(mod.metadata?.runtime, runtimeEnv);
+ const dockerConfigForBanner = resolveDockerConfig(resolvedModuleMetadata?.runtime, runtimeEnv);
const credPreflight = preflightAgentCredentials({
mod,
inputAbs,
@@ -301,7 +302,8 @@ async function runWorkflowRaw(
sandboxFlags: { inplace?: boolean; unsafe?: boolean; yes?: boolean },
): Promise {
const mod = parsejaiph(readFileSync(inputAbs, "utf8"), inputAbs);
- const effectiveConfig = metadataToConfig(mod.metadata);
+ const resolvedModuleMetadata = resolveModuleMetadata(mod, process.env);
+ const effectiveConfig = metadataToConfig(resolvedModuleMetadata);
const outDir = target ? resolve(target) : mkdtempSync(join(tmpdir(), "jaiph-run-"));
const shouldCleanup = !target;
try {
@@ -373,7 +375,8 @@ function spawnExec(
runArgs: string[],
isTTY: boolean,
): { execResult: ReturnType; dockerResult: ReturnType | undefined; dockerConfig: ReturnType } {
- const dockerConfig = resolveDockerConfig(mod.metadata?.runtime, runtimeEnv);
+ const resolvedMetadata = resolveModuleMetadata(mod, runtimeEnv);
+ const dockerConfig = resolveDockerConfig(resolvedMetadata?.runtime, runtimeEnv);
let dockerResult: ReturnType | undefined;
let execResult;
diff --git a/src/config.ts b/src/config.ts
index 5819bf60..24069394 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,4 +1,6 @@
-import type { WorkflowMetadata } from "./types";
+import type { EnvDeclDef, WorkflowMetadata } from "./types";
+import { interpolate } from "./runtime/kernel/runtime-arg-parser";
+import { configValueHasInterpolation } from "./parse/metadata";
export type JaiphConfig = {
agent?: {
@@ -29,3 +31,96 @@ export function metadataToConfig(metadata: WorkflowMetadata | undefined): JaiphC
}
return cfg;
}
+
+/** Resolve module-level `config { }` string interpolation from module `const` values and env. */
+export function resolveModuleMetadata(
+ mod: { metadata?: WorkflowMetadata; envDecls?: EnvDeclDef[] },
+ env?: NodeJS.ProcessEnv,
+): WorkflowMetadata | undefined {
+ if (!mod.metadata) return undefined;
+ const vars = buildConstVars(mod.envDecls, undefined, env);
+ return interpolateWorkflowMetadata(mod.metadata, vars, env);
+}
+
+function interpolateStringField(
+ value: string,
+ vars: Map,
+ env?: NodeJS.ProcessEnv,
+): string {
+ return configValueHasInterpolation(value) ? interpolate(value, vars, env) : value;
+}
+
+/** Build a variable map from module-level `const` declarations (with chained interpolation). */
+export function buildConstVars(
+ envDecls: EnvDeclDef[] | undefined,
+ parent?: Map,
+ env?: NodeJS.ProcessEnv,
+): Map {
+ const vars = new Map(parent ? Array.from(parent.entries()) : []);
+ if (!envDecls) return vars;
+ for (const decl of envDecls) {
+ vars.set(decl.name, interpolate(decl.value, vars, env));
+ }
+ return vars;
+}
+
+/** Resolve `${…}` references in workflow/module metadata string fields. */
+export function interpolateWorkflowMetadata(
+ metadata: WorkflowMetadata,
+ vars: Map,
+ env?: NodeJS.ProcessEnv,
+): WorkflowMetadata {
+ const out: WorkflowMetadata = {};
+ if (metadata.agent) {
+ out.agent = {};
+ if (metadata.agent.defaultModel !== undefined) {
+ out.agent.defaultModel = interpolateStringField(metadata.agent.defaultModel, vars, env);
+ }
+ if (metadata.agent.command !== undefined) {
+ out.agent.command = interpolateStringField(metadata.agent.command, vars, env);
+ }
+ if (metadata.agent.backend !== undefined) {
+ out.agent.backend = interpolateStringField(metadata.agent.backend, vars, env) as
+ | "cursor"
+ | "claude"
+ | "codex";
+ }
+ if (metadata.agent.trustedWorkspace !== undefined) {
+ out.agent.trustedWorkspace = interpolateStringField(metadata.agent.trustedWorkspace, vars, env);
+ }
+ if (metadata.agent.cursorFlags !== undefined) {
+ out.agent.cursorFlags = interpolateStringField(metadata.agent.cursorFlags, vars, env);
+ }
+ if (metadata.agent.claudeFlags !== undefined) {
+ out.agent.claudeFlags = interpolateStringField(metadata.agent.claudeFlags, vars, env);
+ }
+ }
+ if (metadata.run) {
+ out.run = { ...metadata.run };
+ if (metadata.run.logsDir !== undefined) {
+ out.run.logsDir = interpolateStringField(metadata.run.logsDir, vars, env);
+ }
+ }
+ if (metadata.runtime) {
+ out.runtime = { ...metadata.runtime };
+ if (metadata.runtime.dockerImage !== undefined) {
+ out.runtime.dockerImage = interpolateStringField(metadata.runtime.dockerImage, vars, env);
+ }
+ if (metadata.runtime.dockerNetwork !== undefined) {
+ out.runtime.dockerNetwork = interpolateStringField(metadata.runtime.dockerNetwork, vars, env);
+ }
+ }
+ if (metadata.module) {
+ out.module = {};
+ if (metadata.module.name !== undefined) {
+ out.module.name = interpolateStringField(metadata.module.name, vars, env);
+ }
+ if (metadata.module.version !== undefined) {
+ out.module.version = interpolateStringField(metadata.module.version, vars, env);
+ }
+ if (metadata.module.description !== undefined) {
+ out.module.description = interpolateStringField(metadata.module.description, vars, env);
+ }
+ }
+ return out;
+}
diff --git a/src/format/emit.ts b/src/format/emit.ts
index c8467117..976d4edc 100644
--- a/src/format/emit.ts
+++ b/src/format/emit.ts
@@ -141,32 +141,39 @@ export function emitModule(
return sections.join("\n\n") + "\n";
}
+/** Emit a config string RHS, preserving bare-identifier sugar for a single `${name}` reference. */
+function emitConfigStringRhs(value: string): string {
+ const singleRef = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/);
+ if (singleRef) return singleRef[1]!;
+ return JSON.stringify(value);
+}
+
function emitConfigKeyLines(meta: WorkflowMetadata, key: string, pad: string): string[] {
switch (key) {
case "agent.default_model":
if (meta.agent?.defaultModel === undefined) return [];
- return [`${pad}agent.default_model = "${meta.agent.defaultModel}"`];
+ return [`${pad}agent.default_model = ${emitConfigStringRhs(meta.agent.defaultModel)}`];
case "agent.command":
if (meta.agent?.command === undefined) return [];
- return [`${pad}agent.command = "${meta.agent.command}"`];
+ return [`${pad}agent.command = ${emitConfigStringRhs(meta.agent.command)}`];
case "agent.backend":
if (meta.agent?.backend === undefined) return [];
- return [`${pad}agent.backend = "${meta.agent.backend}"`];
+ return [`${pad}agent.backend = ${emitConfigStringRhs(meta.agent.backend)}`];
case "agent.trusted_workspace":
if (meta.agent?.trustedWorkspace === undefined) return [];
- return [`${pad}agent.trusted_workspace = "${meta.agent.trustedWorkspace}"`];
+ return [`${pad}agent.trusted_workspace = ${emitConfigStringRhs(meta.agent.trustedWorkspace)}`];
case "agent.cursor_flags":
if (meta.agent?.cursorFlags === undefined) return [];
- return [`${pad}agent.cursor_flags = "${meta.agent.cursorFlags}"`];
+ return [`${pad}agent.cursor_flags = ${emitConfigStringRhs(meta.agent.cursorFlags)}`];
case "agent.claude_flags":
if (meta.agent?.claudeFlags === undefined) return [];
- return [`${pad}agent.claude_flags = "${meta.agent.claudeFlags}"`];
+ return [`${pad}agent.claude_flags = ${emitConfigStringRhs(meta.agent.claudeFlags)}`];
case "run.debug":
if (meta.run?.debug === undefined) return [];
return [`${pad}run.debug = ${meta.run.debug}`];
case "run.logs_dir":
if (meta.run?.logsDir === undefined) return [];
- return [`${pad}run.logs_dir = "${meta.run.logsDir}"`];
+ return [`${pad}run.logs_dir = ${emitConfigStringRhs(meta.run.logsDir)}`];
case "run.recover_limit":
if (meta.run?.recoverLimit === undefined) return [];
return [`${pad}run.recover_limit = ${meta.run.recoverLimit}`];
@@ -174,22 +181,22 @@ function emitConfigKeyLines(meta: WorkflowMetadata, key: string, pad: string): s
return [];
case "runtime.docker_image":
if (meta.runtime?.dockerImage === undefined) return [];
- return [`${pad}runtime.docker_image = "${meta.runtime.dockerImage}"`];
+ return [`${pad}runtime.docker_image = ${emitConfigStringRhs(meta.runtime.dockerImage)}`];
case "runtime.docker_network":
if (meta.runtime?.dockerNetwork === undefined) return [];
- return [`${pad}runtime.docker_network = "${meta.runtime.dockerNetwork}"`];
+ return [`${pad}runtime.docker_network = ${emitConfigStringRhs(meta.runtime.dockerNetwork)}`];
case "runtime.docker_timeout_seconds":
if (meta.runtime?.dockerTimeoutSeconds === undefined) return [];
return [`${pad}runtime.docker_timeout_seconds = ${meta.runtime.dockerTimeoutSeconds}`];
case "module.name":
if (meta.module?.name === undefined) return [];
- return [`${pad}module.name = "${meta.module.name}"`];
+ return [`${pad}module.name = ${emitConfigStringRhs(meta.module.name)}`];
case "module.version":
if (meta.module?.version === undefined) return [];
- return [`${pad}module.version = "${meta.module.version}"`];
+ return [`${pad}module.version = ${emitConfigStringRhs(meta.module.version)}`];
case "module.description":
if (meta.module?.description === undefined) return [];
- return [`${pad}module.description = "${meta.module.description}"`];
+ return [`${pad}module.description = ${emitConfigStringRhs(meta.module.description)}`];
default:
return [];
}
@@ -210,29 +217,29 @@ function emitConfig(meta: WorkflowMetadata, pad: string, trivia: Trivia): string
return lines.join("\n");
}
if (meta.agent) {
- if (meta.agent.defaultModel !== undefined) lines.push(`${pad}agent.default_model = "${meta.agent.defaultModel}"`);
- if (meta.agent.command !== undefined) lines.push(`${pad}agent.command = "${meta.agent.command}"`);
- if (meta.agent.backend !== undefined) lines.push(`${pad}agent.backend = "${meta.agent.backend}"`);
- if (meta.agent.trustedWorkspace !== undefined) lines.push(`${pad}agent.trusted_workspace = "${meta.agent.trustedWorkspace}"`);
- if (meta.agent.cursorFlags !== undefined) lines.push(`${pad}agent.cursor_flags = "${meta.agent.cursorFlags}"`);
- if (meta.agent.claudeFlags !== undefined) lines.push(`${pad}agent.claude_flags = "${meta.agent.claudeFlags}"`);
+ if (meta.agent.defaultModel !== undefined) lines.push(`${pad}agent.default_model = ${emitConfigStringRhs(meta.agent.defaultModel)}`);
+ if (meta.agent.command !== undefined) lines.push(`${pad}agent.command = ${emitConfigStringRhs(meta.agent.command)}`);
+ if (meta.agent.backend !== undefined) lines.push(`${pad}agent.backend = ${emitConfigStringRhs(meta.agent.backend)}`);
+ if (meta.agent.trustedWorkspace !== undefined) lines.push(`${pad}agent.trusted_workspace = ${emitConfigStringRhs(meta.agent.trustedWorkspace)}`);
+ if (meta.agent.cursorFlags !== undefined) lines.push(`${pad}agent.cursor_flags = ${emitConfigStringRhs(meta.agent.cursorFlags)}`);
+ if (meta.agent.claudeFlags !== undefined) lines.push(`${pad}agent.claude_flags = ${emitConfigStringRhs(meta.agent.claudeFlags)}`);
}
if (meta.run) {
if (meta.run.debug !== undefined) lines.push(`${pad}run.debug = ${meta.run.debug}`);
- if (meta.run.logsDir !== undefined) lines.push(`${pad}run.logs_dir = "${meta.run.logsDir}"`);
+ if (meta.run.logsDir !== undefined) lines.push(`${pad}run.logs_dir = ${emitConfigStringRhs(meta.run.logsDir)}`);
if (meta.run.recoverLimit !== undefined) lines.push(`${pad}run.recover_limit = ${meta.run.recoverLimit}`);
}
if (meta.runtime) {
- if (meta.runtime.dockerImage !== undefined) lines.push(`${pad}runtime.docker_image = "${meta.runtime.dockerImage}"`);
- if (meta.runtime.dockerNetwork !== undefined) lines.push(`${pad}runtime.docker_network = "${meta.runtime.dockerNetwork}"`);
+ if (meta.runtime.dockerImage !== undefined) lines.push(`${pad}runtime.docker_image = ${emitConfigStringRhs(meta.runtime.dockerImage)}`);
+ if (meta.runtime.dockerNetwork !== undefined) lines.push(`${pad}runtime.docker_network = ${emitConfigStringRhs(meta.runtime.dockerNetwork)}`);
if (meta.runtime.dockerTimeoutSeconds !== undefined) {
lines.push(`${pad}runtime.docker_timeout_seconds = ${meta.runtime.dockerTimeoutSeconds}`);
}
}
if (meta.module) {
- if (meta.module.name !== undefined) lines.push(`${pad}module.name = "${meta.module.name}"`);
- if (meta.module.version !== undefined) lines.push(`${pad}module.version = "${meta.module.version}"`);
- if (meta.module.description !== undefined) lines.push(`${pad}module.description = "${meta.module.description}"`);
+ if (meta.module.name !== undefined) lines.push(`${pad}module.name = ${emitConfigStringRhs(meta.module.name)}`);
+ if (meta.module.version !== undefined) lines.push(`${pad}module.version = ${emitConfigStringRhs(meta.module.version)}`);
+ if (meta.module.description !== undefined) lines.push(`${pad}module.description = ${emitConfigStringRhs(meta.module.description)}`);
}
lines.push("}");
return lines.join("\n");
diff --git a/src/parse/metadata.ts b/src/parse/metadata.ts
index 0c913ba6..03c67fb1 100644
--- a/src/parse/metadata.ts
+++ b/src/parse/metadata.ts
@@ -1,6 +1,14 @@
import type { WorkflowMetadata } from "../types";
import type { Trivia, ConfigBodyPart } from "./trivia";
-import { colFromRaw, fail } from "./core";
+import { colFromRaw, fail, isBareIdentifier } from "./core";
+import { validateJaiphStringContent } from "../transpile/validate-string";
+
+const CONFIG_INTERPOLATION_RE = /\$\{[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?\}/;
+
+/** True when a config string value contains `${name}` or `${name.field}` interpolation. */
+export function configValueHasInterpolation(value: string): boolean {
+ return CONFIG_INTERPOLATION_RE.test(value);
+}
const ALLOWED_KEYS = new Set([
"agent.default_model",
@@ -57,10 +65,22 @@ function parseMetadataValue(filePath: string, rawLine: string, valuePart: string
return fail(filePath, 'single-quoted strings are not supported; use double quotes ("...") instead', lineNo, colFromRaw(rawLine));
}
if (trimmed.startsWith(`"`) && trimmed.endsWith(`"`)) {
- return trimmed.slice(1, -1).replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, `"`).replace(/\\\\/g, `\\`);
+ const content = trimmed.slice(1, -1).replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, `"`).replace(/\\\\/g, `\\`);
+ if (configValueHasInterpolation(content)) {
+ validateJaiphStringContent(content, filePath, lineNo, colFromRaw(rawLine), "config");
+ }
+ return content;
+ }
+ if (isBareIdentifier(trimmed)) {
+ return `\${${trimmed}}`;
}
const col = rawLine.indexOf(valuePart) >= 0 ? colFromRaw(rawLine) : 1;
- return fail(filePath, `config value must be a quoted string or true/false: ${trimmed}`, lineNo, col);
+ return fail(
+ filePath,
+ `config value must be a quoted string, bare identifier, or true/false: ${trimmed}`,
+ lineNo,
+ col,
+ );
}
function validateKeyType(
@@ -164,10 +184,16 @@ function assignConfigKey(
): void {
validateKeyType(filePath, key, value, lineNo, raw);
if (key === "agent.backend") {
- if (value !== "cursor" && value !== "claude" && value !== "codex") {
+ if (
+ typeof value === "string" &&
+ !configValueHasInterpolation(value) &&
+ value !== "cursor" &&
+ value !== "claude" &&
+ value !== "codex"
+ ) {
fail(filePath, 'agent.backend must be "cursor", "claude", or "codex"', lineNo, colFromRaw(raw));
}
- (out.agent ??= {}).backend = value;
+ (out.agent ??= {}).backend = value as "cursor" | "claude" | "codex";
return;
}
KEY_SETTERS[key]?.(out, value);
diff --git a/src/parse/parse-metadata.test.ts b/src/parse/parse-metadata.test.ts
index 107114bb..8df38a2b 100644
--- a/src/parse/parse-metadata.test.ts
+++ b/src/parse/parse-metadata.test.ts
@@ -157,15 +157,45 @@ test("parseConfigBlock: fails on line without = separator", () => {
test("parseConfigBlock: fails on bare unquoted string value", () => {
const lines = [
"config {",
- " agent.default_model = gpt4",
+ " agent.default_model = gpt-4",
"}",
];
assert.throws(
() => parseConfigBlock("test.jh", lines, 0),
- /config value must be a quoted string or true\/false/,
+ /config value must be a quoted string, bare identifier, or true\/false/,
);
});
+test("parseConfigBlock: bare identifier is sugar for interpolated string", () => {
+ const lines = [
+ "config {",
+ " agent.default_model = model",
+ "}",
+ ];
+ const { metadata } = parseConfigBlock("test.jh", lines, 0);
+ assert.equal(metadata.agent?.defaultModel, "${model}");
+});
+
+test("parseConfigBlock: quoted string with interpolation is stored literally", () => {
+ const lines = [
+ "config {",
+ ' agent.default_model = "prefix-${model}-suffix"',
+ "}",
+ ];
+ const { metadata } = parseConfigBlock("test.jh", lines, 0);
+ assert.equal(metadata.agent?.defaultModel, "prefix-${model}-suffix");
+});
+
+test("parseConfigBlock: interpolated agent.backend is accepted at parse time", () => {
+ const lines = [
+ "config {",
+ " agent.backend = backend",
+ "}",
+ ];
+ const { metadata } = parseConfigBlock("test.jh", lines, 0);
+ assert.equal(metadata.agent?.backend, "${backend}");
+});
+
test("parseConfigBlock: handles escape sequences in string values", () => {
const lines = [
"config {",
diff --git a/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts b/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts
index 5d4c589f..bb4a1871 100644
--- a/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts
+++ b/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts
@@ -654,6 +654,63 @@ test("NodeWorkflowRuntime: sibling workflows do not inherit each other's metadat
}
});
+test("NodeWorkflowRuntime: workflow config interpolates workflow parameters", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-node-meta-param-"));
+ try {
+ const jh = join(root, "param_config.jh");
+ const metaFile = join(root, "param_scope.log");
+ writeFileSync(
+ jh,
+ [
+ 'script log_model = `printf \'model:%s\\n\' "$JAIPH_AGENT_MODEL" >> "$JAIPH_PARAM_LOG"`',
+ "",
+ "workflow implement(model) {",
+ " config {",
+ " agent.default_model = model",
+ " }",
+ ' run log_model()',
+ "}",
+ "",
+ "workflow default() {",
+ ' run implement("workflow-model")',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const scriptsDir = join(root, "scripts");
+ mkdirSync(scriptsDir, { recursive: true });
+ writeFileSync(
+ join(scriptsDir, "log_model"),
+ [
+ "#!/usr/bin/env bash",
+ 'printf \'model:%s\n\' "$JAIPH_AGENT_MODEL" >> "$JAIPH_PARAM_LOG"',
+ "",
+ ].join("\n"),
+ { mode: 0o755 },
+ );
+
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ JAIPH_SCRIPTS: scriptsDir,
+ JAIPH_PARAM_LOG: metaFile,
+ };
+ delete env.JAIPH_AGENT_MODEL;
+ delete env.JAIPH_AGENT_MODEL_LOCKED;
+
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ const status = await runtime.runDefault([]);
+ assert.equal(status, 0);
+
+ const actual = readFileSync(metaFile, "utf8");
+ assert.equal(actual, "model:workflow-model\n");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("NodeWorkflowRuntime: prompt STEP_START params include named vars referenced in prompt text", async () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-node-prompt-params-"));
try {
diff --git a/src/runtime/kernel/node-workflow-runtime.ts b/src/runtime/kernel/node-workflow-runtime.ts
index 4e8b923e..de68d28c 100644
--- a/src/runtime/kernel/node-workflow-runtime.ts
+++ b/src/runtime/kernel/node-workflow-runtime.ts
@@ -12,6 +12,7 @@ import { appendRunSummaryLine } from "./emit";
import { buildStepDisplayParamPairs } from "../../cli/commands/format-params.js";
import { resolveRuleRef, resolveScriptRef, resolveWorkflowRef, type RuntimeGraph } from "./graph";
import type { WorkflowMetadata } from "../../types";
+import { interpolateWorkflowMetadata } from "../../config";
import { extractJson, validateFields } from "./schema";
import { canonicalizeTripleQuotedString } from "../../parse/triple-quote";
import {
@@ -434,6 +435,10 @@ export class NodeWorkflowRuntime {
const calleeModulePath = resolvePath(resolved.filePath);
const crossModuleNested = callerModulePath !== calleeModulePath;
return this.executeManagedStep("workflow", `${workflowName}`, args, async (io) => {
+ const metadataVars = this.newScopeVars(resolved.filePath, scope.vars, scope.env);
+ resolved.workflow.params.forEach((name, i) => {
+ if (i < args.length) metadataVars.set(name, args[i]);
+ });
// Root entry (`runDefault`, inheritCallerMetadataScope=false): apply entry module + workflow metadata.
// Nested cross-module `run`: layer callee module + workflow metadata on top of the caller's
// effective env (same mechanics as root entry, respecting `${NAME}_LOCKED`). A module's
@@ -447,25 +452,24 @@ export class NodeWorkflowRuntime {
scope.env,
this.graph.modules.get(resolved.filePath)?.ast.metadata,
resolved.workflow.metadata,
+ metadataVars,
);
} else if (inheritCallerMetadataScope) {
- workflowEnv = this.applyMetadataScope(scope.env, undefined, resolved.workflow.metadata);
+ workflowEnv = this.applyMetadataScope(scope.env, undefined, resolved.workflow.metadata, metadataVars);
} else {
workflowEnv = this.applyMetadataScope(
scope.env,
this.graph.modules.get(resolved.filePath)?.ast.metadata,
resolved.workflow.metadata,
+ metadataVars,
);
}
const childScope: Scope = {
filePath: resolved.filePath,
- vars: this.newScopeVars(resolved.filePath, scope.vars, workflowEnv),
+ vars: metadataVars,
env: workflowEnv,
declaredParamNames: resolved.workflow.params,
};
- resolved.workflow.params.forEach((name, i) => {
- if (i < args.length) childScope.vars.set(name, args[i]);
- });
const ctx: WorkflowContext = {
workflowName,
routes: new Map(),
@@ -513,8 +517,9 @@ export class NodeWorkflowRuntime {
// for cross-module rule references so we don't overwrite workflow-level overrides.
const sameModule = resolvePath(scope.filePath) === resolvePath(resolved.filePath);
const moduleMeta = sameModule ? undefined : this.graph.modules.get(resolved.filePath)?.ast.metadata;
- const ruleEnv = this.applyMetadataScope(scope.env, moduleMeta);
- const ruleVars = new Map(scope.vars);
+ const metadataVars = this.newScopeVars(resolved.filePath, scope.vars, scope.env);
+ const ruleEnv = this.applyMetadataScope(scope.env, moduleMeta, undefined, metadataVars);
+ const ruleVars = new Map(metadataVars);
resolved.rule.params.forEach((name, i) => {
if (i < args.length) ruleVars.set(name, args[i]);
});
@@ -1633,36 +1638,38 @@ export class NodeWorkflowRuntime {
parentEnv: NodeJS.ProcessEnv,
moduleMeta?: WorkflowMetadata,
workflowMeta?: WorkflowMetadata,
+ vars?: Map,
): NodeJS.ProcessEnv {
const nextEnv: NodeJS.ProcessEnv = { ...parentEnv };
const apply = (meta?: WorkflowMetadata): void => {
if (!meta) return;
- if (parentEnv.JAIPH_AGENT_MODEL_LOCKED !== "1" && meta.agent?.defaultModel !== undefined) {
- nextEnv.JAIPH_AGENT_MODEL = meta.agent.defaultModel;
+ const resolved = vars ? interpolateWorkflowMetadata(meta, vars, parentEnv) : meta;
+ if (parentEnv.JAIPH_AGENT_MODEL_LOCKED !== "1" && resolved.agent?.defaultModel !== undefined) {
+ nextEnv.JAIPH_AGENT_MODEL = resolved.agent.defaultModel;
}
- if (parentEnv.JAIPH_AGENT_COMMAND_LOCKED !== "1" && meta.agent?.command !== undefined) {
- nextEnv.JAIPH_AGENT_COMMAND = meta.agent.command;
+ if (parentEnv.JAIPH_AGENT_COMMAND_LOCKED !== "1" && resolved.agent?.command !== undefined) {
+ nextEnv.JAIPH_AGENT_COMMAND = resolved.agent.command;
}
- if (parentEnv.JAIPH_AGENT_BACKEND_LOCKED !== "1" && meta.agent?.backend !== undefined) {
- nextEnv.JAIPH_AGENT_BACKEND = meta.agent.backend;
+ if (parentEnv.JAIPH_AGENT_BACKEND_LOCKED !== "1" && resolved.agent?.backend !== undefined) {
+ nextEnv.JAIPH_AGENT_BACKEND = resolved.agent.backend;
}
if (
parentEnv.JAIPH_AGENT_TRUSTED_WORKSPACE_LOCKED !== "1" &&
- meta.agent?.trustedWorkspace !== undefined
+ resolved.agent?.trustedWorkspace !== undefined
) {
- nextEnv.JAIPH_AGENT_TRUSTED_WORKSPACE = meta.agent.trustedWorkspace;
+ nextEnv.JAIPH_AGENT_TRUSTED_WORKSPACE = resolved.agent.trustedWorkspace;
}
- if (parentEnv.JAIPH_AGENT_CURSOR_FLAGS_LOCKED !== "1" && meta.agent?.cursorFlags !== undefined) {
- nextEnv.JAIPH_AGENT_CURSOR_FLAGS = meta.agent.cursorFlags;
+ if (parentEnv.JAIPH_AGENT_CURSOR_FLAGS_LOCKED !== "1" && resolved.agent?.cursorFlags !== undefined) {
+ nextEnv.JAIPH_AGENT_CURSOR_FLAGS = resolved.agent.cursorFlags;
}
- if (parentEnv.JAIPH_AGENT_CLAUDE_FLAGS_LOCKED !== "1" && meta.agent?.claudeFlags !== undefined) {
- nextEnv.JAIPH_AGENT_CLAUDE_FLAGS = meta.agent.claudeFlags;
+ if (parentEnv.JAIPH_AGENT_CLAUDE_FLAGS_LOCKED !== "1" && resolved.agent?.claudeFlags !== undefined) {
+ nextEnv.JAIPH_AGENT_CLAUDE_FLAGS = resolved.agent.claudeFlags;
}
- if (parentEnv.JAIPH_RUNS_DIR_LOCKED !== "1" && meta.run?.logsDir !== undefined) {
- nextEnv.JAIPH_RUNS_DIR = meta.run.logsDir;
+ if (parentEnv.JAIPH_RUNS_DIR_LOCKED !== "1" && resolved.run?.logsDir !== undefined) {
+ nextEnv.JAIPH_RUNS_DIR = resolved.run.logsDir;
}
- if (parentEnv.JAIPH_DEBUG_LOCKED !== "1" && meta.run?.debug !== undefined) {
- nextEnv.JAIPH_DEBUG = meta.run.debug ? "true" : "false";
+ if (parentEnv.JAIPH_DEBUG_LOCKED !== "1" && resolved.run?.debug !== undefined) {
+ nextEnv.JAIPH_DEBUG = resolved.run.debug ? "true" : "false";
}
};
apply(moduleMeta);
diff --git a/src/transpile/compiler-golden.test.ts b/src/transpile/compiler-golden.test.ts
index cc89a45e..78c34abd 100644
--- a/src/transpile/compiler-golden.test.ts
+++ b/src/transpile/compiler-golden.test.ts
@@ -223,7 +223,7 @@ test("parser: unknown config key throws E_PARSE with file location", () => {
test("parser: invalid config value throws E_PARSE", () => {
const source = [
"config {",
- " run.debug = yes",
+ " agent.default_model = gpt-4",
"}",
"workflow default() {",
" log \"ok\"",
@@ -231,7 +231,7 @@ test("parser: invalid config value throws E_PARSE", () => {
].join("\n");
assert.throws(
() => parsejaiph(source, "/fake/entry.jh"),
- /\/fake\/entry\.jh:2:.* E_PARSE.*config value must be a quoted string or true\/false/,
+ /\/fake\/entry\.jh:2:.* E_PARSE.*config value must be a quoted string, bare identifier, or true\/false/,
);
});
diff --git a/src/transpile/validate-config.test.ts b/src/transpile/validate-config.test.ts
new file mode 100644
index 00000000..26c7c6b9
--- /dev/null
+++ b/src/transpile/validate-config.test.ts
@@ -0,0 +1,112 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { parsejaiph } from "../parser";
+import { loadModuleGraph } from "./module-graph";
+import { collectDiagnostics } from "./validate";
+import { buildConstVars, interpolateWorkflowMetadata } from "../config";
+
+test("validateConfig: rejects unknown identifier in workflow config", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-validate-config-"));
+ try {
+ writeFileSync(
+ join(root, "test.jh"),
+ [
+ "workflow implement(model) {",
+ " config {",
+ " agent.default_model = model",
+ " }",
+ "}",
+ "",
+ "workflow other() {",
+ " config {",
+ " agent.default_model = missing",
+ " }",
+ "}",
+ "",
+ "workflow default() {",
+ " log \"ok\"",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const graph = loadModuleGraph(join(root, "test.jh"));
+ const diag = collectDiagnostics(graph);
+ assert.equal(diag.errors.length, 1);
+ assert.match(diag.errors[0]!.message, /unknown identifier "missing"/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("validateConfig: accepts workflow parameter in workflow config", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-validate-config-"));
+ try {
+ writeFileSync(
+ join(root, "test.jh"),
+ [
+ "workflow implement(model) {",
+ " config {",
+ " agent.default_model = model",
+ " }",
+ "}",
+ "",
+ "workflow default() {",
+ " log \"ok\"",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const graph = loadModuleGraph(join(root, "test.jh"));
+ const diag = collectDiagnostics(graph);
+ assert.equal(diag.errors.length, 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("validateConfig: accepts module const in module config", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-validate-config-"));
+ try {
+ writeFileSync(
+ join(root, "test.jh"),
+ [
+ 'const DEFAULT_MODEL = "claude-sonnet-5"',
+ "",
+ "config {",
+ " agent.default_model = DEFAULT_MODEL",
+ "}",
+ "",
+ "workflow default() {",
+ " log \"ok\"",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const graph = loadModuleGraph(join(root, "test.jh"));
+ const diag = collectDiagnostics(graph);
+ assert.equal(diag.errors.length, 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("interpolateWorkflowMetadata: resolves workflow parameter", () => {
+ const vars = new Map([["model", "claude-sonnet-5"]]);
+ const resolved = interpolateWorkflowMetadata({ agent: { defaultModel: "${model}" } }, vars);
+ assert.equal(resolved.agent?.defaultModel, "claude-sonnet-5");
+});
+
+test("buildConstVars: resolves module const chain for module config", () => {
+ const ast = parsejaiph(
+ ['const DEFAULT_MODEL = "claude-sonnet-5"', "", "config {", " agent.default_model = DEFAULT_MODEL", "}"].join(
+ "\n",
+ ),
+ "test.jh",
+ );
+ const vars = buildConstVars(ast.envDecls);
+ const resolved = interpolateWorkflowMetadata(ast.metadata!, vars);
+ assert.equal(resolved.agent?.defaultModel, "claude-sonnet-5");
+});
diff --git a/src/transpile/validate-config.ts b/src/transpile/validate-config.ts
new file mode 100644
index 00000000..74b0a171
--- /dev/null
+++ b/src/transpile/validate-config.ts
@@ -0,0 +1,69 @@
+import type { jaiphModule, WorkflowMetadata } from "../types";
+import { configValueHasInterpolation } from "../parse/metadata";
+import { Diagnostics } from "../diagnostics";
+import { validateSimpleInterpolationIdentifiers } from "./validate-string";
+
+type StringField = {
+ key: string;
+ read: (meta: WorkflowMetadata) => string | undefined;
+};
+
+const STRING_FIELDS: StringField[] = [
+ { key: "agent.default_model", read: (m) => m.agent?.defaultModel },
+ { key: "agent.command", read: (m) => m.agent?.command },
+ { key: "agent.backend", read: (m) => m.agent?.backend },
+ { key: "agent.trusted_workspace", read: (m) => m.agent?.trustedWorkspace },
+ { key: "agent.cursor_flags", read: (m) => m.agent?.cursorFlags },
+ { key: "agent.claude_flags", read: (m) => m.agent?.claudeFlags },
+ { key: "run.logs_dir", read: (m) => m.run?.logsDir },
+ { key: "runtime.docker_image", read: (m) => m.runtime?.dockerImage },
+ { key: "runtime.docker_network", read: (m) => m.runtime?.dockerNetwork },
+ { key: "module.name", read: (m) => m.module?.name },
+ { key: "module.version", read: (m) => m.module?.version },
+ { key: "module.description", read: (m) => m.module?.description },
+];
+
+function validateMetadataInterpolation(
+ diag: Diagnostics,
+ filePath: string,
+ meta: WorkflowMetadata | undefined,
+ knownVars: Set,
+ scopeLabel: "workflow" | "rule",
+ line: number,
+): void {
+ if (!meta) return;
+ for (const field of STRING_FIELDS) {
+ const value = field.read(meta);
+ if (value === undefined || !configValueHasInterpolation(value)) continue;
+ diag.capture(() => {
+ validateSimpleInterpolationIdentifiers(
+ value,
+ filePath,
+ line,
+ 1,
+ `config ${field.key}`,
+ knownVars,
+ scopeLabel,
+ );
+ });
+ }
+}
+
+export function validateConfigInto(ast: jaiphModule, diag: Diagnostics): void {
+ const moduleVars = new Set();
+ if (ast.envDecls) {
+ for (const decl of ast.envDecls) moduleVars.add(decl.name);
+ }
+
+ diag.capture(() => {
+ validateMetadataInterpolation(diag, ast.filePath, ast.metadata, moduleVars, "rule", 1);
+ });
+
+ for (const workflow of ast.workflows) {
+ const wfVars = new Set(moduleVars);
+ for (const param of workflow.params) wfVars.add(param);
+ diag.capture(() => {
+ validateMetadataInterpolation(diag, ast.filePath, workflow.metadata, wfVars, "workflow", workflow.loc.line);
+ });
+ }
+}
diff --git a/src/transpile/validate.ts b/src/transpile/validate.ts
index e9d4d486..38bdfe80 100644
--- a/src/transpile/validate.ts
+++ b/src/transpile/validate.ts
@@ -13,6 +13,7 @@ import {
WORKFLOW_SCOPE,
type ValidatorCtx,
} from "./validate-step";
+import { validateConfigInto } from "./validate-config";
/**
* One step entry in the flat list built by the single workflow walk.
@@ -291,6 +292,8 @@ export function validateModuleInto(
importedAstCache,
} as const;
+ validateConfigInto(ast, diag);
+
for (const rule of ast.rules) {
let ruleWalk: StepTreeWalk | undefined;
diag.capture(() => {
diff --git a/test-fixtures/compiler-txtar/parse-errors-snapshot.json b/test-fixtures/compiler-txtar/parse-errors-snapshot.json
index 008f300b..0937dd0c 100644
--- a/test-fixtures/compiler-txtar/parse-errors-snapshot.json
+++ b/test-fixtures/compiler-txtar/parse-errors-snapshot.json
@@ -221,7 +221,7 @@
"line": 2,
"col": 3,
"code": "E_PARSE",
- "message": "config value must be a quoted string or true/false: yes"
+ "message": "config value must be a quoted string, bare identifier, or true/false: gpt-4"
},
"config integer key rejects string value": {
"file": "input.jh",
diff --git a/test-fixtures/compiler-txtar/parse-errors.txt b/test-fixtures/compiler-txtar/parse-errors.txt
index 77a3b9a0..f5d8d000 100644
--- a/test-fixtures/compiler-txtar/parse-errors.txt
+++ b/test-fixtures/compiler-txtar/parse-errors.txt
@@ -215,10 +215,10 @@ workflow default() {
}
=== invalid config value not quoted
-# @expect error E_PARSE "config value must be a quoted string or true/false" @2:3
+# @expect error E_PARSE "config value must be a quoted string, bare identifier, or true/false" @2:3
--- input.jh
config {
- run.debug = yes
+ agent.default_model = gpt-4
}
workflow default() {
log "ok"
From 71d7380ef167abfb64f526896e3ab55440732991 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Tue, 14 Jul 2026 18:43:52 +0200
Subject: [PATCH 11/85] =?UTF-8?q?WIP:=20jaiph=20mcp=20=E2=80=94=20serve=20?=
=?UTF-8?q?workflows=20as=20MCP=20tools=20over=20stdio=20(partial=20implem?=
=?UTF-8?q?entation)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spiked MVP of `jaiph mcp ` plus design doc and queue tasks.
PARTIAL — do not treat as landed: verification gaps are inventoried in
QUEUE.md ("MCP 1-4/8") and must be closed by the queue task before this
feature is considered done.
Implemented:
- src/cli/mcp/tools.ts — tool derivation (exports narrowing, route-target
exclusion, default→file-slug rename, comment
descriptions, string schemas); 10 unit tests
- src/cli/mcp/server.ts — newline-delimited JSON-RPC 2.0 stdio server
(initialize/ping/tools/list/tools/call,
list_changed); 16 unit tests
- src/cli/mcp/call.ts — per-call runner spawn, event capture, result
composition (return_value.txt → logs → note)
- src/cli/commands/mcp.ts — command wiring, hot reload (watchFile), host
execution like `jaiph run --raw`
- src/cli/index.ts, usage — `mcp` subcommand + `--mcp` alias
- runtime: runRoot(name, args) generalizes runDefault; runner dispatches any
symbol; workflow-launch.ts fix — buildRunModuleLaunch hardcoded "default"
and dropped the requested workflow symbol
- design/2026-07-14-mcp-server.md — full design; QUEUE.md — grouped task with
per-piece verification state + ENV --env passthrough task
Verified: tsc + build clean; 26/26 mcp unit tests; live stdio session
(handshake, list, calls incl. -32602 and isError legs, concurrency).
NOT verified (see QUEUE.md acceptance):
- `jaiph run` direction of the workflow-launch fix (touches every run)
- hot reload path (never exercised)
- runRoot/launch argv have no dedicated unit tests
- full `npm test` suite not run since these changes
Co-Authored-By: Claude Opus 4.8 (1M context)
---
QUEUE.md | 145 ++++++++++++
design/2026-07-14-mcp-server.md | 128 +++++++++++
src/cli/commands/mcp.ts | 234 ++++++++++++++++++++
src/cli/index.ts | 5 +
src/cli/mcp/call.ts | 158 +++++++++++++
src/cli/mcp/server.test.ts | 217 ++++++++++++++++++
src/cli/mcp/server.ts | 188 ++++++++++++++++
src/cli/mcp/tools.test.ts | 136 ++++++++++++
src/cli/mcp/tools.ts | 132 +++++++++++
src/cli/shared/usage.ts | 9 +
src/runtime/kernel/node-workflow-runner.ts | 2 +-
src/runtime/kernel/node-workflow-runtime.ts | 24 +-
src/runtime/kernel/workflow-launch.ts | 4 +-
13 files changed, 1374 insertions(+), 8 deletions(-)
create mode 100644 design/2026-07-14-mcp-server.md
create mode 100644 src/cli/commands/mcp.ts
create mode 100644 src/cli/mcp/call.ts
create mode 100644 src/cli/mcp/server.test.ts
create mode 100644 src/cli/mcp/server.ts
create mode 100644 src/cli/mcp/tools.test.ts
create mode 100644 src/cli/mcp/tools.ts
diff --git a/QUEUE.md b/QUEUE.md
index 72c078fc..82a837cc 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,3 +14,148 @@ Process rules:
***
+## MCP 1–4/8 — Land the spiked `jaiph mcp` MVP: verify what exists, close the test gaps, run the full suite #dev-ready
+
+Design: `design/2026-07-14-mcp-server.md` — the whole doc; it records the contracts as verified during the spike.
+
+**State: largely implemented, partially verified.** A working MVP was spiked on 2026-07-14 and left **uncommitted in the working tree on `nightly`**. If that diff is present when you pick this up, the job is verification and gap-closing; if it is absent (discarded or already landed differently), implement from scratch per the design doc. The acceptance list below is the contract either way and is deliberately state-independent.
+
+### What exists and how far each piece is verified
+
+- **Runtime root generalization** — `src/runtime/kernel/node-workflow-runtime.ts`: `runDefault(args)` generalized to `runRoot(workflowName, args)` (emits `WORKFLOW_START`/`WORKFLOW_END` with the symbol, binds args to params by position, persists `return_value.txt` on success; `runDefault` delegates; unknown non-default symbol → `jaiph run: unknown workflow '' in the input file`, status 1). *Implemented; exercised only indirectly through the live MCP session; **no dedicated unit tests**.*
+- **Runner symbol dispatch** — `src/runtime/kernel/node-workflow-runner.ts` calls `runtime.runRoot(workflowName, runArgs)` (previously non-`default` symbols short-circuited to status 1). *Implemented; no dedicated test.*
+- **Launch-path bug fix** — `src/runtime/kernel/workflow-launch.ts` `buildRunModuleLaunch` now passes `workflowSymbol || "default"` into the runner argv; it previously destructured the symbol and **hardcoded `"default"`**, so every spawned run executed `default` regardless of the requested symbol. *Implemented; verified in the MCP direction (non-default symbols run correctly). **The `jaiph run` direction was NOT re-verified after this edit** (the manual regression check was interrupted) and there is **no unit test pinning the argv**. This edit touches every `jaiph run` — treat it as unverified until tested.*
+- **Tool derivation** — `src/cli/mcp/tools.ts` (`deriveTools`, `toolNameFromFile`) + `src/cli/mcp/tools.test.ts`. *Implemented; **10 unit tests passing** (exports narrowing, route-target exclusion, lone-`default` rename, skip-with-warning, comment descriptions with shebang dropped, fallback, schemas, name sanitization).*
+- **Protocol server** — `src/cli/mcp/server.ts` (`McpServer`, injected `{serverVersion, getTools, callTool, write, log}`) + `src/cli/mcp/server.test.ts`. *Implemented; **16 unit tests passing** (initialize version negotiation known/unknown, tools/list shape, call arg mapping, failure-as-`isError`, `-32602` variants, `-32603` + log, ping, notifications ignored, `-32700` id-null, `-32601`, blank lines, `list_changed` gating).*
+- **Per-call execution** — `src/cli/mcp/call.ts` (`callWorkflow`: per-call runner spawn, `__JAIPH_EVENT__` stderr parsing, meta-file → `return_value.txt`, success/failure text composition). *Implemented; verified via a live stdio session (return values round-trip, failure text carries the run-dir pointer, concurrent calls interleave correctly); **no automated test**.*
+- **Command + wiring** — `src/cli/commands/mcp.ts` (`runMcp`: arg parsing, diagnostics-to-stderr, generation temp dirs, concurrent in-flight tracking, shutdown on stdin close/SIGINT/SIGTERM), dispatch of `mcp` + `--mcp` alias in `src/cli/index.ts`, `printUsage` additions in `src/cli/shared/usage.ts`. *Implemented; happy path verified via the live session. **The hot-reload path (`fs.watchFile` → new generation → `notifications/tools/list_changed`) was written but never exercised — not even manually.** The diagnostics-exit path, help output, and alias dispatch have no tests.*
+
+Overall verification done during the spike: `tsc --noEmit` clean; `npm run build` clean; `node --test dist/src/cli/mcp/*.test.js` → **26/26 pass**; live scripted stdio session (initialize → tools/list with comment-derived descriptions → tools/call returning workflow `return` values → missing-arg `-32602` → failure leg with `isError: true`). **The full `npm test` suite has NOT been run since these changes.**
+
+Manual probe (useful while working):
+
+```bash
+printf '%s\n' \
+ '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
+ '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
+ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"","arguments":{...}}}' \
+ | node dist/src/cli.js mcp
+```
+
+### Work
+
+1. If the diff is absent, implement per the design doc (module layout above).
+2. Add the missing tests: launch argv pin, `runRoot` unit tests, scripted stdio session, diagnostics/help/alias, hot reload, `jaiph run` regression.
+3. Run the **full** `npm test` suite (and `npm run test:e2e` if touched paths warrant) and fix fallout — the launch-path edit is shared with `jaiph run`.
+
+### Acceptance
+
+Existing (keep passing — they fail if the contract regresses):
+
+- The 10 tool-derivation tests and 16 protocol-server tests described above.
+
+New (each must fail when its contract is violated):
+
+- Unit test: `buildRunModuleLaunch(["meta", "built.sh", "mywf", "arg1"], env)` produces an argv containing `mywf` (pins the fixed hardcoded-`default` bug).
+- Unit tests on the runtime: a non-`default` workflow runs as root via `runRoot` with params bound positionally and `return_value.txt` written on success; `runRoot("missing", [])` returns 1 and writes no `return_value.txt`; `runDefault` behaviour unchanged.
+- Scripted stdio session against a fixture `.jh` (two workflows, one with a param and a `return`): initialize → tools/list shows both tools with comment-derived descriptions → tools/call returns the workflow's return value as text → missing-arg call → `-32602` → failing workflow → `isError: true` with a run-dir pointer. Every stdout line parses as JSON-RPC (no banner/progress leakage).
+- Compile diagnostics go to stderr with exit 1 and nothing on stdout.
+- `jaiph --help` output includes `jaiph mcp`; `jaiph --mcp ` dispatches to the same command.
+- Hot reload: edit the fixture to add a workflow → `notifications/tools/list_changed` is emitted and a subsequent `tools/list` shows the new tool; break the fixture → the previous tool set still serves and diagnostics appear on stderr.
+- `jaiph run` regression: a `default` workflow exits 0 and prints its return value (pins the shared launch path in the `run` direction).
+- The full `npm test` suite passes.
+
+## ENV — `--env` passthrough into the workflow env and across the Docker sandbox boundary (`jaiph run` + `jaiph mcp`) #dev-ready
+
+The Docker sandbox forwards environment fail-closed: only the `ENV_ALLOW_PREFIXES` allowlist (`JAIPH_`, `ANTHROPIC_`, `CURSOR_`, `CLAUDE_`; see `src/runtime/docker.ts` — `isEnvAllowed`, consumed in the `buildDockerArgs` env loop) crosses into the container. There is no per-key escape hatch, so a workflow that needs e.g. `GITHUB_TOKEN` or `MY_API_URL` cannot receive it in a sandboxed run. Add an explicit, user-consented passthrough.
+
+Contract:
+
+- New repeatable flag on **`jaiph run`** and **`jaiph mcp`** (parsed in `parseArgs`, `src/cli/shared/usage.ts`):
+ - `--env KEY=VALUE` — define `KEY` with that exact value (first `=` splits; value may contain `=`; empty value allowed).
+ - `--env KEY` — forward the host's current value; if `KEY` is unset on the host, abort before spawning with a specific error (`E_ENV_MISSING`), never silently drop.
+ - `KEY` must match `[A-Za-z_][A-Za-z0-9_]*`; anything else aborts with `E_ENV_INVALID`.
+- **Semantics: `--env` defines the workflow process's env var in every execution mode.**
+ - Host (non-Docker, including `jaiph run --raw`-style spawns and `jaiph mcp` tool calls): applied to the runner env after `resolveRuntimeEnv`/`applySandboxFlags`, overriding inherited values.
+ - Docker: appended as explicit `-e KEY=VALUE` container args **bypassing `isEnvAllowed`** — the flag is the per-key consent. Thread the pairs through `DockerSpawnOptions` (new field, e.g. `extraEnv: Record`) into `buildDockerArgs`; ensure a key appears once, with the `--env` value winning over an allowlist-forwarded host value.
+- **Reserved keys are rejected** (`E_ENV_RESERVED`, both flag forms, all modes): sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, anything `JAIPH_DOCKER_*`) and runtime-managed keys that `resolveRuntimeEnv`/`remapDockerEnv` own (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`). Use the sandbox flags (`--inplace`/`--unsafe`) or real env vars for control keys instead.
+- `jaiph mcp --env …` applies the pairs to **every** tool call's runner env for the server's lifetime (and, once Docker-backed MCP calls exist, they must flow through the same `extraEnv` container path — the field is the single choke point).
+- No remapping of `--env` values: they are passed verbatim (path remapping stays confined to the runtime-managed keys, which are rejected above).
+- Update `printUsage` (`src/cli/shared/usage.ts`) and per-command usage strings; document the flag and its sandbox-boundary meaning in `docs/cli.md`, `docs/env-vars.md`, and the env-exposure paragraph of `docs/sandboxing.md`.
+
+Acceptance:
+
+- `parseArgs` unit tests: repeatable `--env` collected in order, `KEY=VALUE` vs bare `KEY` forms, `=` inside value preserved, empty value, invalid name rejected, missing value for bare form deferred to spawn-time host lookup.
+- Unit test on `buildDockerArgs`: a key that fails `isEnvAllowed` (e.g. `MY_TOKEN`) appears as `-e MY_TOKEN=…` when supplied via `extraEnv`, and does NOT appear without it (fails if the allowlist bypass or the fail-closed default regresses). A key both allowlist-forwarded and in `extraEnv` appears exactly once with the `extraEnv` value.
+- Reserved-key rejection test per category (control key, runtime-managed key), both flag forms.
+- `--env KEY` with `KEY` unset on the host aborts with `E_ENV_MISSING` before any process is spawned.
+- Integration (host mode): `jaiph run --env GREETING=hi file.jh` where the workflow shells out `echo $GREETING` — output contains `hi`. Same via bare-forward form with the var exported on the host.
+- Integration (MCP): `jaiph mcp --env GREETING=hi tools.jh` — a `tools/call` whose workflow returns the env var yields `hi` in the result text on every call.
+- Docker integration leg (where CI allows Docker): a sandboxed `jaiph run --env MY_TOKEN=s3cret` workflow reads the var inside the container; without `--env` the same workflow sees it unset.
+
+## MCP 5/8 — Docs: serving workflows over MCP #dev-ready
+
+Design: `design/2026-07-14-mcp-server.md` → "Documentation" (and the rest of the doc for the contracts being documented). Documents the `jaiph mcp` subcommand as it exists in the tree.
+
+Work:
+
+- `docs/mcp.md` — Diátaxis how-to: serving a file; exposure rules (`export workflow` narrowing, route-target exclusion, `default`-only rename to file slug); writing tool descriptions as `#` comments above workflows; client config examples (`claude mcp add mytools -- jaiph mcp ./tools.jh`, Claude Desktop/Cursor JSON); safety posture (host execution like `jaiph run --raw`, Docker sandbox not launched yet, an exposed workflow is arbitrary shell reachable by the connected agent); hot reload; run artifacts under `.jaiph/runs/`; concurrency caveat (two calls mutating one workspace can race).
+- `docs/cli.md` — `jaiph mcp` reference section in the subcommand summary table + its own section: every flag the command accepts in the tree at execution time (at minimum `--workspace`; include `--env` if present), exit behaviour, protocol subset table, stdout-is-protocol invariant, `--mcp` alias.
+- README — feature bullet under Features and a docs-note link, following the existing style.
+- Front-matter (`title`, `permalink`, `diataxis`) consistent with sibling docs pages.
+
+Acceptance:
+
+- `docs/mcp.md` exists with the sections above; `docs/cli.md` documents every flag and the alias; README links resolve.
+- Every documented behaviour matches an assertion in the `jaiph mcp` test suite (no documented flag or rule without a covering test — cross-check exposure rules, naming, stdout invariant).
+- Docs build/link checks used by the repo (if any run in CI) pass.
+
+## MCP 6/8 — e2e: `jaiph mcp` scripted session + `jaiph run` regression #dev-ready
+
+Design: `design/2026-07-14-mcp-server.md` → "Testing". The unit/acceptance tests for `jaiph mcp` run the command in-repo; this task adds black-box e2e coverage through the real binary entrypoint alongside the existing `e2e/` suite (`npm run test:e2e`).
+
+Work:
+
+- An e2e script that starts `jaiph mcp ` as a child process, performs `initialize` / `tools/list` / `tools/call` (param round-trip through a workflow `return`) / a failing call, and closes stdin.
+- A regression leg asserting `jaiph run ` still passes and prints the return value — the launch path (`workflow-launch.ts`) is shared between `run` and `mcp`, and it previously hardcoded the `default` symbol; this leg pins both directions.
+
+Acceptance:
+
+- e2e asserts: every stdout line is valid JSON-RPC (no banner/progress leakage), the tool result text equals the workflow return value, a failing workflow yields `isError: true` and exit-0 server shutdown on stdin close.
+- e2e asserts `jaiph run` on a `default` workflow exits 0 and prints the return value (fails if the shared launch path regresses).
+- Wired into `npm run test:e2e` so CI executes it.
+
+## MCP 7/8 — Docker sandbox parity for `jaiph mcp` (inplace by default) #dev-ready
+
+Design: `design/2026-07-14-mcp-server.md` → "Safety posture". Today `jaiph mcp` runs tool calls on the host like `jaiph run --raw` and prints a stderr notice when the env would have enabled Docker. This task makes MCP tool calls honor the same env-driven sandbox selection as `jaiph run` (`resolveDockerConfig`, `selectSandboxMode`, `spawnDockerProcess`, image prepare/availability checks), with two deliberate MCP-specific rules:
+
+- **Inplace is the default sandbox mode** for `jaiph mcp` when Docker is enabled and no mode is explicitly selected via env: the calling agent operates on the real workspace and expects tool effects to land live. Explicit env selection (overlay/copy) is honored and restores workspace isolation.
+- **The inplace confirmation prompt is implied by starting the server.** stdin is the protocol channel, so no interactive prompt is possible; starting `jaiph mcp` on a workspace is the consent act. No `--yes` flag is required. Document this in `docs/mcp.md`, including how to opt back into isolation.
+
+Work items include: carrying the workflow symbol into the containerized inner run (the Docker path currently assumes the `default` symbol — same class of bug as the fixed host launch path), per-call container lifecycle (spawn/cleanup/timeout via the existing `cleanupDocker`/`withDockerExitGuard` helpers), run-dir discovery from the sandbox runs root for result/`return_value.txt` reading (`discoverDockerRunDir`, `remapContainerPath`), and startup `checkDockerAvailable`/`prepareImage` once rather than per call.
+
+Acceptance:
+
+- With Docker enabled in env, a `tools/call` runs in a container in inplace mode by default (test may assert mode selection + spawn wiring at the unit level, plus an e2e/integration leg where the CI environment allows Docker).
+- A non-`default` tool symbol executes correctly inside the container (fails if the inner run hardcodes `default`).
+- Explicit overlay/copy env selection is honored for MCP calls; the workspace is untouched after such a call.
+- Host fallback (`JAIPH_UNSAFE=true` or win32) still works and is covered.
+- Result composition (return value, failure text, run-dir pointer) works with container runs, using host-side remapped paths.
+- If the CLI supports `--env` passthrough (see `docs/cli.md`; explicit per-key exceptions to the container env allowlist carried via `DockerSpawnOptions.extraEnv`), `jaiph mcp --env` pairs reach the per-call container: a test asserts a non-allowlisted key supplied via `--env` is readable by the workflow inside the container.
+
+## MCP 8/8 — MCP progress notifications and cancellation #dev-ready
+
+Design: `design/2026-07-14-mcp-server.md` → "Out of scope (queued separately)". Long workflows should stream progress to the client and be cancellable. Builds on the `jaiph mcp` command as it exists in the tree (per-call runner spawn; child stderr already parsed via `parseStepEvent`/`parseLogEvent`).
+
+Work:
+
+- When a `tools/call` request carries `params._meta.progressToken`, translate the run's `STEP_START`/`STEP_END` events into `notifications/progress` (`{progressToken, progress: , message: ""}`); stop notifying after the call's response is sent (spec requirement).
+- Handle `notifications/cancelled` for an in-flight request id: terminate that call's child process tree (reuse `terminateRunProcessGroup` semantics from `src/cli/run/lifecycle.ts` — SIGINT, force-kill timer), do not send a response for the cancelled id, keep the server serving.
+- No behaviour change for calls without a progressToken.
+
+Acceptance:
+
+- Scripted session: a `tools/call` with a progressToken over a multi-step fixture workflow yields ≥1 `notifications/progress` with that token before the response, monotonically increasing `progress`, and none after the response (test fails on post-response notifications).
+- Scripted session: `notifications/cancelled` for an in-flight call kills the child (observable: the run terminates, no response for that id arrives, a subsequent `ping` still answers).
+- Calls without a progressToken emit no progress notifications.
diff --git a/design/2026-07-14-mcp-server.md b/design/2026-07-14-mcp-server.md
new file mode 100644
index 00000000..2512ebb3
--- /dev/null
+++ b/design/2026-07-14-mcp-server.md
@@ -0,0 +1,128 @@
+# mcp-server — design doc
+
+*`jaiph mcp ` serves the file's workflows as MCP tools over stdio. Any MCP client (Claude Code, Claude Desktop, Cursor) can call tested, deterministic Jaiph workflows as tools — a `.jh` file becomes an MCP server with zero boilerplate.*
+
+**Status:** design — ready for implementation (an MVP was spiked and verified end-to-end; this doc records the verified contracts)
+**Date (UTC):** 2026-07-14
+
+## Problem
+
+Jaiph orchestrates agents (`prompt` → Claude/Cursor/Codex). The reverse direction is missing: an agent that wants to run a Jaiph workflow has to shell out to `jaiph run` and scrape output. MCP is the standard way to hand tools to agents; a workflow encodes a multi-step, tested, repair-capable procedure (`ensure`, `catch`, `recover`, artifacts) — exactly what an agent should call as one tool instead of improvising shell commands.
+
+Goals, in order:
+
+1. **Workflows as tools** — every suitable workflow in the entry file becomes an MCP tool with a name, description, and typed input schema.
+2. **Zero-friction serving** — `claude mcp add mytools -- jaiph mcp ./tools.jh`. No SDK project, no build step.
+3. **Reuse the runtime** — compile-time validation, per-run artifacts under `.jaiph/runs/`, the `__JAIPH_EVENT__` stream, and (follow-up) Docker sandboxing all apply unchanged.
+4. **Zero dependencies** — the project has no runtime deps; the MCP stdio surface needed here (5 methods) is hand-rolled, not `@modelcontextprotocol/sdk`.
+
+## CLI surface
+
+```
+jaiph mcp [--workspace ]
+```
+
+- `--mcp` is accepted as an alias for the subcommand in `src/cli/index.ts` (`jaiph --mcp tools.jh`), dispatched after `compile`.
+- `--workspace ` behaves exactly as in `jaiph run` (import resolution root; validated to be an existing directory). Default: `detectWorkspaceRoot(dirname(file))`.
+- `-h`/`--help` prints usage and exits 0. Reuse `hasHelpFlag` / `parseArgs` from `src/cli/shared/usage.ts`.
+- Startup: load module graph (`loadModuleGraph`), run `collectDiagnostics`; any diagnostic → print `file:line:col CODE message` lines to **stderr**, exit 1 (same formatting as `jaiph compile`).
+- The server runs until stdin closes or SIGINT/SIGTERM; exit 0 on clean shutdown, after letting in-flight calls settle.
+
+**stdout hygiene is a hard invariant:** from the moment the server starts, stdout carries only newline-delimited JSON-RPC. Every banner, warning, reload notice, and diagnostic goes to stderr.
+
+## Transport & protocol subset
+
+Newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport). One JSON object per line, UTF-8. Handled methods:
+
+| Method | Behaviour |
+|---|---|
+| `initialize` | Reply `{protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: {name: "jaiph", title: "Jaiph workflows", version: VERSION}}`. Version negotiation: echo the client's `protocolVersion` if it is one of `["2024-11-05", "2025-03-26", "2025-06-18"]`, else reply with the newest of that list. |
+| `ping` | `{}` result. |
+| `tools/list` | `{tools: [{name, description, inputSchema}]}` from the current tool set (re-derived reference on every request so hot reload needs no cache invalidation). |
+| `tools/call` | Run the workflow (see Execution). Result: `{content: [{type: "text", text}], isError: bool}`. |
+| any notification | Ignored (`notifications/initialized`, `notifications/cancelled`, …). No response. |
+| unknown request | JSON-RPC error `-32601`. |
+
+Error mapping: invalid JSON → `-32700` with `id: null`; non-object message → `-32600`; unknown tool, missing/non-string required argument, or unexpected argument key → `-32602` (protocol error, the call never starts); a **workflow failure is not a protocol error** — it returns a result with `isError: true`; an infrastructure crash while running the call → `-32603`.
+
+Requests are handled **concurrently** (a long `tools/call` must not stall `ping` or further calls); JSON-RPC ids make interleaved responses legal. Each outbound message is a single atomic `process.stdout.write` of `JSON.stringify(msg) + "\n"`.
+
+The protocol layer lives in `src/cli/mcp/server.ts` as a class taking injected `{serverVersion, getTools, callTool, write, log}` so unit tests drive it line-in/message-out with no processes.
+
+## Tool derivation (`src/cli/mcp/tools.ts`)
+
+Pure function `deriveTools(mod: jaiphModule, inputAbs: string) → {tools, warnings}` over the **entry module only** (imports are not exposed):
+
+1. **Exports narrow.** If the module has `export workflow` declarations (`mod.exports` filtered to workflow names), exactly those are exposed. `export` already exists in the grammar and is the module's public-API marker.
+2. **Otherwise all top-level workflows**, minus channel route targets (any name appearing in `mod.channels[].routes[].value` — those are inbox handlers, not tools). Emit a warning per exclusion.
+3. **`default` special-case:** exposed only when it is the *only* candidate, under a tool name derived from the file basename — `.jh` stripped, chars outside `[A-Za-z0-9_-]` replaced with `_` (MCP tool-name charset), truncated to 128. With other candidates present, `default` is skipped with a warning (it stays the `jaiph run` entrypoint). On a (rare) slug collision with a named workflow, skip `default` with a warning.
+4. **Description** = the workflow's leading `#` comment lines (`WorkflowDef.comments` — the parser already attaches them, stored raw *including* `#`), with `#!` shebang lines dropped and the `#` prefix stripped, joined with `\n`. Fallback: `Run the "" workflow from .` Descriptions decide whether an agent picks the tool — the docs must tell authors to write them.
+5. **Input schema:** all Jaiph params are strings, so `{type: "object", properties: {: {type: "string"}}, required: [], additionalProperties: false}` (`required` omitted when there are no params). Each tool spec carries `params` in declared order for positional mapping at call time.
+
+Warnings surface once on stderr at (re)load, never on stdout.
+
+## Execution model (`src/cli/mcp/call.ts`)
+
+Per-generation shared state, built at startup and on each hot reload into `mkdtemp(jaiph-mcp-)/gen-/`:
+
+- `buildScriptsFromGraph(graph, outDir)` → `scriptsDir` (read-only at call time, safe to share across concurrent calls),
+- `writeModuleGraph(outDir/.jaiph-module-graph.json)` → runner consumes it via `JAIPH_MODULE_GRAPH_FILE` (no re-parse per call),
+- `metadataToConfig(resolveModuleMetadata(mod, process.env))` → `effectiveConfig`.
+
+Per `tools/call`:
+
+1. Map the arguments object to positional args in declared param order.
+2. `resolveRuntimeEnv(effectiveConfig, workspaceRoot, inputAbs)`; set `JAIPH_SOURCE_ABS`, fresh `JAIPH_RUN_ID` (randomUUID), `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`; per-call meta file under the generation dir.
+3. `spawnRunProcess([metaFile, dummyBuiltPath, workflowSymbol, ...args], {cwd: workspaceRoot, env})` — the same self-spawn path as `jaiph run` (piped stdio).
+4. Parse child **stderr** line-wise: `parseLogEvent` → collected log output; `parseStepEvent` with `STEP_END`/nonzero status → first failing step (`kind name` + `err_content`/`out_content`); everything else → raw stderr. Child stdout is captured, never forwarded.
+5. `waitForRunExit`; read the meta file (`run_dir=`, `summary_file=` lines).
+6. **Success text**, in order of preference: `/return_value.txt` (the runtime persists the root workflow's `return` value), else collected `log` output, else `workflow completed`.
+7. **Failure text:** `workflow failed (exit N)` / `terminated by signal S`, the failing step and its captured output, non-event stderr, collected logs, and `run dir: ` for investigation. Returned with `isError: true`.
+
+Run artifacts land under `.jaiph/runs/` in the workspace exactly as for `jaiph run` — every tool call is a durable, inspectable run. Concurrent calls are isolated by per-call run ids/dirs; two calls mutating the same workspace can still race, which the docs state plainly.
+
+### Runtime prerequisites (verified)
+
+Two host-side generalizations are required so a non-`default` symbol can be a run root:
+
+- `NodeWorkflowRuntime.runDefault(args)` generalizes to `runRoot(workflowName, args)` — same contract (emits `WORKFLOW_START`/`WORKFLOW_END` with the symbol name, binds args to params by position, persists `return_value.txt` on success); `runDefault` delegates. Unknown symbol keeps the existing message for `default` (`jaiph run requires workflow 'default' in the input file`) and gets `jaiph run: unknown workflow '' in the input file` otherwise. `node-workflow-runner.ts` calls `runRoot(workflowName, runArgs)` instead of hard-failing non-default symbols (`status 1`).
+- **Bug (spike-verified):** `buildRunModuleLaunch` in `src/runtime/kernel/workflow-launch.ts` destructures the workflow symbol from its positional args and then hardcodes `"default"` into the runner argv. It must pass `workflowSymbol || "default"` through. Without this fix every MCP call runs `default` regardless of the tool invoked; the symptom is `jaiph run requires workflow 'default' in the input file` on files without a `default`.
+
+## Hot reload
+
+`fs.watchFile` (polling, ~750ms — portable, no per-platform `fs.watch` quirks) on every file in `graph.modules.keys()`. On change:
+
+- Reload the graph, re-validate, re-derive tools, rebuild scripts into a new generation dir; swap the state, re-watch the (possibly changed) module set, delete the previous generation dir.
+- Send `notifications/tools/list_changed` (only after `initialize` has happened).
+- On parse/validation failure: keep serving the previous generation; log the diagnostics to stderr. Guard against re-entrant reloads.
+
+## Safety posture
+
+MVP: **calls run on the host**, like `jaiph run --raw` (which by contract never launches Docker). Docker is on by default on macOS/Linux, so the server prints a one-line stderr notice at startup when the env would have enabled Docker. Credential pre-flight (`preflightAgentCredentials`, `dockerEnabled: false`) runs once at startup; in MCP mode errors are demoted to warnings (the server may outlive a credential fix; per-call failures still surface to the client).
+
+An MCP-exposed workflow is arbitrary shell reachable by the connected agent — that is the point, and the docs say so explicitly.
+
+**`--env` passthrough (queued separately, applies to `jaiph run` and `jaiph mcp`):** repeatable `--env KEY[=VALUE]` defines the workflow's env var in every execution mode and, in Docker mode, crosses the container boundary as an explicit `-e` arg bypassing the fail-closed `ENV_ALLOW_PREFIXES` allowlist — the flag is the per-key consent. Bare `KEY` forwards the host value (unset host var = hard error); sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE*`, `JAIPH_DOCKER_*`) and runtime-managed keys (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, …) are rejected. For `jaiph mcp` the pairs apply to every tool call for the server's lifetime; the Docker-parity task must route them through the same `DockerSpawnOptions.extraEnv` choke point. Full contract: QUEUE.md → "ENV" task.
+
+**Follow-up — Docker parity:** MCP calls should honor the same env-driven sandbox selection as `jaiph run`, with one deliberate difference: **inplace is the default mode** for `jaiph mcp` (the calling agent operates on the real workspace and expects tool effects to land live), and the inplace confirmation is implied by starting the server (stdin is the protocol channel; no prompt is possible — starting `jaiph mcp` on a workspace *is* the consent act). Explicit env (`JAIPH_INPLACE=0` + overlay/copy selection) can restore isolation. The container invocation must carry the workflow symbol (today the Docker inner run assumes `default`).
+
+## Testing
+
+- `src/cli/mcp/tools.test.ts` — derivation rules via `parsejaiph` fixtures: exports-narrowing, route-target exclusion (param names must avoid reserved keywords like `channel`), lone-`default` rename to file slug, `default` skipped when others exist, shebang-filtered comment descriptions, fallback description, schema for n≥1 and 0 params, `toolNameFromFile` sanitization.
+- `src/cli/mcp/server.test.ts` — protocol via injected fakes: initialize version negotiation (known + unknown), tools/list shape, tools/call arg mapping + text result, workflow failure as `isError` result (not protocol error), unknown tool / missing arg / unexpected arg → `-32602`, crashing `callTool` → `-32603` + stderr log, ping, notifications ignored, parse error → `-32700` id null, unknown method → `-32601`, blank lines ignored, `notifyToolsChanged` gated on initialize.
+- e2e (follow-up task): drive `jaiph mcp` as a real child process with a scripted stdio session; assert stdout contains *only* JSON-RPC lines, tool calls return workflow return values, and `jaiph run` still works (regression on the shared launch path).
+
+Verified in the spike: full handshake, list, calls (return values round-trip), concurrent handling (a validation error response overtakes slower in-flight calls), invalid-params rejection.
+
+## Documentation
+
+- `docs/mcp.md` — how-to (Diátaxis): serving a file, exposure & naming rules, writing tool descriptions as comments, client config (`claude mcp add mytools -- jaiph mcp ./tools.jh`), safety posture, hot reload, artifacts.
+- `docs/cli.md` — `jaiph mcp` reference section (flags, exit behavior, stdout invariant, exposure table).
+- `printUsage` in `src/cli/shared/usage.ts` — subcommand line + section + example.
+- README — feature bullet + docs-note link.
+
+## Out of scope (queued separately)
+
+- Docker sandbox parity with inplace default (above).
+- `notifications/progress` streamed from `STEP_START`/`STEP_END` events during a call, and `notifications/cancelled` killing the child run.
+- MCP resources (e.g. exposing run artifacts) and structured output schemas.
diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts
new file mode 100644
index 00000000..bf6680ce
--- /dev/null
+++ b/src/cli/commands/mcp.ts
@@ -0,0 +1,234 @@
+import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, unwatchFile, watchFile } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, extname, join, resolve } from "node:path";
+import { createInterface } from "node:readline";
+import { loadModuleGraph, writeModuleGraph, type ModuleGraph } from "../../transpile/module-graph";
+import { collectDiagnostics } from "../../transpile/validate";
+import { buildScriptsFromGraph } from "../../transpiler";
+import { resolveModuleMetadata, metadataToConfig } from "../../config";
+import { resolveDockerConfig } from "../../runtime/docker";
+import { detectWorkspaceRoot } from "../shared/paths";
+import { hasHelpFlag, parseArgs } from "../shared/usage";
+import { resolveRuntimeEnv } from "../run/env";
+import { preflightAgentCredentials } from "../run/preflight-credentials";
+import { deriveTools, type McpToolSpec } from "../mcp/tools";
+import { McpServer } from "../mcp/server";
+import { callWorkflow, type McpCallEnvironment } from "../mcp/call";
+import { VERSION } from "../../version";
+
+const MCP_USAGE =
+ "Usage: jaiph mcp [--workspace ] \n\n" +
+ "Serve the file's workflows as MCP tools over stdio (newline-delimited JSON-RPC).\n" +
+ "Exposure: `export workflow` declarations if any exist, otherwise every top-level\n" +
+ "workflow except channel route targets. `default` is exposed only when it is the\n" +
+ "only workflow, under a tool name derived from the file's basename.\n" +
+ "Tool descriptions come from the `#` comment lines directly above each workflow.\n" +
+ "Sources are re-validated on change and clients get notifications/tools/list_changed.\n\n" +
+ "Calls run on the host (like `jaiph run --raw`); the Docker sandbox is not launched.\n\n" +
+ " --workspace workspace root for import resolution (default: auto-detect)\n" +
+ " -h, --help show this help\n\n" +
+ "Example:\n" +
+ " claude mcp add mytools -- jaiph mcp ./tools.jh\n";
+
+/** How often watchFile polls module sources for hot reload (ms). */
+const WATCH_INTERVAL_MS = 750;
+
+interface McpState {
+ graph: ModuleGraph;
+ tools: McpToolSpec[];
+ callEnv: McpCallEnvironment;
+}
+
+/**
+ * Load (or reload) everything one generation of the server needs: module
+ * graph, compile-time validation, tool derivation, emitted scripts, and the
+ * serialized graph the spawned runners consume. Throws on parse errors;
+ * returns diagnostics without throwing on validation errors.
+ */
+function loadState(
+ inputAbs: string,
+ workspaceRoot: string,
+ tempRoot: string,
+ generation: number,
+ log: (line: string) => void,
+): { state?: McpState; failures: string[] } {
+ const graph = loadModuleGraph(inputAbs, workspaceRoot);
+ const diag = collectDiagnostics(graph);
+ if (diag.errors.length > 0) {
+ return {
+ failures: diag.sorted().map((d) => `${d.file}:${d.line}:${d.col} ${d.code} ${d.message}`),
+ };
+ }
+
+ const mod = graph.modules.get(inputAbs)!.ast;
+ const { tools, warnings } = deriveTools(mod, inputAbs);
+ for (const w of warnings) log(`jaiph mcp: ${w}`);
+
+ const outDir = join(tempRoot, `gen-${generation}`);
+ mkdirSync(outDir, { recursive: true });
+ const { scriptsDir } = buildScriptsFromGraph(graph, outDir);
+ const graphFile = join(outDir, ".jaiph-module-graph.json");
+ writeModuleGraph(graphFile, graph);
+
+ const resolvedModuleMetadata = resolveModuleMetadata(mod, process.env);
+ const effectiveConfig = metadataToConfig(resolvedModuleMetadata);
+
+ return {
+ state: {
+ graph,
+ tools,
+ callEnv: { inputAbs, workspaceRoot, effectiveConfig, scriptsDir, graphFile, outDir },
+ },
+ failures: [],
+ };
+}
+
+export async function runMcp(rest: string[]): Promise {
+ if (hasHelpFlag(rest)) {
+ process.stdout.write(MCP_USAGE);
+ return 0;
+ }
+ let parsed: ReturnType;
+ try {
+ parsed = parseArgs(rest);
+ } catch (err) {
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
+ return 1;
+ }
+ const { workspace, positional } = parsed;
+ const input = positional[0];
+ if (!input) {
+ process.stderr.write("jaiph mcp requires a .jh file path\n");
+ return 1;
+ }
+ const inputAbs = resolve(input);
+ if (!existsSync(inputAbs) || !statSync(inputAbs).isFile() || extname(inputAbs) !== ".jh") {
+ process.stderr.write("jaiph mcp expects a single .jh file\n");
+ return 1;
+ }
+ const workspaceRoot = workspace ? resolve(workspace) : detectWorkspaceRoot(dirname(inputAbs));
+ if (workspace && (!existsSync(workspaceRoot) || !statSync(workspaceRoot).isDirectory())) {
+ process.stderr.write(`--workspace path is not a directory: ${workspaceRoot}\n`);
+ return 1;
+ }
+
+ // stdout is the protocol channel from here on; every diagnostic goes to stderr.
+ const log = (line: string): void => {
+ process.stderr.write(`${line}\n`);
+ };
+
+ const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-mcp-"));
+ let generation = 0;
+ let state: McpState;
+ try {
+ const loaded = loadState(inputAbs, workspaceRoot, tempRoot, generation, log);
+ if (!loaded.state) {
+ for (const f of loaded.failures) log(f);
+ rmSync(tempRoot, { recursive: true, force: true });
+ return 1;
+ }
+ state = loaded.state;
+ } catch (err) {
+ log(err instanceof Error ? err.message : String(err));
+ rmSync(tempRoot, { recursive: true, force: true });
+ return 1;
+ }
+
+ // Credential pre-flight once at startup (warnings only in MCP mode: the
+ // server may outlive a credential fix, and per-call failures still surface).
+ const mod = state.graph.modules.get(inputAbs)!.ast;
+ const startupEnv = resolveRuntimeEnv(state.callEnv.effectiveConfig, workspaceRoot, inputAbs);
+ const dockerConfig = resolveDockerConfig(resolveModuleMetadata(mod, process.env)?.runtime, startupEnv);
+ if (dockerConfig.enabled) {
+ log(
+ "jaiph mcp: the Docker sandbox is not launched for MCP tool calls yet; workflows run on the host (like `jaiph run --raw`).",
+ );
+ }
+ const credPreflight = preflightAgentCredentials({
+ mod,
+ inputAbs,
+ runtimeEnv: startupEnv,
+ dockerEnabled: false,
+ });
+ for (const w of [...credPreflight.warnings, ...credPreflight.errors]) log(w);
+
+ const server = new McpServer({
+ serverVersion: VERSION,
+ getTools: () => state.tools,
+ callTool: (spec, args) =>
+ callWorkflow(
+ state.callEnv,
+ spec.workflow,
+ spec.params.map((p) => args[p] ?? ""),
+ ),
+ write: (message) => {
+ process.stdout.write(`${JSON.stringify(message)}\n`);
+ },
+ log,
+ });
+
+ // Hot reload: poll every module source; on change re-validate and swap the
+ // generation. Validation failures keep the previous generation serving.
+ let watched: string[] = [];
+ const rewatch = (): void => {
+ for (const f of watched) unwatchFile(f, onSourceChange);
+ watched = [...state.graph.modules.keys()];
+ for (const f of watched) watchFile(f, { interval: WATCH_INTERVAL_MS }, onSourceChange);
+ };
+ let reloading = false;
+ const onSourceChange = (): void => {
+ if (reloading) return;
+ reloading = true;
+ try {
+ generation += 1;
+ const loaded = loadState(inputAbs, workspaceRoot, tempRoot, generation, log);
+ if (!loaded.state) {
+ log("jaiph mcp: reload failed; keeping the previous tool set:");
+ for (const f of loaded.failures) log(` ${f}`);
+ return;
+ }
+ const previousOutDir = state.callEnv.outDir;
+ state = loaded.state;
+ rewatch();
+ server.notifyToolsChanged();
+ log(`jaiph mcp: sources reloaded (${state.tools.length} tool(s))`);
+ rmSync(previousOutDir, { recursive: true, force: true });
+ } catch (err) {
+ log(`jaiph mcp: reload failed; keeping the previous tool set: ${err instanceof Error ? err.message : String(err)}`);
+ } finally {
+ reloading = false;
+ }
+ };
+ rewatch();
+
+ log(`jaiph mcp: serving ${state.tools.length} tool(s) from ${inputAbs} over stdio`);
+
+ return await new Promise((resolveExit) => {
+ let settled = false;
+ const shutdown = (code: number): void => {
+ if (settled) return;
+ settled = true;
+ for (const f of watched) unwatchFile(f, onSourceChange);
+ rmSync(tempRoot, { recursive: true, force: true });
+ resolveExit(code);
+ };
+
+ const rl = createInterface({ input: process.stdin, terminal: false });
+ // Handle requests concurrently: a long tools/call must not stall pings or
+ // further calls. JSON-RPC matches responses by id, so ordering is free to
+ // interleave; each outbound message is a single atomic stdout write.
+ const inFlight = new Set>();
+ rl.on("line", (line) => {
+ const p = server.handleLine(line).catch((err) => {
+ log(`jaiph mcp: ${err instanceof Error ? err.message : String(err)}`);
+ });
+ inFlight.add(p);
+ void p.finally(() => inFlight.delete(p));
+ });
+ rl.on("close", () => {
+ void Promise.allSettled([...inFlight]).then(() => shutdown(0));
+ });
+ process.once("SIGINT", () => shutdown(0));
+ process.once("SIGTERM", () => shutdown(0));
+ });
+}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 4fc3ffc1..d7b18122 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -8,6 +8,7 @@ import { runUse } from "./commands/use";
import { runFormat } from "./commands/format";
import { runInstall } from "./commands/install";
import { runCompile } from "./commands/compile";
+import { runMcp } from "./commands/mcp";
import { runWorkflowRunner, WORKFLOW_RUNNER_ARG } from "../runtime/kernel/node-workflow-runner";
import { VERSION } from "../version";
@@ -62,6 +63,10 @@ export async function main(argv: string[]): Promise {
if (cmd === "compile") {
return runCompile(rest);
}
+ // `--mcp` is an ergonomic alias for the `mcp` subcommand (`jaiph --mcp tools.jh`).
+ if (cmd === "mcp" || cmd === "--mcp") {
+ return await runMcp(rest);
+ }
process.stderr.write(`Unknown command: ${cmd}\n`);
printUsage();
return 1;
diff --git a/src/cli/mcp/call.ts b/src/cli/mcp/call.ts
new file mode 100644
index 00000000..8939a669
--- /dev/null
+++ b/src/cli/mcp/call.ts
@@ -0,0 +1,158 @@
+import { existsSync, readFileSync } from "node:fs";
+import { randomUUID } from "node:crypto";
+import { join } from "node:path";
+import type { JaiphConfig } from "../../config";
+import { spawnRunProcess, waitForRunExit } from "../run/lifecycle";
+import { resolveRuntimeEnv } from "../run/env";
+import { parseLogEvent, parseStepEvent } from "../run/events";
+import type { McpCallResult } from "./server";
+
+/**
+ * Everything a `tools/call` needs from the server session. Built once per
+ * module-graph generation (startup and each hot reload) — scripts and the
+ * serialized graph are read-only, so concurrent calls can share them.
+ */
+export interface McpCallEnvironment {
+ inputAbs: string;
+ workspaceRoot: string;
+ effectiveConfig: JaiphConfig;
+ /** Emitted scripts dir for this generation (`buildScriptsFromGraph`). */
+ scriptsDir: string;
+ /** Serialized module graph consumed by the spawned runner. */
+ graphFile: string;
+ /** Generation dir for per-call meta files. */
+ outDir: string;
+}
+
+/**
+ * Execute one workflow as an MCP tool call: spawn the workflow runner (host
+ * execution, like `jaiph run --raw` — the Docker sandbox is not launched),
+ * capture `__JAIPH_EVENT__` lines from stderr, and compose the result text.
+ *
+ * Success text, in order of preference: the workflow's return value
+ * (`return_value.txt`), collected `log` output, or a completion note.
+ */
+export async function callWorkflow(
+ env: McpCallEnvironment,
+ workflowSymbol: string,
+ positionalArgs: string[],
+): Promise {
+ const runtimeEnv = resolveRuntimeEnv(env.effectiveConfig, env.workspaceRoot, env.inputAbs);
+ const runId = randomUUID();
+ runtimeEnv.JAIPH_SOURCE_ABS = env.inputAbs;
+ runtimeEnv.JAIPH_RUN_ID = runId;
+ runtimeEnv.JAIPH_SCRIPTS = env.scriptsDir;
+ runtimeEnv.JAIPH_MODULE_GRAPH_FILE = env.graphFile;
+
+ const metaFile = join(env.outDir, `.jaiph-run-meta-${runId}.txt`);
+ const dummyBuiltPath = join(env.outDir, "entry.sh");
+
+ const child = spawnRunProcess([metaFile, dummyBuiltPath, workflowSymbol, ...positionalArgs], {
+ cwd: env.workspaceRoot,
+ env: runtimeEnv,
+ });
+
+ const logs: string[] = [];
+ let failedStep: { name: string; detail: string } | undefined;
+ let rawStderr = "";
+ let rawStdout = "";
+
+ const onStderrLine = (line: string): void => {
+ const logEvent = parseLogEvent(line);
+ if (logEvent) {
+ logs.push(logEvent.message);
+ return;
+ }
+ const stepEvent = parseStepEvent(line);
+ if (stepEvent) {
+ if (stepEvent.type === "STEP_END" && stepEvent.status !== null && stepEvent.status !== 0 && !failedStep) {
+ const detail = stepEvent.err_content.trim() || stepEvent.out_content.trim();
+ failedStep = { name: `${stepEvent.kind} ${stepEvent.name}`.trim(), detail };
+ }
+ return;
+ }
+ rawStderr += `${line}\n`;
+ };
+
+ let stderrBuf = "";
+ child.stderr?.setEncoding("utf8");
+ child.stderr?.on("data", (chunk: string) => {
+ stderrBuf += chunk;
+ let idx = stderrBuf.indexOf("\n");
+ while (idx !== -1) {
+ onStderrLine(stderrBuf.slice(0, idx).replace(/\r$/, ""));
+ stderrBuf = stderrBuf.slice(idx + 1);
+ idx = stderrBuf.indexOf("\n");
+ }
+ });
+ child.stdout?.setEncoding("utf8");
+ child.stdout?.on("data", (chunk: string) => {
+ rawStdout += chunk;
+ });
+
+ const exit = await waitForRunExit(child);
+ if (stderrBuf.length > 0) onStderrLine(stderrBuf.replace(/\r$/, ""));
+
+ const meta = readMetaFile(metaFile);
+ const failed = exit.status !== 0 || exit.signal !== null;
+
+ if (!failed) {
+ const returnValue = readReturnValue(meta.runDir);
+ const text =
+ returnValue !== undefined && returnValue.length > 0
+ ? returnValue
+ : logs.length > 0
+ ? logs.join("\n")
+ : `workflow ${workflowSymbol} completed`;
+ return { text: trimTrailingNewline(text), isError: false };
+ }
+
+ const parts: string[] = [];
+ parts.push(
+ exit.signal
+ ? `workflow ${workflowSymbol} terminated by signal ${exit.signal}`
+ : `workflow ${workflowSymbol} failed (exit ${exit.status})`,
+ );
+ if (failedStep) {
+ parts.push(`failed step: ${failedStep.name}`);
+ if (failedStep.detail) parts.push(failedStep.detail);
+ }
+ const stderrTrimmed = rawStderr.trim();
+ if (stderrTrimmed) parts.push(stderrTrimmed);
+ const stdoutTrimmed = rawStdout.trim();
+ if (!failedStep && !stderrTrimmed && stdoutTrimmed) parts.push(stdoutTrimmed);
+ if (logs.length > 0) parts.push(`log output:\n${logs.join("\n")}`);
+ if (meta.runDir) parts.push(`run dir: ${meta.runDir}`);
+ return { text: parts.join("\n\n"), isError: true };
+}
+
+function readMetaFile(metaFile: string): { runDir?: string; summaryFile?: string } {
+ if (!existsSync(metaFile)) return {};
+ const out: { runDir?: string; summaryFile?: string } = {};
+ for (const line of readFileSync(metaFile, "utf8").split(/\r?\n/)) {
+ if (line.startsWith("run_dir=")) {
+ const value = line.slice("run_dir=".length).trim();
+ if (value) out.runDir = value;
+ }
+ if (line.startsWith("summary_file=")) {
+ const value = line.slice("summary_file=".length).trim();
+ if (value) out.summaryFile = value;
+ }
+ }
+ return out;
+}
+
+function readReturnValue(runDir: string | undefined): string | undefined {
+ if (!runDir) return undefined;
+ const candidate = join(runDir, "return_value.txt");
+ if (!existsSync(candidate)) return undefined;
+ try {
+ return readFileSync(candidate, "utf8");
+ } catch {
+ return undefined;
+ }
+}
+
+function trimTrailingNewline(text: string): string {
+ return text.endsWith("\n") ? text.slice(0, -1) : text;
+}
diff --git a/src/cli/mcp/server.test.ts b/src/cli/mcp/server.test.ts
new file mode 100644
index 00000000..514416b7
--- /dev/null
+++ b/src/cli/mcp/server.test.ts
@@ -0,0 +1,217 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { McpServer, type McpCallResult } from "./server";
+import type { McpToolSpec } from "./tools";
+
+const BUILD_TOOL: McpToolSpec = {
+ name: "build",
+ workflow: "build",
+ description: "Builds the target.",
+ params: ["target"],
+ inputSchema: {
+ type: "object",
+ properties: { target: { type: "string" } },
+ required: ["target"],
+ additionalProperties: false,
+ },
+};
+
+function makeServer(overrides?: {
+ callTool?: (spec: McpToolSpec, args: Record) => Promise;
+ tools?: McpToolSpec[];
+}) {
+ const written: Array> = [];
+ const logged: string[] = [];
+ const server = new McpServer({
+ serverVersion: "0.0.0-test",
+ getTools: () => overrides?.tools ?? [BUILD_TOOL],
+ callTool: overrides?.callTool ?? (async () => ({ text: "done", isError: false })),
+ write: (m) => written.push(m),
+ log: (m) => logged.push(m),
+ });
+ return { server, written, logged };
+}
+
+async function initialize(server: McpServer): Promise {
+ await server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: "2025-06-18" } }),
+ );
+}
+
+// === initialize ===
+
+test("initialize: echoes a supported protocol version and advertises tools", async () => {
+ const { server, written } = makeServer();
+ await initialize(server);
+ const res = written[0] as { id: number; result: Record };
+ assert.equal(res.id, 0);
+ assert.equal((res.result as { protocolVersion: string }).protocolVersion, "2025-06-18");
+ assert.deepEqual((res.result as { capabilities: unknown }).capabilities, { tools: { listChanged: true } });
+});
+
+test("initialize: falls back to the latest known version for unknown requests", async () => {
+ const { server, written } = makeServer();
+ await server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2099-01-01" } }),
+ );
+ const res = written[0] as { result: { protocolVersion: string } };
+ assert.equal(res.result.protocolVersion, "2025-06-18");
+});
+
+// === tools/list ===
+
+test("tools/list: returns name, description, inputSchema", async () => {
+ const { server, written } = makeServer();
+ await initialize(server);
+ await server.handleLine(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }));
+ const res = written[1] as { result: { tools: Array> } };
+ assert.equal(res.result.tools.length, 1);
+ assert.deepEqual(res.result.tools[0], {
+ name: "build",
+ description: "Builds the target.",
+ inputSchema: BUILD_TOOL.inputSchema,
+ });
+});
+
+// === tools/call ===
+
+test("tools/call: maps arguments by name and returns text content", async () => {
+ const calls: Array<{ workflow: string; args: Record }> = [];
+ const { server, written } = makeServer({
+ callTool: async (spec, args) => {
+ calls.push({ workflow: spec.workflow, args });
+ return { text: "built app", isError: false };
+ },
+ });
+ await initialize(server);
+ await server.handleLine(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 3,
+ method: "tools/call",
+ params: { name: "build", arguments: { target: "app" } },
+ }),
+ );
+ assert.deepEqual(calls, [{ workflow: "build", args: { target: "app" } }]);
+ const res = written[1] as { result: { content: unknown; isError: boolean } };
+ assert.deepEqual(res.result.content, [{ type: "text", text: "built app" }]);
+ assert.equal(res.result.isError, false);
+});
+
+test("tools/call: workflow failure surfaces as isError result, not protocol error", async () => {
+ const { server, written } = makeServer({
+ callTool: async () => ({ text: "workflow build failed (exit 1)", isError: true }),
+ });
+ await initialize(server);
+ await server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "build", arguments: { target: "x" } } }),
+ );
+ const res = written[1] as { result: { isError: boolean }; error?: unknown };
+ assert.equal(res.error, undefined);
+ assert.equal(res.result.isError, true);
+});
+
+test("tools/call: unknown tool is invalid params", async () => {
+ const { server, written } = makeServer();
+ await initialize(server);
+ await server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "nope", arguments: {} } }),
+ );
+ const res = written[1] as { error: { code: number; message: string } };
+ assert.equal(res.error.code, -32602);
+ assert.match(res.error.message, /unknown tool/);
+});
+
+test("tools/call: missing required argument is invalid params", async () => {
+ const { server, written } = makeServer();
+ await initialize(server);
+ await server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "build", arguments: {} } }),
+ );
+ const res = written[1] as { error: { code: number; message: string } };
+ assert.equal(res.error.code, -32602);
+ assert.match(res.error.message, /target/);
+});
+
+test("tools/call: unexpected argument key is invalid params", async () => {
+ const { server, written } = makeServer();
+ await initialize(server);
+ await server.handleLine(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 7,
+ method: "tools/call",
+ params: { name: "build", arguments: { target: "x", bogus: "y" } },
+ }),
+ );
+ const res = written[1] as { error: { code: number; message: string } };
+ assert.equal(res.error.code, -32602);
+ assert.match(res.error.message, /bogus/);
+});
+
+test("tools/call: a crashing callTool becomes an internal JSON-RPC error", async () => {
+ const { server, written, logged } = makeServer({
+ callTool: async () => {
+ throw new Error("spawn ENOENT");
+ },
+ });
+ await initialize(server);
+ await server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 8, method: "tools/call", params: { name: "build", arguments: { target: "x" } } }),
+ );
+ const res = written[1] as { error: { code: number } };
+ assert.equal(res.error.code, -32603);
+ assert.equal(logged.length, 1);
+});
+
+// === protocol plumbing ===
+
+test("ping: responds with an empty result", async () => {
+ const { server, written } = makeServer();
+ await server.handleLine(JSON.stringify({ jsonrpc: "2.0", id: 9, method: "ping" }));
+ assert.deepEqual(written[0], { jsonrpc: "2.0", id: 9, result: {} });
+});
+
+test("notifications are ignored (no response written)", async () => {
+ const { server, written } = makeServer();
+ await server.handleLine(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
+ await server.handleLine(JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: {} }));
+ assert.equal(written.length, 0);
+});
+
+test("invalid JSON produces a parse error with null id", async () => {
+ const { server, written } = makeServer();
+ await server.handleLine("{not json");
+ const res = written[0] as { id: null; error: { code: number } };
+ assert.equal(res.id, null);
+ assert.equal(res.error.code, -32700);
+});
+
+test("unknown request method is method-not-found", async () => {
+ const { server, written } = makeServer();
+ await server.handleLine(JSON.stringify({ jsonrpc: "2.0", id: 10, method: "resources/list" }));
+ const res = written[0] as { error: { code: number } };
+ assert.equal(res.error.code, -32601);
+});
+
+test("blank lines are ignored", async () => {
+ const { server, written } = makeServer();
+ await server.handleLine("");
+ await server.handleLine(" ");
+ assert.equal(written.length, 0);
+});
+
+// === notifyToolsChanged ===
+
+test("notifyToolsChanged: emits only after initialize", () => {
+ const { server, written } = makeServer();
+ server.notifyToolsChanged();
+ assert.equal(written.length, 0);
+});
+
+test("notifyToolsChanged: emits the list_changed notification once initialized", async () => {
+ const { server, written } = makeServer();
+ await initialize(server);
+ server.notifyToolsChanged();
+ assert.deepEqual(written[1], { jsonrpc: "2.0", method: "notifications/tools/list_changed" });
+});
diff --git a/src/cli/mcp/server.ts b/src/cli/mcp/server.ts
new file mode 100644
index 00000000..26e62a01
--- /dev/null
+++ b/src/cli/mcp/server.ts
@@ -0,0 +1,188 @@
+import type { McpToolSpec } from "./tools";
+
+/**
+ * Minimal MCP server over newline-delimited JSON-RPC 2.0 (stdio transport).
+ *
+ * The transport and the workflow execution are injected so the protocol layer
+ * stays a pure line-in / message-out state machine (unit-testable without
+ * spawning processes). Handles: `initialize`, `ping`, `tools/list`,
+ * `tools/call`; emits `notifications/tools/list_changed` on hot reload.
+ * All diagnostics go through `log` (stderr) — stdout carries protocol JSON only.
+ */
+
+/** MCP protocol revisions this server knows; the newest is the fallback. */
+const SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18"];
+const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
+
+const JSONRPC_PARSE_ERROR = -32700;
+const JSONRPC_INVALID_REQUEST = -32600;
+const JSONRPC_METHOD_NOT_FOUND = -32601;
+const JSONRPC_INVALID_PARAMS = -32602;
+const JSONRPC_INTERNAL_ERROR = -32603;
+
+export interface McpCallResult {
+ /** Text returned to the client as the tool result. */
+ text: string;
+ /** True when the workflow failed; surfaces as `isError` on the result. */
+ isError: boolean;
+}
+
+export interface McpServerOptions {
+ serverVersion: string;
+ /** Current tool list (re-read on every request so hot reload just works). */
+ getTools: () => McpToolSpec[];
+ /** Execute one workflow call; must never write to stdout. */
+ callTool: (spec: McpToolSpec, args: Record) => Promise;
+ /** Outbound protocol message (one JSON line on stdout). */
+ write: (message: Record) => void;
+ /** Diagnostic line (stderr). */
+ log: (message: string) => void;
+}
+
+type JsonRpcId = string | number;
+
+export class McpServer {
+ private readonly opts: McpServerOptions;
+ private initialized = false;
+
+ constructor(opts: McpServerOptions) {
+ this.opts = opts;
+ }
+
+ /** Tell connected clients the tool list changed (hot reload). */
+ notifyToolsChanged(): void {
+ if (!this.initialized) return;
+ this.opts.write({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
+ }
+
+ /** Handle one inbound line. Async because `tools/call` runs a workflow. */
+ async handleLine(line: string): Promise {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) return;
+
+ let message: Record;
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ this.writeError(null, JSONRPC_INVALID_REQUEST, "request must be a JSON object");
+ return;
+ }
+ message = parsed as Record;
+ } catch {
+ this.writeError(null, JSONRPC_PARSE_ERROR, "parse error: invalid JSON");
+ return;
+ }
+
+ const method = typeof message.method === "string" ? message.method : undefined;
+ const hasId = typeof message.id === "string" || typeof message.id === "number";
+ const id = hasId ? (message.id as JsonRpcId) : undefined;
+
+ // Responses from the client (to server-initiated requests) and unknown
+ // notifications are ignored; only requests need an answer.
+ if (method === undefined) return;
+ if (!hasId) {
+ // Notification. `notifications/initialized` etc. — nothing to do.
+ return;
+ }
+
+ const params =
+ typeof message.params === "object" && message.params !== null && !Array.isArray(message.params)
+ ? (message.params as Record)
+ : {};
+
+ switch (method) {
+ case "initialize":
+ this.handleInitialize(id!, params);
+ return;
+ case "ping":
+ this.writeResult(id!, {});
+ return;
+ case "tools/list":
+ this.handleToolsList(id!);
+ return;
+ case "tools/call":
+ await this.handleToolsCall(id!, params);
+ return;
+ default:
+ this.writeError(id!, JSONRPC_METHOD_NOT_FOUND, `method not found: ${method}`);
+ }
+ }
+
+ private handleInitialize(id: JsonRpcId, params: Record): void {
+ const requested = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
+ const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
+ ? requested
+ : LATEST_PROTOCOL_VERSION;
+ this.initialized = true;
+ this.writeResult(id, {
+ protocolVersion,
+ capabilities: { tools: { listChanged: true } },
+ serverInfo: { name: "jaiph", title: "Jaiph workflows", version: this.opts.serverVersion },
+ });
+ }
+
+ private handleToolsList(id: JsonRpcId): void {
+ const tools = this.opts.getTools().map((t) => ({
+ name: t.name,
+ description: t.description,
+ inputSchema: t.inputSchema,
+ }));
+ this.writeResult(id, { tools });
+ }
+
+ private async handleToolsCall(id: JsonRpcId, params: Record): Promise {
+ const name = typeof params.name === "string" ? params.name : "";
+ const spec = this.opts.getTools().find((t) => t.name === name);
+ if (!spec) {
+ this.writeError(id, JSONRPC_INVALID_PARAMS, `unknown tool: ${name || "(missing name)"}`);
+ return;
+ }
+
+ const rawArgs =
+ typeof params.arguments === "object" && params.arguments !== null && !Array.isArray(params.arguments)
+ ? (params.arguments as Record)
+ : {};
+
+ const missing = spec.params.filter((p) => typeof rawArgs[p] !== "string");
+ if (missing.length > 0) {
+ this.writeError(
+ id,
+ JSONRPC_INVALID_PARAMS,
+ `missing or non-string argument(s) for tool "${spec.name}": ${missing.join(", ")}`,
+ );
+ return;
+ }
+ const unknown = Object.keys(rawArgs).filter((k) => !spec.params.includes(k));
+ if (unknown.length > 0) {
+ this.writeError(
+ id,
+ JSONRPC_INVALID_PARAMS,
+ `unknown argument(s) for tool "${spec.name}": ${unknown.join(", ")}`,
+ );
+ return;
+ }
+
+ const args: Record = {};
+ for (const p of spec.params) args[p] = rawArgs[p] as string;
+
+ try {
+ const result = await this.opts.callTool(spec, args);
+ this.writeResult(id, {
+ content: [{ type: "text", text: result.text }],
+ isError: result.isError,
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.opts.log(`jaiph mcp: tool "${spec.name}" crashed: ${message}`);
+ this.writeError(id, JSONRPC_INTERNAL_ERROR, `tool "${spec.name}" failed: ${message}`);
+ }
+ }
+
+ private writeResult(id: JsonRpcId, result: Record): void {
+ this.opts.write({ jsonrpc: "2.0", id, result });
+ }
+
+ private writeError(id: JsonRpcId | null, code: number, message: string): void {
+ this.opts.write({ jsonrpc: "2.0", id, error: { code, message } });
+ }
+}
diff --git a/src/cli/mcp/tools.test.ts b/src/cli/mcp/tools.test.ts
new file mode 100644
index 00000000..c842e61e
--- /dev/null
+++ b/src/cli/mcp/tools.test.ts
@@ -0,0 +1,136 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { parsejaiph } from "../../parser";
+import { deriveTools, toolNameFromFile } from "./tools";
+
+const FILE = "/ws/deploy-tools.jh";
+
+function mod(source: string) {
+ return parsejaiph(source, FILE);
+}
+
+// === toolNameFromFile ===
+
+test("toolNameFromFile: strips .jh and sanitizes to [A-Za-z0-9_-]", () => {
+ assert.equal(toolNameFromFile("/ws/deploy-tools.jh"), "deploy-tools");
+ assert.equal(toolNameFromFile("/ws/my flow!.jh"), "my_flow_");
+});
+
+// === deriveTools: exposure rules ===
+
+test("deriveTools: exposes all top-level workflows when nothing is exported", () => {
+ const m = mod(
+ [
+ "workflow build(target) {",
+ " log target",
+ "}",
+ "workflow lint() {",
+ ' log "lint"',
+ "}",
+ ].join("\n"),
+ );
+ const { tools } = deriveTools(m, FILE);
+ assert.deepEqual(tools.map((t) => t.name).sort(), ["build", "lint"]);
+});
+
+test("deriveTools: export workflow narrows exposure to exported ones", () => {
+ const m = mod(
+ [
+ "export workflow build(target) {",
+ " log target",
+ "}",
+ "workflow helper() {",
+ ' log "internal"',
+ "}",
+ ].join("\n"),
+ );
+ const { tools } = deriveTools(m, FILE);
+ assert.deepEqual(tools.map((t) => t.name), ["build"]);
+});
+
+test("deriveTools: channel route targets are excluded with a warning", () => {
+ const m = mod(
+ [
+ "channel alerts -> on_alert",
+ "workflow on_alert(message, chan, sender) {",
+ " log message",
+ "}",
+ "workflow build() {",
+ ' log "build"',
+ "}",
+ ].join("\n"),
+ );
+ const { tools, warnings } = deriveTools(m, FILE);
+ assert.deepEqual(tools.map((t) => t.name), ["build"]);
+ assert.equal(warnings.length, 1);
+ assert.match(warnings[0], /on_alert/);
+});
+
+test("deriveTools: lone default workflow is exposed under the file basename", () => {
+ const m = mod(["workflow default(task) {", " log task", "}"].join("\n"));
+ const { tools } = deriveTools(m, FILE);
+ assert.equal(tools.length, 1);
+ assert.equal(tools[0].name, "deploy-tools");
+ assert.equal(tools[0].workflow, "default");
+});
+
+test("deriveTools: default is skipped (with warning) when other workflows exist", () => {
+ const m = mod(
+ [
+ "workflow default() {",
+ ' log "entry"',
+ "}",
+ "workflow build() {",
+ ' log "build"',
+ "}",
+ ].join("\n"),
+ );
+ const { tools, warnings } = deriveTools(m, FILE);
+ assert.deepEqual(tools.map((t) => t.name), ["build"]);
+ assert.ok(warnings.some((w) => w.includes('"default"')));
+});
+
+// === deriveTools: descriptions and schema ===
+
+test("deriveTools: description comes from leading # comments, shebang dropped", () => {
+ const m = mod(
+ [
+ "#!/usr/bin/env jaiph",
+ "# Builds the target and runs the smoke suite.",
+ "# Retries flaky steps once.",
+ "workflow build(target) {",
+ " log target",
+ "}",
+ ].join("\n"),
+ );
+ const { tools } = deriveTools(m, FILE);
+ assert.equal(tools[0].description, "Builds the target and runs the smoke suite.\nRetries flaky steps once.");
+});
+
+test("deriveTools: fallback description names the workflow and file", () => {
+ const m = mod(["workflow build() {", ' log "x"', "}"].join("\n"));
+ const { tools } = deriveTools(m, FILE);
+ assert.match(tools[0].description, /"build"/);
+ assert.match(tools[0].description, /deploy-tools\.jh/);
+});
+
+test("deriveTools: params map to required string properties", () => {
+ const m = mod(["workflow build(target, mode) {", " log target", "}"].join("\n"));
+ const { tools } = deriveTools(m, FILE);
+ assert.deepEqual(tools[0].inputSchema, {
+ type: "object",
+ properties: { target: { type: "string" }, mode: { type: "string" } },
+ required: ["target", "mode"],
+ additionalProperties: false,
+ });
+});
+
+test("deriveTools: zero params produce an object schema without required", () => {
+ const m = mod(["workflow build() {", ' log "x"', "}"].join("\n"));
+ const { tools } = deriveTools(m, FILE);
+ assert.deepEqual(tools[0].inputSchema, {
+ type: "object",
+ properties: {},
+ additionalProperties: false,
+ });
+});
diff --git a/src/cli/mcp/tools.ts b/src/cli/mcp/tools.ts
new file mode 100644
index 00000000..fe2f3b5c
--- /dev/null
+++ b/src/cli/mcp/tools.ts
@@ -0,0 +1,132 @@
+import { basename } from "node:path";
+import type { jaiphModule, WorkflowDef } from "../../types";
+
+/** JSON Schema fragment for one MCP tool input (all Jaiph params are strings). */
+export interface McpInputSchema {
+ type: "object";
+ properties: Record;
+ required?: string[];
+ additionalProperties: false;
+}
+
+/** One exposed workflow: MCP surface plus the workflow symbol to invoke. */
+export interface McpToolSpec {
+ /** MCP tool name (`^[a-zA-Z0-9_-]{1,128}$`). */
+ name: string;
+ /** Workflow symbol in the entry module (`default` may differ from `name`). */
+ workflow: string;
+ description: string;
+ /** Declared parameter names, in call order. */
+ params: string[];
+ inputSchema: McpInputSchema;
+}
+
+export interface DeriveToolsResult {
+ tools: McpToolSpec[];
+ /** Human-readable notes about skipped workflows (stderr, never stdout). */
+ warnings: string[];
+}
+
+/**
+ * Sanitize a file basename into an MCP tool name: strip the `.jh` suffix and
+ * replace anything outside `[A-Za-z0-9_-]` with `_`.
+ */
+export function toolNameFromFile(inputAbs: string): string {
+ const base = basename(inputAbs).replace(/\.jh$/, "");
+ const slug = base.replace(/[^A-Za-z0-9_-]/g, "_");
+ return slug.length > 0 ? slug.slice(0, 128) : "workflow";
+}
+
+/**
+ * Build the tool description from the workflow's leading comments.
+ * Comment lines are stored raw (including `#`); shebang lines are dropped.
+ */
+function describeWorkflow(wf: WorkflowDef, inputAbs: string): string {
+ const lines = wf.comments
+ .filter((c) => !c.startsWith("#!"))
+ .map((c) => c.replace(/^#\s?/, "").trimEnd())
+ .filter((c) => c.length > 0);
+ if (lines.length > 0) return lines.join("\n");
+ return `Run the "${wf.name}" workflow from ${basename(inputAbs)}.`;
+}
+
+function schemaForParams(params: string[]): McpInputSchema {
+ const properties: Record = {};
+ for (const p of params) properties[p] = { type: "string" };
+ const schema: McpInputSchema = { type: "object", properties, additionalProperties: false };
+ if (params.length > 0) schema.required = [...params];
+ return schema;
+}
+
+/**
+ * Derive the MCP tool list from the entry module.
+ *
+ * Exposure rules (documented in docs/mcp.md):
+ * 1. If the module declares `export workflow …`, exactly those are exposed.
+ * 2. Otherwise every top-level workflow is exposed, minus channel route
+ * targets (the three-param inbox handlers wired via `channel … -> wf`).
+ * 3. `default` is exposed only when it is the only candidate, under a tool
+ * name derived from the file's basename (`deploy.jh` → `deploy`); with
+ * other candidates present it is skipped (it is the `jaiph run`
+ * entrypoint, not a public tool).
+ */
+export function deriveTools(mod: jaiphModule, inputAbs: string): DeriveToolsResult {
+ const warnings: string[] = [];
+ const routeTargets = new Set();
+ for (const ch of mod.channels) {
+ for (const route of ch.routes ?? []) routeTargets.add(route.value);
+ }
+
+ const exportedWorkflows = mod.workflows.filter((w) => mod.exports.includes(w.name));
+ let candidates: WorkflowDef[];
+ if (exportedWorkflows.length > 0) {
+ candidates = exportedWorkflows;
+ } else {
+ candidates = mod.workflows.filter((w) => {
+ if (routeTargets.has(w.name)) {
+ warnings.push(`workflow "${w.name}" is a channel route target; not exposed as an MCP tool`);
+ return false;
+ }
+ return true;
+ });
+ }
+
+ const tools: McpToolSpec[] = [];
+ const taken = new Set();
+ const defaultWf = candidates.find((w) => w.name === "default");
+ const named = candidates.filter((w) => w.name !== "default");
+
+ for (const wf of named) {
+ tools.push({
+ name: wf.name,
+ workflow: wf.name,
+ description: describeWorkflow(wf, inputAbs),
+ params: [...wf.params],
+ inputSchema: schemaForParams(wf.params),
+ });
+ taken.add(wf.name);
+ }
+
+ if (defaultWf) {
+ if (named.length > 0) {
+ warnings.push(
+ 'workflow "default" is not exposed as an MCP tool (other workflows exist; default stays the `jaiph run` entrypoint)',
+ );
+ } else {
+ const slug = toolNameFromFile(inputAbs);
+ if (taken.has(slug)) {
+ warnings.push(`workflow "default" skipped: tool name "${slug}" already taken`);
+ } else {
+ tools.push({
+ name: slug,
+ workflow: "default",
+ description: describeWorkflow(defaultWf, inputAbs),
+ params: [...defaultWf.params],
+ inputSchema: schemaForParams(defaultWf.params),
+ });
+ }
+ }
+ }
+
+ return { tools, warnings };
+}
diff --git a/src/cli/shared/usage.ts b/src/cli/shared/usage.ts
index a8fd3535..06349d6b 100644
--- a/src/cli/shared/usage.ts
+++ b/src/cli/shared/usage.ts
@@ -12,6 +12,7 @@ export function printUsage(): void {
" jaiph use ",
" jaiph format [--check] [--indent ] ",
" jaiph compile [--json] [--workspace ] ...",
+ " jaiph mcp [--workspace ] # serve the file's workflows as MCP tools over stdio (alias: jaiph --mcp)",
"",
"Global options:",
" -h, --help show this usage (jaiph --help) — each subcommand also accepts -h / --help",
@@ -50,6 +51,13 @@ export function printUsage(): void {
" --json stdout: JSON array of { file, line, col, code, message } (empty array if ok).",
" --workspace workspace root for import resolution (default: auto-detect per file).",
"",
+ "jaiph mcp:",
+ " Serve the file's workflows as MCP tools over stdio. Exposes `export workflow` declarations",
+ " if any exist, otherwise all top-level workflows except channel route targets; `default` is",
+ " exposed only when it is the only workflow, named after the file's basename. Tool descriptions",
+ " come from `#` comments directly above each workflow. Calls run on the host (like jaiph run --raw).",
+ " --workspace workspace root for import resolution (default: auto-detect).",
+ "",
"Examples:",
" jaiph --help",
" jaiph --version",
@@ -74,6 +82,7 @@ export function printUsage(): void {
" jaiph format --indent 4 flow.jh",
" jaiph compile flow.jh",
" jaiph compile --json .",
+ " jaiph mcp ./tools.jh",
"",
].join("\n"),
);
diff --git a/src/runtime/kernel/node-workflow-runner.ts b/src/runtime/kernel/node-workflow-runner.ts
index 025f7633..317da673 100644
--- a/src/runtime/kernel/node-workflow-runner.ts
+++ b/src/runtime/kernel/node-workflow-runner.ts
@@ -50,7 +50,7 @@ export async function runWorkflowRunner(positional: string[]): Promise {
const moduleGraph = graphFile ? readModuleGraph(graphFile) : loadModuleGraph(sourceFile, workspaceRoot);
const graph = buildRuntimeGraph(moduleGraph);
const runtime = new NodeWorkflowRuntime(graph, { env: process.env, cwd: process.cwd() });
- const status = workflowName === "default" ? await runtime.runDefault(runArgs) : 1;
+ const status = await runtime.runRoot(workflowName, runArgs);
writeFileSync(
metaFile,
`status=${status}\nrun_dir=${runtime.getRunDir()}\nsummary_file=${runtime.getSummaryFile()}\n`,
diff --git a/src/runtime/kernel/node-workflow-runtime.ts b/src/runtime/kernel/node-workflow-runtime.ts
index de68d28c..b4916b30 100644
--- a/src/runtime/kernel/node-workflow-runtime.ts
+++ b/src/runtime/kernel/node-workflow-runtime.ts
@@ -349,19 +349,33 @@ export class NodeWorkflowRuntime {
}
async runDefault(args: string[]): Promise {
- this.emitter.emitWorkflow("WORKFLOW_START", "default");
+ return this.runRoot("default", args);
+ }
+
+ /**
+ * Run a workflow from the entry module as the root of a run (same contract
+ * as `runDefault`, with the entry symbol parameterized): emits
+ * WORKFLOW_START/END and persists `return_value.txt`. Used by `jaiph run`
+ * (`default`) and by `jaiph mcp` tool calls (any exposed workflow).
+ */
+ async runRoot(workflowName: string, args: string[]): Promise {
+ this.emitter.emitWorkflow("WORKFLOW_START", workflowName);
const rootScope: Scope = {
filePath: this.graph.entryFile,
vars: this.newScopeVars(this.graph.entryFile, undefined, this.env),
env: { ...this.env },
};
const resolved = resolveWorkflowRef(this.graph, this.graph.entryFile, {
- value: "default",
+ value: workflowName,
loc: { line: 1, col: 1 },
});
if (!resolved) {
- process.stderr.write("jaiph run requires workflow 'default' in the input file\n");
- this.emitter.emitWorkflow("WORKFLOW_END", "default");
+ process.stderr.write(
+ workflowName === "default"
+ ? "jaiph run requires workflow 'default' in the input file\n"
+ : `jaiph run: unknown workflow '${workflowName}' in the input file\n`,
+ );
+ this.emitter.emitWorkflow("WORKFLOW_END", workflowName);
this.stopHeartbeat();
return 1;
}
@@ -380,7 +394,7 @@ export class NodeWorkflowRuntime {
// Best-effort capture; the run succeeded regardless.
}
}
- this.emitter.emitWorkflow("WORKFLOW_END", "default");
+ this.emitter.emitWorkflow("WORKFLOW_END", workflowName);
this.stopHeartbeat();
return result.status;
}
diff --git a/src/runtime/kernel/workflow-launch.ts b/src/runtime/kernel/workflow-launch.ts
index e82532f4..6fb817de 100644
--- a/src/runtime/kernel/workflow-launch.ts
+++ b/src/runtime/kernel/workflow-launch.ts
@@ -21,7 +21,7 @@ export function buildRunModuleLaunch(
positionalArgs: string[],
env: NodeJS.ProcessEnv,
): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
- const [metaFile, builtScript, _workflowSymbol, ...runArgs] = positionalArgs;
+ const [metaFile, builtScript, workflowSymbol, ...runArgs] = positionalArgs;
if (!metaFile || !builtScript) {
throw new Error("jaiph run launch requires meta_file and built_script");
}
@@ -29,7 +29,7 @@ export function buildRunModuleLaunch(
if (!sourceAbs) {
throw new Error("JAIPH_SOURCE_ABS is required for workflow launch");
}
- const runnerArgv = [WORKFLOW_RUNNER_ARG, metaFile, sourceAbs, builtScript, "default", ...runArgs];
+ const runnerArgv = [WORKFLOW_RUNNER_ARG, metaFile, sourceAbs, builtScript, workflowSymbol || "default", ...runArgs];
const launchEnv = { ...env, JAIPH_META_FILE: metaFile };
if (isBunCompiledStandalone()) {
return { command: process.execPath, args: runnerArgv, env: launchEnv };
From 4bc47e3eed10f8565ef74fab86c3970cd3adbc2d Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Tue, 14 Jul 2026 19:05:43 +0200
Subject: [PATCH 12/85] Test: close jaiph mcp test gaps and document the
subcommand
Land the verification and gap-closing work for the spiked `jaiph mcp`
MVP whose implementation was committed earlier as WIP. Adds the missing
automated coverage and user-facing docs; no runtime behaviour changes.
Tests: pin the launch argv (the workflow symbol must reach the runner
verbatim, guarding the fixed hardcoded-"default" bug, plus the empty
fallback); unit tests for runtime `runRoot` (non-default workflow runs
as root with positional param binding and return_value.txt on success,
unknown workflow returns 1 with no return value, runDefault delegates
unchanged); and an end-to-end scripted stdio session covering
initialize, tools/list with comment-derived descriptions, tools/call
return values, missing-arg -32602, workflow-failure isError, compile
diagnostics to stderr, --help/--mcp alias dispatch, hot reload
(list_changed) and broken-edit tolerance, and a `jaiph run` regression.
Docs: new docs/mcp.md how-to, a `jaiph mcp` reference section in
docs/cli.md, README and docs nav entries, CHANGELOG, and QUEUE cleanup.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 52 ---
README.md | 5 +-
docs/_layouts/docs.html | 1 +
docs/cli.md | 72 ++++
docs/mcp.md | 127 +++++++
integration/mcp-server.test.ts | 316 ++++++++++++++++++
.../node-workflow-runtime.run-root.test.ts | 82 +++++
src/runtime/kernel/workflow-launch.test.ts | 27 +-
9 files changed, 627 insertions(+), 56 deletions(-)
create mode 100644 docs/mcp.md
create mode 100644 integration/mcp-server.test.ts
create mode 100644 src/runtime/kernel/node-workflow-runtime.run-root.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2940602..8f5631b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feat — `jaiph mcp `: serve a file's workflows as MCP tools over stdio:** A new **`jaiph mcp`** subcommand (with **`jaiph --mcp `** as an ergonomic alias, dispatched alongside the subcommand in `src/cli/index.ts`) turns a `.jh` file into a [Model Context Protocol](https://modelcontextprotocol.io/) server so any MCP client (Claude Code, Claude Desktop, Cursor) can call the file's workflows as tools — `claude mcp add mytools -- jaiph mcp ./tools.jh`, no SDK project or build step. The transport is hand-rolled newline-delimited **JSON-RPC 2.0** over stdio with zero new dependencies (`src/cli/mcp/server.ts`, `McpServer`, injected `{serverVersion, getTools, callTool, write, log}`): `initialize` (version negotiation over `2024-11-05` / `2025-03-26` / `2025-06-18`, newest as fallback), `ping`, `tools/list`, `tools/call`, and `notifications/tools/list_changed` on hot reload; notifications are ignored, unknown methods are `-32601`, invalid JSON is `-32700` with `id: null`, and unknown tool / missing-or-non-string / unexpected argument are `-32602` (the call never starts) — while a **workflow failure is not a protocol error** but a normal result with `isError: true` and a `run dir:` pointer. **stdout carries only protocol JSON**; every banner, warning, exclusion notice, reload message, Docker notice, and credential-pre-flight warning goes to stderr, and compile diagnostics exit `1` with nothing on stdout. Tool derivation (`src/cli/mcp/tools.ts`, `deriveTools` / `toolNameFromFile`) runs over the entry file only: `export workflow` declarations if any exist, otherwise all top-level workflows minus channel route targets, with `default` exposed only when it is the sole candidate (named after the sanitized file basename); descriptions come from the `#` comment lines above each workflow (shebang lines dropped), and every parameter is a required string in the input schema. Calls execute on the host like `jaiph run --raw` (the Docker sandbox is not launched — a one-line stderr notice fires when the env would have enabled it), each as a durable `.jaiph/runs/` run with per-call run ids so concurrent calls are isolated; success text is the workflow's `return` value (`return_value.txt`) → `log` output → completion note (`src/cli/mcp/call.ts`, `src/cli/commands/mcp.ts`). Sources are watched (polling, ~750 ms): a valid edit re-derives tools and emits `notifications/tools/list_changed`, while an edit that fails to compile keeps the previous tool set serving. Two host-side runtime generalizations back this: `NodeWorkflowRuntime.runDefault(args)` is generalized to **`runRoot(workflowName, args)`** (same contract — emits `WORKFLOW_START`/`WORKFLOW_END` with the symbol, binds args to params by position, persists `return_value.txt` on success; `runDefault` delegates) and the runner dispatches any symbol via `runRoot` (`src/runtime/kernel/node-workflow-runtime.ts`, `node-workflow-runner.ts`); and a **launch-path fix** in `src/runtime/kernel/workflow-launch.ts` — `buildRunModuleLaunch` previously destructured the workflow symbol and then hardcoded `"default"` into the runner argv, so every spawned run executed `default` regardless of the requested symbol; it now passes `workflowSymbol || "default"` through, which also affects every `jaiph run`. `printUsage` gains a `jaiph mcp` line, section, and example (`src/cli/shared/usage.ts`). Tests: `src/cli/mcp/tools.test.ts`, `src/cli/mcp/server.test.ts`, `src/runtime/kernel/node-workflow-runtime.run-root.test.ts`, `src/runtime/kernel/workflow-launch.test.ts`, `integration/mcp-server.test.ts`. Docs: new `docs/mcp.md` (how-to), `docs/cli.md` (`jaiph mcp` reference section), `README.md`.
- **Feat — Config interpolation: `${identifier}` and bare-identifier sugar in `config { }` string values:** Workflow and module `config` blocks previously accepted only static string literals, booleans, and integers — `agent.default_model = model` was a parse error and `"${model}"` was stored literally with no runtime expansion. String config values now support the same interpolation model as orchestration strings: a **bare identifier** on the RHS is sugar for a single `${identifier}` reference (`agent.default_model = model` ≡ `agent.default_model = "${model}"`), and quoted strings may embed `${identifier}` anywhere in the value. **Module-level** config resolves references from module `const` values and environment variables at CLI startup (`resolveModuleMetadata` in `src/config.ts`, wired through `jaiph run` in `src/cli/commands/run.ts`). **Workflow-level** config additionally resolves that workflow's parameters; the runtime now binds workflow params before applying workflow metadata (`applyMetadataScope` in `src/runtime/kernel/node-workflow-runtime.ts` calls `interpolateWorkflowMetadata`). Compile-time validation (`src/transpile/validate-config.ts`) rejects unknown identifiers in interpolated config values. `agent.backend` enum checking is deferred when the value contains interpolation. Formatter round-trip preserves bare-identifier sugar (`src/format/emit.ts`). Tests: `src/parse/parse-metadata.test.ts`, `src/transpile/validate-config.test.ts`, `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts`, `e2e/tests/87_workflow_config.sh`. Docs: `docs/configuration.md`, `docs/grammar.md`.
- **Feature — Distro: native Windows smoke job in CI:** CI's only Windows coverage was `e2e-wsl`, which runs the suite inside WSL against the *Linux* binary. A new **`windows-native-smoke`** job (`.github/workflows/ci.yml`, `windows-latest`) proves the *native* `jaiph.exe` runs. It cross-compiles the standalone binary from the checkout (`bun build --compile --target=bun-windows-x64`, the same build as the release / `installer-powershell` legs) and runs **`e2e/tests/windows_native_smoke.ps1`** against it. The harness (a PowerShell acceptance test, same convention as `installer_powershell.ps1`, pointed at the binary via `JAIPH_TEST_WINDOWS_EXE`) covers three contracts, each failing when violated: (1) a **sample workflow** runs host-only (`JAIPH_UNSAFE=true`) exercising an inline shell line (through Git for Windows' `sh.exe`), a `script` step with a non-bash lang tag (` ```node `), `${…}` string interpolation, and `log` output — with assertions against the real `jaiph.exe` **stdout** (exit code `0` plus the interpolated `log` line and the `PASS` footer); (2) a **mid-run cancellation** records the workflow leader's descendant PID tree (`Win32_Process` parent/child links), delivers a real **Ctrl-C** isolated to the leader's own console (`AttachConsole` + `GenerateConsoleCtrlEvent`, so the CI runner shell is untouched), and fails if **any** descendant survives — exercising the win32 `taskkill /T /F` teardown path in `killProcessTree`; (3) a **`prompt`-step credential pre-flight** (`agent.backend = "codex"`, `OPENAI_API_KEY` unset, and `JAIPH_UNSAFE` dropped so the pre-flight runs — win32 forces host-only regardless) fails fast with the documented `E_AGENT_CREDENTIALS` error, bounded by a 30s `WaitForExit` so a hang is a failure, rather than calling any backend. The job never touches WSL — it shadows `wsl` so an accidental call throws — and now gates merge alongside `test`/`e2e`/`e2e-wsl` (added to `docker-publish`'s `needs`); `e2e-wsl` is left in place. Host-portable guards in `integration/windows-native-smoke.test.ts` pin the CI job shape and the harness contract (build command, stdout assertions, cancellation orphan check, pre-flight error, no-WSL, gate membership) so a regression fails `npm test` on any platform.
- **Feature — Distro: main-page install tabs — Windows variant with platform auto-detect:** The landing page (`docs/index.html`) hero install card now offers a Windows PowerShell path alongside the POSIX `curl … | bash` one-liners, and defaults to the right one for the visitor's platform. A new **`.os-switch`** sub-toggle (two buttons, `data-os="posix"` "macOS / Linux" and `data-os="windows"` "Windows", `role="group"`) sits above the existing run-sample / init-project / just-install tabs, and every tab panel now wraps its content in two **`.os-variant`** blocks (`data-os="posix"` / `data-os="windows"`). On load, **`attachOsSwitch()`** (`docs/assets/js/main.js`, scoped to `section.try-it-out .card`) auto-selects the Windows variant for Windows visitors via **`isWindowsPlatform()`** — which prefers `navigator.userAgentData?.platform` and falls back to `navigator.platform`, matching `/win/i` — while macOS/Linux visitors keep exactly today's POSIX default with no layout shift; **`setOsVariant()`** flips the `is-active` class on both the switch buttons and the variants across all three panels, so a manual platform choice (or a tab switch) is remembered card-wide. The **"Just install"** Windows variant is the copy-able `irm https://jaiph.org/install.ps1 | iex` line (via the existing copy button) plus the `npm install -g jaiph` alternative. The **"Run sample"** and **"Init project"** tabs have no PowerShell equivalent for their `curl … | bash -s` / `curl … | bash` pipes, so instead of showing a bash-only command as the Windows default they show the install one-liner followed by a short "then run:" `jaiph run say_hello.jh Adam` / `jaiph init` step (installing to `%LOCALAPPDATA%\jaiph\bin`). The static-render constraint holds: the POSIX variant carries `is-active` in the shipped markup and CSS keeps `.os-variant { display: none }` / `.os-variant.is-active { display: block }` (`docs/assets/css/style.css`), so with JS disabled all three bash one-liners render, no panel is blank, and the Windows variant stays reachable in the tab markup — JS only reveals it on demand (and by default for Windows). New Playwright cases in the docs suite (`e2e/playwright/landing-page.spec.ts`, alongside the "Try it out" test) emulate the platform before page scripts run and assert: a Windows visitor defaults to the `irm … | iex` command, a macOS/Linux visitor's default is unchanged from today, manual platform + tab switching works in both directions, the copy button on the Windows variant copies the exact `irm … | iex` line (asserted through a clipboard stub), and with JavaScript disabled all bash commands render with no blank panel. No `docs/*.md` or `README.md` change is needed — the Windows install path is already documented in `docs/setup.md` (`/how-to/install`) and the README install section; this task is landing-page presentation only, with no runtime, CLI, or installer behavior change.
diff --git a/QUEUE.md b/QUEUE.md
index 82a837cc..4c725e45 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,58 +14,6 @@ Process rules:
***
-## MCP 1–4/8 — Land the spiked `jaiph mcp` MVP: verify what exists, close the test gaps, run the full suite #dev-ready
-
-Design: `design/2026-07-14-mcp-server.md` — the whole doc; it records the contracts as verified during the spike.
-
-**State: largely implemented, partially verified.** A working MVP was spiked on 2026-07-14 and left **uncommitted in the working tree on `nightly`**. If that diff is present when you pick this up, the job is verification and gap-closing; if it is absent (discarded or already landed differently), implement from scratch per the design doc. The acceptance list below is the contract either way and is deliberately state-independent.
-
-### What exists and how far each piece is verified
-
-- **Runtime root generalization** — `src/runtime/kernel/node-workflow-runtime.ts`: `runDefault(args)` generalized to `runRoot(workflowName, args)` (emits `WORKFLOW_START`/`WORKFLOW_END` with the symbol, binds args to params by position, persists `return_value.txt` on success; `runDefault` delegates; unknown non-default symbol → `jaiph run: unknown workflow '' in the input file`, status 1). *Implemented; exercised only indirectly through the live MCP session; **no dedicated unit tests**.*
-- **Runner symbol dispatch** — `src/runtime/kernel/node-workflow-runner.ts` calls `runtime.runRoot(workflowName, runArgs)` (previously non-`default` symbols short-circuited to status 1). *Implemented; no dedicated test.*
-- **Launch-path bug fix** — `src/runtime/kernel/workflow-launch.ts` `buildRunModuleLaunch` now passes `workflowSymbol || "default"` into the runner argv; it previously destructured the symbol and **hardcoded `"default"`**, so every spawned run executed `default` regardless of the requested symbol. *Implemented; verified in the MCP direction (non-default symbols run correctly). **The `jaiph run` direction was NOT re-verified after this edit** (the manual regression check was interrupted) and there is **no unit test pinning the argv**. This edit touches every `jaiph run` — treat it as unverified until tested.*
-- **Tool derivation** — `src/cli/mcp/tools.ts` (`deriveTools`, `toolNameFromFile`) + `src/cli/mcp/tools.test.ts`. *Implemented; **10 unit tests passing** (exports narrowing, route-target exclusion, lone-`default` rename, skip-with-warning, comment descriptions with shebang dropped, fallback, schemas, name sanitization).*
-- **Protocol server** — `src/cli/mcp/server.ts` (`McpServer`, injected `{serverVersion, getTools, callTool, write, log}`) + `src/cli/mcp/server.test.ts`. *Implemented; **16 unit tests passing** (initialize version negotiation known/unknown, tools/list shape, call arg mapping, failure-as-`isError`, `-32602` variants, `-32603` + log, ping, notifications ignored, `-32700` id-null, `-32601`, blank lines, `list_changed` gating).*
-- **Per-call execution** — `src/cli/mcp/call.ts` (`callWorkflow`: per-call runner spawn, `__JAIPH_EVENT__` stderr parsing, meta-file → `return_value.txt`, success/failure text composition). *Implemented; verified via a live stdio session (return values round-trip, failure text carries the run-dir pointer, concurrent calls interleave correctly); **no automated test**.*
-- **Command + wiring** — `src/cli/commands/mcp.ts` (`runMcp`: arg parsing, diagnostics-to-stderr, generation temp dirs, concurrent in-flight tracking, shutdown on stdin close/SIGINT/SIGTERM), dispatch of `mcp` + `--mcp` alias in `src/cli/index.ts`, `printUsage` additions in `src/cli/shared/usage.ts`. *Implemented; happy path verified via the live session. **The hot-reload path (`fs.watchFile` → new generation → `notifications/tools/list_changed`) was written but never exercised — not even manually.** The diagnostics-exit path, help output, and alias dispatch have no tests.*
-
-Overall verification done during the spike: `tsc --noEmit` clean; `npm run build` clean; `node --test dist/src/cli/mcp/*.test.js` → **26/26 pass**; live scripted stdio session (initialize → tools/list with comment-derived descriptions → tools/call returning workflow `return` values → missing-arg `-32602` → failure leg with `isError: true`). **The full `npm test` suite has NOT been run since these changes.**
-
-Manual probe (useful while working):
-
-```bash
-printf '%s\n' \
- '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
- '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
- '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
- '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"","arguments":{...}}}' \
- | node dist/src/cli.js mcp
-```
-
-### Work
-
-1. If the diff is absent, implement per the design doc (module layout above).
-2. Add the missing tests: launch argv pin, `runRoot` unit tests, scripted stdio session, diagnostics/help/alias, hot reload, `jaiph run` regression.
-3. Run the **full** `npm test` suite (and `npm run test:e2e` if touched paths warrant) and fix fallout — the launch-path edit is shared with `jaiph run`.
-
-### Acceptance
-
-Existing (keep passing — they fail if the contract regresses):
-
-- The 10 tool-derivation tests and 16 protocol-server tests described above.
-
-New (each must fail when its contract is violated):
-
-- Unit test: `buildRunModuleLaunch(["meta", "built.sh", "mywf", "arg1"], env)` produces an argv containing `mywf` (pins the fixed hardcoded-`default` bug).
-- Unit tests on the runtime: a non-`default` workflow runs as root via `runRoot` with params bound positionally and `return_value.txt` written on success; `runRoot("missing", [])` returns 1 and writes no `return_value.txt`; `runDefault` behaviour unchanged.
-- Scripted stdio session against a fixture `.jh` (two workflows, one with a param and a `return`): initialize → tools/list shows both tools with comment-derived descriptions → tools/call returns the workflow's return value as text → missing-arg call → `-32602` → failing workflow → `isError: true` with a run-dir pointer. Every stdout line parses as JSON-RPC (no banner/progress leakage).
-- Compile diagnostics go to stderr with exit 1 and nothing on stdout.
-- `jaiph --help` output includes `jaiph mcp`; `jaiph --mcp ` dispatches to the same command.
-- Hot reload: edit the fixture to add a workflow → `notifications/tools/list_changed` is emitted and a subsequent `tools/list` shows the new tool; break the fixture → the previous tool set still serves and diagnostics appear on stderr.
-- `jaiph run` regression: a `default` workflow exits 0 and prints its return value (pins the shared launch path in the `run` direction).
-- The full `npm test` suite passes.
-
## ENV — `--env` passthrough into the workflow env and across the Docker sandbox boundary (`jaiph run` + `jaiph mcp`) #dev-ready
The Docker sandbox forwards environment fail-closed: only the `ENV_ALLOW_PREFIXES` allowlist (`JAIPH_`, `ANTHROPIC_`, `CURSOR_`, `CLAUDE_`; see `src/runtime/docker.ts` — `isEnvAllowed`, consumed in the `buildDockerArgs` env loop) crosses into the container. There is no per-key escape hatch, so a workflow that needs e.g. `GITHUB_TOKEN` or `MY_API_URL` cannot receive it in a sandboxed run. Add an explicit, user-consented passthrough.
diff --git a/README.md b/README.md
index bd6dbbe6..796c6f16 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[jaiph.org](https://jaiph.org) · [Your first workflow](docs/first-workflow.md) · [Your first agent + sandboxed run](docs/first-agent-run.md) · [Install & switch versions](docs/setup.md) · [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) · [Architecture](docs/architecture.md) · [CLI](docs/cli.md) · [Contributing](docs/contributing.md)
-> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
+> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
---
@@ -26,10 +26,11 @@
- **Testing** — `*.test.jh` files run in-process (`jaiph test`) with mocks and `expect_*` assertions ([Write & run tests](docs/testing.md)).
- **Safety and inspectability** — Docker-backed sandbox for **`jaiph run`** (env-controlled; see [Sandboxing](docs/sandboxing.md) and [Run in a Docker sandbox](docs/sandbox-run.md)); live **`__JAIPH_EVENT__`** on stderr and durable **`.jaiph/runs/`** artifacts ([Architecture](docs/architecture.md)).
- **Tooling** — `jaiph compile`, `jaiph format`, `jaiph install` / `.jaiph/libs/` ([Use & publish a library](docs/libraries.md)), and optional `hooks.json` ([CLI](docs/cli.md), [Add a hook](docs/hooks.md)).
+- **MCP server** — `jaiph mcp ./tools.jh` serves a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio, so any MCP client (Claude Code, Cursor) can call tested Jaiph workflows as tools ([Serve workflows as MCP tools](docs/mcp.md)).
## Core components
-- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use`; prepares scripts, spawns the workflow runner (or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only.
+- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use` / `mcp`; prepares scripts, spawns the workflow runner (or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only.
- **Parser** (`src/parser.ts`, `src/parse/*`) — `.jh` / `.test.jh` → AST.
- **Validator** (`src/transpile/validate.ts`) — imports and symbol references at compile time.
- **Transpiler** (`src/transpile/*`) — emits atomic `script` files under `scripts/` only (no workflow-level shell).
diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html
index 51694220..8d9cd48d 100644
--- a/docs/_layouts/docs.html
+++ b/docs/_layouts/docs.html
@@ -55,6 +55,7 @@
diff --git a/docs/cli.md b/docs/cli.md
index 2bc3fd07..718133bd 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -22,6 +22,7 @@ The published `jaiph` bin is `node dist/src/cli.js` (npm) or the standalone `dis
| `jaiph --version` / `-v` | Print the CLI version and exit `0`. |
| `jaiph [-h \| --help]` | Print the subcommand's usage (flags + one example) and exit `0`. Recognised anywhere in the arg list before `--` (except `compile`: help flags must precede path arguments). |
| `jaiph ` | File shorthand. Paths ending in `*.test.jh` route to `jaiph test`; other `*.jh` paths route to `jaiph run`. Non-existent paths fall through to normal command parsing. |
+| `jaiph --mcp ` | Alias for `jaiph mcp `, dispatched alongside the subcommand. |
| `jaiph ` | Print `Unknown command: `, repeat the overview, exit `1`. |
The reserved internal marker `__workflow-runner` is excluded from `--help`/usage and from the file-shorthand path; it is used by `process.execPath` self-spawn (see [Architecture — Distribution: Node vs Bun standalone](architecture.md#distribution-node-vs-bun-standalone)).
@@ -37,6 +38,7 @@ The reserved internal marker `__workflow-runner` is excluded from `--help`/usage
| `init` | Initialize `.jaiph/` directory layout in a workspace. |
| `install` | Install project-scoped libraries from the registry or git URLs. |
| `use` | Reinstall `jaiph` globally with a selected version or channel. |
+| `mcp` | Serve a file's workflows as MCP tools over stdio (newline-delimited JSON-RPC). |
## `jaiph run`
{: #jaiph-run}
@@ -268,6 +270,75 @@ jaiph use
Implementation: re-invokes `JAIPH_INSTALL_COMMAND` (default `curl -fsSL https://jaiph.org/install | bash`) with `JAIPH_REPO_REF` set to `nightly` or `v`. The installer downloads the matching per-platform binary plus `SHA256SUMS`, verifies the checksum, and replaces `~/.local/bin/jaiph` (or `JAIPH_BIN_DIR`).
+## `jaiph mcp`
+{: #jaiph-mcp}
+
+Serve a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio. See [Serve workflows as MCP tools](/how-to/mcp) for the recipe and client-registration steps.
+
+```text
+jaiph mcp [--workspace ]
+```
+
+`jaiph --mcp ` is an equivalent alias, dispatched after `compile` in `src/cli/index.ts`.
+
+| Flag | Argument | Effect |
+|---|---|---|
+| `--workspace` | `` | Workspace root for import resolution (default: auto-detected from the file's directory). A missing value or non-directory path aborts with a specific message. |
+| `-h`, `--help` | — | Print the subcommand usage and exit `0`. |
+
+### Startup and exit behaviour
+
+- Loads the module graph and runs `collectDiagnostics` (the same compile-time pass as `jaiph compile`). Any diagnostic prints `file:line:col CODE message` lines to **stderr** and exits `1`.
+- A missing path, a non-`.jh` path, or a path that is not a file exits `1` with a message on stderr.
+- On success the server runs until stdin closes or it receives `SIGINT` / `SIGTERM`, then drains in-flight calls and exits `0`.
+
+### stdout invariant
+
+From the moment the server starts, **stdout carries only newline-delimited JSON-RPC**. Every banner, warning, workflow-exclusion notice, reload message, Docker notice, and credential-pre-flight warning goes to **stderr**. Each outbound protocol message is a single atomic write of `JSON.stringify(msg) + "\n"`.
+
+### Protocol subset
+
+Newline-delimited JSON-RPC 2.0. Requests are handled concurrently (a long `tools/call` never stalls `ping` or further calls).
+
+| Method | Behaviour |
+|---|---|
+| `initialize` | Replies with `protocolVersion`, `capabilities: {tools: {listChanged: true}}`, and `serverInfo: {name: "jaiph", title: "Jaiph workflows", version}`. Echoes the client's `protocolVersion` if it is one of `2024-11-05`, `2025-03-26`, `2025-06-18`; otherwise replies with the newest of that set. |
+| `ping` | Empty result. |
+| `tools/list` | `{tools: [{name, description, inputSchema}]}` from the current tool set (re-read per request, so hot reload needs no cache invalidation). |
+| `tools/call` | Runs the workflow on the host. Result: `{content: [{type: "text", text}], isError}`. |
+| notifications | Ignored (`notifications/initialized`, `notifications/cancelled`, …); no response. |
+| unknown request | JSON-RPC error `-32601`. |
+
+The server emits `notifications/tools/list_changed` after a successful hot reload (only once `initialize` has happened).
+
+### Error mapping
+
+| Condition | Code |
+|---|---|
+| Invalid JSON | `-32700` (with `id: null`) |
+| Non-object message | `-32600` |
+| Unknown method | `-32601` |
+| Unknown tool, missing/non-string required argument, or unexpected argument key | `-32602` (the call never starts) |
+| Infrastructure crash while running a call | `-32603` (also logged to stderr) |
+| **Workflow failure** | *not* a protocol error — a normal result with `isError: true` and a `run dir:` pointer |
+
+### Exposure and naming
+
+The tool surface is derived from the **entry file only** (imports are never exposed):
+
+| Rule | Behaviour |
+|---|---|
+| `export workflow …` present | Exactly the exported workflows are exposed. |
+| No exports | Every top-level workflow except channel route targets (skipped with a warning). |
+| `default` | Exposed only when it is the sole candidate, named after the sanitized file basename (`.jh` stripped, non-`[A-Za-z0-9_-]` → `_`, truncated to 128); otherwise skipped. |
+
+Tool descriptions come from the `#` comment lines directly above each workflow (shebang lines dropped, `#` prefix stripped); the fallback is `Run the "" workflow from .` Every parameter is a required string in the input schema.
+
+### Execution and hot reload
+
+- Calls spawn the workflow runner on the host, like `jaiph run --raw`; the Docker sandbox is **not** launched. Run artifacts land under `.jaiph/runs/` exactly as for `jaiph run`.
+- Source files in the module graph are watched (polling, ~750 ms). A valid edit re-derives tools and emits `notifications/tools/list_changed`; an edit that fails to compile keeps the previous tool set serving and logs diagnostics to stderr.
+
## Environment variables
See [Environment variables](env-vars.md) for the complete inventory. The variables most relevant to CLI behaviour:
@@ -296,3 +367,4 @@ See [Environment variables](env-vars.md) for the complete inventory. The variabl
- [Grammar](grammar.md) — syntax and validation catalog.
- [Language](language.md) — step semantics and step-output contract.
- [Environment variables](env-vars.md) — every variable Jaiph reads.
+- [Serve workflows as MCP tools](/how-to/mcp) — exposing a file's workflows to MCP clients via `jaiph mcp`.
diff --git a/docs/mcp.md b/docs/mcp.md
new file mode 100644
index 00000000..12894092
--- /dev/null
+++ b/docs/mcp.md
@@ -0,0 +1,127 @@
+---
+title: Serve workflows as MCP tools
+permalink: /how-to/mcp
+diataxis: how-to
+---
+
+# Serve workflows as MCP tools
+
+This recipe turns a `.jh` file into an [MCP](https://modelcontextprotocol.io/) server so that any MCP client (Claude Code, Claude Desktop, Cursor) can call the file's workflows as tools. A workflow encodes a tested, multi-step, repair-capable procedure (`ensure`, `catch`, `recover`, artifacts) — exposing it as a tool lets an agent invoke that procedure instead of improvising shell commands.
+
+No SDK project and no build step are involved: `jaiph mcp ./tools.jh` reuses the same compile-time validation, runner, and `.jaiph/runs/` artifacts as [`jaiph run`](cli.md#jaiph-run).
+
+## Prerequisites
+
+- A `.jh` file with at least one workflow.
+- Agent credentials on the host if any exposed workflow uses `prompt` — see [Authenticate agent backends](/how-to/agent-auth). Calls run on the host, so credentials are read from the host environment, not a container.
+
+## 1. Serve a file over stdio
+
+```bash
+jaiph mcp ./tools.jh
+```
+
+The server speaks newline-delimited [JSON-RPC 2.0](https://www.jsonrpc.org/specification) over stdio (the MCP stdio transport) and runs until stdin closes or it receives `SIGINT` / `SIGTERM`. `jaiph --mcp ./tools.jh` is an equivalent alias.
+
+Add `--workspace ` to set the import-resolution root explicitly (default: auto-detected from the file's directory, exactly as in `jaiph run`).
+
+> **stdout carries only protocol JSON.** From the moment the server starts, stdout is the JSON-RPC channel. Every banner, warning, reload notice, and compile diagnostic goes to **stderr**. If the file has compile errors, the server prints `file:line:col CODE message` lines to stderr and exits `1` with nothing on stdout.
+
+## 2. Register the server with a client
+
+For Claude Code:
+
+```bash
+claude mcp add mytools -- jaiph mcp ./tools.jh
+```
+
+Any client that launches a command and speaks the MCP stdio transport works the same way — point it at `jaiph mcp `. The client sends `initialize`, then `tools/list`, then `tools/call`; the server needs no other configuration.
+
+## 3. Choose which workflows are exposed
+
+Not every workflow in the file becomes a tool. `deriveTools` applies these rules to the **entry file only** (imported modules are never exposed):
+
+1. **If the file declares `export workflow …`, exactly those are exposed.** `export` is the module's public-API marker; use it to publish a deliberate tool surface and hide helpers.
+2. **Otherwise every top-level workflow is exposed**, except **channel route targets** (workflows wired as inbox handlers via `channel name -> handler`) — those are message handlers, not tools, and are skipped with a warning.
+3. **`default` is special.** It is exposed only when it is the *only* candidate, under a tool name derived from the file's basename (`deploy.jh` → `deploy`). When other workflows exist, `default` is skipped (it stays the `jaiph run` entrypoint, not a public tool). It is also skipped if its file-slug name collides with a named workflow.
+
+The tool name for a named workflow is the workflow name itself. For a lone `default`, the file basename is sanitized to the MCP tool-name charset: the `.jh` suffix is stripped and any character outside `[A-Za-z0-9_-]` becomes `_`, truncated to 128 characters.
+
+Skips and exclusions are logged as warnings on **stderr** at load time — they never appear on stdout.
+
+## 4. Write tool descriptions as comments
+
+The description an agent reads when deciding whether to call a tool comes from the **`#` comment lines directly above the workflow**. Shebang lines (`#!…`) are dropped; the leading `#` is stripped from each remaining line; the lines are joined with newlines. Descriptions are the primary signal a client uses to pick a tool, so write them for the calling agent.
+
+```jaiph
+# Deploy the application to the named environment.
+# Runs the test suite first and aborts the deploy if it fails.
+export workflow deploy(environment) {
+ ensure tests_pass()
+ run `./deploy.sh ${environment}`()
+ return "deployed to ${environment}"
+}
+```
+
+If a workflow has no leading comment, the description falls back to `Run the "" workflow from .`
+
+## 5. Understand the input schema
+
+Every Jaiph parameter is a string, so each tool's input schema is a flat object of string properties with **all parameters required** and no additional properties allowed. The `deploy` workflow above produces:
+
+```json
+{
+ "type": "object",
+ "properties": { "environment": { "type": "string" } },
+ "required": ["environment"],
+ "additionalProperties": false
+}
+```
+
+A workflow with no parameters produces the same shape with an empty `properties` and no `required` key.
+
+## 6. Call a tool and read the result
+
+On `tools/call`, the server maps the arguments object to positional workflow arguments in declared order and spawns the workflow runner — the same host-execution path as `jaiph run --raw`. The result is a text content block:
+
+- **On success**, the text is the workflow's `return` value (persisted as `return_value.txt`); if the workflow returns nothing, it falls back to the workflow's `log` output, then to a `workflow completed` note.
+- **On failure**, the result carries `isError: true` and text describing the failing step, its captured output, and a `run dir: ` pointer so the client can inspect the full run.
+
+A **workflow failure is not a protocol error** — it comes back as a normal result with `isError: true`. Protocol-level errors (JSON-RPC `-32602`) are reserved for calls that never start: an unknown tool name, a missing or non-string required argument, or an unexpected argument key.
+
+Every call is a durable, inspectable run under `.jaiph/runs/` in the workspace, exactly as for `jaiph run`. Concurrent calls are isolated by per-call run ids and run directories, so a slow call never stalls other calls or a `ping`. Two calls that mutate the *same* files in the workspace can still race — that is inherent to running against a live workspace.
+
+## 7. Edit the file while the server runs (hot reload)
+
+The server watches every source file in the module graph (polling, ~750 ms). When you edit and save:
+
+- The graph is reloaded and re-validated, tools are re-derived, and the server emits `notifications/tools/list_changed`. A subsequent `tools/list` reflects the new tool set.
+- If the edit introduces a **compile error**, the server keeps serving the previous, valid tool set and logs the diagnostics to stderr — clients are never left with a broken tool list.
+
+## Safety posture
+
+An MCP-exposed workflow is **arbitrary shell reachable by the connected agent** — that is the point of the feature. Treat every exposed workflow as code the client may run at will, and scope the exposed surface with `export` accordingly.
+
+In this MVP, calls run **on the host**, like `jaiph run --raw`; the Docker sandbox is not launched, even when Docker would be enabled for `jaiph run`. When the environment would have enabled Docker, the server prints a one-line notice on stderr at startup. Agent-credential pre-flight runs once at startup, but in MCP mode its findings are demoted to warnings (the server can outlive a credential fix, and per-call failures still surface to the client).
+
+## Verification
+
+With the server running, a scripted stdio session drives the full handshake. Every stdout line is a JSON-RPC message:
+
+```bash
+printf '%s\n' \
+ '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
+ '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
+ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"deploy","arguments":{"environment":"staging"}}}' \
+ | jaiph mcp ./tools.jh
+```
+
+You should see three responses on stdout — the `initialize` result, the `tools/list` array with your comment-derived descriptions, and the `tools/call` result carrying the workflow's return value — and startup/warning lines only on stderr.
+
+## Related
+
+- [CLI — `jaiph mcp`](cli.md#jaiph-mcp) — the flag, exit behaviour, and error-code reference.
+- [Authenticate agent backends](/how-to/agent-auth) — host credentials for workflows that use `prompt`.
+- [Grammar — Imports and exports](grammar.md#imports-and-exports) — how `export` marks the public surface.
+- [Save artifacts](/how-to/artifacts) — the `.jaiph/runs/` layout every call writes to.
diff --git a/integration/mcp-server.test.ts b/integration/mcp-server.test.ts
new file mode 100644
index 00000000..98569442
--- /dev/null
+++ b/integration/mcp-server.test.ts
@@ -0,0 +1,316 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+
+const CLI_PATH = join(process.cwd(), "dist/src/cli.js");
+
+/**
+ * Drives a live `jaiph mcp` child over its stdio JSON-RPC transport. Buffers
+ * every stdout line (so tests can assert stdout is *only* JSON-RPC) and every
+ * stderr chunk (diagnostics land there), and lets tests await responses by id
+ * or notifications by method.
+ */
+interface McpClient {
+ send(message: Record): void;
+ waitFor(predicate: (m: Record) => boolean, label: string, timeoutMs?: number): Promise>;
+ stdoutLines(): string[];
+ stderr(): string;
+ close(): Promise;
+}
+
+function startMcp(fixture: string, cwd: string, env: NodeJS.ProcessEnv, alias = false): McpClient {
+ const argv = alias ? ["--mcp", fixture] : ["mcp", fixture];
+ const child: ChildProcessWithoutNullStreams = spawn("node", [CLI_PATH, ...argv], {
+ cwd,
+ env,
+ stdio: ["pipe", "pipe", "pipe"],
+ }) as ChildProcessWithoutNullStreams;
+
+ const rawLines: string[] = [];
+ const messages: Array<{ msg: Record; claimed: boolean }> = [];
+ const waiters: Array<{ predicate: (m: Record) => boolean; resolve: (m: Record) => void }> = [];
+ let stderrBuf = "";
+ let stdoutBuf = "";
+
+ const tryWaiters = (): void => {
+ for (let wi = 0; wi < waiters.length; wi += 1) {
+ const w = waiters[wi];
+ const entry = messages.find((e) => !e.claimed && w.predicate(e.msg));
+ if (entry) {
+ entry.claimed = true;
+ waiters.splice(wi, 1);
+ w.resolve(entry.msg);
+ wi -= 1;
+ }
+ }
+ };
+
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk: string) => {
+ stdoutBuf += chunk;
+ let idx = stdoutBuf.indexOf("\n");
+ while (idx !== -1) {
+ const line = stdoutBuf.slice(0, idx);
+ stdoutBuf = stdoutBuf.slice(idx + 1);
+ if (line.length > 0) {
+ rawLines.push(line);
+ messages.push({ msg: JSON.parse(line) as Record, claimed: false });
+ }
+ idx = stdoutBuf.indexOf("\n");
+ }
+ tryWaiters();
+ });
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk: string) => {
+ stderrBuf += chunk;
+ });
+
+ return {
+ send(message) {
+ child.stdin.write(`${JSON.stringify(message)}\n`);
+ },
+ waitFor(predicate, label, timeoutMs = 20_000) {
+ return new Promise((resolve, reject) => {
+ const entry = messages.find((e) => !e.claimed && predicate(e.msg));
+ if (entry) {
+ entry.claimed = true;
+ resolve(entry.msg);
+ return;
+ }
+ const timer = setTimeout(() => {
+ reject(new Error(`timed out waiting for ${label}\nstderr:\n${stderrBuf}\nstdout:\n${rawLines.join("\n")}`));
+ }, timeoutMs);
+ waiters.push({
+ predicate,
+ resolve: (m) => {
+ clearTimeout(timer);
+ resolve(m);
+ },
+ });
+ });
+ },
+ stdoutLines: () => [...rawLines],
+ stderr: () => stderrBuf,
+ close() {
+ return new Promise((resolve) => {
+ child.on("exit", () => resolve());
+ child.stdin.end();
+ setTimeout(() => {
+ child.kill("SIGKILL");
+ }, 5_000).unref();
+ });
+ },
+ };
+}
+
+function mcpEnv(runsRoot: string): NodeJS.ProcessEnv {
+ return {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ JAIPH_RUNS_DIR: runsRoot,
+ PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`,
+ };
+}
+
+const TWO_WORKFLOW_FIXTURE = [
+ "# Greets the given name.",
+ "workflow greet(name) {",
+ ' return "hello ${name}"',
+ "}",
+ "",
+ "# Fails on purpose for tests.",
+ "workflow boom() {",
+ ' fail "boom went off"',
+ "}",
+ "",
+].join("\n");
+
+async function initialize(client: McpClient): Promise> {
+ client.send({
+ jsonrpc: "2.0",
+ id: 0,
+ method: "initialize",
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "probe", version: "1" } },
+ });
+ const res = await client.waitFor((m) => m.id === 0, "initialize response");
+ client.send({ jsonrpc: "2.0", method: "notifications/initialized" });
+ return res;
+}
+
+test("jaiph mcp: scripted stdio session (initialize, list, call, invalid-params, failure)", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-mcp-session-"));
+ const client = startMcp(join(root, "tools.jh"), root, mcpEnv(join(root, ".jaiph/runs")));
+ try {
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, TWO_WORKFLOW_FIXTURE);
+
+ // initialize
+ const init = await initialize(client);
+ const initResult = init.result as { protocolVersion: string; capabilities: unknown; serverInfo: { name: string } };
+ assert.equal(initResult.protocolVersion, "2025-06-18");
+ assert.deepEqual(initResult.capabilities, { tools: { listChanged: true } });
+ assert.equal(initResult.serverInfo.name, "jaiph");
+
+ // tools/list — both tools, comment-derived descriptions
+ client.send({ jsonrpc: "2.0", id: 1, method: "tools/list" });
+ const list = await client.waitFor((m) => m.id === 1, "tools/list response");
+ const tools = (list.result as { tools: Array<{ name: string; description: string }> }).tools;
+ const byName = new Map(tools.map((t) => [t.name, t]));
+ assert.deepEqual([...byName.keys()].sort(), ["boom", "greet"]);
+ assert.equal(byName.get("greet")!.description, "Greets the given name.");
+ assert.equal(byName.get("boom")!.description, "Fails on purpose for tests.");
+
+ // tools/call greet — returns the workflow's return value as text
+ client.send({
+ jsonrpc: "2.0",
+ id: 2,
+ method: "tools/call",
+ params: { name: "greet", arguments: { name: "world" } },
+ });
+ const call = await client.waitFor((m) => m.id === 2, "greet call response");
+ const callResult = call.result as { content: Array<{ type: string; text: string }>; isError: boolean };
+ assert.equal(callResult.isError, false);
+ assert.deepEqual(callResult.content, [{ type: "text", text: "hello world" }]);
+
+ // missing required arg → -32602
+ client.send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "greet", arguments: {} } });
+ const missing = await client.waitFor((m) => m.id === 3, "missing-arg response");
+ assert.equal((missing.error as { code: number }).code, -32602);
+
+ // failing workflow → isError with a run-dir pointer (not a protocol error)
+ client.send({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "boom", arguments: {} } });
+ const boom = await client.waitFor((m) => m.id === 4, "boom call response");
+ assert.equal(boom.error, undefined, "workflow failure must not be a protocol error");
+ const boomResult = boom.result as { content: Array<{ text: string }>; isError: boolean };
+ assert.equal(boomResult.isError, true);
+ assert.match(boomResult.content[0].text, /run dir:/);
+
+ // Every stdout line is valid JSON-RPC 2.0 — no banner/progress leakage.
+ for (const line of client.stdoutLines()) {
+ const parsed = JSON.parse(line) as { jsonrpc?: string };
+ assert.equal(parsed.jsonrpc, "2.0", `non-JSON-RPC stdout line: ${line}`);
+ }
+ } finally {
+ await client.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph mcp: hot reload adds a tool (list_changed) and a broken edit keeps the previous tool set", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-mcp-reload-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, TWO_WORKFLOW_FIXTURE);
+ const client = startMcp(jh, root, mcpEnv(join(root, ".jaiph/runs")));
+ try {
+ await initialize(client);
+
+ // Edit the fixture to add a third workflow → list_changed, then it lists.
+ writeFileSync(
+ jh,
+ `${TWO_WORKFLOW_FIXTURE}\n# A freshly added tool.\nworkflow extra() {\n return "extra"\n}\n`,
+ );
+ await client.waitFor(
+ (m) => m.method === "notifications/tools/list_changed",
+ "list_changed after adding a workflow",
+ );
+ client.send({ jsonrpc: "2.0", id: 10, method: "tools/list" });
+ const list = await client.waitFor((m) => m.id === 10, "tools/list after reload");
+ const names = (list.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name).sort();
+ assert.deepEqual(names, ["boom", "extra", "greet"]);
+
+ // Break the fixture → reload fails; the previous tool set still serves and
+ // a diagnostic appears on stderr.
+ writeFileSync(jh, "workflow greet(name) {\n return \"broken\n}\n");
+ await pollUntil(() => /reload failed/.test(client.stderr()), 20_000, "reload-failure diagnostic on stderr");
+ client.send({ jsonrpc: "2.0", id: 11, method: "tools/list" });
+ const stillList = await client.waitFor((m) => m.id === 11, "tools/list after a broken edit");
+ const stillNames = (stillList.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name).sort();
+ assert.deepEqual(stillNames, ["boom", "extra", "greet"], "broken reload must keep the previous tool set");
+ } finally {
+ await client.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph mcp: compile diagnostics go to stderr with exit 1 and nothing on stdout", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-mcp-diag-"));
+ try {
+ const jh = join(root, "broken.jh");
+ // Reference to an undeclared workflow — a recoverable compile diagnostic.
+ writeFileSync(jh, ["workflow default() {", " run nonexistent()", "}", ""].join("\n"));
+ const result = spawnSync("node", [CLI_PATH, "mcp", jh], {
+ encoding: "utf8",
+ cwd: root,
+ env: mcpEnv(join(root, ".jaiph/runs")),
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ assert.equal(result.status, 1, `expected exit 1, got ${result.status}`);
+ assert.equal(result.stdout, "", `stdout must be empty, got: ${JSON.stringify(result.stdout)}`);
+ assert.ok(result.stderr.length > 0, "a diagnostic should appear on stderr");
+ assert.match(result.stderr, /nonexistent/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph --help lists jaiph mcp", () => {
+ const result = spawnSync("node", [CLI_PATH, "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
+ assert.equal(result.status, 0);
+ assert.match(result.stdout, /jaiph mcp/);
+});
+
+test("jaiph --mcp dispatches to the same command as jaiph mcp", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-mcp-alias-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, TWO_WORKFLOW_FIXTURE);
+ const client = startMcp(jh, root, mcpEnv(join(root, ".jaiph/runs")), true);
+ try {
+ const init = await initialize(client);
+ assert.equal((init.result as { serverInfo: { name: string } }).serverInfo.name, "jaiph");
+ client.send({ jsonrpc: "2.0", id: 1, method: "tools/list" });
+ const list = await client.waitFor((m) => m.id === 1, "tools/list over --mcp alias");
+ const names = (list.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name).sort();
+ assert.deepEqual(names, ["boom", "greet"]);
+ } finally {
+ await client.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run regression: a default workflow exits 0 and prints its return value", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-regression-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, ["workflow default() {", ' return "ran-ok"', "}", ""].join("\n"));
+ const result = spawnSync("node", [CLI_PATH, "run", jh], {
+ encoding: "utf8",
+ cwd: root,
+ env: mcpEnv(join(root, ".jaiph/runs")),
+ });
+ assert.equal(result.status, 0, `expected exit 0, got ${result.status}\nstderr: ${result.stderr}`);
+ assert.match(result.stdout, /ran-ok/, `stdout should print the return value: ${result.stdout}`);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+function pollUntil(predicate: () => boolean, timeoutMs: number, label: string): Promise {
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const tick = (): void => {
+ if (predicate()) {
+ resolve();
+ return;
+ }
+ if (Date.now() - start > timeoutMs) {
+ reject(new Error(`timed out waiting for ${label}`));
+ return;
+ }
+ setTimeout(tick, 100);
+ };
+ tick();
+ });
+}
diff --git a/src/runtime/kernel/node-workflow-runtime.run-root.test.ts b/src/runtime/kernel/node-workflow-runtime.run-root.test.ts
new file mode 100644
index 00000000..4c63355a
--- /dev/null
+++ b/src/runtime/kernel/node-workflow-runtime.run-root.test.ts
@@ -0,0 +1,82 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildRuntimeGraph } from "./graph";
+import { NodeWorkflowRuntime } from "./node-workflow-runtime";
+
+function makeRuntime(root: string, jh: string): NodeWorkflowRuntime {
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ };
+ return new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+}
+
+test("runRoot: a non-default workflow runs as root, binds params positionally, writes return_value.txt", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-root-named-"));
+ try {
+ const jh = join(root, "tools.jh");
+ writeFileSync(
+ jh,
+ [
+ "workflow greet(name, punctuation) {",
+ ' return "hello ${name}${punctuation}"',
+ "}",
+ "",
+ "workflow default() {",
+ ' log "unused"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const runtime = makeRuntime(root, jh);
+ const status = await runtime.runRoot("greet", ["world", "!"]);
+ assert.equal(status, 0);
+
+ const returnValueFile = join(runtime.getRunDir(), "return_value.txt");
+ assert.ok(existsSync(returnValueFile), `expected return_value.txt in ${runtime.getRunDir()}`);
+ assert.equal(readFileSync(returnValueFile, "utf8"), "hello world!");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("runRoot: an unknown workflow returns 1 and writes no return_value.txt", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-root-missing-"));
+ try {
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, ["workflow greet(name) {", ' return "hi ${name}"', "}", ""].join("\n"));
+ const runtime = makeRuntime(root, jh);
+ const status = await runtime.runRoot("missing", []);
+ assert.equal(status, 1);
+
+ const returnValueFile = join(runtime.getRunDir(), "return_value.txt");
+ assert.ok(!existsSync(returnValueFile), "expected no return_value.txt for an unknown workflow");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("runRoot: runDefault delegates to runRoot('default') with unchanged behaviour", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-root-default-"));
+ try {
+ const jh = join(root, "tools.jh");
+ writeFileSync(
+ jh,
+ ["workflow default(name) {", ' return "hello ${name}"', "}", ""].join("\n"),
+ );
+ const runtime = makeRuntime(root, jh);
+ const status = await runtime.runDefault(["world"]);
+ assert.equal(status, 0);
+
+ const returnValueFile = join(runtime.getRunDir(), "return_value.txt");
+ assert.ok(existsSync(returnValueFile), "runDefault should still write return_value.txt");
+ assert.equal(readFileSync(returnValueFile, "utf8"), "hello world");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/src/runtime/kernel/workflow-launch.test.ts b/src/runtime/kernel/workflow-launch.test.ts
index 7f487e87..143d3f38 100644
--- a/src/runtime/kernel/workflow-launch.test.ts
+++ b/src/runtime/kernel/workflow-launch.test.ts
@@ -9,17 +9,40 @@ test("buildRunModuleLaunch routes through the __workflow-runner dispatch (node b
{ JAIPH_SOURCE_ABS: "/tmp/source.jh" },
);
assert.equal(launch.command, process.execPath);
- // Node build: [cli.js, __workflow-runner, meta, source, built, "default", ...runArgs]
+ // Node build: [cli.js, __workflow-runner, meta, source, built, workflowSymbol, ...runArgs]
assert.match(launch.args[0]!, /cli\.js$/);
assert.equal(launch.args[1], WORKFLOW_RUNNER_ARG);
assert.equal(launch.args[2], "/tmp/meta.txt");
assert.equal(launch.args[3], "/tmp/source.jh");
assert.equal(launch.args[4], "/tmp/workflow.sh");
- assert.equal(launch.args[5], "default");
+ assert.equal(launch.args[5], "entry");
assert.equal(launch.args[6], "a");
assert.equal(launch.env.JAIPH_META_FILE, "/tmp/meta.txt");
});
+test("buildRunModuleLaunch passes the requested workflow symbol through to the runner argv", () => {
+ // Pins the fixed hardcoded-"default" bug: the symbol must reach the runner
+ // verbatim, otherwise every jaiph run / MCP call would execute `default`.
+ const launch = buildRunModuleLaunch(
+ ["meta", "built.sh", "mywf", "arg1"],
+ { JAIPH_SOURCE_ABS: "/tmp/source.jh" },
+ );
+ assert.ok(launch.args.includes("mywf"), `argv should contain the workflow symbol: ${launch.args.join(" ")}`);
+ // The symbol occupies the workflowSymbol slot, immediately before its run args.
+ const symbolIdx = launch.args.indexOf("mywf");
+ assert.equal(launch.args[symbolIdx + 1], "arg1");
+});
+
+test("buildRunModuleLaunch falls back to 'default' when the workflow symbol is empty", () => {
+ const launch = buildRunModuleLaunch(
+ ["/tmp/meta.txt", "/tmp/workflow.sh", "", "a"],
+ { JAIPH_SOURCE_ABS: "/tmp/source.jh" },
+ );
+ // [cli.js, __workflow-runner, meta, source, built, "default", "a"]
+ assert.equal(launch.args[5], "default");
+ assert.equal(launch.args[6], "a");
+});
+
test("buildRunModuleLaunch throws without JAIPH_SOURCE_ABS", () => {
assert.throws(
() => buildRunModuleLaunch(["/tmp/meta.txt", "/tmp/workflow.sh", "entry"], {}),
From fd9b19ec9553397bec8294f5c07cdcacc01a54f4 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Wed, 15 Jul 2026 00:27:36 +0200
Subject: [PATCH 13/85] Feat: add --env per-key passthrough to jaiph run and
jaiph mcp
Introduce a repeatable --env flag that lets a workflow receive a specific
host variable outside the fail-closed Docker env allowlist, with the flag
itself standing in as per-key consent. Supports both --env KEY=VALUE
(first = splits, value may contain =, empty allowed) and bare --env KEY
(forwards the host value, aborting with E_ENV_MISSING before spawning if
unset). Names must match [A-Za-z_][A-Za-z0-9_]* or abort with
E_ENV_INVALID, and sandbox-control / runtime-managed keys are refused with
E_ENV_RESERVED.
Semantics are uniform across every execution mode: in host modes the pairs
are applied to the runner env after resolveRuntimeEnv/applySandboxFlags; in
a Docker sandbox they are threaded through DockerSpawnOptions.extraEnv into
buildDockerArgs as explicit -e KEY=VALUE args that bypass isEnvAllowed, each
key emitted exactly once with the --env value winning. On jaiph mcp the
pairs are resolved once at startup and applied to every tool call for the
server's lifetime. Values cross verbatim with no path remapping.
Updates usage strings, docs (cli, env-vars, sandboxing), and adds parse,
buildDockerArgs, resolve-env, MCP, and e2e coverage.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 28 -------
docs/cli.md | 6 +-
docs/env-vars.md | 2 +
docs/sandboxing.md | 3 +-
e2e/tests/140_env_passthrough.sh | 126 +++++++++++++++++++++++++++++++
integration/mcp-server.test.ts | 100 +++++++++++++++++++++++-
src/cli/commands/mcp.ts | 20 +++--
src/cli/commands/run.ts | 28 ++++++-
src/cli/mcp/call.ts | 10 +++
src/cli/run/env.ts | 30 ++++++++
src/cli/run/resolve-env.test.ts | 40 +++++++++-
src/cli/shared/usage.test.ts | 91 ++++++++++++++++++++++
src/cli/shared/usage.ts | 100 +++++++++++++++++++++++-
src/runtime/docker.test.ts | 51 +++++++++++++
src/runtime/docker.ts | 17 +++++
16 files changed, 607 insertions(+), 46 deletions(-)
create mode 100755 e2e/tests/140_env_passthrough.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f5631b8..c887e214 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feat — `--env` per-key environment passthrough on `jaiph run` and `jaiph mcp`:** A new repeatable **`--env`** flag lets a workflow receive a specific host variable — e.g. `GITHUB_TOKEN` or `MY_API_URL` — that the fail-closed Docker env allowlist (`ENV_ALLOW_PREFIXES`: `JAIPH_`, `ANTHROPIC_`, `CURSOR_`, `CLAUDE_`) would otherwise drop, with the flag itself standing in as the per-key user consent. **Two forms** (parsed in `parseArgs` / `parseEnvSpec`, `src/cli/shared/usage.ts`): `--env KEY=VALUE` defines `KEY` with that exact value (only the **first** `=` splits, so the value may contain `=`, and an empty value `KEY=` is allowed); bare `--env KEY` forwards the host's current value, resolved once at spawn time and **aborting before any process is spawned** with `E_ENV_MISSING` if `KEY` is unset on the host rather than silently dropping. `KEY` must match `[A-Za-z_][A-Za-z0-9_]*` or the run aborts with `E_ENV_INVALID`. **Semantics are uniform across every execution mode — `--env` defines the workflow process's env var, overriding inherited values.** In host modes (including `jaiph run --raw` and `jaiph mcp` tool calls) the pairs are applied to the runner env after `resolveRuntimeEnv` / `applySandboxFlags` (`Object.assign` in `src/cli/commands/run.ts`, `src/cli/mcp/call.ts`); in a Docker sandbox they are threaded through a new **`DockerSpawnOptions.extraEnv`** field into `buildDockerArgs` (`src/runtime/docker.ts`) and appended as explicit `-e KEY=VALUE` container args **bypassing `isEnvAllowed`**, with each key emitted **exactly once** — an `extraEnv` entry wins over the same key forwarded through the allowlist. Values cross **verbatim**: no path remapping is applied (that stays confined to the runtime-managed keys, which are rejected below). **Reserved keys are refused in both forms and all modes** with `E_ENV_RESERVED` (`isReservedEnvKey`): sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, anything `JAIPH_DOCKER_*`) and the runtime-managed keys `resolveRuntimeEnv` / `remapDockerEnv` own (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`) — use the sandbox flags (`--inplace` / `--unsafe`) or real env vars for those. On **`jaiph mcp`** the pairs (and the bare-form host lookup / `E_ENV_MISSING`) are resolved once at startup by `resolveEnvPairs` (`src/cli/run/env.ts`) and applied to **every** tool call's runner env for the server's lifetime; `extraEnv` is threaded through `McpCallEnvironment` as the single choke point so Docker-backed MCP calls, once they exist, flow through the same container path. `printUsage`, the per-command usage strings, and the examples gain the flag (`src/cli/shared/usage.ts`). Tests: `src/cli/shared/usage.test.ts` (parse: repeatable/ordered collection, both forms, `=` preserved in value, empty value, invalid name, reserved-key rejection per category, deferred bare-form host lookup), `src/runtime/docker.test.ts` (allowlist bypass, fail-closed default, single-emission de-dup), `src/cli/run/resolve-env.test.ts` (`E_ENV_MISSING`), `integration/mcp-server.test.ts`, `e2e/tests/140_env_passthrough.sh` (host `KEY=VALUE` / bare-forward legs, `E_ENV_MISSING` / `E_ENV_RESERVED` / `E_ENV_INVALID` aborts, and the Docker allowlist-bypass leg where Docker is available). Docs: `docs/cli.md` (`jaiph run` / `jaiph mcp` flag rows), `docs/env-vars.md`, `docs/sandboxing.md` (environment-exposure paragraph).
- **Feat — `jaiph mcp `: serve a file's workflows as MCP tools over stdio:** A new **`jaiph mcp`** subcommand (with **`jaiph --mcp `** as an ergonomic alias, dispatched alongside the subcommand in `src/cli/index.ts`) turns a `.jh` file into a [Model Context Protocol](https://modelcontextprotocol.io/) server so any MCP client (Claude Code, Claude Desktop, Cursor) can call the file's workflows as tools — `claude mcp add mytools -- jaiph mcp ./tools.jh`, no SDK project or build step. The transport is hand-rolled newline-delimited **JSON-RPC 2.0** over stdio with zero new dependencies (`src/cli/mcp/server.ts`, `McpServer`, injected `{serverVersion, getTools, callTool, write, log}`): `initialize` (version negotiation over `2024-11-05` / `2025-03-26` / `2025-06-18`, newest as fallback), `ping`, `tools/list`, `tools/call`, and `notifications/tools/list_changed` on hot reload; notifications are ignored, unknown methods are `-32601`, invalid JSON is `-32700` with `id: null`, and unknown tool / missing-or-non-string / unexpected argument are `-32602` (the call never starts) — while a **workflow failure is not a protocol error** but a normal result with `isError: true` and a `run dir:` pointer. **stdout carries only protocol JSON**; every banner, warning, exclusion notice, reload message, Docker notice, and credential-pre-flight warning goes to stderr, and compile diagnostics exit `1` with nothing on stdout. Tool derivation (`src/cli/mcp/tools.ts`, `deriveTools` / `toolNameFromFile`) runs over the entry file only: `export workflow` declarations if any exist, otherwise all top-level workflows minus channel route targets, with `default` exposed only when it is the sole candidate (named after the sanitized file basename); descriptions come from the `#` comment lines above each workflow (shebang lines dropped), and every parameter is a required string in the input schema. Calls execute on the host like `jaiph run --raw` (the Docker sandbox is not launched — a one-line stderr notice fires when the env would have enabled it), each as a durable `.jaiph/runs/` run with per-call run ids so concurrent calls are isolated; success text is the workflow's `return` value (`return_value.txt`) → `log` output → completion note (`src/cli/mcp/call.ts`, `src/cli/commands/mcp.ts`). Sources are watched (polling, ~750 ms): a valid edit re-derives tools and emits `notifications/tools/list_changed`, while an edit that fails to compile keeps the previous tool set serving. Two host-side runtime generalizations back this: `NodeWorkflowRuntime.runDefault(args)` is generalized to **`runRoot(workflowName, args)`** (same contract — emits `WORKFLOW_START`/`WORKFLOW_END` with the symbol, binds args to params by position, persists `return_value.txt` on success; `runDefault` delegates) and the runner dispatches any symbol via `runRoot` (`src/runtime/kernel/node-workflow-runtime.ts`, `node-workflow-runner.ts`); and a **launch-path fix** in `src/runtime/kernel/workflow-launch.ts` — `buildRunModuleLaunch` previously destructured the workflow symbol and then hardcoded `"default"` into the runner argv, so every spawned run executed `default` regardless of the requested symbol; it now passes `workflowSymbol || "default"` through, which also affects every `jaiph run`. `printUsage` gains a `jaiph mcp` line, section, and example (`src/cli/shared/usage.ts`). Tests: `src/cli/mcp/tools.test.ts`, `src/cli/mcp/server.test.ts`, `src/runtime/kernel/node-workflow-runtime.run-root.test.ts`, `src/runtime/kernel/workflow-launch.test.ts`, `integration/mcp-server.test.ts`. Docs: new `docs/mcp.md` (how-to), `docs/cli.md` (`jaiph mcp` reference section), `README.md`.
- **Feat — Config interpolation: `${identifier}` and bare-identifier sugar in `config { }` string values:** Workflow and module `config` blocks previously accepted only static string literals, booleans, and integers — `agent.default_model = model` was a parse error and `"${model}"` was stored literally with no runtime expansion. String config values now support the same interpolation model as orchestration strings: a **bare identifier** on the RHS is sugar for a single `${identifier}` reference (`agent.default_model = model` ≡ `agent.default_model = "${model}"`), and quoted strings may embed `${identifier}` anywhere in the value. **Module-level** config resolves references from module `const` values and environment variables at CLI startup (`resolveModuleMetadata` in `src/config.ts`, wired through `jaiph run` in `src/cli/commands/run.ts`). **Workflow-level** config additionally resolves that workflow's parameters; the runtime now binds workflow params before applying workflow metadata (`applyMetadataScope` in `src/runtime/kernel/node-workflow-runtime.ts` calls `interpolateWorkflowMetadata`). Compile-time validation (`src/transpile/validate-config.ts`) rejects unknown identifiers in interpolated config values. `agent.backend` enum checking is deferred when the value contains interpolation. Formatter round-trip preserves bare-identifier sugar (`src/format/emit.ts`). Tests: `src/parse/parse-metadata.test.ts`, `src/transpile/validate-config.test.ts`, `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts`, `e2e/tests/87_workflow_config.sh`. Docs: `docs/configuration.md`, `docs/grammar.md`.
- **Feature — Distro: native Windows smoke job in CI:** CI's only Windows coverage was `e2e-wsl`, which runs the suite inside WSL against the *Linux* binary. A new **`windows-native-smoke`** job (`.github/workflows/ci.yml`, `windows-latest`) proves the *native* `jaiph.exe` runs. It cross-compiles the standalone binary from the checkout (`bun build --compile --target=bun-windows-x64`, the same build as the release / `installer-powershell` legs) and runs **`e2e/tests/windows_native_smoke.ps1`** against it. The harness (a PowerShell acceptance test, same convention as `installer_powershell.ps1`, pointed at the binary via `JAIPH_TEST_WINDOWS_EXE`) covers three contracts, each failing when violated: (1) a **sample workflow** runs host-only (`JAIPH_UNSAFE=true`) exercising an inline shell line (through Git for Windows' `sh.exe`), a `script` step with a non-bash lang tag (` ```node `), `${…}` string interpolation, and `log` output — with assertions against the real `jaiph.exe` **stdout** (exit code `0` plus the interpolated `log` line and the `PASS` footer); (2) a **mid-run cancellation** records the workflow leader's descendant PID tree (`Win32_Process` parent/child links), delivers a real **Ctrl-C** isolated to the leader's own console (`AttachConsole` + `GenerateConsoleCtrlEvent`, so the CI runner shell is untouched), and fails if **any** descendant survives — exercising the win32 `taskkill /T /F` teardown path in `killProcessTree`; (3) a **`prompt`-step credential pre-flight** (`agent.backend = "codex"`, `OPENAI_API_KEY` unset, and `JAIPH_UNSAFE` dropped so the pre-flight runs — win32 forces host-only regardless) fails fast with the documented `E_AGENT_CREDENTIALS` error, bounded by a 30s `WaitForExit` so a hang is a failure, rather than calling any backend. The job never touches WSL — it shadows `wsl` so an accidental call throws — and now gates merge alongside `test`/`e2e`/`e2e-wsl` (added to `docker-publish`'s `needs`); `e2e-wsl` is left in place. Host-portable guards in `integration/windows-native-smoke.test.ts` pin the CI job shape and the harness contract (build command, stdout assertions, cancellation orphan check, pre-flight error, no-WSL, gate membership) so a regression fails `npm test` on any platform.
diff --git a/QUEUE.md b/QUEUE.md
index 4c725e45..34a919eb 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,34 +14,6 @@ Process rules:
***
-## ENV — `--env` passthrough into the workflow env and across the Docker sandbox boundary (`jaiph run` + `jaiph mcp`) #dev-ready
-
-The Docker sandbox forwards environment fail-closed: only the `ENV_ALLOW_PREFIXES` allowlist (`JAIPH_`, `ANTHROPIC_`, `CURSOR_`, `CLAUDE_`; see `src/runtime/docker.ts` — `isEnvAllowed`, consumed in the `buildDockerArgs` env loop) crosses into the container. There is no per-key escape hatch, so a workflow that needs e.g. `GITHUB_TOKEN` or `MY_API_URL` cannot receive it in a sandboxed run. Add an explicit, user-consented passthrough.
-
-Contract:
-
-- New repeatable flag on **`jaiph run`** and **`jaiph mcp`** (parsed in `parseArgs`, `src/cli/shared/usage.ts`):
- - `--env KEY=VALUE` — define `KEY` with that exact value (first `=` splits; value may contain `=`; empty value allowed).
- - `--env KEY` — forward the host's current value; if `KEY` is unset on the host, abort before spawning with a specific error (`E_ENV_MISSING`), never silently drop.
- - `KEY` must match `[A-Za-z_][A-Za-z0-9_]*`; anything else aborts with `E_ENV_INVALID`.
-- **Semantics: `--env` defines the workflow process's env var in every execution mode.**
- - Host (non-Docker, including `jaiph run --raw`-style spawns and `jaiph mcp` tool calls): applied to the runner env after `resolveRuntimeEnv`/`applySandboxFlags`, overriding inherited values.
- - Docker: appended as explicit `-e KEY=VALUE` container args **bypassing `isEnvAllowed`** — the flag is the per-key consent. Thread the pairs through `DockerSpawnOptions` (new field, e.g. `extraEnv: Record`) into `buildDockerArgs`; ensure a key appears once, with the `--env` value winning over an allowlist-forwarded host value.
-- **Reserved keys are rejected** (`E_ENV_RESERVED`, both flag forms, all modes): sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, anything `JAIPH_DOCKER_*`) and runtime-managed keys that `resolveRuntimeEnv`/`remapDockerEnv` own (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`). Use the sandbox flags (`--inplace`/`--unsafe`) or real env vars for control keys instead.
-- `jaiph mcp --env …` applies the pairs to **every** tool call's runner env for the server's lifetime (and, once Docker-backed MCP calls exist, they must flow through the same `extraEnv` container path — the field is the single choke point).
-- No remapping of `--env` values: they are passed verbatim (path remapping stays confined to the runtime-managed keys, which are rejected above).
-- Update `printUsage` (`src/cli/shared/usage.ts`) and per-command usage strings; document the flag and its sandbox-boundary meaning in `docs/cli.md`, `docs/env-vars.md`, and the env-exposure paragraph of `docs/sandboxing.md`.
-
-Acceptance:
-
-- `parseArgs` unit tests: repeatable `--env` collected in order, `KEY=VALUE` vs bare `KEY` forms, `=` inside value preserved, empty value, invalid name rejected, missing value for bare form deferred to spawn-time host lookup.
-- Unit test on `buildDockerArgs`: a key that fails `isEnvAllowed` (e.g. `MY_TOKEN`) appears as `-e MY_TOKEN=…` when supplied via `extraEnv`, and does NOT appear without it (fails if the allowlist bypass or the fail-closed default regresses). A key both allowlist-forwarded and in `extraEnv` appears exactly once with the `extraEnv` value.
-- Reserved-key rejection test per category (control key, runtime-managed key), both flag forms.
-- `--env KEY` with `KEY` unset on the host aborts with `E_ENV_MISSING` before any process is spawned.
-- Integration (host mode): `jaiph run --env GREETING=hi file.jh` where the workflow shells out `echo $GREETING` — output contains `hi`. Same via bare-forward form with the var exported on the host.
-- Integration (MCP): `jaiph mcp --env GREETING=hi tools.jh` — a `tools/call` whose workflow returns the env var yields `hi` in the result text on every call.
-- Docker integration leg (where CI allows Docker): a sandboxed `jaiph run --env MY_TOKEN=s3cret` workflow reads the var inside the container; without `--env` the same workflow sees it unset.
-
## MCP 5/8 — Docs: serving workflows over MCP #dev-ready
Design: `design/2026-07-14-mcp-server.md` → "Documentation" (and the rest of the doc for the contracts being documented). Documents the `jaiph mcp` subcommand as it exists in the tree.
diff --git a/docs/cli.md b/docs/cli.md
index 718133bd..bfbd38b4 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -46,7 +46,7 @@ The reserved internal marker `__workflow-runner` is excluded from `--help`/usage
Compile and execute a workflow's `default` entrypoint.
```text
-jaiph run [--target ] [--raw] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--] [args...]
+jaiph run [--target ] [--raw] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... [--] [args...]
```
Sandbox selection is environment-driven; there is no `--docker` flag. The boolean sandbox flags (`--inplace`, `--unsafe`, `--yes`) are CLI front-ends that mutate the launched runtime env for one run only — see [Configuration — Precedence](configuration.md#precedence) and [Environment variables](env-vars.md).
@@ -61,6 +61,7 @@ Sandbox selection is environment-driven; there is no `--docker` flag. The boolea
| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`. |
| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`. Cannot be combined with `--inplace` (`E_FLAG_CONFLICT`). |
| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1`. Required to use `--inplace` non-interactively. |
+| `--env` | `KEY=VALUE` or `KEY` | Repeatable per-key environment passthrough into the workflow process. `--env KEY=VALUE` defines `KEY` with that exact value (first `=` splits; the value may contain `=`; empty is allowed). `--env KEY` forwards the host's current value, aborting with `E_ENV_MISSING` before spawning if `KEY` is unset on the host. `KEY` must match `[A-Za-z_][A-Za-z0-9_]*` (else `E_ENV_INVALID`). Reserved sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, any `JAIPH_DOCKER_*`) and runtime-managed keys (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`) are rejected with `E_ENV_RESERVED` — use the sandbox flags or real env vars for those. **In a Docker sandbox `--env` is the per-key consent that crosses the fail-closed env allowlist verbatim** (added as explicit `-e KEY=VALUE` container args, winning over any allowlist-forwarded value); see [Sandboxing — Environment exposure](sandboxing.md#env-exposure). Values are never path-remapped. |
| `--` | — | End of Jaiph flags; remaining tokens are forwarded to `workflow default`. |
### Pre-flight
@@ -276,7 +277,7 @@ Implementation: re-invokes `JAIPH_INSTALL_COMMAND` (default `curl -fsSL https://
Serve a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio. See [Serve workflows as MCP tools](/how-to/mcp) for the recipe and client-registration steps.
```text
-jaiph mcp [--workspace ]
+jaiph mcp [--workspace ] [--env KEY[=VALUE]]...
```
`jaiph --mcp ` is an equivalent alias, dispatched after `compile` in `src/cli/index.ts`.
@@ -284,6 +285,7 @@ jaiph mcp [--workspace ]
| Flag | Argument | Effect |
|---|---|---|
| `--workspace` | `` | Workspace root for import resolution (default: auto-detected from the file's directory). A missing value or non-directory path aborts with a specific message. |
+| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env` (same forms, validation, and reserved-key rejection), resolved once at startup and applied to **every** tool call's runner env for the server's lifetime. A bare `--env KEY` unset on the host aborts server startup with `E_ENV_MISSING`. MCP calls run on the host today; once Docker-backed calls exist the pairs flow through the same container passthrough. |
| `-h`, `--help` | — | Print the subcommand usage and exit `0`. |
### Startup and exit behaviour
diff --git a/docs/env-vars.md b/docs/env-vars.md
index 42927c27..37f71b13 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -98,6 +98,8 @@ The host CLI checks these before spawning the runner or container when [credenti
Forwarding allowlist prefixes into the Docker container: `JAIPH_*` (except `JAIPH_DOCKER_*`, `JAIPH_INPLACE`, and `JAIPH_INPLACE_YES`), `ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`. Everything else — including `OPENAI_*` — is silently dropped — see [Sandboxing](sandboxing.md).
+To forward a variable outside the allowlist (for example `GITHUB_TOKEN` or `OPENAI_API_KEY`) into a specific run, use the per-key **`--env`** flag on `jaiph run` / `jaiph mcp`: `--env KEY=VALUE` sets an exact value and `--env KEY` forwards the host's current value. In host mode `--env` defines the variable on the workflow process directly; in a Docker sandbox it crosses the boundary verbatim as an explicit `-e KEY=VALUE` container arg **bypassing the allowlist above** (the flag is the per-key consent), winning over any allowlist-forwarded value for the same key. A bare `--env KEY` unset on the host aborts with `E_ENV_MISSING` before anything is spawned; invalid names give `E_ENV_INVALID`; and the sandbox-control / runtime-managed keys the CLI owns (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, any `JAIPH_DOCKER_*`, `JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`) are rejected with `E_ENV_RESERVED` — use the sandbox flags or real env vars for those. Values are never path-remapped. See [CLI — `jaiph run` flags](cli.md#jaiph-run).
+
## Installer and `jaiph use`
These variables are consumed by `docs/install` (the installer shell script) and by `jaiph use` when it re-invokes the installer. They are **not** read from inside the Jaiph TypeScript source.
diff --git a/docs/sandboxing.md b/docs/sandboxing.md
index c1523b4a..f15df683 100644
--- a/docs/sandboxing.md
+++ b/docs/sandboxing.md
@@ -51,7 +51,8 @@ The Docker sandbox is designed to contain damage from untrusted or semi-trusted
- **Filesystem reach** — scripts inside the container cannot read or write arbitrary host paths outside the workspace mount and the run-artifacts mount. The rest of the host is invisible to the container. Overlay and copy modes additionally make the workspace itself non-persistent.
- **Process isolation** — container processes cannot see or signal host processes. Every sandboxed container runs with `--cap-drop ALL` and `--security-opt no-new-privileges`. Overlay mode adds back a small set of capabilities required to mount `fuse-overlayfs` and then drop privileges; copy and inplace modes do not add any back.
- **Mount safety** — the host root filesystem, the Docker daemon socket, and OS-internal paths (`/proc`, `/sys`, `/dev`) cannot be mounted into the container. Attempting to do so produces a validation error before launch.
-- **Environment exposure** — host environment variables do not cross the boundary by default. Only an explicit prefix allowlist (`JAIPH_*`, `ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`, with `JAIPH_DOCKER_*` and the inplace-control flags excluded) is forwarded. Every other variable is dropped, including unrelated cloud credentials, SSH agents, and registry tokens.
+- **Environment exposure** — host environment variables do not cross the boundary by default. Only an explicit prefix allowlist (`JAIPH_*`, `ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`, with `JAIPH_DOCKER_*` and the inplace-control flags excluded) is forwarded. Every other variable is dropped, including unrelated cloud credentials, SSH agents, and registry tokens. The per-key escape hatch is **`--env`** (`jaiph run` / `jaiph mcp`): `--env KEY=VALUE` or `--env KEY` (forward the host value) crosses that variable into the workflow verbatim as an explicit `-e KEY=VALUE` container arg **bypassing the allowlist** — the flag *is* the consent — and wins over any allowlist-forwarded value for the same key. Sandbox-control and runtime-managed keys are rejected (`E_ENV_RESERVED`); values are never path-remapped. See [CLI — `jaiph run` flags](cli.md#jaiph-run).
+{: #env-exposure}
- **Shell injection safety** — every `docker` invocation passes an explicit argv array (`execFileSync` or `spawn`), never `/bin/sh`. Image names and other parameters are passed as literal arguments, so values containing shell metacharacters are never expanded.
## What Docker does **not** protect against
diff --git a/e2e/tests/140_env_passthrough.sh b/e2e/tests/140_env_passthrough.sh
new file mode 100755
index 00000000..e6a5bf47
--- /dev/null
+++ b/e2e/tests/140_env_passthrough.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+
+# Contract: `jaiph run --env` defines the workflow process's env var in every
+# execution mode. Host mode applies the pairs to the runner env; Docker forwards
+# them as explicit `-e KEY=VALUE` container args that bypass the fail-closed
+# ENV_ALLOW_PREFIXES allowlist. Bare `--env KEY` forwards the host value, and a
+# host-unset bare key aborts with E_ENV_MISSING before any process is spawned.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+source "${ROOT_DIR}/e2e/lib/common.sh"
+trap e2e::cleanup EXIT
+
+e2e::prepare_test_env "env_passthrough"
+TEST_DIR="${JAIPH_E2E_TEST_DIR}"
+
+# A workflow that returns whatever GREETING the workflow process sees.
+e2e::file "env_show.jh" <<'EOF'
+script show_impl = `echo "$GREETING"`
+workflow default() {
+ const g = run show_impl()
+ return "${g}"
+}
+EOF
+
+e2e::section "host mode — --env KEY=VALUE defines the var"
+
+# Ensure the host has no inherited GREETING: the value can only come from --env.
+unset GREETING || true
+host_out="$(JAIPH_DOCKER_ENABLED=false jaiph run --env GREETING=hi "${TEST_DIR}/env_show.jh")"
+e2e::expect_stdout "${host_out}" <<'EOF'
+
+Jaiph: Running env_show.jh
+
+workflow default
+ ▸ script show_impl
+ ✓ script show_impl (