javi-forge 1.16.0 → 1.17.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.
@@ -900,6 +900,44 @@ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
900
900
  * `onOutcome` (optional) receives each gate's structured result for the headless
901
901
  * JSON run path.
902
902
  */
903
+ /**
904
+ * Route a single gate command to its execution path and normalize the result to
905
+ * the EXACT `GateRunResult` shape the collector consumes, so the JSON/reason
906
+ * semantics are byte-identical for native and containerized gates.
907
+ *
908
+ * - `gate.image === undefined` → `runGateNative` (UNCHANGED), fed the full host
909
+ * env MAP (`nativeEnv`). A spawn env map never lands in argv, so the host env
910
+ * is safe there.
911
+ * - `gate.image !== undefined` → `runInContainer`, fed ONLY `containerEnv` — the
912
+ * EXPLICIT ALLOWLIST (`CI` + injected JAVI_FORGE_* + gate.env), NEVER
913
+ * `process.env` (JDB-001: no host secret in the `-e` argv / `ps aux`).
914
+ *
915
+ * The container's `timedOut` flag is normalized to `GATE_TIMEOUT_EXIT_CODE` (124)
916
+ * HERE — the one place gate semantics live — so `docker.ts` stays gate-agnostic
917
+ * and the collector's existing `timeoutReason` branch fires identically.
918
+ *
919
+ * NOTE: fail-closed (image gate + no Docker → refuse) is slice 3. In slice 2 an
920
+ * image gate always routes to the container when reached.
921
+ */
922
+ async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
923
+ if (gate.image === undefined) {
924
+ return await runGateNative(cmd, projectDir, nativeEnv, gate.timeout);
925
+ }
926
+ const result = await runInContainer({
927
+ projectDir,
928
+ image: gate.image,
929
+ // Gates run at the mount root (native gates run at the repo root).
930
+ command: `cd /home/runner/work && ${cmd}`,
931
+ timeout: gate.timeout, // undefined ⇒ unbounded (docker.ts gate 7)
932
+ env: containerEnv,
933
+ stream: true,
934
+ });
935
+ // Enforce the native invariant in ONE place: timedOut ⇒ 124.
936
+ return {
937
+ code: result.timedOut ? GATE_TIMEOUT_EXIT_CODE : result.exitCode,
938
+ timedOut: result.timedOut,
939
+ };
940
+ }
903
941
  async function runGates(gates, projectDir, onStep, onOutcome) {
904
942
  if (gates.length === 0)
905
943
  return;
@@ -951,8 +989,17 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
951
989
  ...extra,
952
990
  });
953
991
  report(onStep, stepId, label, "running");
954
- // Per-gate env map: engine keys, then baseline, then gate.env LAST (last-wins).
955
- const gateEnv = { ...baseEnv };
992
+ // Per-gate env: build the INJECTED allowlist (engine keys + baseline) once,
993
+ // then split into two maps (JDB-001):
994
+ // - nativeEnv: full host env + injected + gate.env — a spawn env MAP (never
995
+ // argv), so the host env is safe there.
996
+ // - containerEnv: EXPLICIT ALLOWLIST ONLY (CI + injected + gate.env), NEVER
997
+ // process.env — every entry becomes a `-e KEY=VALUE` argv element, so
998
+ // forwarding process.env would leak host secrets to `ps aux` and defeat
999
+ // the container's isolation.
1000
+ // Gate env spreads LAST in BOTH — a gate MAY override CI / CHANGED_FILES /
1001
+ // BASELINE (documented last-wins).
1002
+ const injected = {};
956
1003
  let gateChangedFiles;
957
1004
  if (gate.scope === GATE_SCOPE.CHANGED) {
958
1005
  const scope = await resolveChangedScope();
@@ -970,15 +1017,22 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
970
1017
  continue;
971
1018
  }
972
1019
  gateChangedFiles = scope.files;
973
- gateEnv[CHANGED_FILES_ENV] = scope.files.join("\n");
1020
+ injected[CHANGED_FILES_ENV] = scope.files.join("\n");
974
1021
  }
975
1022
  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);
1023
+ injected[BASELINE_ENV] = gate.baseline;
981
1024
  }
1025
+ const gateOverrides = gate.env ?? {};
1026
+ const nativeEnv = {
1027
+ ...baseEnv,
1028
+ ...injected,
1029
+ ...gateOverrides,
1030
+ };
1031
+ const containerEnv = {
1032
+ CI: "true",
1033
+ ...injected,
1034
+ ...gateOverrides,
1035
+ };
982
1036
  let exitCode = 0;
983
1037
  let timedOut = false;
984
1038
  let spawnError;
@@ -987,7 +1041,7 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
987
1041
  // timeout is per-command (matches the fail-fast model): each command
988
1042
  // gets its own wall-clock budget. A timed-out command is killed and
989
1043
  // resolves non-zero, so fail-fast stops the gate here.
990
- const result = await runGateNative(cmd, projectDir, gateEnv, gate.timeout);
1044
+ const result = await runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv);
991
1045
  exitCode = result.code;
992
1046
  timedOut = result.timedOut;
993
1047
  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.17.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {