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/README.md +13 -2
- package/bin/engine/orchestrate-canary.mjs +15 -0
- package/bin/engine/orchestrate-canary.test.mjs +28 -0
- package/bin/forgedock.mjs +91 -21
- package/bin/hooks/session-start.mjs +22 -1
- package/bin/opencode-adapter.mjs +435 -0
- package/bin/registry.mjs +87 -0
- package/bin/runner.mjs +354 -43
- package/commands/orchestrate/phase-3-dependency.md +29 -4
- package/commands/orchestrate/phase-4-execution.md +197 -26
- package/commands/work-on/build.md +10 -1
- package/commands/work-on.md +10 -1
- package/docs/OPENCODE.md +132 -252
- package/package.json +3 -2
- package/scripts/check-spec-markers.sh +1 -0
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
|
|
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
|
|
212
|
-
*
|
|
213
|
-
* `
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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(
|
|
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(
|
|
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`
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
* `shell:
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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").
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
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.
|
|
829
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|