forgedock 1.6.0 → 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, extname } 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.
@@ -153,12 +155,9 @@ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
153
155
  const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
154
156
  const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
155
157
  const cliAvailabilityCache = new Map();
156
- // Windows file extensions `spawnSync(shell:false)` can actually launch:
157
- // native executables (`.exe`, `.com`) directly, and `.cmd`/`.bat` via Node's
158
- // own built-in special-cased re-exec through cmd.exe (see the comment on
159
- // `runCliBackend()` below). An extensionless file — e.g. the POSIX shell
160
- // shim `npm`/`npx` also drop alongside these on Windows — cannot be
161
- // launched by `CreateProcess` at all, with or without a full absolute path.
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.
162
161
  const WINDOWS_SPAWNABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".com"]);
163
162
  // Cap tool-result payloads so a large file read or verbose command does not
164
163
  // blow the context window in a single turn.
@@ -216,13 +215,10 @@ export function resolveConfiguredDefaultModel(cwd) {
216
215
  // ---------------------------------------------------------------------------
217
216
 
218
217
  /**
219
- * Split the combined probe command's stdout (issue #2741 — see
220
- * `isClaudeCliAvailable()`) into the leading "path-like" lines (from the
221
- * `where`/`command -v` half of the command) versus everything after them
222
- * (the `claude --version` output). A path-like line is one containing a
223
- * path separator (`/` or `\`); `claude --version` output (e.g.
224
- * `"1.2.3 (Claude Code)"`) never does, so the first non-path-like line
225
- * marks the boundary.
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.
226
222
  *
227
223
  * Pure/side-effect-free so it can be unit tested directly against
228
224
  * synthetic multi-line `where` output without spawning anything.
@@ -230,20 +226,16 @@ export function resolveConfiguredDefaultModel(cwd) {
230
226
  * @param {string} raw
231
227
  * @returns {string[]} Leading path-like lines, in original order.
232
228
  */
233
- function parseProbeOutput(raw) {
229
+ export function parseProbeOutput(raw) {
234
230
  const lines = String(raw ?? "")
235
231
  .split(/\r?\n/)
236
232
  .map((line) => line.trim())
237
233
  .filter(Boolean);
238
- const pathLines = [];
239
- for (const line of lines) {
240
- if (line.includes("/") || line.includes("\\")) {
241
- pathLines.push(line);
242
- } else {
243
- break;
244
- }
245
- }
246
- return pathLines;
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("\\"));
247
239
  }
248
240
 
249
241
  /**
@@ -266,9 +258,12 @@ function parseProbeOutput(raw) {
266
258
  * @param {string[]} pathLines
267
259
  * @returns {string|null}
268
260
  */
269
- function selectResolvedCliPath(pathLines) {
261
+ export function selectResolvedCliPath(
262
+ pathLines,
263
+ { platform = process.platform, existsImpl = existsSync } = {},
264
+ ) {
270
265
  if (pathLines.length === 0) return null;
271
- if (process.platform !== "win32") return pathLines[0];
266
+ if (platform !== "win32") return pathLines[0];
272
267
 
273
268
  const withExt = pathLines.find((line) =>
274
269
  WINDOWS_SPAWNABLE_EXTENSIONS.has(extname(line).toLowerCase()),
@@ -280,11 +275,49 @@ function selectResolvedCliPath(pathLines) {
280
275
  const base = basename(bare);
281
276
  for (const ext of [".cmd", ".exe", ".bat", ".com"]) {
282
277
  const candidate = join(dir, `${base}${ext}`);
283
- if (existsSync(candidate)) return candidate;
278
+ if (existsImpl(candidate)) return candidate;
284
279
  }
285
280
  return bare;
286
281
  }
287
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
+
288
321
  /**
289
322
  * Probe whether the Claude Code CLI (`claude`) is present and responds on
290
323
  * this machine, so `forgedock run` can drive it directly instead of
@@ -345,7 +378,10 @@ function selectResolvedCliPath(pathLines) {
345
378
  * `runCliBackend()`.
346
379
  * @returns {boolean}
347
380
  */
348
- 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
+ ) {
349
385
  const cached = cliAvailabilityCache.get(cwd);
350
386
  if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
351
387
  return cached.available;
@@ -354,14 +390,17 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
354
390
  let available;
355
391
  let cliPath = null;
356
392
  try {
357
- const raw = execImpl(`${resolveCmd} && claude --version`, {
393
+ const raw = execImpl(
394
+ `${resolveCmd} && echo ${CLI_PROBE_OUTPUT_SENTINEL} && claude --version`,
395
+ {
358
396
  cwd,
359
397
  stdio: ["ignore", "pipe", "ignore"],
360
398
  encoding: "utf-8",
361
399
  timeout: CLI_PROBE_TIMEOUT_MS,
362
- });
400
+ },
401
+ );
363
402
  available = true;
364
- cliPath = selectResolvedCliPath(parseProbeOutput(raw));
403
+ cliPath = selectResolvedCliPath(parseProbeOutput(raw), { platform, existsImpl });
365
404
  } catch {
366
405
  available = false;
367
406
  }
@@ -397,7 +436,102 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
397
436
  */
398
437
  export function resolveClaudeCliBinary(cwd = process.cwd(), opts = {}) {
399
438
  isClaudeCliAvailable(cwd, opts);
400
- return cliAvailabilityCache.get(cwd)?.cliPath ?? null;
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" };
401
535
  }
402
536
 
403
537
  /**
@@ -512,15 +646,11 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
512
646
  * arbitrary shell commands with this process's privileges). Do not
513
647
  * reintroduce `shell: true` or string-command interpolation here.
514
648
  *
515
- * Windows `.cmd`/`.bat` shim resolution works WITHOUT `shell: true` — BUT
516
- * only when `bin` is already a resolvable target: Node's spawn/spawnSync
517
- * safely re-invoke through cmd.exe internally when the *given* target is a
518
- * `.cmd`/`.bat` file, using a properly escaped mechanism, and argv elements
519
- * are never parsed as shell syntax (verified empirically on Windows: a
520
- * malicious-looking argv element is delivered to the child process
521
- * byte-for-byte, not executed). This is the correct fix for the
522
- * `execFileSync("claude", [...])` ENOENT-on-Windows regression from issue
523
- * #382 for a `claude.cmd` sitting directly on a real PATH entry.
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.
524
654
  *
525
655
  * CORRECTED (issue #2741): the claim that a bare `"claude"` name resolves
526
656
  * via "spawnSync's own PATH/PATHEXT resolution" was overconfident and
@@ -534,8 +664,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
534
664
  * above); it is resolving the *exact* absolute path the probe validated
535
665
  * once, via `resolveClaudeCliBinary()`, and passing that resolved path as
536
666
  * `bin` — `spawnSync(shell:false)` launches an already-fully-resolved
537
- * absolute path (native `.exe`, or `.cmd`/`.bat` via the special-cased
538
- * re-exec described above) without needing any further PATH/PATHEXT
667
+ * native executable path (including the target extracted from a recognized
668
+ * npm shim) without needing any further PATH/PATHEXT
539
669
  * resolution of its own. See `isClaudeCliAvailable()`/
540
670
  * `resolveClaudeCliBinary()` above for how that resolution happens.
541
671
  *
@@ -971,10 +1101,9 @@ export function runCliBackend({
971
1101
  // No `shell` option (defaults to false): argv is passed as discrete,
972
1102
  // unparsed elements — see the SECURITY note above. `bin` is expected to
973
1103
  // already be a fully-resolved absolute path (see `resolveClaudeCliBinary()`
974
- // and the `opts.bin` doc comment above, issue #2741) — spawnSync launches
975
- // an already-resolved `.exe`/`.cmd`/`.bat` path directly (with Node's
976
- // built-in special-cased `.cmd`/`.bat` re-exec) without needing any
977
- // further shell/PATHEXT resolution of its own.
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.
978
1107
  const result = spawnFn(bin, cliArgs, {
979
1108
  cwd,
980
1109
  encoding: "utf-8",
@@ -1015,7 +1144,9 @@ export function runCliBackend({
1015
1144
  );
1016
1145
  }
1017
1146
  if (result.error) {
1018
- 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;
1019
1150
  }
1020
1151
 
1021
1152
  const output = (stdout + stderr).trim();
@@ -1973,7 +2104,12 @@ export async function runCommand(opts = {}) {
1973
2104
  // misbehaving. An explicitly invalid `backend` value DOES throw here —
1974
2105
  // surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
1975
2106
  // work is attempted, rather than silently ignoring it.
1976
- 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
+ }
1977
2113
 
1978
2114
  if (dryRun) {
1979
2115
  logger.log(
@@ -2050,16 +2186,28 @@ export async function runCommand(opts = {}) {
2050
2186
  // the bare "claude" name only if resolution genuinely found nothing
2051
2187
  // (e.g. an injected-execImpl test scenario with no path-like output),
2052
2188
  // preserving prior behavior in that edge case.
2053
- const resolvedBin = resolveClaudeCliBinary(cwd) ?? "claude";
2054
- return runCliBackend({
2055
- spec,
2056
- userMessage,
2057
- systemPrompt: cliSystemPrompt,
2058
- args,
2059
- cwd,
2060
- logger,
2061
- bin: resolvedBin,
2062
- });
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
+ }
2063
2211
  }
2064
2212
 
2065
2213
  if (!apiKey) {
@@ -1136,8 +1136,23 @@ Background dispatch (via `run_in_background=true` on each `Agent()` call) is the
1136
1136
  Check both at the start of Phase 4, before the first dispatch:
1137
1137
 
1138
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
+
1139
1152
  # Feature gate check — run once before Phase 4 dispatch begins
1140
- BACKGROUND_DISPATCH_ENABLED=true
1153
+ if [ "${BACKGROUND_DISPATCH_ENABLED:-}" != "false" ]; then
1154
+ BACKGROUND_DISPATCH_ENABLED=true
1155
+ fi
1141
1156
 
1142
1157
  if [ -n "${CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:-}" ]; then
1143
1158
  echo "Background dispatch disabled: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set."
@@ -1147,7 +1162,7 @@ fi
1147
1162
  # Version check: if the Claude Code version can be read, compare it.
1148
1163
  # If the version cannot be determined, default to ENABLED (optimistic).
1149
1164
  CC_VERSION=$(claude --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "")
1150
- if [ -n "$CC_VERSION" ]; then
1165
+ if [ "$FORGE_RUNTIME" != "opencode" ] && [ -n "$CC_VERSION" ]; then
1151
1166
  # Compare major.minor.patch numerically
1152
1167
  IFS='.' read -r CC_MAJOR CC_MINOR CC_PATCH <<< "$CC_VERSION"
1153
1168
  if [ "$CC_MAJOR" -lt 2 ] || \
@@ -1159,6 +1174,12 @@ if [ -n "$CC_VERSION" ]; then
1159
1174
  fi
1160
1175
  ```
1161
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
+
1162
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.
1163
1184
 
1164
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.
@@ -1359,4 +1380,3 @@ done
1359
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.
1360
1381
 
1361
1382
  ---
1362
-