javi-forge 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -316,6 +316,18 @@ function describeRunners(resolved) {
316
316
  }
317
317
  export async function runCI(options, onStep, onGateOutcome) {
318
318
  const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
319
+ // ── Run-scoped Docker availability (lazy-memoized) ─────────────────────────
320
+ // Computed at most ONCE per run and only when an image gate needs it. The
321
+ // full/quick prologue below assigns its own `isDockerAvailable()` result back
322
+ // into this cache so `runGates` never re-probes; the gates-only path leaves it
323
+ // undefined until an image gate lazily triggers the probe (a native-only
324
+ // gates-only repo never touches Docker — behaves exactly as before slice 3).
325
+ let dockerAvailableCache;
326
+ const dockerAvailable = async () => (dockerAvailableCache ??= await isDockerAvailable());
327
+ const dockerGate = {
328
+ noDocker,
329
+ isAvailable: dockerAvailable,
330
+ };
319
331
  // ── Resolve runners (once — nothing downstream re-detects) ─────────────────
320
332
  const stepDetect = "detect";
321
333
  report(onStep, stepDetect, "Detecting stack", "running");
@@ -343,7 +355,7 @@ export async function runCI(options, onStep, onGateOutcome) {
343
355
  report(onStep, mode, `${mode} mode`, "error", detail);
344
356
  throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
345
357
  }
346
- await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
358
+ await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
347
359
  return;
348
360
  }
349
361
  // Legacy single-runner view for the zero-config auto path. Keeping this
