@gajae-code/coding-agent 0.12.2 → 0.12.4
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/CHANGELOG.md +11 -2
- package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +5 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -2
- package/package.json +8 -8
- package/src/config/provider-ranking.ts +1 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -5
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +2 -1
- package/src/gjc-runtime/ultragoal-evidence.ts +160 -31
- package/src/gjc-runtime/ultragoal-runtime.ts +231 -19
- package/src/internal-urls/docs-index.generated.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.12.4] - 2026-07-30
|
|
6
|
+
|
|
7
|
+
## [0.12.3] - 2026-07-30
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- The release cut no longer deletes previously released changelog headings whose body is empty. `releasedChangelogContent` ran `removeEmptyVersionEntries` before transforming `## [Unreleased]`, so cutting a release replaced the prior empty semver heading instead of inserting the new one above it — v0.12.2 dropped the `## [0.12.1] - 2026-07-29` heading from four package changelogs. Released sections are now immutable history and are always preserved.
|
|
12
|
+
|
|
5
13
|
## [0.12.2] - 2026-07-30
|
|
6
14
|
|
|
7
15
|
### Added
|
|
@@ -15,8 +23,9 @@
|
|
|
15
23
|
### Fixed
|
|
16
24
|
|
|
17
25
|
- Terminal input now normalizes Option/Meta navigation and psmux modified-Enter encodings through the native key parser, keeping legacy, Kitty CSI-u, and modifyOtherKeys behavior consistent.
|
|
18
|
-
- Ultragoal CLI replay
|
|
19
|
-
- Ultragoal CLI replay evidence now reads the replay file referenced by an `executorQa.artifactRefs` entry whose `kind` is `cli-replay`.
|
|
26
|
+
- Ultragoal CLI replay no longer executes model-authored test source or trusts `replaySafe: true` as arbitrary command authority. Runtime replay is limited to the pinned Bun runtime for `--version` and literal `-e "console.log(...)"`; shells, interpreter code strings, path-qualified executables, tests, install/publish/network/git mutation commands, and arbitrary argv are rejected. Replay cwd/artifact files are realpath-confined, ambiguous rows fail closed, stdout and stderr are checked, and POSIX timeout cleanup signals the process group. Structured test-report fallback remains deliberately unsupported pending a separate trusted-provenance design (#3533).
|
|
27
|
+
- Ultragoal CLI replay evidence now reads the replay file referenced by an `executorQa.artifactRefs` entry whose `kind` is `cli-replay`. Inline, nested, and file-backed replay forms are disambiguated explicitly; mixed or malformed rows fail closed (#3533).
|
|
28
|
+
|
|
20
29
|
- Broker artifact cleanup no longer promotes a non-empty `cleanup_pending` quarantine to transcript-phase completion. The broker advances only when the retained quarantine is root-only/empty or when the lower layer returns `artifacts_removed`, so artifact bytes cannot vanish behind a success receipt (#3489).
|
|
21
30
|
- `gjc --worktree` / `gjc -w` launch no longer crashes with a raw uncaught `EEXIST` when the worktree bucket directory (`<repo>.gajae-code-worktrees`) is a broken symbolic link to unmounted or offloaded cold storage. The launch distinguishes dangling links and non-directory entries from valid directory symlinks or Windows junctions, reclassifies mkdir races, avoids disclosing raw link targets or unsafe shell commands, and never deletes or replaces an obstructing entry.
|
|
22
31
|
- POSIX parent identity reproof/fsync is now centralized before every promotable artifact-phase result, preventing a crash-window where a rename is lost after durable retirement is recorded (#3489).
|
|
@@ -17,12 +17,16 @@ export declare function validateStructuralArtifact(cwd: string, row: JsonObject,
|
|
|
17
17
|
live: boolean;
|
|
18
18
|
}): Promise<boolean>;
|
|
19
19
|
export declare function isAllowedGitReplayCommand(args: readonly string[]): boolean;
|
|
20
|
+
export declare function resolveCliReplayCommand(command: string[], options?: {
|
|
21
|
+
compiled?: boolean;
|
|
22
|
+
}): string[];
|
|
20
23
|
export declare function readCliReplayRecord(cwd: string, row: JsonObject, fieldName: string): Promise<JsonObject | null>;
|
|
21
24
|
export interface ReplayProcessHandle {
|
|
22
25
|
readonly exited: Promise<number>;
|
|
26
|
+
readonly pid?: number;
|
|
23
27
|
kill(signal?: number | NodeJS.Signals): void;
|
|
24
28
|
}
|
|
25
|
-
export declare function waitForReplayProcessWithTimeout(
|
|
29
|
+
export declare function waitForReplayProcessWithTimeout(handle: ReplayProcessHandle, timeoutMs: number, graceMs?: number): Promise<number>;
|
|
26
30
|
export declare function validateReplayExemptFallback(cwd: string, record: JsonObject, fieldName: string, artifactRefs: Map<string, JsonObject>, options: {
|
|
27
31
|
surfaceFamily: SurfaceFamily;
|
|
28
32
|
live: boolean;
|
|
@@ -315,9 +315,9 @@ export declare function isSubstantiveEvidence(value: unknown): boolean;
|
|
|
315
315
|
export declare function hasTypedVerifiedReceipt(value: unknown): boolean;
|
|
316
316
|
export declare function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise<boolean>;
|
|
317
317
|
export declare function readArtifactBytes(cwd: string, row: JsonObject, fieldName: string): Promise<Buffer | null>;
|
|
318
|
-
import { validateArtifactProof, validateCliReplay, validateLiveSurfaceProofPresence, validateReplayExemptFallback, validateStructuralArtifact, validateSurfaceStructuralRequirement, waitForReplayProcessWithTimeout } from "./ultragoal-evidence";
|
|
318
|
+
import { resolveCliReplayCommand, validateArtifactProof, validateCliReplay, validateLiveSurfaceProofPresence, validateReplayExemptFallback, validateStructuralArtifact, validateSurfaceStructuralRequirement, waitForReplayProcessWithTimeout } from "./ultragoal-evidence";
|
|
319
319
|
export type { ReplayProcessHandle } from "./ultragoal-evidence";
|
|
320
|
-
export { validateArtifactProof, validateCliReplay, validateLiveSurfaceProofPresence, validateReplayExemptFallback, validateStructuralArtifact, validateSurfaceStructuralRequirement, waitForReplayProcessWithTimeout, };
|
|
320
|
+
export { resolveCliReplayCommand, validateArtifactProof, validateCliReplay, validateLiveSurfaceProofPresence, validateReplayExemptFallback, validateStructuralArtifact, validateSurfaceStructuralRequirement, waitForReplayProcessWithTimeout, };
|
|
321
321
|
export declare function validateExecutorQaRedTeamEvidenceForReview(cwd: string, executorQa: Record<string, unknown>, options?: {
|
|
322
322
|
mode?: "review";
|
|
323
323
|
changeSet?: UltragoalChangeSet;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/coding-agent",
|
|
4
|
-
"version": "0.12.
|
|
4
|
+
"version": "0.12.4",
|
|
5
5
|
"description": "Gajae Code CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -53,13 +53,13 @@
|
|
|
53
53
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
54
54
|
"@babel/parser": "^7.29.3",
|
|
55
55
|
"@mozilla/readability": "^0.6.0",
|
|
56
|
-
"@gajae-code/stats": "0.12.
|
|
57
|
-
"@gajae-code/agent-core": "0.12.
|
|
58
|
-
"@gajae-code/ai": "0.12.
|
|
59
|
-
"@gajae-code/bridge-client": "0.12.
|
|
60
|
-
"@gajae-code/natives": "0.12.
|
|
61
|
-
"@gajae-code/tui": "0.12.
|
|
62
|
-
"@gajae-code/utils": "0.12.
|
|
56
|
+
"@gajae-code/stats": "0.12.4",
|
|
57
|
+
"@gajae-code/agent-core": "0.12.4",
|
|
58
|
+
"@gajae-code/ai": "0.12.4",
|
|
59
|
+
"@gajae-code/bridge-client": "0.12.4",
|
|
60
|
+
"@gajae-code/natives": "0.12.4",
|
|
61
|
+
"@gajae-code/tui": "0.12.4",
|
|
62
|
+
"@gajae-code/utils": "0.12.4",
|
|
63
63
|
"@puppeteer/browsers": "^2.13.0",
|
|
64
64
|
"@types/turndown": "5.0.6",
|
|
65
65
|
"@xterm/headless": "^6.0.0",
|
|
@@ -230,9 +230,22 @@ Team remains explicit and separate: Team is not auto-launched, not a hidden pipe
|
|
|
230
230
|
|
|
231
231
|
Heavyweight review runs **once per boundary**, not once per story. In aggregate mode the whole required-goal set is one implicit boundary by default: every checkpoint before the run's final required goal may present the lightweight `deferredToBatch` gate, and only the final goal carries the full strict gate. Nothing needs to be declared to get this — it is the default.
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
A deferred gate is just the proof the runtime cannot know: that targeted verification ran. Everything mechanical — `kind`, the batch tuple, `deferredLanes`, and the whole `changeSet` block (`paths`, `changeSetHash`) — is auto-filled from durable state and the computed cumulative git diff. Never hand-compute a hash. The minimal valid gate:
|
|
234
234
|
|
|
235
|
-
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"deferredToBatch": {
|
|
238
|
+
"ranLanes": ["targetedVerification"],
|
|
239
|
+
"targetedVerification": {
|
|
240
|
+
"status": "passed",
|
|
241
|
+
"commands": ["bun test <targeted suite>"],
|
|
242
|
+
"evidence": "what was verified and how it passed"
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`deferredToBatch.ranLanes` lists the lanes you actually ran (`targetedVerification`, plus optionally `aiSlopCleaner` / `iteration`); declaration and evidence must match in both directions. `ranLanes` can never claim `architectReview` or `executorQa`, and a deferred gate can never contain `architectReview`, `executorQa`, or `validationBatchClose` — review always belongs to the boundary, and deferring never manufactures approvals. Any optional field you do supply must match reality; a wrong value fails closed. Check with `gjc ultragoal quality-gate validate` before checkpointing.
|
|
236
249
|
|
|
237
250
|
### Validation batches (explicit phase/module boundaries)
|
|
238
251
|
|
|
@@ -247,7 +260,7 @@ gjc ultragoal create-goals --brief-file <path> --validation-batch-json '[{"schem
|
|
|
247
260
|
Checkpoint contract summary — the full contract lives in the `validation-batch-contracts` fragment (`skill-fragments/ultragoal/validation-batch-contracts.md`); load it before checkpointing any batch member:
|
|
248
261
|
|
|
249
262
|
- **Non-final members** checkpoint `complete` with a single top-level `deferredToBatch` quality gate (kind `validation-batch-deferred`) proving targeted verification, a declaration-matched lane set, and a cumulative-since-base change set — never `architectReview`, `executorQa`, or `validationBatchClose`; deferring never manufactures fake review approvals.
|
|
250
|
-
- **The final member** (`finalGoalId`) checkpoints `complete` with the normal full strict gate PLUS a top-level `validationBatchClose` proof covering all members; out-of-order close is rejected, close state is append-only proof on the final member only, and batch invalidation is fail-closed.
|
|
263
|
+
- **The final member** (`finalGoalId`) checkpoints `complete` with the normal full strict gate PLUS a top-level `validationBatchClose` proof covering all members; out-of-order close is rejected, close state is append-only proof on the final member only, and batch invalidation is fail-closed. Like the deferred gate, every close field except `coverageEvidence` is auto-filled from durable receipts and the computed diff — the minimal close is `{"validationBatchClose":{"coverageEvidence":"..."}}` alongside the strict gate.
|
|
251
264
|
|
|
252
265
|
### Intra-goal validation-lane parallelism
|
|
253
266
|
|
|
@@ -291,7 +304,7 @@ One generation freezes the change set and reviews it exactly once:
|
|
|
291
304
|
5. Delegate an `executor` QA/red-team lane with typed `executionMode: "ultragoal-red-team"` (preferred) — or assignment text that explicitly labels Ultragoal completion QA/red-team — to build and run the e2e/red-teaming QA suite appropriate for the story. A bare `executorQa` field-name mention is not enough to activate the mode. This lane must try to break the change, not just confirm the happy path. It must start from the approved plan/spec/acceptance criteria, then user-facing contracts, and only then implementation code as supporting evidence. Plan/code mismatches are blockers, not items to paper over with implementation intent.
|
|
292
305
|
6. The executor QA/red-team lane must prove evidence by the real surface under test:
|
|
293
306
|
- GUI/web surfaces require a valid automation transcript plus a non-uniform screenshot. Bare `inlineEvidence` text or typed receipts never prove live GUI/web execution.
|
|
294
|
-
- CLI surfaces require runtime argv replay
|
|
307
|
+
- CLI surfaces require a safe runtime argv replay (`schemaVersion: 1`, `kind: "cli-replay"`, `replaySafe: true`) or the existing audited `replayExempt` path with a screenshot, automation, or PTY structural fallback. Runtime replay is limited to the pinned Bun runtime for `bun --version` or literal `bun -e "console.log(...)"`; the gate never executes model-authored test files. Shells, interpreters with code strings, path-qualified executables, package/git/network mutation commands, `bun test`, and arbitrary argv are rejected. Structured `test-report` fallback remains unsupported pending a separately reviewed provenance design.
|
|
295
308
|
- Native/desktop/tui surfaces require a structurally valid screenshot, PTY capture with terminal control codes, or app-automation transcript.
|
|
296
309
|
- API/package surfaces require a real artifact file or typed receipt whose artifact `kind` contains one of `api`, `package`, `consumer`, `black-box`, or `test-report`; examples: `api-package-test-report`, `package-consumer-report`, `black-box-api-receipt`. Algorithm/math surfaces require a real artifact file or typed receipt whose artifact `kind` contains one of `property`, `boundary`, `edge`, `adversarial`, `failure`, `math`, `algorithm`, or `test-report`; examples: `property-test-report`, `algorithm-boundary-report`. Bare `inlineEvidence` text alone is not sufficient for any surface.
|
|
297
310
|
- The mandatory **computer-use** red-team suite (`kill-switch-bypass`, `suspended-enforcement`, `permission-revoked`, …) is conditional, not universal: require it only when computer/desktop control is genuinely part of the product surface being dogfooded. For every other product type, prove the change through the matching live surface instead — browser-use automation for web/GUI, bash/CLI live invocation or argv replay for CLI, and real artifacts or typed receipts for API/package/algorithm/math. Editing docs, prompts, or skills that merely mention computer-use does not by itself make the computer-use suite applicable; pick the red-team surface that matches what the change actually ships.
|
|
@@ -364,7 +377,11 @@ Validate before you checkpoint. `gjc ultragoal quality-gate validate --quality-g
|
|
|
364
377
|
|
|
365
378
|
Provide one `artifactRefs` entry per live surface actually exercised, using the surface-appropriate `kind` and evidence rules from steps 6–7 above; the CLI rejects missing or shallow gates. `status: "not_applicable"` rows are allowed only in `contractCoverage` and `surfaceEvidence` and each requires `contractRef` plus `reason`.
|
|
366
379
|
|
|
367
|
-
For CLI replay artifacts, the JSON at `path` must be an object like `{"schemaVersion":1,"kind":"cli-replay","replaySafe":true,"command":["bun","-e","console.log(\"ultragoal-cli-ok\")"],"cwd":".","env":{"LC_ALL":"C"},"timeoutMs":30000,"expectedExitCode":0,"recordedStdout":"ultragoal-cli-ok\n","recordedStderr":"","invariants":[{"type":"substring","value":"ultragoal-cli-ok"},{"type":"
|
|
380
|
+
For safe CLI replay artifacts, the JSON at `path` must be an object like `{"schemaVersion":1,"kind":"cli-replay","replaySafe":true,"command":["bun","-e","console.log(\"ultragoal-cli-ok\")"],"cwd":".","env":{"LC_ALL":"C"},"timeoutMs":30000,"expectedExitCode":0,"recordedStdout":"ultragoal-cli-ok\n","recordedStderr":"","invariants":[{"type":"substring","value":"ultragoal-cli-ok"},{"type":"not_substring","value":"error"}]}`. `replaySafe: true` is required but is never authority by itself: executable replay is limited to the pinned Bun runtime for `bun --version` or deterministic literal `bun -e "console.log(...)"`. Shells, nested interpreters, path-qualified executables, test source, install/publish commands, git mutation, network clients, and every other argv are rejected. The declared cwd and artifact files are realpath-confined beneath the repository, but the safe probe itself runs from a fresh empty temporary cwd/home so repository `bunfig.toml` preloads and user configuration cannot execute. Mixed inline/nested/file-backed rows fail closed, POSIX timeout cleanup signals the replay process group, stdout and stderr are validated after normalization, output is capped at 1 MiB, and the child environment is scrubbed to `CI`, `NO_COLOR`, `GJC_ULTRAGOAL_REPLAY`, trusted temporary `HOME`/`TMPDIR`, plus optional `LANG`, `LC_ALL`, `LC_CTYPE`, and `TZ`.
|
|
381
|
+
|
|
382
|
+
Compiled GJC binaries fail executable replay closed because their `process.execPath` launches GJC rather than a Bun CLI. Those runs must use the existing audited `replayExempt` structural fallback; the validator never resolves an untrusted `bun` from `PATH`.
|
|
383
|
+
|
|
384
|
+
Focused `bun test` execution is blocked because repository test source is still arbitrary host code without an operating-system sandbox. The current `replayExempt` contract continues to require an existing screenshot, automation transcript, or PTY structural fallback; a `test-report` or `bun-test-report` JSON file is intentionally not accepted yet. Keep that design work open until test-result provenance, output binding, and consumer authority can be made fail-closed. Allowed `reasonCode` values remain `unsafe_side_effect`, `requires_credentials`, `requires_network`, `non_deterministic_external`, `destructive`, `interactive_only`, and `platform_unavailable`.
|
|
368
385
|
|
|
369
386
|
## Terminal critic gate
|
|
370
387
|
|
|
@@ -6,7 +6,8 @@ Internal Ultragoal sub-skill fragment (`kind: "skill-fragment"`, parent skill `u
|
|
|
6
6
|
|
|
7
7
|
- **Non-final members** checkpoint `complete` with a single top-level `deferredToBatch` quality gate (kind `validation-batch-deferred`): a passing `targetedVerification` lane plus a cumulative-since-base change set. An ai-slop-cleaner pass and a full verification rerun are boundary duties and are optional here; when either is supplied it must pass and be blocker-free. A `deferredToBatch` gate must NOT contain `architectReview`, `executorQa`, or `validationBatchClose` — deferring never manufactures fake review approvals.
|
|
8
8
|
- **Lane declaration is fail-closed.** `deferredToBatch.ranLanes` declares which lanes actually ran. A declared lane whose evidence is missing is rejected, evidence for an undeclared lane is rejected, `targetedVerification` must always be declared when `ranLanes` is present, and `ranLanes` can never declare `architectReview` or `executorQa`. Omitting `ranLanes` is allowed only for a gate that carries the mandatory targeted lane alone.
|
|
9
|
-
- **
|
|
9
|
+
- **Derivable fields are auto-filled — never hand-compute a hash.** When omitted, the runtime fills `kind`, the batch tuple, `deferredLanes`, and the whole `changeSet` block (`memberGoalId`, `cumulativeFromBase`, `paths` from the computed cumulative diff, `changeSetHash`); the minimal deferred gate is `{"deferredToBatch":{"targetedVerification":{"status":"passed","commands":["..."],"evidence":"..."}}}`. A supplied value must still match reality; `changeSet.paths` rows may be plain path strings or `{path, status}` objects.
|
|
10
|
+
- **The final member** (`finalGoalId`) checkpoints `complete` with the normal full strict gate PLUS a top-level `validationBatchClose` proof that covers all member IDs, member metadata hashes, member receipt/checkpoint-ledger-event IDs, per-member change-set hashes, and union change-set coverage. Every close field except `coverageEvidence` is auto-filled from durable receipts and the computed cumulative diff when omitted — the minimal close is `{"validationBatchClose":{"coverageEvidence":"..."}}` alongside the strict gate, and a supplied value must still match durable state. The final close only starts once every non-final member is already `complete` with a structurally fresh deferred receipt (out-of-order close is rejected).
|
|
10
11
|
- Close state is append-only proof: it lives in the final member's checkpoint receipt and matching `goal_checkpointed` ledger row only. Never stamp `closedReceiptId`/`closedAt` or any close-state field onto member goals, and never append a separate close ledger event.
|
|
11
12
|
- Change sets are cumulative-since-base: each member's `changeSet.paths` is the whole-worktree diff vs base (`cumulativeFromBase: true`), `memberGoalId` is a label not a per-path attribution, and `unionChangeSet.paths` carries no per-goal attribution.
|
|
12
13
|
- Batch invalidation is fail-closed: steering mutations that would invalidate a batch are rejected while any member holds a fresh deferred receipt.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
1
3
|
import * as path from "node:path";
|
|
2
4
|
import { inflateSync } from "node:zlib";
|
|
5
|
+
import { isCompiledBinary } from "@gajae-code/utils/env";
|
|
3
6
|
import {
|
|
4
7
|
evidenceKindMatches,
|
|
5
8
|
hasExistingNonEmptyArtifact,
|
|
@@ -382,6 +385,25 @@ function clampCliReplayTimeout(value: unknown): number {
|
|
|
382
385
|
function basenameCommand(value: string): string {
|
|
383
386
|
return path.basename(value).toLowerCase();
|
|
384
387
|
}
|
|
388
|
+
|
|
389
|
+
function isDeterministicConsoleLogReplay(code: string): boolean {
|
|
390
|
+
let remaining = code.trim();
|
|
391
|
+
if (remaining.length === 0) return false;
|
|
392
|
+
let matched = false;
|
|
393
|
+
while (remaining.length > 0) {
|
|
394
|
+
const match =
|
|
395
|
+
/^console\.log\(\s*("(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\$])*`)\s*\)\s*;?\s*/.exec(
|
|
396
|
+
remaining,
|
|
397
|
+
);
|
|
398
|
+
if (!match) return false;
|
|
399
|
+
const statement = match[0]!;
|
|
400
|
+
const literal = match[1]!;
|
|
401
|
+
if (literal.startsWith("`") && literal.includes("${")) return false;
|
|
402
|
+
matched = true;
|
|
403
|
+
remaining = remaining.slice(statement.length);
|
|
404
|
+
}
|
|
405
|
+
return matched;
|
|
406
|
+
}
|
|
385
407
|
function hasShellRedirectionToken(value: string): boolean {
|
|
386
408
|
return /^(?:[<>]|\d?[<>]|\d?>&\d|\|\|?|&&|;)$/.test(value) || /(?:^|[^\w])-?>/.test(value);
|
|
387
409
|
}
|
|
@@ -413,19 +435,73 @@ export function isAllowedGitReplayCommand(args: readonly string[]): boolean {
|
|
|
413
435
|
return true;
|
|
414
436
|
}
|
|
415
437
|
|
|
416
|
-
function
|
|
417
|
-
|
|
418
|
-
|
|
438
|
+
function isBareExecutableName(value: string): boolean {
|
|
439
|
+
return (
|
|
440
|
+
value.length > 0 &&
|
|
441
|
+
!value.includes("/") &&
|
|
442
|
+
!value.includes("\\") &&
|
|
443
|
+
value === path.basename(value) &&
|
|
444
|
+
value === value.toLowerCase()
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isAllowedCliReplayCommand(command: readonly string[]): boolean {
|
|
449
|
+
if (
|
|
450
|
+
command.length === 0 ||
|
|
451
|
+
command.some(arg => arg.trim() !== arg || arg.length === 0 || hasShellRedirectionToken(arg))
|
|
452
|
+
)
|
|
453
|
+
return false;
|
|
454
|
+
if (!isBareExecutableName(command[0]!) || command[0] !== "bun") return false;
|
|
455
|
+
const args = command.slice(1);
|
|
456
|
+
if (args.length === 1 && args[0] === "--version") return true;
|
|
457
|
+
return args.length === 2 && args[0] === "-e" && isDeterministicConsoleLogReplay(args[1]!);
|
|
419
458
|
}
|
|
420
459
|
|
|
421
|
-
function
|
|
460
|
+
function summarizeBlockedCliReplayCommand(command: readonly string[]): string {
|
|
461
|
+
const executable = command[0] ? basenameCommand(command[0]) : "<missing>";
|
|
462
|
+
const argCount = Math.max(0, command.length - 1);
|
|
463
|
+
return `${JSON.stringify(executable)} with ${argCount} arg${argCount === 1 ? "" : "s"}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function cliReplayAllowlistDescription(): string {
|
|
467
|
+
return '`bun --version` or deterministic `bun -e "console.log(...)"`; focused bun test execution is blocked and replayExempt still requires an existing screenshot, automation, or PTY structural fallback';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function resolveCliReplayCommand(command: string[], options?: { compiled?: boolean }): string[] {
|
|
471
|
+
if (options?.compiled ?? isCompiledBinary()) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
"CLI replay execution is unavailable in the compiled GJC runtime because process.execPath is the GJC application, not a Bun CLI executable",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return [process.execPath, ...command.slice(1)];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function resolveUnderCwd(cwd: string, replayCwd: unknown, fieldName: string): Promise<string> {
|
|
422
480
|
const relative = replayCwd === undefined ? "." : nonEmptyString(replayCwd);
|
|
423
481
|
if (!relative) throw new Error(`qualityGate ${fieldName}.cwd must be a non-empty string when provided`);
|
|
424
|
-
const
|
|
425
|
-
const
|
|
482
|
+
const lexicalRoot = path.resolve(cwd);
|
|
483
|
+
const lexical = path.resolve(lexicalRoot, relative);
|
|
484
|
+
const relativeToLexicalRoot = path.relative(lexicalRoot, lexical);
|
|
485
|
+
if (
|
|
486
|
+
relativeToLexicalRoot === ".." ||
|
|
487
|
+
relativeToLexicalRoot.startsWith(`..${path.sep}`) ||
|
|
488
|
+
path.isAbsolute(relativeToLexicalRoot)
|
|
489
|
+
) {
|
|
490
|
+
throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd`);
|
|
491
|
+
}
|
|
492
|
+
let root: string;
|
|
493
|
+
let resolved: string;
|
|
494
|
+
try {
|
|
495
|
+
[root, resolved] = await Promise.all([fs.realpath(lexicalRoot), fs.realpath(lexical)]);
|
|
496
|
+
} catch {
|
|
497
|
+
throw new Error(`qualityGate ${fieldName}.cwd must reference an existing directory under the repository cwd`);
|
|
498
|
+
}
|
|
426
499
|
const relativeToRoot = path.relative(root, resolved);
|
|
427
500
|
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
|
|
428
|
-
throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd`);
|
|
501
|
+
throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd without symlink escape`);
|
|
502
|
+
}
|
|
503
|
+
if (!(await fs.stat(resolved)).isDirectory()) {
|
|
504
|
+
throw new Error(`qualityGate ${fieldName}.cwd must reference a directory`);
|
|
429
505
|
}
|
|
430
506
|
return resolved;
|
|
431
507
|
}
|
|
@@ -467,16 +543,40 @@ function normalizeCliReplayOutput(value: string, cwd: string): string {
|
|
|
467
543
|
}
|
|
468
544
|
|
|
469
545
|
export async function readCliReplayRecord(cwd: string, row: JsonObject, fieldName: string): Promise<JsonObject | null> {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
546
|
+
const nestedReplay = row.replay === undefined ? null : qualityGateObject(row.replay);
|
|
547
|
+
if (row.replay !== undefined && !nestedReplay) {
|
|
548
|
+
throw new Error(`qualityGate ${fieldName}.replay must be an object when provided`);
|
|
549
|
+
}
|
|
550
|
+
const inlineFieldNames = [
|
|
551
|
+
"schemaVersion",
|
|
552
|
+
"replaySafe",
|
|
553
|
+
"command",
|
|
554
|
+
"cwd",
|
|
555
|
+
"env",
|
|
556
|
+
"timeoutMs",
|
|
557
|
+
"expectedExitCode",
|
|
558
|
+
"recordedStdout",
|
|
559
|
+
"recordedStderr",
|
|
560
|
+
"normalization",
|
|
561
|
+
"invariants",
|
|
562
|
+
"replayExempt",
|
|
563
|
+
];
|
|
564
|
+
const hasTopLevelInlineFields = inlineFieldNames.some(name => row[name] !== undefined);
|
|
565
|
+
const hasPath = row.path !== undefined;
|
|
566
|
+
if (nestedReplay && (hasPath || hasTopLevelInlineFields)) {
|
|
567
|
+
throw new Error(`qualityGate ${fieldName} must not mix nested replay, artifact path, or top-level replay fields`);
|
|
568
|
+
}
|
|
569
|
+
if (hasTopLevelInlineFields && hasPath) {
|
|
570
|
+
throw new Error(`qualityGate ${fieldName} must not mix inline replay fields with an artifact path`);
|
|
571
|
+
}
|
|
572
|
+
if (nestedReplay) return nestedReplay;
|
|
573
|
+
if (row.kind === "cli-replay" && hasTopLevelInlineFields) return row;
|
|
477
574
|
if (!evidenceKindMatches(normalizedEvidenceKind(row), ["cli-replay", "command-replay"])) return null;
|
|
575
|
+
if (!hasPath) {
|
|
576
|
+
throw new Error(`qualityGate ${fieldName} CLI replay artifact must provide either replay fields or path`);
|
|
577
|
+
}
|
|
478
578
|
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
479
|
-
if (!bytes)
|
|
579
|
+
if (!bytes) throw new Error(`qualityGate ${fieldName} CLI replay artifact path must reference a readable file`);
|
|
480
580
|
try {
|
|
481
581
|
return requireQualityGateObject(JSON.parse(bytes.toString("utf8")), `${fieldName}.replay`);
|
|
482
582
|
} catch (error) {
|
|
@@ -494,6 +594,7 @@ function parseCliReplayRecord(
|
|
|
494
594
|
timeoutMs: number;
|
|
495
595
|
expectedExitCode: number;
|
|
496
596
|
recordedStdout: string;
|
|
597
|
+
recordedStderr: string;
|
|
497
598
|
invariants: JsonObject[];
|
|
498
599
|
} {
|
|
499
600
|
if (record.schemaVersion !== 1) throw new Error(`qualityGate ${fieldName}.schemaVersion must be 1`);
|
|
@@ -505,6 +606,11 @@ function parseCliReplayRecord(
|
|
|
505
606
|
if (!command) throw new Error(`qualityGate ${fieldName}.command must be a non-empty string array`);
|
|
506
607
|
if (record.replaySafe !== true)
|
|
507
608
|
throw new Error(`qualityGate ${fieldName}.replaySafe must be true before CLI replay executes`);
|
|
609
|
+
if (!isAllowedCliReplayCommand(command)) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`qualityGate ${fieldName}.command is not in the deterministic CLI replay allowlist; command ${summarizeBlockedCliReplayCommand(command)} is blocked. Allowed replay commands: ${cliReplayAllowlistDescription()}. For other commands, provide audited replayExempt metadata with reasonCode, reason, approvedBy, and fallbackArtifactRefs that point to a structurally valid fallback artifact.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
508
614
|
if (record.normalization !== undefined && record.normalization !== "default") {
|
|
509
615
|
throw new Error(`qualityGate ${fieldName}.normalization must be default when provided`);
|
|
510
616
|
}
|
|
@@ -526,6 +632,7 @@ function parseCliReplayRecord(
|
|
|
526
632
|
timeoutMs: clampCliReplayTimeout(record.timeoutMs),
|
|
527
633
|
expectedExitCode,
|
|
528
634
|
recordedStdout: record.recordedStdout,
|
|
635
|
+
recordedStderr: typeof record.recordedStderr === "string" ? record.recordedStderr : "",
|
|
529
636
|
invariants,
|
|
530
637
|
};
|
|
531
638
|
}
|
|
@@ -597,11 +704,24 @@ async function collectCliReplayOutput(
|
|
|
597
704
|
|
|
598
705
|
export interface ReplayProcessHandle {
|
|
599
706
|
readonly exited: Promise<number>;
|
|
707
|
+
readonly pid?: number;
|
|
600
708
|
kill(signal?: number | NodeJS.Signals): void;
|
|
601
709
|
}
|
|
602
710
|
|
|
711
|
+
function signalReplayProcessTree(handle: ReplayProcessHandle, signal: NodeJS.Signals): void {
|
|
712
|
+
if (process.platform !== "win32" && Number.isInteger(handle.pid) && (handle.pid ?? 0) > 0) {
|
|
713
|
+
try {
|
|
714
|
+
process.kill(-handle.pid!, signal);
|
|
715
|
+
return;
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
handle.kill(signal);
|
|
721
|
+
}
|
|
722
|
+
|
|
603
723
|
export async function waitForReplayProcessWithTimeout(
|
|
604
|
-
|
|
724
|
+
handle: ReplayProcessHandle,
|
|
605
725
|
timeoutMs: number,
|
|
606
726
|
graceMs = 2000,
|
|
607
727
|
): Promise<number> {
|
|
@@ -611,21 +731,21 @@ export async function waitForReplayProcessWithTimeout(
|
|
|
611
731
|
const timeout = new Promise<typeof timedOut>(resolve => {
|
|
612
732
|
timeoutTimer = setTimeout(() => resolve(timedOut), timeoutMs);
|
|
613
733
|
});
|
|
614
|
-
const first = await Promise.race([
|
|
734
|
+
const first = await Promise.race([handle.exited, timeout]);
|
|
615
735
|
if (first !== timedOut) {
|
|
616
736
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
617
737
|
return first;
|
|
618
738
|
}
|
|
619
|
-
|
|
739
|
+
signalReplayProcessTree(handle, "SIGTERM");
|
|
620
740
|
const killed = Symbol("killed");
|
|
621
741
|
const grace = new Promise<typeof killed>(resolve => {
|
|
622
742
|
graceTimer = setTimeout(() => {
|
|
623
|
-
|
|
743
|
+
signalReplayProcessTree(handle, "SIGKILL");
|
|
624
744
|
resolve(killed);
|
|
625
745
|
}, graceMs);
|
|
626
746
|
});
|
|
627
|
-
await Promise.race([
|
|
628
|
-
await
|
|
747
|
+
await Promise.race([handle.exited, grace]);
|
|
748
|
+
await handle.exited.catch(() => undefined);
|
|
629
749
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
630
750
|
if (graceTimer) clearTimeout(graceTimer);
|
|
631
751
|
throw new Error("timeout");
|
|
@@ -684,18 +804,20 @@ export async function validateCliReplay(
|
|
|
684
804
|
}
|
|
685
805
|
void options.live;
|
|
686
806
|
const replay = parseCliReplayRecord(record, fieldName);
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
cwd: replayCwd,
|
|
690
|
-
env: replay.env,
|
|
691
|
-
stdout: "pipe",
|
|
692
|
-
stderr: "pipe",
|
|
693
|
-
});
|
|
807
|
+
await resolveUnderCwd(cwd, replay.replayCwd, fieldName);
|
|
808
|
+
const executionCwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-cli-replay-"));
|
|
694
809
|
try {
|
|
810
|
+
const subprocess = Bun.spawn(resolveCliReplayCommand(replay.command), {
|
|
811
|
+
cwd: executionCwd,
|
|
812
|
+
env: { ...replay.env, HOME: executionCwd, TMPDIR: executionCwd },
|
|
813
|
+
stdout: "pipe",
|
|
814
|
+
stderr: "pipe",
|
|
815
|
+
detached: process.platform !== "win32",
|
|
816
|
+
});
|
|
695
817
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
696
|
-
collectCliReplayOutput(
|
|
697
|
-
collectCliReplayOutput(
|
|
698
|
-
waitForReplayProcessWithTimeout(
|
|
818
|
+
collectCliReplayOutput(subprocess.stdout),
|
|
819
|
+
collectCliReplayOutput(subprocess.stderr),
|
|
820
|
+
waitForReplayProcessWithTimeout(subprocess, replay.timeoutMs),
|
|
699
821
|
]);
|
|
700
822
|
if (stdout.truncated || stderr.truncated)
|
|
701
823
|
throw new Error(`qualityGate ${fieldName} CLI replay output exceeded 1 MiB buffer cap`);
|
|
@@ -706,6 +828,11 @@ export async function validateCliReplay(
|
|
|
706
828
|
}
|
|
707
829
|
const actualStdout = normalizeCliReplayOutput(stdout.text, cwd);
|
|
708
830
|
const recordedStdout = normalizeCliReplayOutput(replay.recordedStdout, cwd);
|
|
831
|
+
const actualStderr = normalizeCliReplayOutput(stderr.text, cwd);
|
|
832
|
+
const recordedStderr = normalizeCliReplayOutput(replay.recordedStderr, cwd);
|
|
833
|
+
if (actualStderr !== recordedStderr) {
|
|
834
|
+
throw new Error(`qualityGate ${fieldName} CLI replay stderr did not match recordedStderr after normalization`);
|
|
835
|
+
}
|
|
709
836
|
if (!replay.invariants.length || !validateCliReplayInvariants(replay.invariants, actualStdout, fieldName)) {
|
|
710
837
|
if (actualStdout !== recordedStdout) {
|
|
711
838
|
throw new Error(
|
|
@@ -719,6 +846,8 @@ export async function validateCliReplay(
|
|
|
719
846
|
throw new Error(`qualityGate ${fieldName} CLI replay timed out after ${replay.timeoutMs}ms`);
|
|
720
847
|
}
|
|
721
848
|
throw error;
|
|
849
|
+
} finally {
|
|
850
|
+
await fs.rm(executionCwd, { recursive: true, force: true }).catch(() => undefined);
|
|
722
851
|
}
|
|
723
852
|
}
|
|
724
853
|
|