forgedock 1.3.0 → 1.5.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/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
package/bin/labels.json
CHANGED
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
"color": "E4E669",
|
|
65
65
|
"description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
|
|
66
66
|
},
|
|
67
|
+
{
|
|
68
|
+
"name": "workflow:engine-error",
|
|
69
|
+
"color": "B60205",
|
|
70
|
+
"description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
|
|
71
|
+
},
|
|
67
72
|
{
|
|
68
73
|
"name": "blocked-on-human-merge",
|
|
69
74
|
"color": "006B75",
|
package/bin/runner.mjs
CHANGED
|
@@ -431,9 +431,17 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
431
431
|
* Model selection is intentionally NOT forwarded to the CLI in this first
|
|
432
432
|
* increment — the CLI backend uses whatever model the `claude` CLI itself is
|
|
433
433
|
* configured for. `opts.model`/FORGEDOCK_MODEL only affects the API backend.
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
434
|
+
*
|
|
435
|
+
* Token usage: the invocation requests `--output-format json`, and on a
|
|
436
|
+
* successful exit the captured stdout is parsed as the CLI's single-result
|
|
437
|
+
* JSON envelope. When parsing succeeds and the envelope carries a `usage`
|
|
438
|
+
* object, it is normalized to the same shape the API backend returns
|
|
439
|
+
* (`{input_tokens, output_tokens, cache_creation_input_tokens,
|
|
440
|
+
* cache_read_input_tokens}`, each field `?? 0`). When the output isn't valid
|
|
441
|
+
* JSON (older CLI versions, or a CLI that ignores `--output-format`) or the
|
|
442
|
+
* envelope has no `usage` field, this degrades gracefully to `usage: null`
|
|
443
|
+
* (an already fully-supported value throughout renderSummaryCard/renderDryRun)
|
|
444
|
+
* — this parsing never throws on the success path.
|
|
437
445
|
*
|
|
438
446
|
* @param {object} opts
|
|
439
447
|
* @param {{path: string, name: string, content: string}} opts.spec
|
|
@@ -464,8 +472,292 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
464
472
|
* This is additive to the existing `bin` override seam above — tests may
|
|
465
473
|
* still use a real fake-binary-on-disk + real `spawnSync` (the original
|
|
466
474
|
* seam), or fully mock the call via `spawnFn` (no fake binary needed).
|
|
467
|
-
* @returns {{status: string, command: string, iterations: number, stopReason: string, usage: null, model: string, backend: "cli"}}
|
|
475
|
+
* @returns {{status: string, command: string, iterations: number, stopReason: string, usage: ({input_tokens: number, output_tokens: number, cache_creation_input_tokens: number, cache_read_input_tokens: number}|null), model: string, backend: "cli"}}
|
|
476
|
+
*/
|
|
477
|
+
|
|
478
|
+
// Diagnostic-only bound on each logged argv element (see sanitizeArgvForLog
|
|
479
|
+
// below) — independent of DEFAULT_SPAWN_MAX_BUFFER_BYTES, which governs the
|
|
480
|
+
// actual child process's stdout/stderr capture, not this summary string.
|
|
481
|
+
const MAX_LOGGED_ARGV_ELEMENT_LEN = 200;
|
|
482
|
+
|
|
483
|
+
// Diagnostic-only bound on the captured-output excerpt embedded directly in
|
|
484
|
+
// a CLI_BACKEND_FAILED error message (forge#2355). Deliberately much larger
|
|
485
|
+
// than MAX_LOGGED_ARGV_ELEMENT_LEN (200 chars) — that bound exists to keep a
|
|
486
|
+
// single argv element's *summary* short, but a truncated-to-200-chars
|
|
487
|
+
// captured-output excerpt would defeat the point of this fix (the whole goal
|
|
488
|
+
// is to give an operator reading the persisted `~/.forge/runs/*.jsonl`
|
|
489
|
+
// run-log enough of the CLI's actual failure text to diagnose it without
|
|
490
|
+
// re-running). 4000 chars is generous enough to carry a real stack
|
|
491
|
+
// trace/error while still bounding JSONL run-log line growth for very
|
|
492
|
+
// verbose CLI failures (issue #2355 AC4).
|
|
493
|
+
const MAX_LOGGED_OUTPUT_EXCERPT_LEN = 4000;
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Escape control/bidi-override characters and cap length, without joining
|
|
497
|
+
* multiple elements. Shared core of `sanitizeArgvForLog` (which maps this
|
|
498
|
+
* over an argv array with a small per-element bound) and
|
|
499
|
+
* `sanitizeOutputExcerptForLog` (which applies it once to a full captured
|
|
500
|
+
* stdout+stderr blob with a much larger bound) — both need the identical
|
|
501
|
+
* escaping and surrogate-pair-safe truncation behavior hardened across
|
|
502
|
+
* #2277/#2292/#2293; extracting it here keeps that behavior in one place
|
|
503
|
+
* instead of duplicating it per bound.
|
|
504
|
+
*
|
|
505
|
+
* @param {string} str
|
|
506
|
+
* @param {number} maxLen
|
|
507
|
+
* @returns {string}
|
|
508
|
+
*/
|
|
509
|
+
function sanitizeAndCap(str, maxLen) {
|
|
510
|
+
// eslint-disable-next-line no-control-regex -- intentional: neutralizing C0/DEL/C1 control chars and Unicode bidi-override/format chars is the point of this function
|
|
511
|
+
const escaped = str.replace(/[\x00-\x1F\x7F-\x9F\u202A-\u202E\u2066-\u2069]/g, (ch) => {
|
|
512
|
+
const code = ch.charCodeAt(0);
|
|
513
|
+
return code <= 0xff ? `\\x${code.toString(16).padStart(2, "0")}` : `\\u${code.toString(16).padStart(4, "0")}`;
|
|
514
|
+
});
|
|
515
|
+
if (escaped.length <= maxLen) return escaped;
|
|
516
|
+
// `.slice()` counts UTF-16 code units, not Unicode code points. If the cut
|
|
517
|
+
// lands between a high surrogate (\uD800-\uDBFF) and its paired low
|
|
518
|
+
// surrogate (\uDC00-\uDFFF) — e.g. an astral-plane emoji straddling the
|
|
519
|
+
// boundary — a plain slice bisects the pair and leaves a lone/unpaired
|
|
520
|
+
// surrogate at the tail, which can render as U+FFFD or otherwise
|
|
521
|
+
// mis-encode downstream (#2293). Trim one additional unit in that case so
|
|
522
|
+
// the cut never lands mid-pair.
|
|
523
|
+
let cutLen = maxLen;
|
|
524
|
+
const lastCode = escaped.charCodeAt(cutLen - 1);
|
|
525
|
+
if (lastCode >= 0xd800 && lastCode <= 0xdbff) cutLen -= 1;
|
|
526
|
+
return `${escaped.slice(0, cutLen)}…[truncated, ${escaped.length} chars]`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Build a safe-for-logs/error-messages summary of a CLI argv array.
|
|
531
|
+
*
|
|
532
|
+
* `cliArgs` (see `runCliBackend` below) includes `userMessage` verbatim, and
|
|
533
|
+
* `userMessage` is built from untrusted third-party content (issue/PR bodies
|
|
534
|
+
* fetched via `gh` — see the SECURITY note in `bin/engine.mjs`). The
|
|
535
|
+
* non-zero-exit diagnostic added in #2258 embeds `cliArgs.join(" ")` directly
|
|
536
|
+
* into a thrown `Error.message`, which `bin/forgedock.mjs`'s `run-issue` case
|
|
537
|
+
* later writes verbatim to stderr (`process.stderr.write(err.message)`) — so
|
|
538
|
+
* a crafted issue/PR body could otherwise inject raw ANSI escape sequences or
|
|
539
|
+
* newline-heavy content into operator-visible CI/terminal logs, or simply
|
|
540
|
+
* balloon log size with an arbitrarily large body (#2277).
|
|
541
|
+
*
|
|
542
|
+
* This function is used ONLY to build the human-readable diagnostic string —
|
|
543
|
+
* it must NEVER be applied to the actual `cliArgs` array passed to `spawnFn`,
|
|
544
|
+
* which must remain byte-exact for the real invocation. It intentionally
|
|
545
|
+
* keeps (rather than removes) the argv/cwd context: that context is exactly
|
|
546
|
+
* what #2258 added to fix a prior defect where a real CLI failure produced a
|
|
547
|
+
* diagnostic pointing at output that didn't exist. The goal here is to bound
|
|
548
|
+
* and neutralize each element, not to delete the context.
|
|
549
|
+
*
|
|
550
|
+
* - Control characters (`\x00`-`\x1F`, `\x7F`-`\x9F` — C0/DEL and C1) are
|
|
551
|
+
* escaped to a visible `\xHH` form, neutralizing ANSI escape sequences and
|
|
552
|
+
* log-line spoofing.
|
|
553
|
+
* - Unicode bidirectional-override/format characters (``-``
|
|
554
|
+
* LRE/RLE/PDF/LRO/RLO and ``-`` LRI/RLI/FSI/PDI) are escaped to
|
|
555
|
+
* a visible `\uHHHH` form, neutralizing visual reordering of the diagnostic
|
|
556
|
+
* line in terminals/log viewers that render bidi controls (#2292).
|
|
557
|
+
* - Each element is independently truncated to `MAX_LOGGED_ARGV_ELEMENT_LEN`
|
|
558
|
+
* characters, with an explicit `…[truncated, N chars]` marker appended
|
|
559
|
+
* when truncation occurs, so log growth is bounded regardless of how large
|
|
560
|
+
* the untrusted `userMessage` is.
|
|
561
|
+
*
|
|
562
|
+
* @param {string[]} cliArgs
|
|
563
|
+
* @returns {string} space-joined, sanitized argv summary
|
|
564
|
+
*/
|
|
565
|
+
export function sanitizeArgvForLog(cliArgs) {
|
|
566
|
+
return cliArgs.map((arg) => sanitizeAndCap(String(arg), MAX_LOGGED_ARGV_ELEMENT_LEN)).join(" ");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Build a safe-for-error-messages excerpt of the CLI's captured stdout+stderr
|
|
571
|
+
* output, for embedding directly in a CLI_BACKEND_FAILED error's message
|
|
572
|
+
* (forge#2355).
|
|
573
|
+
*
|
|
574
|
+
* Prior to this fix, the non-zero-exit diagnostic only *logged* the captured
|
|
575
|
+
* output (`logger.log(diagnostic)` in `runCliBackend` below) and threw a
|
|
576
|
+
* self-referential message pointing at that log line ("See captured output
|
|
577
|
+
* above."). `logger.log()` writes to the orchestrating process's own
|
|
578
|
+
* console/CI stream, which is never persisted into the durable
|
|
579
|
+
* `~/.forge/runs/*.jsonl` run-log — only the thrown `Error.message` (via
|
|
580
|
+
* `bin/engine.mjs`'s `reason: e.message` / fail-fast `detail` string) reaches
|
|
581
|
+
* that persisted record. As a result, the single dominant engine failure mode
|
|
582
|
+
* (50 of 69 `PHASE_FAILED` events in a 52-run-log audit) carried a reason that
|
|
583
|
+
* pointed at output the run-log never captured, leaving operators nothing to
|
|
584
|
+
* diagnose post-hoc. This function embeds a bounded excerpt of the real
|
|
585
|
+
* output directly in the message instead.
|
|
586
|
+
*
|
|
587
|
+
* `output` is raw, untrusted CLI stdout/stderr — the CLI itself echoes
|
|
588
|
+
* untrusted issue/PR body content it was fed (see the SECURITY note on
|
|
589
|
+
* `sanitizeArgvForLog` above) — so it carries the identical injection risk
|
|
590
|
+
* class already hardened for `argvSummary` across #2277/#2292/#2293. This
|
|
591
|
+
* function reuses the exact same `sanitizeAndCap` escaping/truncation core,
|
|
592
|
+
* just with a much larger bound (`MAX_LOGGED_OUTPUT_EXCERPT_LEN`) appropriate
|
|
593
|
+
* for a diagnostic excerpt rather than a short argv summary.
|
|
594
|
+
*
|
|
595
|
+
* @param {string} output - combined, already-captured stdout+stderr text
|
|
596
|
+
* @returns {string} bounded, sanitized excerpt safe to embed in Error.message
|
|
597
|
+
*/
|
|
598
|
+
export function sanitizeOutputExcerptForLog(output) {
|
|
599
|
+
return sanitizeAndCap(String(output), MAX_LOGGED_OUTPUT_EXCERPT_LEN);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Best-effort extraction of a Claude CLI session-limit reset time from
|
|
604
|
+
* captured stdout/stderr (forge#2241). The CLI's own session-limit message
|
|
605
|
+
* looks like `You've hit your session limit · resets 12:50am (Asia/Calcutta)`
|
|
606
|
+
* — when present, this lets a non-zero-exit CLI_BACKEND_FAILED surface *when*
|
|
607
|
+
* the quota resets instead of forcing a human to read raw logs (issue #2241
|
|
608
|
+
* AC3). Deliberately narrow and case-insensitive: anchored on the literal
|
|
609
|
+
* "session limit" phrase plus a trailing "resets ..." clause, so it can never
|
|
610
|
+
* misattribute an unrelated crash's output as a reset time (see #2258/#2277/
|
|
611
|
+
* #2292/#2293 — this same diagnostic branch has repeatedly needed defensive
|
|
612
|
+
* narrowing around untrusted CLI output).
|
|
613
|
+
*
|
|
614
|
+
* @param {string} output - combined, already-captured stdout+stderr text
|
|
615
|
+
* @returns {string|undefined} the reset-time text (trimmed), or undefined if
|
|
616
|
+
* no session-limit pattern was found — callers must not fabricate a value
|
|
617
|
+
* when this returns undefined.
|
|
618
|
+
*/
|
|
619
|
+
export function extractSessionLimitResetTime(output) {
|
|
620
|
+
if (!output) return undefined;
|
|
621
|
+
const match = /session limit[^\n]*?resets?\s+([^\n]+?)\s*$/im.exec(output);
|
|
622
|
+
if (!match) return undefined;
|
|
623
|
+
const resetAt = match[1].trim();
|
|
624
|
+
if (resetAt.length === 0) return undefined;
|
|
625
|
+
// Security review finding (forge#2241, PR #2323): `resetAt` is extracted
|
|
626
|
+
// from raw, untrusted CLI stdout/stderr — the same `output` that #2277/
|
|
627
|
+
// #2292/#2293 fixed control-char/bidi-override/surrogate-pair injection
|
|
628
|
+
// for in the neighboring CLI_BACKEND_FAILED diagnostic. Route it through
|
|
629
|
+
// the same sanitizeArgvForLog() escaping + length-capping used for
|
|
630
|
+
// argvSummary before it can reach err.resetAt -> the engine-error
|
|
631
|
+
// terminate detail -> operator-visible terminal/CI logs, so this new call
|
|
632
|
+
// site inherits the identical defensive posture instead of reopening that
|
|
633
|
+
// threat model via a fresh, unsanitized path.
|
|
634
|
+
return sanitizeArgvForLog([resetAt]);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Computes the UTC offset (in ms) `timeZone` observes at `instantMs`, defined
|
|
639
|
+
* such that `wallClockAsUTC = instantMs + offset` — i.e. a positive offset
|
|
640
|
+
* means the zone is ahead of UTC at that instant. Used by
|
|
641
|
+
* `wallTimeInZoneToEpochMs()` below to convert a local wall-clock time back
|
|
642
|
+
* into a real UTC epoch, correctly handling DST for zones that observe it
|
|
643
|
+
* (though the only zone shape actually seen in production CLI output today —
|
|
644
|
+
* `Asia/Calcutta`, a fixed +5:30 IANA link name with no DST — never needs the
|
|
645
|
+
* iteration this enables; the general form is kept so any zone name the CLI
|
|
646
|
+
* reports is handled correctly, not just the one observed so far).
|
|
647
|
+
*
|
|
648
|
+
* @param {number} instantMs - a real UTC epoch instant
|
|
649
|
+
* @param {string} timeZone - IANA zone name (e.g. "Asia/Calcutta")
|
|
650
|
+
* @returns {number} offset in ms
|
|
651
|
+
*/
|
|
652
|
+
function tzOffsetMsAtInstant(instantMs, timeZone) {
|
|
653
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
654
|
+
timeZone,
|
|
655
|
+
hourCycle: "h23",
|
|
656
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
657
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
658
|
+
});
|
|
659
|
+
const parts = Object.fromEntries(dtf.formatToParts(new Date(instantMs)).map((p) => [p.type, p.value]));
|
|
660
|
+
const localAsUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
|
|
661
|
+
return localAsUTC - instantMs;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Converts a local wall-clock time `(y, mo, d, h, mi, s)` *in* `timeZone*
|
|
666
|
+
* into the real UTC epoch ms instant it represents. Two-iteration
|
|
667
|
+
* fixed-point refinement using `tzOffsetMsAtInstant()` above — sufficient
|
|
668
|
+
* for every real-world zone (including DST transitions, which shift the
|
|
669
|
+
* offset by at most a couple of hours, well within one refinement step).
|
|
670
|
+
*
|
|
671
|
+
* @returns {number} UTC epoch ms
|
|
672
|
+
*/
|
|
673
|
+
function wallTimeInZoneToEpochMs(y, mo, d, h, mi, s, timeZone) {
|
|
674
|
+
const naiveUtc = Date.UTC(y, mo - 1, d, h, mi, s);
|
|
675
|
+
let guess = naiveUtc;
|
|
676
|
+
for (let i = 0; i < 2; i++) {
|
|
677
|
+
guess = naiveUtc - tzOffsetMsAtInstant(guess, timeZone);
|
|
678
|
+
}
|
|
679
|
+
return guess;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Parses the CLI's session-limit reset-time text (forge#2241's
|
|
684
|
+
* `extractSessionLimitResetTime()` output — e.g. `"12:50am (Asia/Calcutta)"`)
|
|
685
|
+
* into a machine-usable UTC epoch ms timestamp (forge#2524 AC5). The CLI only
|
|
686
|
+
* ever reports a bare time-of-day plus an IANA zone name, never a date — the
|
|
687
|
+
* reset is always the *next* occurrence of that wall-clock time from `nowMs`,
|
|
688
|
+
* so this resolves "today" in the target zone and rolls forward one day if
|
|
689
|
+
* that candidate has already passed.
|
|
690
|
+
*
|
|
691
|
+
* Deliberately narrow, mirroring `extractSessionLimitResetTime()`'s own
|
|
692
|
+
* "never fabricate a value" contract: returns `undefined` on any input that
|
|
693
|
+
* doesn't match the exact expected shape, or names a timezone
|
|
694
|
+
* `Intl.DateTimeFormat` doesn't recognize — callers must treat `undefined` as
|
|
695
|
+
* "could not compute a wait duration", never substitute a guessed default.
|
|
696
|
+
*
|
|
697
|
+
* @param {string|undefined} resetAtText - the (already-sanitized) display
|
|
698
|
+
* string from `extractSessionLimitResetTime()`
|
|
699
|
+
* @param {number} [nowMs] - current time; defaults to `Date.now()`, overridable for tests
|
|
700
|
+
* @returns {number|undefined} UTC epoch ms of the next occurrence of that
|
|
701
|
+
* wall-clock time in that zone, or `undefined` if unparseable
|
|
468
702
|
*/
|
|
703
|
+
export function parseSessionLimitResetEpochMs(resetAtText, nowMs = Date.now()) {
|
|
704
|
+
if (!resetAtText) return undefined;
|
|
705
|
+
const match = /^(\d{1,2}):(\d{2})\s*([ap]m)\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
|
|
706
|
+
if (!match) return undefined;
|
|
707
|
+
const [, hourStr, minuteStr, ampm, timeZone] = match;
|
|
708
|
+
const rawHour = parseInt(hourStr, 10);
|
|
709
|
+
// Reject out-of-range hours BEFORE the `% 12` conversion below — otherwise
|
|
710
|
+
// malformed input like "13:30pm" or "99:30pm" silently aliases to a
|
|
711
|
+
// plausible-but-wrong hour (13 % 12 = 1, 99 % 12 = 3) instead of being
|
|
712
|
+
// rejected, violating this function's own "never fabricate a value"
|
|
713
|
+
// contract. Mirrors the `minute > 59` guard further down.
|
|
714
|
+
if (rawHour < 1 || rawHour > 12) return undefined;
|
|
715
|
+
let hour = rawHour % 12;
|
|
716
|
+
if (/pm/i.test(ampm)) hour += 12;
|
|
717
|
+
const minute = parseInt(minuteStr, 10);
|
|
718
|
+
if (!Number.isFinite(hour) || !Number.isFinite(minute) || minute > 59) return undefined;
|
|
719
|
+
|
|
720
|
+
let todayParts;
|
|
721
|
+
try {
|
|
722
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
723
|
+
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
724
|
+
});
|
|
725
|
+
todayParts = Object.fromEntries(dtf.formatToParts(new Date(nowMs)).map((p) => [p.type, p.value]));
|
|
726
|
+
} catch {
|
|
727
|
+
// Unrecognized IANA zone name — Intl throws a RangeError. Do not fabricate
|
|
728
|
+
// a value; the caller must treat this identically to any other parse failure.
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const y = +todayParts.year, mo = +todayParts.month, d = +todayParts.day;
|
|
733
|
+
let epoch = wallTimeInZoneToEpochMs(y, mo, d, hour, minute, 0, timeZone);
|
|
734
|
+
// The CLI reports a reset time that is, by definition, in the future
|
|
735
|
+
// relative to when it printed the message — if today's candidate has
|
|
736
|
+
// already passed `nowMs`, the real reset is tomorrow at the same wall-clock time.
|
|
737
|
+
if (epoch <= nowMs) {
|
|
738
|
+
epoch = wallTimeInZoneToEpochMs(y, mo, d + 1, hour, minute, 0, timeZone);
|
|
739
|
+
}
|
|
740
|
+
return epoch;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Coerce a single CLI-reported `usage.*` field to a finite number, or `0`
|
|
745
|
+
* when it isn't one (forge#2424). `?? 0` only replaces `null`/`undefined` —
|
|
746
|
+
* it does not coerce or reject other non-numeric values (e.g. a string), so
|
|
747
|
+
* a malformed/unexpected field previously flowed through unchanged and later
|
|
748
|
+
* string-concatenated instead of numerically adding in
|
|
749
|
+
* bin/batch-runner.mjs's `tokenCost()` (`input + output`). Applied to all
|
|
750
|
+
* four `usage.*` fields parsed from the CLI's `--output-format json`
|
|
751
|
+
* envelope, which — unlike the API backend's SDK-typed `response.usage` — is
|
|
752
|
+
* arbitrary CLI stdout and must not be trusted to already be well-typed.
|
|
753
|
+
*
|
|
754
|
+
* @param {unknown} value - raw field value from the parsed CLI JSON envelope
|
|
755
|
+
* @returns {number} a finite number, or `0` when `value` is not one
|
|
756
|
+
*/
|
|
757
|
+
function toFiniteUsageNumber(value) {
|
|
758
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
759
|
+
}
|
|
760
|
+
|
|
469
761
|
export function runCliBackend({
|
|
470
762
|
spec,
|
|
471
763
|
userMessage,
|
|
@@ -495,7 +787,13 @@ export function runCliBackend({
|
|
|
495
787
|
// `writeFileSync` still leaves `tmpDir` set and the already-created
|
|
496
788
|
// directory gets cleaned up.
|
|
497
789
|
let tmpDir = null;
|
|
498
|
-
let cliArgs = [
|
|
790
|
+
let cliArgs = [
|
|
791
|
+
"--print",
|
|
792
|
+
userMessage,
|
|
793
|
+
"--output-format",
|
|
794
|
+
"json",
|
|
795
|
+
"--dangerously-skip-permissions",
|
|
796
|
+
];
|
|
499
797
|
|
|
500
798
|
try {
|
|
501
799
|
if (systemPrompt) {
|
|
@@ -506,6 +804,8 @@ export function runCliBackend({
|
|
|
506
804
|
cliArgs = [
|
|
507
805
|
"--print",
|
|
508
806
|
userMessage,
|
|
807
|
+
"--output-format",
|
|
808
|
+
"json",
|
|
509
809
|
"--append-system-prompt-file",
|
|
510
810
|
systemPromptPath,
|
|
511
811
|
"--dangerously-skip-permissions",
|
|
@@ -545,9 +845,25 @@ export function runCliBackend({
|
|
|
545
845
|
const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
|
|
546
846
|
if (timedOut) {
|
|
547
847
|
const timeoutSecs = Math.round(timeoutMs / 1000);
|
|
848
|
+
// forge#2360: spawnSync's `timeout` option kills the child outright on
|
|
849
|
+
// expiry, but any stdout/stderr the CLI had already produced up to
|
|
850
|
+
// that point is still populated on `result` — surface it rather than
|
|
851
|
+
// silently discarding it, mirroring both the `run_bash` timeout path
|
|
852
|
+
// below (which appends its own captured `partial` output) and the
|
|
853
|
+
// sibling `result.status !== 0` branch just below this one (forge#2355
|
|
854
|
+
// / PR #2374), which embeds a bounded/sanitized excerpt of captured
|
|
855
|
+
// output via `sanitizeOutputExcerptForLog()` for the same reason: only
|
|
856
|
+
// `err.message` is persisted into the durable `~/.forge/runs/*.jsonl`
|
|
857
|
+
// run-log (via bin/engine.mjs's `reason: e.message`), never the
|
|
858
|
+
// `logger.log()` call. Reuses the exact same hardened helper — do NOT
|
|
859
|
+
// add a new unsanitized/unbounded path here (this file has 4 prior
|
|
860
|
+
// review findings — #2277, #2292, #2293, #2355 — for that defect
|
|
861
|
+
// class).
|
|
862
|
+
const partial = (stdout + stderr).trim();
|
|
548
863
|
throw new Error(
|
|
549
864
|
`claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
|
|
550
|
-
`Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api
|
|
865
|
+
`Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
|
|
866
|
+
(partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
|
|
551
867
|
);
|
|
552
868
|
}
|
|
553
869
|
if (result.error) {
|
|
@@ -555,23 +871,175 @@ export function runCliBackend({
|
|
|
555
871
|
}
|
|
556
872
|
|
|
557
873
|
const output = (stdout + stderr).trim();
|
|
558
|
-
if (output) logger.log(output);
|
|
559
874
|
|
|
560
875
|
if (result.status !== 0) {
|
|
876
|
+
// Non-zero exit: always emit a self-contained diagnostic, regardless of
|
|
877
|
+
// whether stdout/stderr captured anything. Previously this branch threw
|
|
878
|
+
// a self-referential message that unconditionally pointed to
|
|
879
|
+
// previously-logged output, even when `output` was empty (the
|
|
880
|
+
// success-path log call above was gated on `if (output)` and never
|
|
881
|
+
// ran) -- leaving the operator with nothing to consult. See issue
|
|
882
|
+
// #2258 / parent #2244.
|
|
883
|
+
const hadOutput = output.length > 0;
|
|
884
|
+
const signalPart = result.signal ? `, signal ${result.signal}` : "";
|
|
885
|
+
// Sanitized for the diagnostic string ONLY — the actual `cliArgs` array
|
|
886
|
+
// above (passed to spawnFn) is untouched. See sanitizeArgvForLog's doc
|
|
887
|
+
// comment for why (#2277 — untrusted userMessage content must not reach
|
|
888
|
+
// stderr/CI logs unbounded/unescaped).
|
|
889
|
+
const argvSummary = sanitizeArgvForLog(cliArgs);
|
|
890
|
+
const diagnostic = hadOutput
|
|
891
|
+
? `Captured output (stdout+stderr):\n${output}`
|
|
892
|
+
: "No output was captured on stdout or stderr.";
|
|
893
|
+
logger.log(diagnostic);
|
|
894
|
+
|
|
895
|
+
// forge#2355: when `hadOutput` is true, embed a bounded/sanitized
|
|
896
|
+
// excerpt of the actual captured output directly in the thrown
|
|
897
|
+
// Error's message, rather than only pointing at the `logger.log()`
|
|
898
|
+
// call above. `logger.log()` writes to the orchestrating process's
|
|
899
|
+
// console/CI stream — it is NEVER persisted into the durable
|
|
900
|
+
// `~/.forge/runs/*.jsonl` run-log. Only `e.message` (via
|
|
901
|
+
// `bin/engine.mjs`'s `reason: e.message` / fail-fast `detail` string,
|
|
902
|
+
// both of which already interpolate `e.message` verbatim) reaches that
|
|
903
|
+
// persisted record. Without this, the run-log's `PHASE_FAILED.reason`
|
|
904
|
+
// carried a self-referential pointer to output that was already gone
|
|
905
|
+
// by the time anyone read the log — this was the single dominant
|
|
906
|
+
// engine failure mode (50 of 69 `PHASE_FAILED` events in a 52-run-log
|
|
907
|
+
// audit — see issue #2355). The `!hadOutput` branch below is
|
|
908
|
+
// intentionally left unchanged: it was already fixed by #2258/PR #2276
|
|
909
|
+
// to be self-contained.
|
|
910
|
+
const outputExcerpt = hadOutput ? sanitizeOutputExcerptForLog(output) : "";
|
|
561
911
|
const err = new Error(
|
|
562
|
-
`claude CLI exited with status ${result.status ?? "?"}.
|
|
912
|
+
`claude CLI exited with status ${result.status ?? "?"}${signalPart}. ` +
|
|
913
|
+
(hadOutput
|
|
914
|
+
? `Output: ${outputExcerpt}`
|
|
915
|
+
: "No output was captured (stdout and stderr were both empty).") +
|
|
916
|
+
` Invocation: ${bin} ${argvSummary} (cwd: ${cwd})`,
|
|
563
917
|
);
|
|
564
918
|
err.code = "CLI_BACKEND_FAILED";
|
|
919
|
+
// forge#2241: attach the CLI's reported session-limit reset time (when
|
|
920
|
+
// present) so callers (bin/engine.mjs) can surface *when* a
|
|
921
|
+
// quota-exhaustion failure will clear without reading raw logs. Only
|
|
922
|
+
// ever set when the pattern actually matches — never fabricated.
|
|
923
|
+
const resetAt = extractSessionLimitResetTime(output);
|
|
924
|
+
if (resetAt) {
|
|
925
|
+
err.resetAt = resetAt;
|
|
926
|
+
// forge#2524: also attach a machine-usable epoch-ms timestamp so
|
|
927
|
+
// bin/engine.mjs can compute an actual wait duration and pause
|
|
928
|
+
// in-process instead of merely displaying the reset time. Only ever
|
|
929
|
+
// set when parsing succeeds — never fabricated (mirrors resetAt's
|
|
930
|
+
// own contract). A future/unparseable reset time simply leaves this
|
|
931
|
+
// field absent, and the caller's fail-fast/terminate behavior is
|
|
932
|
+
// unaffected (see bin/engine.mjs's runIssue() pause-loop guard).
|
|
933
|
+
const resetAtEpochMs = parseSessionLimitResetEpochMs(resetAt, Date.now());
|
|
934
|
+
if (resetAtEpochMs !== undefined) err.resetAtEpochMs = resetAtEpochMs;
|
|
935
|
+
}
|
|
565
936
|
throw err;
|
|
566
937
|
}
|
|
567
938
|
|
|
939
|
+
// Parse the `--output-format json` single-result envelope requested
|
|
940
|
+
// above. `claude --print --output-format json` emits a JSON object with
|
|
941
|
+
// (among other fields) a top-level `result` string — the same
|
|
942
|
+
// human-readable text the CLI would otherwise print in plain-text mode —
|
|
943
|
+
// and a top-level `usage` object shaped like the Anthropic SDK's
|
|
944
|
+
// `response.usage` (`input_tokens`/`output_tokens`/
|
|
945
|
+
// `cache_creation_input_tokens`/`cache_read_input_tokens`, plus extra
|
|
946
|
+
// fields we don't need). Older CLI versions that ignore
|
|
947
|
+
// `--output-format` (or any other non-JSON stdout) are handled
|
|
948
|
+
// defensively: any parse failure, or a parsed value missing `usage`,
|
|
949
|
+
// degrades to the pre-existing `usage: null` behavior — this must never
|
|
950
|
+
// throw on the success path.
|
|
951
|
+
//
|
|
952
|
+
// forge#2422: parse `stdout` ALONE here, not `output` (the combined
|
|
953
|
+
// `stdout + stderr` string used above for the timeout/non-zero-exit
|
|
954
|
+
// diagnostics). `--output-format json` writes exactly one JSON object to
|
|
955
|
+
// stdout; any warning/banner text a CLI version writes to stderr on an
|
|
956
|
+
// otherwise-clean, zero-exit run (deprecation notice, Node
|
|
957
|
+
// `--trace-warnings`, etc.) would corrupt the combined string and break
|
|
958
|
+
// `JSON.parse`, silently degrading `usage` to `null` even though valid
|
|
959
|
+
// JSON was on stdout. `output` itself is untouched — the diagnostic
|
|
960
|
+
// branches above still need the combined stream.
|
|
961
|
+
//
|
|
962
|
+
// forge#2424: each usage field is coerced with `toFiniteUsageNumber()`
|
|
963
|
+
// rather than `?? 0`. `?? 0` only replaces `null`/`undefined` — a
|
|
964
|
+
// non-numeric value (e.g. a string) would pass through unchanged and
|
|
965
|
+
// later string-concatenate instead of numerically add in
|
|
966
|
+
// bin/batch-runner.mjs's `tokenCost()` (`input + output`).
|
|
967
|
+
const stdoutTrimmed = stdout.trim();
|
|
968
|
+
let parsedResult = null;
|
|
969
|
+
let usage = null;
|
|
970
|
+
try {
|
|
971
|
+
const parsed = JSON.parse(stdoutTrimmed);
|
|
972
|
+
if (parsed && typeof parsed === "object") {
|
|
973
|
+
parsedResult = typeof parsed.result === "string" ? parsed.result : null;
|
|
974
|
+
if (parsed.usage && typeof parsed.usage === "object") {
|
|
975
|
+
usage = {
|
|
976
|
+
input_tokens: toFiniteUsageNumber(parsed.usage.input_tokens),
|
|
977
|
+
output_tokens: toFiniteUsageNumber(parsed.usage.output_tokens),
|
|
978
|
+
cache_creation_input_tokens: toFiniteUsageNumber(
|
|
979
|
+
parsed.usage.cache_creation_input_tokens,
|
|
980
|
+
),
|
|
981
|
+
cache_read_input_tokens: toFiniteUsageNumber(parsed.usage.cache_read_input_tokens),
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
} catch {
|
|
986
|
+
// Non-JSON output (older CLI, or --output-format was ignored) —
|
|
987
|
+
// parsedResult/usage stay null; fall back to raw output below.
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Prefer the parsed envelope's human-readable `.result` string so
|
|
991
|
+
// console output stays prose, not a raw JSON blob; fall back to the raw
|
|
992
|
+
// captured output when parsing failed or `.result` was absent.
|
|
993
|
+
//
|
|
994
|
+
// forge#2456: `.result` alone would otherwise silently drop any non-empty
|
|
995
|
+
// `stderr` on an exit-0 run (deprecation notice, Node runtime banner,
|
|
996
|
+
// etc.) — a real behavior change from the pre-#2398 baseline, where the
|
|
997
|
+
// combined stdout+stderr stream was always logged. Append trimmed
|
|
998
|
+
// `stderr` after the parsed result when present, so operators still see
|
|
999
|
+
// it; when `stderr` is empty (the common case) the logged string is
|
|
1000
|
+
// unchanged. This mirrors the same "combine streams instead of dropping
|
|
1001
|
+
// one" fix already applied to the `run_bash` tool handler's success path
|
|
1002
|
+
// in #1229. `JSON.parse` itself still targets `stdout` alone (forge#2422)
|
|
1003
|
+
// — only the logged/displayed string composition changes here.
|
|
1004
|
+
//
|
|
1005
|
+
// forge#2483/forge#2484 (batch #2522): two follow-up fixes to the
|
|
1006
|
+
// forge#2456 composition above, applied together since they share the
|
|
1007
|
+
// exact same lines:
|
|
1008
|
+
// - forge#2483: `stderrTrimmed` is untrusted subprocess output (the CLI
|
|
1009
|
+
// echoes untrusted issue/PR body content — the same threat class
|
|
1010
|
+
// `sanitizeArgvForLog`/`sanitizeOutputExcerptForLog` already harden
|
|
1011
|
+
// for the timeout/non-zero-exit diagnostic paths above). It is now
|
|
1012
|
+
// routed through the same `sanitizeOutputExcerptForLog()` helper
|
|
1013
|
+
// before being concatenated into `humanOutput`, instead of reaching
|
|
1014
|
+
// `logger.log()` raw.
|
|
1015
|
+
// - forge#2484: `parsedResult !== null` is true even when
|
|
1016
|
+
// `parsedResult === ""` (the earlier `typeof parsed.result ===
|
|
1017
|
+
// "string"` check accepts `""`). Previously that produced
|
|
1018
|
+
// `"" + "\n" + stderrTrimmed` — a leading-newline-only artifact.
|
|
1019
|
+
// `parsedResult` (truthy check, not `!== null`) now gates the
|
|
1020
|
+
// "prefix with parsedResult" behavior, so an empty `.result` falls
|
|
1021
|
+
// through to the sanitized stderr alone, with no separator.
|
|
1022
|
+
// The `parsedResult === null` fallback branch (raw `output`) is
|
|
1023
|
+
// untouched by either fix — out of scope for both findings.
|
|
1024
|
+
const stderrTrimmed = stderr.trim();
|
|
1025
|
+
const sanitizedStderr = stderrTrimmed ? sanitizeOutputExcerptForLog(stderrTrimmed) : "";
|
|
1026
|
+
const humanOutput =
|
|
1027
|
+
parsedResult !== null
|
|
1028
|
+
? parsedResult
|
|
1029
|
+
? sanitizedStderr
|
|
1030
|
+
? `${parsedResult}\n${sanitizedStderr}`
|
|
1031
|
+
: parsedResult
|
|
1032
|
+
: sanitizedStderr
|
|
1033
|
+
: output;
|
|
1034
|
+
if (humanOutput) logger.log(humanOutput);
|
|
1035
|
+
|
|
568
1036
|
logger.log(
|
|
569
1037
|
renderSummaryCard({
|
|
570
1038
|
command: spec.name,
|
|
571
1039
|
args,
|
|
572
1040
|
iterations: 1,
|
|
573
1041
|
stopReason: "cli_exit_0",
|
|
574
|
-
usage
|
|
1042
|
+
usage,
|
|
575
1043
|
}),
|
|
576
1044
|
);
|
|
577
1045
|
|
|
@@ -580,7 +1048,7 @@ export function runCliBackend({
|
|
|
580
1048
|
command: spec.name,
|
|
581
1049
|
iterations: 1,
|
|
582
1050
|
stopReason: "cli_exit_0",
|
|
583
|
-
usage
|
|
1051
|
+
usage,
|
|
584
1052
|
model: "cli",
|
|
585
1053
|
backend: "cli",
|
|
586
1054
|
};
|