@@ -409,6 +421,9 @@ export async function runCI(options, onStep, onGateOutcome) {
409
421
  const stepDocker = "docker-check";
410
422
  report(onStep, stepDocker, "Checking Docker", "running");
411
423
  const dockerOk = await isDockerAvailable();
424
+ // Reuse this result for the gate fail-closed check — `runGates` must not
425
+ // run a second `docker info` (memoization seam).
426
+ dockerAvailableCache = dockerOk;
412
427
  if (!dockerOk) {
413
428
  report(onStep, stepDocker, "Docker not available", "error", "Start Docker or use --no-docker");
414
429
  throw new Error("Docker is not available");
@@ -515,7 +530,7 @@ export async function runCI(options, onStep, onGateOutcome) {
515
530
  // already `full` or `quick` here; the guard makes the contract explicit.
516
531
  // `runGates` no-ops on an empty gate list (a v1 repo carries none).
517
532
  if (mode === "full" || mode === "quick") {
518
- await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
533
+ await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
519
534
  }
520
535
  }
521
536
  // =============================================================================
@@ -900,7 +915,48 @@ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
900
915
  * `onOutcome` (optional) receives each gate's structured result for the headless
901
916
  * JSON run path.
902
917
  */
903
- async function runGates(gates, projectDir, onStep, onOutcome) {
918
+ /**
919
+ * Route a single gate command to its execution path and normalize the result to
920
+ * the EXACT `GateRunResult` shape the collector consumes, so the JSON/reason
921
+ * semantics are byte-identical for native and containerized gates.
922
+ *
923
+ * - `gate.image === undefined` → `runGateNative` (UNCHANGED), fed the full host
924
+ * env MAP (`nativeEnv`). A spawn env map never lands in argv, so the host env
925
+ * is safe there.
926
+ * - `gate.image !== undefined` → `runInContainer`, fed ONLY `containerEnv` — the
927
+ * EXPLICIT ALLOWLIST (`CI` + injected JAVI_FORGE_* + gate.env), NEVER
928
+ * `process.env` (JDB-001: no host secret in the `-e` argv / `ps aux`).
929
+ *
930
+ * The container's `timedOut` flag is normalized to `GATE_TIMEOUT_EXIT_CODE` (124)
931
+ * HERE — the one place gate semantics live — so `docker.ts` stays gate-agnostic
932
+ * and the collector's existing `timeoutReason` branch fires identically.
933
+ *
934
+ * NOTE: fail-closed (image gate + no Docker → refuse) is slice 3. In slice 2 an
935
+ * image gate always routes to the container when reached.
936
+ */
937
+ async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
938
+ if (gate.image === undefined) {
939
+ return await runGateNative(cmd, projectDir, nativeEnv, gate.timeout);
940
+ }
941
+ const result = await runInContainer({
942
+ projectDir,
943
+ image: gate.image,
944
+ // Gates run at the mount root (native gates run at the repo root).
945
+ command: `cd /home/runner/work && ${cmd}`,
946
+ timeout: gate.timeout, // undefined ⇒ unbounded (docker.ts gate 7)
947
+ env: containerEnv,
948
+ stream: true,
949
+ });
950
+ // Enforce the native invariant in ONE place: timedOut ⇒ 124.
951
+ return {
952
+ code: result.timedOut ? GATE_TIMEOUT_EXIT_CODE : result.exitCode,
953
+ timedOut: result.timedOut,
954
+ };
955
+ }
956
+ async function runGates(gates, projectDir, onStep,
957
+ // REQUIRED, so it MUST precede the optional `onOutcome` (TS1016: a required
958
+ // parameter cannot follow an optional one). Both call sites pass it positionally.
959
+ docker, onOutcome) {
904
960
  if (gates.length === 0)
905
961
  return;
906
962
  const blockingFailures = [];
@@ -951,8 +1007,44 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
951
1007
  ...extra,
952
1008
  });
953
1009
  report(onStep, stepId, label, "running");
954
- // Per-gate env map: engine keys, then baseline, then gate.env LAST (last-wins).
955
- const gateEnv = { ...baseEnv };
1010
+ // Fail-closed matrix (slice 3): an image gate that cannot reach Docker is
1011
+ // REFUSED it MUST NOT fall through to native/unpinned execution and MUST
1012
+ // NOT be silently skipped/passed. Blocking → build failure (feeds the
1013
+ // aggregate throw); informative → `warning` (never a false-green). A gate
1014
+ // WITHOUT `image` is unaffected and runs native regardless of --no-docker.
1015
+ //
1016
+ // `isAvailable()` is touched ONLY for an image gate under Docker: the
1017
+ // `noDocker` short-circuit means an image-less set (or a --no-docker run)
1018
+ // never shells out to `docker info`, keeping the native path zero-cost.
1019
+ if (gate.image !== undefined) {
1020
+ if (docker.noDocker || !(await docker.isAvailable())) {
1021
+ const why = docker.noDocker
1022
+ ? "--no-docker set"
1023
+ : "Docker not available";
1024
+ const reason = `gate "${gate.id}" requires image "${gate.image}" but ${why} — refusing (never runs native/unpinned)`;
1025
+ if (blocking) {
1026
+ blockingFailures.push(gate.id);
1027
+ report(onStep, stepId, `${label} failed`, "error", reason);
1028
+ emit("error", { reason });
1029
+ }
1030
+ else {
1031
+ report(onStep, stepId, `${label} failed (informative)`, "warning", reason);
1032
+ emit("warning", { reason });
1033
+ }
1034
+ continue; // NEVER falls through to native execution.
1035
+ }
1036
+ }
1037
+ // Per-gate env: build the INJECTED allowlist (engine keys + baseline) once,
1038
+ // then split into two maps (JDB-001):
1039
+ // - nativeEnv: full host env + injected + gate.env — a spawn env MAP (never
1040
+ // argv), so the host env is safe there.
1041
+ // - containerEnv: EXPLICIT ALLOWLIST ONLY (CI + injected + gate.env), NEVER
1042
+ // process.env — every entry becomes a `-e KEY=VALUE` argv element, so
1043
+ // forwarding process.env would leak host secrets to `ps aux` and defeat
1044
+ // the container's isolation.
1045
+ // Gate env spreads LAST in BOTH — a gate MAY override CI / CHANGED_FILES /
1046
+ // BASELINE (documented last-wins).
1047
+ const injected = {};
956
1048
  let gateChangedFiles;
957
1049
  if (gate.scope === GATE_SCOPE.CHANGED) {
958
1050
  const scope = await resolveChangedScope();
@@ -970,15 +1062,22 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
970
1062
  continue;
971
1063
  }
972
1064
  gateChangedFiles = scope.files;
973
- gateEnv[CHANGED_FILES_ENV] = scope.files.join("\n");
1065
+ injected[CHANGED_FILES_ENV] = scope.files.join("\n");
974
1066
  }
975
1067
  if (gate.baseline !== undefined) {
976
- gateEnv[BASELINE_ENV] = gate.baseline;
977
- }
978
- if (gate.env !== undefined) {
979
- // Gate env spreads LAST — a gate MAY override CI / CHANGED_FILES / BASELINE.
980
- Object.assign(gateEnv, gate.env);
1068
+ injected[BASELINE_ENV] = gate.baseline;
981
1069
  }
