forgedock 1.5.1 → 1.7.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.
package/bin/runner.mjs CHANGED
@@ -47,9 +47,10 @@ import {
47
47
  rmSync,
48
48
  realpathSync,
49
49
  } from "fs";
50
- import { join, dirname, basename, relative, isAbsolute } from "path";
50
+ import { join, dirname, basename, relative, isAbsolute, extname, win32 as win32Path } from "path";
51
51
  import os from "os";
52
52
  import { execSync, spawnSync } from "child_process";
53
+ import { createRequire } from "module";
53
54
  import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
54
55
  import { DEFAULT_SPAWN_MAX_BUFFER_BYTES } from "./cli-spawn-shared.mjs";
55
56
 
@@ -70,6 +71,7 @@ const DEFAULT_CLI_TIMEOUT_MS = 15 * 60 * 1000;
70
71
  // it is far shorter than DEFAULT_CLI_TIMEOUT_MS. Mirrors the timeout already
71
72
  // used by the `claude --version` doctor check in bin/forgedock.mjs.
72
73
  const CLI_PROBE_TIMEOUT_MS = 5000;
74
+ export const CLI_PROBE_OUTPUT_SENTINEL = "__FORGEDOCK_CLI_PATHS_END__";
73
75
  /**
74
76
  * Wraps a Set in a Proxy that blocks add()/delete()/clear(), so the
75
77
  * returned collection is genuinely read-only — not just Object.frozen.
@@ -143,11 +145,20 @@ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
143
145
  // evict the oldest (insertion-order, via `Map`'s iteration order and a
144
146
  // delete+re-set on refresh) so a process that probes an unbounded
145
147
  // number of distinct `cwd` values cannot grow this cache without limit.
146
- // Each entry stores `{ available, cachedAt }` instead of a bare boolean so
147
- // `isClaudeCliAvailable()` can evaluate the TTL.
148
+ // Each entry stores `{ available, cliPath, cachedAt }` instead of a bare
149
+ // boolean so `isClaudeCliAvailable()` can evaluate the TTL, and so the
150
+ // *same* resolved absolute executable path the probe validated can be
151
+ // reused by `runCliBackend()`'s production invocation (issue #2741) — see
152
+ // `resolveClaudeCliBinary()` below. `cliPath` is `null` when unavailable or
153
+ // when the probe succeeded but no path-like line could be parsed out of it
154
+ // (e.g. an injected test `execImpl` that returns a bare version string).
148
155
  const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
149
156
  const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
150
157
  const cliAvailabilityCache = new Map();
158
+ // Windows path types emitted by npm's shim generator. Native executables are
159
+ // directly spawnable; .cmd/.bat files must be resolved to their trusted native
160
+ // target before spawning because Node cannot exec them with shell:false.
161
+ const WINDOWS_SPAWNABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".com"]);
151
162
  // Cap tool-result payloads so a large file read or verbose command does not
152
163
  // blow the context window in a single turn.
153
164
  const MAX_TOOL_RESULT_CHARS = 100_000;
@@ -203,21 +214,144 @@ export function resolveConfiguredDefaultModel(cwd) {
203
214
  // Backend selection (issue #2003) — CLI vs API execution backend
204
215
  // ---------------------------------------------------------------------------
205
216
 
217
+ /**
218
+ * Read path candidates from the explicitly delimited resolver section of the
219
+ * combined CLI probe. Version output is untrusted for parsing purposes: update
220
+ * notices can contain path-like URLs or filenames, so shape-based inference
221
+ * must never allow lines after the sentinel to become executable candidates.
222
+ *
223
+ * Pure/side-effect-free so it can be unit tested directly against
224
+ * synthetic multi-line `where` output without spawning anything.
225
+ *
226
+ * @param {string} raw
227
+ * @returns {string[]} Leading path-like lines, in original order.
228
+ */
229
+ export function parseProbeOutput(raw) {
230
+ const lines = String(raw ?? "")
231
+ .split(/\r?\n/)
232
+ .map((line) => line.trim())
233
+ .filter(Boolean);
234
+ const boundary = lines.indexOf(CLI_PROBE_OUTPUT_SENTINEL);
235
+ if (boundary < 0) return [];
236
+ return lines
237
+ .slice(0, boundary)
238
+ .filter((line) => line.includes("/") || line.includes("\\"));
239
+ }
240
+
241
+ /**
242
+ * Pick the best resolved `claude` executable path out of the candidate
243
+ * path-like lines produced by `parseProbeOutput()` (issue #2741).
244
+ *
245
+ * On Windows, prefers a candidate with a recognized spawnable extension
246
+ * (`WINDOWS_SPAWNABLE_EXTENSIONS`) over an extensionless one — `where`
247
+ * matches bare filenames as well as PATHEXT-suffixed ones, and an
248
+ * npx-transient shim directory can contain both an extensionless POSIX
249
+ * shell script and a `.cmd`/`.ps1` sibling generated by npm's cross-platform
250
+ * shim generator. If every candidate is extensionless, checks the same
251
+ * directory for a `.cmd`/`.exe`/`.bat`/`.com` sibling with the same base
252
+ * name before giving up and returning the bare match as-is.
253
+ *
254
+ * On POSIX, `command -v` returns a single resolvable path directly usable
255
+ * by `spawnSync(shell:false)` (POSIX `execve` honors the shebang line), so
256
+ * no extension preference is needed.
257
+ *
258
+ * @param {string[]} pathLines
259
+ * @returns {string|null}
260
+ */
261
+ export function selectResolvedCliPath(
262
+ pathLines,
263
+ { platform = process.platform, existsImpl = existsSync } = {},
264
+ ) {
265
+ if (pathLines.length === 0) return null;
266
+ if (platform !== "win32") return pathLines[0];
267
+
268
+ const withExt = pathLines.find((line) =>
269
+ WINDOWS_SPAWNABLE_EXTENSIONS.has(extname(line).toLowerCase()),
270
+ );
271
+ if (withExt) return withExt;
272
+
273
+ const bare = pathLines[0];
274
+ const dir = dirname(bare);
275
+ const base = basename(bare);
276
+ for (const ext of [".cmd", ".exe", ".bat", ".com"]) {
277
+ const candidate = join(dir, `${base}${ext}`);
278
+ if (existsImpl(candidate)) return candidate;
279
+ }
280
+ return bare;
281
+ }
282
+
283
+ /**
284
+ * Resolve an npm-generated Windows command shim to the native executable it
285
+ * delegates to. This keeps shell:false and argv-array isolation intact instead
286
+ * of interpolating untrusted command arguments into cmd.exe.
287
+ */
288
+ export function resolveDirectCliExecutable(
289
+ cliPath,
290
+ {
291
+ platform = process.platform,
292
+ existsImpl = existsSync,
293
+ readImpl = readFileSync,
294
+ } = {},
295
+ ) {
296
+ if (!cliPath || platform !== "win32") return cliPath || null;
297
+ const extension = extname(cliPath).toLowerCase();
298
+ if (extension === ".exe" || extension === ".com") return cliPath;
299
+ if (extension !== ".cmd" && extension !== ".bat") return null;
300
+
301
+ try {
302
+ const shim = readImpl(cliPath, "utf-8");
303
+ // npm emits several equivalent native-target wrappers. Only accept a
304
+ // bounded %dp0%-relative .exe/.com target; never evaluate batch syntax.
305
+ const directMatch = shim.match(
306
+ /(?:^|\r?\n)\s*"%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"\s+%\*\s*(?:\r?\n|$)/im,
307
+ );
308
+ const variableMatch = shim.match(
309
+ /(?:^|\r?\n)\s*set\s+"[^"=]+=%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"[\s\S]*?\r?\n\s*"%[^"\r\n]+%"\s+%\*\s*(?:\r?\n|$)/im,
310
+ );
311
+ const match = directMatch || variableMatch;
312
+ if (!match) return null;
313
+ const pathApi = platform === "win32" ? win32Path : { join, dirname };
314
+ const target = pathApi.join(pathApi.dirname(cliPath), ...match[1].split(/[\\/]+/));
315
+ return existsImpl(target) ? target : null;
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
206
321
  /**
207
322
  * Probe whether the Claude Code CLI (`claude`) is present and responds on
208
323
  * this machine, so `forgedock run` can drive it directly instead of
209
324
  * requiring a separate, billable ANTHROPIC_API_KEY.
210
325
  *
211
- * Deliberately uses `execSync("claude --version", ...)` — a single command
212
- * *string* executed through a shell — rather than
213
- * `execFileSync("claude", [...])`. On Windows, `claude` is installed as a
214
- * `claude.cmd` shim; `execFileSync` does not resolve `.cmd` shims and throws
215
- * ENOENT even though the CLI is genuinely installed and on PATH (this exact
216
- * regression previously made an unrelated skill-based enrichment backend
217
- * silently dead on Windows — see issue #382). `execSync` always runs its
218
- * command through a shell, which resolves PATHEXT-registered `.cmd`/`.bat`
219
- * shims correctly on Windows and behaves identically on POSIX. This mirrors
220
- * the pre-existing `claude --version` doctor check in bin/forgedock.mjs.
326
+ * Deliberately runs a single command *string* through a shell (via
327
+ * `execSync`) rather than `execFileSync("claude", [...])` or a no-shell
328
+ * `spawnSync`. On Windows, `claude` may be installed as a `claude.cmd`
329
+ * shim, or present only as an npx-transient shim (an extensionless script
330
+ * in a `_npx` cache directory, reachable only via the shell-augmented
331
+ * PATH); `execFileSync`/no-shell `spawnSync` do not resolve either case the
332
+ * same way a shell does and throw ENOENT even though the CLI is genuinely
333
+ * runnable (see issue #382 for the `.cmd`-shim regression this originally
334
+ * guarded against, and issue #2741 for the npx-shim case). `execSync`
335
+ * always runs its command through a shell, which resolves both cases
336
+ * correctly on Windows and behaves identically on POSIX.
337
+ *
338
+ * issue #2741 — probe/invocation asymmetry fix: this probe now does double
339
+ * duty. In a single `execImpl` call (so per-cwd caching below still pays
340
+ * the probe cost exactly once — existing tests assert this), it both
341
+ * verifies `claude --version` responds AND captures the shell-resolved
342
+ * absolute path (`where claude` on Windows / `command -v claude` on POSIX)
343
+ * that made that resolution possible. That resolved path is cached
344
+ * alongside `available` and exposed via `resolveClaudeCliBinary()` below,
345
+ * so `runCliBackend()`'s production call site can spawn the *exact* binary
346
+ * this probe validated — with `shell:false` — instead of a bare `"claude"`
347
+ * name that the no-shell OS exec-path search may fail to resolve the same
348
+ * way. The two commands are chained with `&&` (path-resolution failure
349
+ * short-circuits the whole probe as "unavailable") rather than run
350
+ * independently: an environment where `claude` only resolves as an
351
+ * unlocatable shell alias/function (no path lookup, `--version` succeeds
352
+ * anyway) was never actually spawnable via `spawnSync` in the first place,
353
+ * so treating that as "unavailable" here is consistent with what
354
+ * `runCliBackend()` can actually do with it.
221
355
  *
222
356
  * Bounded by CLI_PROBE_TIMEOUT_MS so a hung or misbehaving `claude` binary
223
357
  * can never make backend resolution (and therefore --dry-run, or the start
@@ -244,20 +378,29 @@ export function resolveConfiguredDefaultModel(cwd) {
244
378
  * `runCliBackend()`.
245
379
  * @returns {boolean}
246
380
  */
247
- export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync } = {}) {
381
+ export function isClaudeCliAvailable(
382
+ cwd = process.cwd(),
383
+ { execImpl = execSync, platform = process.platform, existsImpl = existsSync } = {},
384
+ ) {
248
385
  const cached = cliAvailabilityCache.get(cwd);
249
386
  if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
250
387
  return cached.available;
251
388
  }
389
+ const resolveCmd = process.platform === "win32" ? "where claude" : "command -v claude";
252
390
  let available;
391
+ let cliPath = null;
253
392
  try {
254
- execImpl("claude --version", {
393
+ const raw = execImpl(
394
+ `${resolveCmd} && echo ${CLI_PROBE_OUTPUT_SENTINEL} && claude --version`,
395
+ {
255
396
  cwd,
256
397
  stdio: ["ignore", "pipe", "ignore"],
257
398
  encoding: "utf-8",
258
399
  timeout: CLI_PROBE_TIMEOUT_MS,
259
- });
400
+ },
401
+ );
260
402
  available = true;
403
+ cliPath = selectResolvedCliPath(parseProbeOutput(raw), { platform, existsImpl });
261
404
  } catch {
262
405
  available = false;
263
406
  }
@@ -266,7 +409,7 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
266
409
  // entry — whether "oldest" means "never refreshed" or "not refreshed in
267
410
  // the longest time."
268
411
  cliAvailabilityCache.delete(cwd);
269
- cliAvailabilityCache.set(cwd, { available, cachedAt: Date.now() });
412
+ cliAvailabilityCache.set(cwd, { available, cliPath, cachedAt: Date.now() });
270
413
  if (cliAvailabilityCache.size > CLI_AVAILABILITY_CACHE_MAX_SIZE) {
271
414
  const oldestKey = cliAvailabilityCache.keys().next().value;
272
415
  cliAvailabilityCache.delete(oldestKey);
@@ -274,6 +417,123 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
274
417
  return available;
275
418
  }
276
419
 
420
+ /**
421
+ * Return the exact absolute `claude` executable path that
422
+ * `isClaudeCliAvailable()` validated for `cwd` (issue #2741), ensuring the
423
+ * probe/cache is populated or refreshed first. This is what closes the
424
+ * probe/invocation asymmetry: `runCommand()`'s production call site passes
425
+ * this resolved path as `runCliBackend()`'s `bin` argument instead of the
426
+ * bare `"claude"` default, so the invocation spawns the identical binary
427
+ * the probe already confirmed responds — with `shell:false` preserved
428
+ * throughout (see the SECURITY note on `runCliBackend()` below).
429
+ *
430
+ * @param {string} [cwd] - Working directory for the probe (default cwd).
431
+ * @param {object} [opts] - Forwarded to `isClaudeCliAvailable()` (e.g.
432
+ * `execImpl` test seam).
433
+ * @returns {string|null} Absolute resolved path, or `null` when the CLI is
434
+ * unavailable or no path-like line could be parsed from the probe output
435
+ * (falls back to the bare `"claude"` name at the call site in that case).
436
+ */
437
+ export function resolveClaudeCliBinary(cwd = process.cwd(), opts = {}) {
438
+ isClaudeCliAvailable(cwd, opts);
439
+ let cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
440
+ if (!cliPath) return null;
441
+
442
+ const existsImpl = opts.existsImpl ?? existsSync;
443
+ try {
444
+ if (existsImpl(cliPath)) {
445
+ const executable = resolveDirectCliExecutable(cliPath, { ...opts, existsImpl });
446
+ if (executable) return executable;
447
+ }
448
+ } catch {
449
+ // Treat an unverifiable cached path as stale and make one bounded refresh.
450
+ }
451
+
452
+ cliAvailabilityCache.delete(cwd);
453
+ isClaudeCliAvailable(cwd, opts);
454
+ cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
455
+ if (!cliPath) return null;
456
+ try {
457
+ return existsImpl(cliPath)
458
+ ? resolveDirectCliExecutable(cliPath, { ...opts, existsImpl })
459
+ : null;
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Invoke the CLI with one recovery attempt for a path that disappeared after
467
+ * resolution. The availability cache is intentionally retained, so only an
468
+ * observed ENOENT triggers a fresh resolution.
469
+ */
470
+ export function runCliWithStalePathRecovery({
471
+ backend,
472
+ cwd,
473
+ bin,
474
+ cliOptions,
475
+ runCliFn = runCliBackend,
476
+ resolveCliFn = resolveClaudeCliBinary,
477
+ }) {
478
+ try {
479
+ return runCliFn({ ...cliOptions, bin });
480
+ } catch (error) {
481
+ if (backend !== "auto" || error?.code !== "ENOENT") throw error;
482
+ const freshBin = resolveCliFn(cwd);
483
+ if (!freshBin) throw error;
484
+ return runCliFn({ ...cliOptions, bin: freshBin });
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Check whether the configured execution backend is locally usable without
490
+ * making a paid model request. CLI checks reuse the production resolver;
491
+ * API checks verify that a key is configured, not that the key is accepted.
492
+ */
493
+ export function checkExecutionBackend({
494
+ requested = process.env.FORGEDOCK_BACKEND || "auto",
495
+ cwd = process.cwd(),
496
+ apiKey = process.env.ANTHROPIC_API_KEY,
497
+ resolveCliFn = resolveClaudeCliBinary,
498
+ spawnImpl = spawnSync,
499
+ sdkAvailableFn = () => {
500
+ try {
501
+ createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
502
+ return true;
503
+ } catch {
504
+ return false;
505
+ }
506
+ },
507
+ } = {}) {
508
+ if (!VALID_BACKENDS.has(requested)) {
509
+ return { ready: false, backend: requested, reason: "invalid-backend" };
510
+ }
511
+
512
+ if (requested !== "api") {
513
+ const cliPath = resolveCliFn(cwd);
514
+ if (cliPath) {
515
+ const result = spawnImpl(cliPath, ["--version"], {
516
+ cwd,
517
+ shell: false,
518
+ encoding: "utf-8",
519
+ timeout: CLI_PROBE_TIMEOUT_MS,
520
+ });
521
+ if (!result?.error && result?.status === 0) {
522
+ return { ready: true, backend: "cli", reason: "resolved-cli" };
523
+ }
524
+ }
525
+ if (requested === "cli") {
526
+ return { ready: false, backend: "cli", reason: "cli-unavailable" };
527
+ }
528
+ }
529
+
530
+ if (apiKey && sdkAvailableFn()) {
531
+ return { ready: true, backend: "api", reason: "api-key-and-sdk-configured" };
532
+ }
533
+ if (apiKey) return { ready: false, backend: "api", reason: "api-sdk-missing" };
534
+ return { ready: false, backend: "api", reason: "api-key-missing" };
535
+ }
536
+
277
537
  /**
278
538
  * Shared backend-resolution primitive (issue #2026) used by both
279
539
  * `resolveBackend()` (below, `forgedock run`'s engine ladder — issue #2003)
@@ -386,19 +646,28 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
386
646
  * arbitrary shell commands with this process's privileges). Do not
387
647
  * reintroduce `shell: true` or string-command interpolation here.
388
648
  *
389
- * Windows `.cmd` shim resolution works WITHOUT `shell: true`: Node's
390
- * spawn/spawnSync resolve a bare `"claude"` command via PATH+PATHEXT and
391
- * safely re-invoke through cmd.exe internally when the resolved target is a
392
- * `.cmd`/`.bat` file, using a properly escaped mechanism, and argv elements
393
- * are never parsed as shell syntax (verified empirically on Windows: a
394
- * malicious-looking argv element is delivered to the child process
395
- * byte-for-byte, not executed). This is the correct fix for the
396
- * `execFileSync("claude", [...])` ENOENT-on-Windows regression from issue
397
- * #382 — that regression was about USING execFileSync with an unresolved
398
- * bare command name in an older/incompatible way, not about needing
399
- * `shell: true`; spawnSync's own PATH/PATHEXT resolution (unlike
400
- * execFileSync's, in the failure mode #382 hit) handles the `.cmd` shim
401
- * transparently here.
649
+ * Windows npm `.cmd`/`.bat` shims cannot be launched by Node with
650
+ * `shell:false`. `resolveDirectCliExecutable()` therefore parses only the
651
+ * strict npm-shim form that delegates directly to a native `.exe`/`.com` and
652
+ * returns that trusted target. Unknown batch scripts fail closed instead of
653
+ * requiring cmd.exe interpolation of the untrusted user message.
654
+ *
655
+ * CORRECTED (issue #2741): the claim that a bare `"claude"` name resolves
656
+ * via "spawnSync's own PATH/PATHEXT resolution" was overconfident and
657
+ * false for an npx-transient shim (an extensionless script living in a
658
+ * `_npx` cache directory rather than a stable PATH entry with a registered
659
+ * PATHEXT extension) — `spawnSync(shell:false)` resolves a bare command
660
+ * name via the OS's own exec-path search, which does not carry the same
661
+ * shell/PATHEXT semantics `isClaudeCliAvailable()`'s shell-based probe
662
+ * uses, and ENOENTs on that shim even though the probe reports the CLI
663
+ * available. The fix is NOT `shell: true` (would reopen the injection hole
664
+ * above); it is resolving the *exact* absolute path the probe validated
665
+ * once, via `resolveClaudeCliBinary()`, and passing that resolved path as
666
+ * `bin` — `spawnSync(shell:false)` launches an already-fully-resolved
667
+ * native executable path (including the target extracted from a recognized
668
+ * npm shim) without needing any further PATH/PATHEXT
669
+ * resolution of its own. See `isClaudeCliAvailable()`/
670
+ * `resolveClaudeCliBinary()` above for how that resolution happens.
402
671
  *
403
672
  * `--dangerously-skip-permissions` mirrors the established headless-CI
404
673
  * invocation pattern already documented in docs/CI.md and
@@ -454,14 +723,19 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
454
723
  * @param {string[]} [opts.args]
455
724
  * @param {string} opts.cwd
456
725
  * @param {{log: Function, error?: Function}} [opts.logger]
457
- * @param {string} [opts.bin] - Executable to invoke (default "claude"). Test
458
- * seam only — production callers must never override this; it exists so
459
- * tests can point at a controlled fake binary instead of either invoking
460
- * the real `claude` CLI (slow, non-deterministic, real side effects) or
461
- * fighting platform-specific PATH-resolution semantics to shim "claude"
462
- * itself (Windows resolves a bare executable name via the *calling*
463
- * process's search path at the OS level, which is not reliably
464
- * overridable per-call from within the same process).
726
+ * @param {string} [opts.bin] - Executable to invoke (default "claude").
727
+ * UPDATED (issue #2741): the real production caller (`runCommand()`
728
+ * below) now deliberately passes `resolveClaudeCliBinary(cwd) ?? "claude"`
729
+ * here — a pre-resolved absolute path, not the bare default — to close
730
+ * the probe/invocation asymmetry described on `isClaudeCliAvailable()`
731
+ * above. The bare `"claude"` default remains purely a fallback for that
732
+ * production call site (when resolution genuinely finds nothing) and a
733
+ * test seam: tests still point this at a controlled fake binary instead
734
+ * of either invoking the real `claude` CLI (slow, non-deterministic, real
735
+ * side effects) or fighting platform-specific PATH-resolution semantics
736
+ * to shim "claude" itself (Windows resolves a bare executable name via
737
+ * the *calling* process's search path at the OS level, which is not
738
+ * reliably overridable per-call from within the same process).
465
739
  * @param {Function} [opts.spawnFn] - Injectable replacement for
466
740
  * `child_process.spawnSync` (default `spawnSync`). Test seam only —
467
741
  * production callers must never override this. Mirrors the identical
@@ -825,8 +1099,11 @@ export function runCliBackend({
825
1099
  delete childEnv.ANTHROPIC_API_KEY;
826
1100
 
827
1101
  // No `shell` option (defaults to false): argv is passed as discrete,
828
- // unparsed elements — see the SECURITY note above. This also correctly
829
- // resolves the Windows `.cmd` shim without needing shell:true.
1102
+ // unparsed elements — see the SECURITY note above. `bin` is expected to
1103
+ // already be a fully-resolved absolute path (see `resolveClaudeCliBinary()`
1104
+ // and the `opts.bin` doc comment above, issue #2741) — Windows npm shims
1105
+ // have already been reduced to their native target, so spawnSync never
1106
+ // needs shell/PATHEXT resolution.
830
1107
  const result = spawnFn(bin, cliArgs, {
831
1108
  cwd,
832
1109
  encoding: "utf-8",
@@ -867,7 +1144,9 @@ export function runCliBackend({
867
1144
  );
868
1145
  }
869
1146
  if (result.error) {
870
- throw new Error(`Failed to invoke claude CLI: ${result.error.message}`);
1147
+ const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
1148
+ error.code = result.error.code;
1149
+ throw error;
871
1150
  }
872
1151
 
873
1152
  const output = (stdout + stderr).trim();
@@ -1825,7 +2104,12 @@ export async function runCommand(opts = {}) {
1825
2104
  // misbehaving. An explicitly invalid `backend` value DOES throw here —
1826
2105
  // surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
1827
2106
  // work is attempted, rather than silently ignoring it.
1828
- const resolvedBackend = resolveBackend({ requested: backend, cwd });
2107
+ let resolvedBackend = resolveBackend({ requested: backend, cwd });
2108
+
2109
+ if (resolvedBackend === "cli" && backend === "auto") {
2110
+ const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey });
2111
+ if (readiness.backend === "api") resolvedBackend = "api";
2112
+ }
1829
2113
 
1830
2114
  if (dryRun) {
1831
2115
  logger.log(
@@ -1896,7 +2180,34 @@ export async function runCommand(opts = {}) {
1896
2180
  }.`,
1897
2181
  );
1898
2182
  }
1899
- return runCliBackend({ spec, userMessage, systemPrompt: cliSystemPrompt, args, cwd, logger });
2183
+ // Pass the exact absolute path isClaudeCliAvailable() already validated
2184
+ // for this cwd (issue #2741) — resolveClaudeCliBinary() reuses that same
2185
+ // per-cwd cache, so this does not trigger a second probe. Falls back to
2186
+ // the bare "claude" name only if resolution genuinely found nothing
2187
+ // (e.g. an injected-execImpl test scenario with no path-like output),
2188
+ // preserving prior behavior in that edge case.
2189
+ const resolvedBin = resolveClaudeCliBinary(cwd);
2190
+ if (!resolvedBin && backend === "auto") {
2191
+ resolvedBackend = "api";
2192
+ } else if (!resolvedBin) {
2193
+ const err = new Error("The configured claude CLI path disappeared before invocation.");
2194
+ err.code = "CLI_BACKEND_UNAVAILABLE";
2195
+ throw err;
2196
+ } else {
2197
+ return runCliWithStalePathRecovery({
2198
+ backend,
2199
+ cwd,
2200
+ bin: resolvedBin,
2201
+ cliOptions: {
2202
+ spec,
2203
+ userMessage,
2204
+ systemPrompt: cliSystemPrompt,
2205
+ args,
2206
+ cwd,
2207
+ logger,
2208
+ },
2209
+ });
2210
+ }
1900
2211
  }
1901
2212
 
1902
2213
  if (!apiKey) {
@@ -1061,7 +1061,12 @@ The engine drives every phase transition deterministically, mirrors state to the
1061
1061
 
1062
1062
  **Why engine-first**: The engine's phase table enforces gate semantics in code — not via LLM interpretation of markdown specs. This eliminates the class of bug where the LLM assumes a review approved when the FORGE:REVIEW comment is absent or unreadable (issue #1714). Interactive sessions that run `/work-on` via Skill invocations additionally bridge to the engine run-log via the SubagentStop hook (`bin/hooks/interactive-engine.mjs`) — state is durable across compaction and context resets.
1063
1063
 
1064
- **Fallback (no forgedock CLI)**: If `forgedock` is not in PATH, fall back to spawning Agent sub-agents that run `Skill("work-on", ...)` per issue. The SubagentStop hook still bridges these runs to the engine run-log for state persistence.
1064
+ **Fallback — best-effort, not all-or-nothing (fixed forge#2743)**: Engine-first is guaranteed to have an Agent-spawn fallback under two distinct trigger conditions, not just CLI absence:
1065
+
1066
+ 1. **CLI absent at dispatch time**: If `forgedock` is not in PATH, `phase-4-execution.md` Step 4A falls back to spawning Agent sub-agents that run `Skill("work-on", ...)` per issue for the whole batch, before any dispatch happens.
1067
+ 2. **Backend unavailable despite CLI presence, or a runtime engine-error with empty committed state**: `command -v forgedock` only proves the orchestrator CLI is installed — it says nothing about whether the engine's execution backend (the `claude` CLI spawn) can actually run a phase (forge#2741 is a concrete case: `spawnSync claude` ENOENTs even though a shell `command -v` probe reports the binary present). Step 4A now runs a cheap backend preflight canary before committing the whole ready set to engine-first, downgrading the entire run to Agent-spawn on failure. And per-issue, Step 4B's completion handler auto-falls-back any individual engine-dispatched issue that completes at `workflow:engine-error` with an empty committed state (`committed=[] branch=null pr=null` — see `bin/engine-cli.mjs`'s `formatTerminalDiagnostics()`) to the same Agent-spawn template, rather than leaving it stuck waiting on a resume mechanism that only exists for Agent-spawn-dispatched issues. An engine-error with partial committed state (branch/PR non-null) is NOT auto-fallen-back, to avoid double-work — it surfaces via the existing stall-detection alert instead.
1068
+
1069
+ In both cases the SubagentStop hook still bridges the fallback Agent-spawn runs to the engine run-log for state persistence. Engine-first is therefore best-effort: it is always backed by a working Agent-spawn path, whether the gap is discovered before dispatch (canary) or after (per-issue fallback) — a whole ready set is never permanently lost to an environmental engine failure.
1065
1070
 
1066
1071
  ### Concurrency model: in-process worker pool + worktree-per-issue
1067
1072
 
@@ -1131,8 +1136,23 @@ Background dispatch (via `run_in_background=true` on each `Agent()` call) is the
1131
1136
  Check both at the start of Phase 4, before the first dispatch:
1132
1137
 
1133
1138
  ```bash
1139
+ # Runtime selection is explicit for non-Claude adapters. OpenCode runs nested
1140
+ # work-on phases through its native task path, so it must not be treated as a
1141
+ # failed Claude probe.
1142
+ FORGE_RUNTIME="${FORGE_RUNTIME:-}"
1143
+ if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_PID:-}" ] || [ -n "${OPENCODE:-}" ]; then
1144
+ FORGE_RUNTIME="opencode"
1145
+ fi
1146
+
1147
+ if [ "$FORGE_RUNTIME" = "opencode" ]; then
1148
+ echo "OpenCode runtime detected: native task dispatch selected; Claude background dispatch is not required."
1149
+ BACKGROUND_DISPATCH_ENABLED=false
1150
+ fi
1151
+
1134
1152
  # Feature gate check — run once before Phase 4 dispatch begins
1135
- BACKGROUND_DISPATCH_ENABLED=true
1153
+ if [ "${BACKGROUND_DISPATCH_ENABLED:-}" != "false" ]; then
1154
+ BACKGROUND_DISPATCH_ENABLED=true
1155
+ fi
1136
1156
 
1137
1157
  if [ -n "${CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:-}" ]; then
1138
1158
  echo "Background dispatch disabled: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set."
@@ -1142,7 +1162,7 @@ fi
1142
1162
  # Version check: if the Claude Code version can be read, compare it.
1143
1163
  # If the version cannot be determined, default to ENABLED (optimistic).
1144
1164
  CC_VERSION=$(claude --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "")
1145
- if [ -n "$CC_VERSION" ]; then
1165
+ if [ "$FORGE_RUNTIME" != "opencode" ] && [ -n "$CC_VERSION" ]; then
1146
1166
  # Compare major.minor.patch numerically
1147
1167
  IFS='.' read -r CC_MAJOR CC_MINOR CC_PATCH <<< "$CC_VERSION"
1148
1168
  if [ "$CC_MAJOR" -lt 2 ] || \
@@ -1154,6 +1174,12 @@ if [ -n "$CC_VERSION" ]; then
1154
1174
  fi
1155
1175
  ```
1156
1176
 
1177
+ When `FORGE_RUNTIME=opencode` (or an OpenCode runtime marker is present), do not
1178
+ interpret a missing `claude` executable as an environment failure. The Phase 4
1179
+ dispatcher must use OpenCode's inline continuation contract instead of the
1180
+ Claude `Agent(...)` fallback. Claude remains the default when no runtime marker
1181
+ is present, preserving the existing Claude behavior.
1182
+
1157
1183
  **When `BACKGROUND_DISPATCH_ENABLED=false`**: fall back to the current streaming dispatch behavior — `run_in_background=true` is still set on each `Agent()` call (existing behavior, already correct), but treat completions as synchronous and do not rely on `agent_completed` notifications. Poll issue labels for terminal state instead.
1158
1184
 
1159
1185
  **When `BACKGROUND_DISPATCH_ENABLED=true`**: use `run_in_background=true` (already in the Step 4A Agent() template) and react to `agent_completed` notifications as documented in Step 4B. Do NOT poll.
@@ -1354,4 +1380,3 @@ done
1354
1380
  **Why this keeps context small**: Each `Agent()` call returns an agent ID stored only in `AGENT_ISSUE_MAP`, which is rebuilt per Step 4A.pre dispatch batch. After compaction, the map is gone — but the DAG state, including `blocked-on-human-merge` tracking (a durable `FORGE:BLOCKED_ON_HUMAN_MERGE` comment plus label, not an in-context variable), is fully on GitHub. The reconstruction above re-derives the ready set, the gated set, and the blocked-on-human-merge set from labels and comments alone, so the orchestrator context never needs to hold cumulative dispatch history.
1355
1381
 
1356
1382
  ---
1357
-