1070
+ const gateOverrides = gate.env ?? {};
1071
+ const nativeEnv = {
1072
+ ...baseEnv,
1073
+ ...injected,
1074
+ ...gateOverrides,
1075
+ };
1076
+ const containerEnv = {
1077
+ CI: "true",
1078
+ ...injected,
1079
+ ...gateOverrides,
1080
+ };
982
1081
  let exitCode = 0;
983
1082
  let timedOut = false;
984
1083
  let spawnError;
@@ -987,7 +1086,7 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
987
1086
  // timeout is per-command (matches the fail-fast model): each command
988
1087
  // gets its own wall-clock budget. A timed-out command is killed and
989
1088
  // resolves non-zero, so fail-fast stops the gate here.
990
- const result = await runGateNative(cmd, projectDir, gateEnv, gate.timeout);
1089
+ const result = await runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv);
991
1090
  exitCode = result.code;
992
1091
  timedOut = result.timedOut;
993
1092
  if (exitCode !== 0)
@@ -10,17 +10,35 @@ export interface DockerRunOptions {
10
10
  image: string;
11
11
  /** Command to run inside the container */
12
12
  command: string;
13
- /** Timeout in seconds (default: 600) */
13
+ /**
14
+ * Wall-clock timeout in seconds, enforced HOST-SIDE (a host timer → `docker
15
+ * stop`), NOT by an in-container `timeout` binary. Omitted → the container
16
+ * runs UNBOUNDED (no timer armed) — there is no silent default cap.
17
+ */
14
18
  timeout?: number;
15
19
  /** Stream output to stdout/stderr (default: true) */
16
20
  stream?: boolean;
17
21
  /** Override the user to run as inside the container (default: runner) */
18
22
  user?: string;
23
+ /**
24
+ * Extra env vars injected as discrete `-e KEY=VALUE` argv elements (one per
25
+ * entry), NEVER shell-spliced. Values with spaces/`=`/newlines survive
26
+ * verbatim (argv, not a shell string); Docker splits only on the first `=`.
27
+ * A caller passing no map yields the same argv as today (only `-e CI=true`).
28
+ */
29
+ env?: Record<string, string>;
19
30
  }
20
31
  export interface DockerRunResult {
21
32
  exitCode: number;
22
33
  stdout: string;
23
34
  stderr: string;
35
+ /**
36
+ * `true` IFF the host wall-clock timer fired and terminated the container —
37
+ * set DETERMINISTICALLY by the host BEFORE the kill, never inferred from a
38
+ * raw 124 exit code. Lets a caller tell a real timeout apart from a command
39
+ * that itself exits 124 (both surface non-zero, only one is a timeout).
40
+ */
41
+ timedOut: boolean;
24
42
  }
25
43
  export interface DockerImageOptions {
26
44
  stack: Stack;
@@ -183,7 +183,7 @@ export async function ensureImage(options) {
183
183
  * Streams output to process.stdout/stderr by default.
184
184
  */
185
185
  export async function runInContainer(options) {
186
- const { projectDir, image, command, timeout = 600, stream = true, user, } = options;
186
+ const { projectDir, image, command, timeout, stream = true, user, env, } = options;
187
187
  // The image is always pre-resolved by the caller (resolveCIRunners →
188
188
  // ensureImage or an explicit/digest-pinned config image). No marker
189
189
  // detection happens on this path — ever.
@@ -203,14 +203,33 @@ export async function runInContainer(options) {
203
203
  const gid = process.getgid?.();
204
204
  const runAsUser = user ??
205
205
  (uid !== undefined && gid !== undefined ? `${uid}:${gid}` : undefined);
206
+ // Env injection (containerized-gates gate 2): CI=true FIRST, then one
207
+ // discrete `-e KEY=VALUE` argv pair per caller entry. These are argv
208
+ // elements handed to spawn (no shell), so values with spaces/`=`/newlines
209
+ // survive verbatim — Docker splits only on the first `=`. A caller passing
210
+ // no map yields the same argv as before (only `-e CI=true`).
211
+ const envArgs = ["-e", "CI=true"];
212
+ for (const [k, v] of Object.entries(env ?? {})) {
213
+ envArgs.push("-e", `${k}=${v}`);
214
+ }
215
+ // A unique container name so the HOST-SIDE timeout can `docker stop <cid>`
216
+ // a concrete target (design gate 1) rather than guessing.
217
+ const cid = `javi-forge-ci-${crypto.randomBytes(6).toString("hex")}`;
206
218
  // Use --mount instead of -v: the -v form parses the value as a single
207
219
  // "src:dst[:opt]" colon-separated string, which breaks (and could be
208
220
  // hijacked) when projectDir itself contains a colon. --mount takes
209
221
  // comma-separated key=value pairs and is colon-safe.
222
+ //
223
+ // NOTE (containerized-gates gate 1): the in-container `timeout <N>` wrapper
224
+ // is REMOVED. The timeout is enforced host-side below (host wall-clock timer
225
+ // → `docker stop`), so the container command is just `bash -c <cmd>`. This
226
+ // mirrors runGateNative (ci.ts) one level up, at the docker-run process.
210
227
  const dockerArgs = [
211
228
  "run",
212
229
  "--rm",
213
230
  ...(isInteractive ? ["-it"] : []),
231
+ "--name",
232
+ cid,
214
233
  "--stop-timeout",
215
234
  "30",
216
235
  "--entrypoint",
@@ -218,11 +237,8 @@ export async function runInContainer(options) {
218
237
  ...(runAsUser ? ["--user", runAsUser] : []),
219
238
  "--mount",
220
239
  `type=bind,source=${projectDir},target=/home/runner/work`,
221
- "-e",
222
- "CI=true",
240
+ ...envArgs,
223
241
  imageName,
224
- "timeout",
225
- String(timeout),
226
242
  "bash",
227
243
  "-c",
228
244
  command,
@@ -233,6 +249,40 @@ export async function runInContainer(options) {
233
249
  });
234
250
  let stdout = "";
235
251
  let stderr = "";
252
+ // HOST-SIDE timeout (design gate 1): arm a host wall-clock timer ONLY when
253
+ // a timeout is provided. When it fires, set `timedOut = true` BEFORE the
254
+ // kill (authoritative — never inferred from the exit code), then
255
+ // `docker stop -t <grace> <cid>` (SIGTERM → SIGKILL after grace) to tear
256
+ // the CONTAINER down by name — NOT `proc.kill()` on the client, which would
257
+ // orphan the container. A `backstopTimer` SIGKILLs the docker-run CLIENT
258
+ // only as a LAST RESORT: if `docker stop` itself wedges (dead daemon) the
259
+ // client would never `close` and the gate would hang forever. `--rm` +
260
+ // `--name <cid>` keep any orphan bounded and identifiable. No timeout →
261
+ // no timer → the container runs UNBOUNDED (no silent cap).
262
+ let killTimer;
263
+ let backstopTimer;
264
+ let timedOut = false;
265
+ const clearTimers = () => {
266
+ if (killTimer !== undefined)
267
+ clearTimeout(killTimer);
268
+ if (backstopTimer !== undefined)
269
+ clearTimeout(backstopTimer);
270
+ };
271
+ if (timeout !== undefined) {
272
+ killTimer = setTimeout(() => {
273
+ timedOut = true;
274
+ // Fire-and-forget teardown: swallow spawn errors (e.g. the docker
275
+ // binary vanished mid-run) so an unhandled 'error' event can't crash
276
+ // the process. The armed backstopTimer still guarantees the run
277
+ // promise resolves via the client SIGKILL. (jd A+B convergent finding)
278
+ spawn("docker", ["stop", "-t", String(DOCKER_STOP_GRACE_SEC), cid], {
279
+ stdio: "ignore",
280
+ }).on("error", () => { });
281
+ backstopTimer = setTimeout(() => {
282
+ proc.kill("SIGKILL");
283
+ }, (DOCKER_STOP_GRACE_SEC + 1) * 1000);
284
+ }, timeout * 1000);
285
+ }
236
286
  if (!stream) {
237
287
  proc.stdout?.on("data", (d) => {
238
288
  stdout += d.toString();
@@ -241,10 +291,22 @@ export async function runInContainer(options) {
241
291
  stderr += d.toString();
242
292
  });
243
293
  }
244
- proc.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
245
- proc.on("error", reject);
294
+ proc.on("close", (code) => {
295
+ clearTimers();
296
+ resolve({ exitCode: code ?? 1, stdout, stderr, timedOut });
297
+ });
298
+ proc.on("error", (e) => {
299
+ clearTimers();
300
+ reject(e);
301
+ });
246
302
  });
247
303
  }
304
+ /**
305
+ * Grace (seconds) passed to `docker stop -t` between the container SIGTERM and
306
+ * the daemon's escalated SIGKILL. The client-side backstop fires one second
307
+ * after this window to guarantee the run promise always resolves.
308
+ */
309
+ const DOCKER_STOP_GRACE_SEC = 10;
248
310
  /**
249
311
  * Open an interactive shell inside the CI container.
250
312
  * The image must be pre-resolved by the caller (no marker detection here).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {