@cat-factory/executor-harness 1.50.8 → 1.50.12
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/dist/agent.js +23 -1
- package/dist/git.js +40 -0
- package/dist/job.js +76 -57
- package/package.json +3 -3
- package/src/agent.ts +26 -0
- package/src/git.ts +51 -0
- package/src/job.ts +119 -54
package/dist/agent.js
CHANGED
|
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
|
|
7
7
|
import { configurePackageRegistries } from './package-registries.js';
|
|
8
8
|
import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
|
|
9
|
-
import { cloneRepo, commitAll, conflictDiff, fetchReferenceBranches, hasAgentChanges, headCommit, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
|
|
9
|
+
import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, inferVcsProvider, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
|
|
10
10
|
import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
|
|
11
11
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
12
12
|
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
@@ -405,6 +405,28 @@ async function runExploreMode(job, opts) {
|
|
|
405
405
|
fetched: fetched.length,
|
|
406
406
|
});
|
|
407
407
|
}
|
|
408
|
+
// The pr-reviewer reviews an EXISTING PR: fetch its HEAD into `origin/pr-head` so the
|
|
409
|
+
// read-only agent can inspect the PROPOSED code — files the PR adds (absent from this base
|
|
410
|
+
// checkout) and the head version of every modified file. The agent holds no git credential
|
|
411
|
+
// of its own, so this harness-side fetch (token out of band) is the only way the head is
|
|
412
|
+
// reachable; the prompt then diffs `origin/<base>...origin/pr-head`. Best-effort: on failure
|
|
413
|
+
// the review proceeds on the base checkout + the injected `.cat-context/pr-diff.md`.
|
|
414
|
+
if (job.reviewPrNumber !== undefined) {
|
|
415
|
+
const provider = job.repo.provider ?? inferVcsProvider(job.repo.cloneUrl);
|
|
416
|
+
const fetched = await fetchPullRequestHead({
|
|
417
|
+
dir,
|
|
418
|
+
number: job.reviewPrNumber,
|
|
419
|
+
provider,
|
|
420
|
+
ghToken: job.ghToken,
|
|
421
|
+
signal: opts.signal,
|
|
422
|
+
onSkip: (reason) => logger.warn('agent(explore): PR head fetch skipped', {
|
|
423
|
+
number: job.reviewPrNumber,
|
|
424
|
+
provider,
|
|
425
|
+
reason,
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched });
|
|
429
|
+
}
|
|
408
430
|
// Optional infra stand-up (the tester): bring the service's docker-compose
|
|
409
431
|
// dependencies up at the repo root for the duration of the run, tearing them down in
|
|
410
432
|
// the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
|
package/dist/git.js
CHANGED
|
@@ -691,6 +691,46 @@ export async function fetchReferenceBranches(opts) {
|
|
|
691
691
|
await excludeFromGit(dir, `${REFERENCE_WORKTREE_DIR}/`, signal);
|
|
692
692
|
return fetched;
|
|
693
693
|
}
|
|
694
|
+
/** The local tracking ref a fetched PR/MR head lands on, so the reviewer reads `origin/pr-head`. */
|
|
695
|
+
export const PR_HEAD_REF = 'refs/remotes/origin/pr-head';
|
|
696
|
+
/**
|
|
697
|
+
* The `git fetch` refspec that maps a PR/MR's server-side HEAD ref onto {@link PR_HEAD_REF}. A
|
|
698
|
+
* PR head is a synthetic ref the host maintains, NOT part of a normal clone: GitHub exposes it at
|
|
699
|
+
* `refs/pull/<n>/head`, GitLab at `refs/merge-requests/<n>/head`. Pure so the provider branch is
|
|
700
|
+
* unit-tested without a network. The leading `+` forces the update (the ref is read-only here).
|
|
701
|
+
*/
|
|
702
|
+
export function pullHeadRefspec(number, provider) {
|
|
703
|
+
const src = provider === 'gitlab' ? `refs/merge-requests/${number}/head` : `refs/pull/${number}/head`;
|
|
704
|
+
return `+${src}:${PR_HEAD_REF}`;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Fetch the reviewed PR/MR's HEAD into {@link PR_HEAD_REF} so a read-only reviewer can inspect the
|
|
708
|
+
* PROPOSED code — files the PR adds (absent from the base checkout) and the head version of every
|
|
709
|
+
* modified file — with `git diff origin/<base>...origin/pr-head`, `git show origin/pr-head:<path>`.
|
|
710
|
+
* The base clone never includes the pull ref, and the container agent holds no git credential of
|
|
711
|
+
* its own (the token lives with the harness), so the agent's own `git fetch pull/<n>/head` fails
|
|
712
|
+
* on a private repo — this harness-side fetch (which carries the token out of band via GIT_ASKPASS,
|
|
713
|
+
* exactly like {@link fetchReferenceBranches}) is what actually makes the head reachable.
|
|
714
|
+
*
|
|
715
|
+
* Best-effort: a fetch failure (a closed/deleted PR, a host without the pull ref, a transient
|
|
716
|
+
* network error) is reported via `onSkip` and swallowed — the review then proceeds on the base
|
|
717
|
+
* checkout + the injected diff, never fails. Returns whether the head was fetched.
|
|
718
|
+
*/
|
|
719
|
+
export async function fetchPullRequestHead(opts) {
|
|
720
|
+
const { dir, number, provider, ghToken, signal, onSkip } = opts;
|
|
721
|
+
try {
|
|
722
|
+
await git(['fetch', '--no-tags', 'origin', pullHeadRefspec(number, provider)], {
|
|
723
|
+
cwd: dir,
|
|
724
|
+
signal,
|
|
725
|
+
env: await authEnv(ghToken),
|
|
726
|
+
});
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
onSkip?.(err instanceof Error ? err.message : String(err));
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
694
734
|
/**
|
|
695
735
|
* Push the work branch to origin. The remote URL carries only the username, so
|
|
696
736
|
* the token is supplied here via the askpass env (never in argv).
|
package/dist/job.js
CHANGED
|
@@ -647,44 +647,82 @@ export function parseAgentJob(input) {
|
|
|
647
647
|
// preview dispatch to send dummy values it has no reason to supply. Every other mode still
|
|
648
648
|
// requires them (throws when missing/empty), exactly as before.
|
|
649
649
|
const agentField = (value, path) => mode === 'preview' ? (typeof value === 'string' ? value : '') : str(value, path);
|
|
650
|
+
// Parse each field, then hand the pieces to `assembleAgentJob` for the (large) object literal —
|
|
651
|
+
// the parse/assemble split keeps both within the cyclomatic-complexity budget. Behaviour is
|
|
652
|
+
// byte-identical (the literal + host validation moved verbatim).
|
|
653
|
+
const job = assembleAgentJob(o, mode, agentField, {
|
|
654
|
+
output: parseAgentOutputSpec(o.output),
|
|
655
|
+
pr: parseAgentPrSpec(o.pr),
|
|
656
|
+
infra: parseAgentInfraSpec(o.infra),
|
|
657
|
+
peerRepos: parsePeerRepos(o.peerRepos),
|
|
658
|
+
referenceRepos: parseReferenceRepos(o.referenceRepos),
|
|
659
|
+
referenceBranches: parseReferenceBranches(o.referenceBranches),
|
|
660
|
+
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
661
|
+
contextFiles: parseContextFiles(o.contextFiles),
|
|
662
|
+
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
663
|
+
skill: parseSkillSpec(o.skill),
|
|
664
|
+
testSecrets: parseTestSecrets(o.testSecrets),
|
|
665
|
+
guardLimits: parseGuardLimits(o.guardLimits),
|
|
666
|
+
validation: parseValidationSpec(o.validation),
|
|
667
|
+
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
668
|
+
});
|
|
669
|
+
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
|
|
670
|
+
if (job.githubApiBase)
|
|
671
|
+
assertAllowedHost(job.githubApiBase, 'githubApiBase');
|
|
672
|
+
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
673
|
+
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
674
|
+
if (job.bootstrap)
|
|
675
|
+
assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl');
|
|
676
|
+
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
677
|
+
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
678
|
+
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
679
|
+
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
680
|
+
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
|
|
681
|
+
}
|
|
682
|
+
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
683
|
+
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
684
|
+
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
685
|
+
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
686
|
+
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
|
|
687
|
+
}
|
|
688
|
+
return job;
|
|
689
|
+
}
|
|
690
|
+
/** Parse the optional structured-output spec (`{ kind, shapeHint?, repair?, failOnUnusableFinal? }`). */
|
|
691
|
+
function parseAgentOutputSpec(raw) {
|
|
692
|
+
if (typeof raw !== 'object' || raw === null)
|
|
693
|
+
return undefined;
|
|
694
|
+
const so = raw;
|
|
695
|
+
const kind = so.kind === 'structured' ? 'structured' : 'prose';
|
|
696
|
+
const spec = { kind };
|
|
697
|
+
if (typeof so.shapeHint === 'string')
|
|
698
|
+
spec.shapeHint = so.shapeHint;
|
|
699
|
+
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
700
|
+
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
701
|
+
// kind that opted out (it keys off `output.repair === false`).
|
|
702
|
+
if (typeof so.repair === 'boolean')
|
|
703
|
+
spec.repair = so.repair;
|
|
704
|
+
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
705
|
+
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
706
|
+
if (so.failOnUnusableFinal === true)
|
|
707
|
+
spec.failOnUnusableFinal = true;
|
|
708
|
+
return spec;
|
|
709
|
+
}
|
|
710
|
+
/** Parse the optional PR spec (`{ title, body }`). */
|
|
711
|
+
function parseAgentPrSpec(raw) {
|
|
712
|
+
if (typeof raw !== 'object' || raw === null)
|
|
713
|
+
return undefined;
|
|
714
|
+
const p = raw;
|
|
715
|
+
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Assemble the {@link AgentJob} object from the request `o` + the pre-parsed {@link
|
|
719
|
+
* ParsedAgentJobParts}. Extracted from {@link parseAgentJob} so the large conditional-spread
|
|
720
|
+
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
721
|
+
*/
|
|
722
|
+
function assembleAgentJob(o, mode, agentField, parts) {
|
|
723
|
+
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, reviewPrNumber, } = parts;
|
|
650
724
|
const repo = (o.repo ?? {});
|
|
651
|
-
|
|
652
|
-
? (() => {
|
|
653
|
-
const so = o.output;
|
|
654
|
-
const kind = so.kind === 'structured' ? 'structured' : 'prose';
|
|
655
|
-
const spec = { kind };
|
|
656
|
-
if (typeof so.shapeHint === 'string')
|
|
657
|
-
spec.shapeHint = so.shapeHint;
|
|
658
|
-
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
659
|
-
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
660
|
-
// kind that opted out (it keys off `output.repair === false`).
|
|
661
|
-
if (typeof so.repair === 'boolean')
|
|
662
|
-
spec.repair = so.repair;
|
|
663
|
-
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
664
|
-
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
665
|
-
if (so.failOnUnusableFinal === true)
|
|
666
|
-
spec.failOnUnusableFinal = true;
|
|
667
|
-
return spec;
|
|
668
|
-
})()
|
|
669
|
-
: undefined;
|
|
670
|
-
const pr = typeof o.pr === 'object' && o.pr !== null
|
|
671
|
-
? (() => {
|
|
672
|
-
const p = o.pr;
|
|
673
|
-
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' };
|
|
674
|
-
})()
|
|
675
|
-
: undefined;
|
|
676
|
-
const infra = parseAgentInfraSpec(o.infra);
|
|
677
|
-
const peerRepos = parsePeerRepos(o.peerRepos);
|
|
678
|
-
const referenceRepos = parseReferenceRepos(o.referenceRepos);
|
|
679
|
-
const referenceBranches = parseReferenceBranches(o.referenceBranches);
|
|
680
|
-
const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
|
|
681
|
-
const contextFiles = parseContextFiles(o.contextFiles);
|
|
682
|
-
const packageRegistries = parsePackageRegistries(o.packageRegistries);
|
|
683
|
-
const skill = parseSkillSpec(o.skill);
|
|
684
|
-
const testSecrets = parseTestSecrets(o.testSecrets);
|
|
685
|
-
const guardLimits = parseGuardLimits(o.guardLimits);
|
|
686
|
-
const validation = parseValidationSpec(o.validation);
|
|
687
|
-
const job = {
|
|
725
|
+
return {
|
|
688
726
|
jobId: str(o.jobId, 'jobId'),
|
|
689
727
|
mode,
|
|
690
728
|
systemPrompt: agentField(o.systemPrompt, 'systemPrompt'),
|
|
@@ -715,30 +753,11 @@ export function parseAgentJob(input) {
|
|
|
715
753
|
...(peerRepos.length ? { peerRepos } : {}),
|
|
716
754
|
...(referenceRepos.length ? { referenceRepos } : {}),
|
|
717
755
|
...(referenceBranches.length ? { referenceBranches } : {}),
|
|
756
|
+
...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
|
|
718
757
|
...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
|
|
719
758
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
720
759
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
721
760
|
...(guardLimits ? { guardLimits } : {}),
|
|
722
761
|
...(validation ? { validation } : {}),
|
|
723
762
|
};
|
|
724
|
-
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
|
|
725
|
-
if (job.githubApiBase)
|
|
726
|
-
assertAllowedHost(job.githubApiBase, 'githubApiBase');
|
|
727
|
-
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
728
|
-
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
729
|
-
if (job.bootstrap)
|
|
730
|
-
assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl');
|
|
731
|
-
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
732
|
-
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
733
|
-
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
734
|
-
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
735
|
-
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
|
|
736
|
-
}
|
|
737
|
-
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
738
|
-
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
739
|
-
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
740
|
-
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
741
|
-
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
|
|
742
|
-
}
|
|
743
|
-
return job;
|
|
744
763
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.12",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"hono": "^4.12.30",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.141.2",
|
|
30
|
+
"@cat-factory/spend": "0.12.73"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent.ts
CHANGED
|
@@ -18,9 +18,11 @@ import {
|
|
|
18
18
|
cloneRepo,
|
|
19
19
|
commitAll,
|
|
20
20
|
conflictDiff,
|
|
21
|
+
fetchPullRequestHead,
|
|
21
22
|
fetchReferenceBranches,
|
|
22
23
|
hasAgentChanges,
|
|
23
24
|
headCommit,
|
|
25
|
+
inferVcsProvider,
|
|
24
26
|
mergeBranch,
|
|
25
27
|
openPullRequest,
|
|
26
28
|
prepareExistingCheckout,
|
|
@@ -493,6 +495,30 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
493
495
|
})
|
|
494
496
|
}
|
|
495
497
|
|
|
498
|
+
// The pr-reviewer reviews an EXISTING PR: fetch its HEAD into `origin/pr-head` so the
|
|
499
|
+
// read-only agent can inspect the PROPOSED code — files the PR adds (absent from this base
|
|
500
|
+
// checkout) and the head version of every modified file. The agent holds no git credential
|
|
501
|
+
// of its own, so this harness-side fetch (token out of band) is the only way the head is
|
|
502
|
+
// reachable; the prompt then diffs `origin/<base>...origin/pr-head`. Best-effort: on failure
|
|
503
|
+
// the review proceeds on the base checkout + the injected `.cat-context/pr-diff.md`.
|
|
504
|
+
if (job.reviewPrNumber !== undefined) {
|
|
505
|
+
const provider = job.repo.provider ?? inferVcsProvider(job.repo.cloneUrl)
|
|
506
|
+
const fetched = await fetchPullRequestHead({
|
|
507
|
+
dir,
|
|
508
|
+
number: job.reviewPrNumber,
|
|
509
|
+
provider,
|
|
510
|
+
ghToken: job.ghToken,
|
|
511
|
+
signal: opts.signal,
|
|
512
|
+
onSkip: (reason) =>
|
|
513
|
+
logger.warn('agent(explore): PR head fetch skipped', {
|
|
514
|
+
number: job.reviewPrNumber,
|
|
515
|
+
provider,
|
|
516
|
+
reason,
|
|
517
|
+
}),
|
|
518
|
+
})
|
|
519
|
+
logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched })
|
|
520
|
+
}
|
|
521
|
+
|
|
496
522
|
// Optional infra stand-up (the tester): bring the service's docker-compose
|
|
497
523
|
// dependencies up at the repo root for the duration of the run, tearing them down in
|
|
498
524
|
// the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
|
package/src/git.ts
CHANGED
|
@@ -862,6 +862,57 @@ export async function fetchReferenceBranches(opts: {
|
|
|
862
862
|
return fetched
|
|
863
863
|
}
|
|
864
864
|
|
|
865
|
+
/** The local tracking ref a fetched PR/MR head lands on, so the reviewer reads `origin/pr-head`. */
|
|
866
|
+
export const PR_HEAD_REF = 'refs/remotes/origin/pr-head'
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The `git fetch` refspec that maps a PR/MR's server-side HEAD ref onto {@link PR_HEAD_REF}. A
|
|
870
|
+
* PR head is a synthetic ref the host maintains, NOT part of a normal clone: GitHub exposes it at
|
|
871
|
+
* `refs/pull/<n>/head`, GitLab at `refs/merge-requests/<n>/head`. Pure so the provider branch is
|
|
872
|
+
* unit-tested without a network. The leading `+` forces the update (the ref is read-only here).
|
|
873
|
+
*/
|
|
874
|
+
export function pullHeadRefspec(number: number, provider: 'github' | 'gitlab'): string {
|
|
875
|
+
const src =
|
|
876
|
+
provider === 'gitlab' ? `refs/merge-requests/${number}/head` : `refs/pull/${number}/head`
|
|
877
|
+
return `+${src}:${PR_HEAD_REF}`
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Fetch the reviewed PR/MR's HEAD into {@link PR_HEAD_REF} so a read-only reviewer can inspect the
|
|
882
|
+
* PROPOSED code — files the PR adds (absent from the base checkout) and the head version of every
|
|
883
|
+
* modified file — with `git diff origin/<base>...origin/pr-head`, `git show origin/pr-head:<path>`.
|
|
884
|
+
* The base clone never includes the pull ref, and the container agent holds no git credential of
|
|
885
|
+
* its own (the token lives with the harness), so the agent's own `git fetch pull/<n>/head` fails
|
|
886
|
+
* on a private repo — this harness-side fetch (which carries the token out of band via GIT_ASKPASS,
|
|
887
|
+
* exactly like {@link fetchReferenceBranches}) is what actually makes the head reachable.
|
|
888
|
+
*
|
|
889
|
+
* Best-effort: a fetch failure (a closed/deleted PR, a host without the pull ref, a transient
|
|
890
|
+
* network error) is reported via `onSkip` and swallowed — the review then proceeds on the base
|
|
891
|
+
* checkout + the injected diff, never fails. Returns whether the head was fetched.
|
|
892
|
+
*/
|
|
893
|
+
export async function fetchPullRequestHead(opts: {
|
|
894
|
+
dir: string
|
|
895
|
+
number: number
|
|
896
|
+
provider: 'github' | 'gitlab'
|
|
897
|
+
ghToken: string
|
|
898
|
+
signal?: AbortSignal
|
|
899
|
+
/** Called when the fetch failed, so the caller (which owns a logger) can warn. */
|
|
900
|
+
onSkip?: (reason: string) => void
|
|
901
|
+
}): Promise<boolean> {
|
|
902
|
+
const { dir, number, provider, ghToken, signal, onSkip } = opts
|
|
903
|
+
try {
|
|
904
|
+
await git(['fetch', '--no-tags', 'origin', pullHeadRefspec(number, provider)], {
|
|
905
|
+
cwd: dir,
|
|
906
|
+
signal,
|
|
907
|
+
env: await authEnv(ghToken),
|
|
908
|
+
})
|
|
909
|
+
return true
|
|
910
|
+
} catch (err) {
|
|
911
|
+
onSkip?.(err instanceof Error ? err.message : String(err))
|
|
912
|
+
return false
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
865
916
|
/**
|
|
866
917
|
* Push the work branch to origin. The remote URL carries only the username, so
|
|
867
918
|
* the token is supplied here via the askpass env (never in argv).
|
package/src/job.ts
CHANGED
|
@@ -784,6 +784,15 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
784
784
|
* the same primary repo. Absent ⇒ none. Consumed by the coding + explore flows.
|
|
785
785
|
*/
|
|
786
786
|
referenceBranches?: string[]
|
|
787
|
+
/**
|
|
788
|
+
* Explore mode (the `pr-reviewer`): the reviewed PR/MR number. Present ⇒ after the base
|
|
789
|
+
* checkout the harness fetches that PR's HEAD into `origin/pr-head` (best-effort) so the
|
|
790
|
+
* read-only reviewer can diff/read the PROPOSED code — files the PR adds are otherwise absent
|
|
791
|
+
* from the base checkout, and the agent has no git credential to fetch the head itself. The
|
|
792
|
+
* GitHub-vs-GitLab pull ref is chosen from `repo.provider` (host-inferred when absent). Absent
|
|
793
|
+
* ⇒ no head fetch (every non-review run). See {@link file://./git.ts} `fetchPullRequestHead`.
|
|
794
|
+
*/
|
|
795
|
+
reviewPrNumber?: number
|
|
787
796
|
/**
|
|
788
797
|
* Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
|
|
789
798
|
* fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
|
|
@@ -1241,43 +1250,116 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1241
1250
|
// requires them (throws when missing/empty), exactly as before.
|
|
1242
1251
|
const agentField = (value: unknown, path: string): string =>
|
|
1243
1252
|
mode === 'preview' ? (typeof value === 'string' ? value : '') : str(value, path)
|
|
1253
|
+
// Parse each field, then hand the pieces to `assembleAgentJob` for the (large) object literal —
|
|
1254
|
+
// the parse/assemble split keeps both within the cyclomatic-complexity budget. Behaviour is
|
|
1255
|
+
// byte-identical (the literal + host validation moved verbatim).
|
|
1256
|
+
const job = assembleAgentJob(o, mode, agentField, {
|
|
1257
|
+
output: parseAgentOutputSpec(o.output),
|
|
1258
|
+
pr: parseAgentPrSpec(o.pr),
|
|
1259
|
+
infra: parseAgentInfraSpec(o.infra),
|
|
1260
|
+
peerRepos: parsePeerRepos(o.peerRepos),
|
|
1261
|
+
referenceRepos: parseReferenceRepos(o.referenceRepos),
|
|
1262
|
+
referenceBranches: parseReferenceBranches(o.referenceBranches),
|
|
1263
|
+
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
1264
|
+
contextFiles: parseContextFiles(o.contextFiles),
|
|
1265
|
+
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1266
|
+
skill: parseSkillSpec(o.skill),
|
|
1267
|
+
testSecrets: parseTestSecrets(o.testSecrets),
|
|
1268
|
+
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1269
|
+
validation: parseValidationSpec(o.validation),
|
|
1270
|
+
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
1271
|
+
})
|
|
1272
|
+
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
1273
|
+
if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
|
|
1274
|
+
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
1275
|
+
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
1276
|
+
if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
|
|
1277
|
+
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
1278
|
+
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
1279
|
+
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
1280
|
+
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
1281
|
+
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
|
|
1282
|
+
}
|
|
1283
|
+
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
1284
|
+
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
1285
|
+
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
1286
|
+
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
1287
|
+
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
|
|
1288
|
+
}
|
|
1289
|
+
return job
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** The pre-parsed field bundle {@link parseAgentJob} hands to {@link assembleAgentJob}. */
|
|
1293
|
+
interface ParsedAgentJobParts {
|
|
1294
|
+
output: AgentOutputSpec | undefined
|
|
1295
|
+
pr: { title: string; body: string } | undefined
|
|
1296
|
+
infra: ReturnType<typeof parseAgentInfraSpec>
|
|
1297
|
+
peerRepos: ReturnType<typeof parsePeerRepos>
|
|
1298
|
+
referenceRepos: ReturnType<typeof parseReferenceRepos>
|
|
1299
|
+
referenceBranches: ReturnType<typeof parseReferenceBranches>
|
|
1300
|
+
bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
|
|
1301
|
+
contextFiles: ReturnType<typeof parseContextFiles>
|
|
1302
|
+
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1303
|
+
skill: ReturnType<typeof parseSkillSpec>
|
|
1304
|
+
testSecrets: ReturnType<typeof parseTestSecrets>
|
|
1305
|
+
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1306
|
+
validation: ReturnType<typeof parseValidationSpec>
|
|
1307
|
+
reviewPrNumber: number | undefined
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Parse the optional structured-output spec (`{ kind, shapeHint?, repair?, failOnUnusableFinal? }`). */
|
|
1311
|
+
function parseAgentOutputSpec(raw: unknown): AgentOutputSpec | undefined {
|
|
1312
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
1313
|
+
const so = raw as Record<string, unknown>
|
|
1314
|
+
const kind = so.kind === 'structured' ? 'structured' : 'prose'
|
|
1315
|
+
const spec: AgentOutputSpec = { kind }
|
|
1316
|
+
if (typeof so.shapeHint === 'string') spec.shapeHint = so.shapeHint
|
|
1317
|
+
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
1318
|
+
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
1319
|
+
// kind that opted out (it keys off `output.repair === false`).
|
|
1320
|
+
if (typeof so.repair === 'boolean') spec.repair = so.repair
|
|
1321
|
+
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
1322
|
+
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
1323
|
+
if (so.failOnUnusableFinal === true) spec.failOnUnusableFinal = true
|
|
1324
|
+
return spec
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Parse the optional PR spec (`{ title, body }`). */
|
|
1328
|
+
function parseAgentPrSpec(raw: unknown): { title: string; body: string } | undefined {
|
|
1329
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
1330
|
+
const p = raw as Record<string, unknown>
|
|
1331
|
+
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' }
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Assemble the {@link AgentJob} object from the request `o` + the pre-parsed {@link
|
|
1336
|
+
* ParsedAgentJobParts}. Extracted from {@link parseAgentJob} so the large conditional-spread
|
|
1337
|
+
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
1338
|
+
*/
|
|
1339
|
+
function assembleAgentJob(
|
|
1340
|
+
o: Record<string, unknown>,
|
|
1341
|
+
mode: AgentJob['mode'],
|
|
1342
|
+
agentField: (value: unknown, path: string) => string,
|
|
1343
|
+
parts: ParsedAgentJobParts,
|
|
1344
|
+
): AgentJob {
|
|
1345
|
+
const {
|
|
1346
|
+
output,
|
|
1347
|
+
pr,
|
|
1348
|
+
infra,
|
|
1349
|
+
peerRepos,
|
|
1350
|
+
referenceRepos,
|
|
1351
|
+
referenceBranches,
|
|
1352
|
+
bootstrap,
|
|
1353
|
+
contextFiles,
|
|
1354
|
+
packageRegistries,
|
|
1355
|
+
skill,
|
|
1356
|
+
testSecrets,
|
|
1357
|
+
guardLimits,
|
|
1358
|
+
validation,
|
|
1359
|
+
reviewPrNumber,
|
|
1360
|
+
} = parts
|
|
1244
1361
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
1245
|
-
|
|
1246
|
-
typeof o.output === 'object' && o.output !== null
|
|
1247
|
-
? (() => {
|
|
1248
|
-
const so = o.output as Record<string, unknown>
|
|
1249
|
-
const kind = so.kind === 'structured' ? 'structured' : 'prose'
|
|
1250
|
-
const spec: AgentOutputSpec = { kind }
|
|
1251
|
-
if (typeof so.shapeHint === 'string') spec.shapeHint = so.shapeHint
|
|
1252
|
-
// Carry an explicit `repair: false` through — the handler defaults to repair-on
|
|
1253
|
-
// when absent, so dropping `false` would silently re-enable the repair call for a
|
|
1254
|
-
// kind that opted out (it keys off `output.repair === false`).
|
|
1255
|
-
if (typeof so.repair === 'boolean') spec.repair = so.repair
|
|
1256
|
-
// Carry the opt-in truncation gate through (document producers set it); dropping
|
|
1257
|
-
// it would silently re-enable laundering a cut-off reply into a half-baked doc.
|
|
1258
|
-
if (so.failOnUnusableFinal === true) spec.failOnUnusableFinal = true
|
|
1259
|
-
return spec
|
|
1260
|
-
})()
|
|
1261
|
-
: undefined
|
|
1262
|
-
const pr =
|
|
1263
|
-
typeof o.pr === 'object' && o.pr !== null
|
|
1264
|
-
? (() => {
|
|
1265
|
-
const p = o.pr as Record<string, unknown>
|
|
1266
|
-
return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' }
|
|
1267
|
-
})()
|
|
1268
|
-
: undefined
|
|
1269
|
-
const infra = parseAgentInfraSpec(o.infra)
|
|
1270
|
-
const peerRepos = parsePeerRepos(o.peerRepos)
|
|
1271
|
-
const referenceRepos = parseReferenceRepos(o.referenceRepos)
|
|
1272
|
-
const referenceBranches = parseReferenceBranches(o.referenceBranches)
|
|
1273
|
-
const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
|
|
1274
|
-
const contextFiles = parseContextFiles(o.contextFiles)
|
|
1275
|
-
const packageRegistries = parsePackageRegistries(o.packageRegistries)
|
|
1276
|
-
const skill = parseSkillSpec(o.skill)
|
|
1277
|
-
const testSecrets = parseTestSecrets(o.testSecrets)
|
|
1278
|
-
const guardLimits = parseGuardLimits(o.guardLimits)
|
|
1279
|
-
const validation = parseValidationSpec(o.validation)
|
|
1280
|
-
const job: AgentJob = {
|
|
1362
|
+
return {
|
|
1281
1363
|
jobId: str(o.jobId, 'jobId'),
|
|
1282
1364
|
mode,
|
|
1283
1365
|
systemPrompt: agentField(o.systemPrompt, 'systemPrompt'),
|
|
@@ -1308,28 +1390,11 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1308
1390
|
...(peerRepos.length ? { peerRepos } : {}),
|
|
1309
1391
|
...(referenceRepos.length ? { referenceRepos } : {}),
|
|
1310
1392
|
...(referenceBranches.length ? { referenceBranches } : {}),
|
|
1393
|
+
...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
|
|
1311
1394
|
...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
|
|
1312
1395
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
1313
1396
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
1314
1397
|
...(guardLimits ? { guardLimits } : {}),
|
|
1315
1398
|
...(validation ? { validation } : {}),
|
|
1316
1399
|
}
|
|
1317
|
-
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
1318
|
-
if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
|
|
1319
|
-
// Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
|
|
1320
|
-
// allowed GitHub host too (the installation token is sent to it on the force-push).
|
|
1321
|
-
if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
|
|
1322
|
-
// Each peer repo's clone URL receives the installation token on clone/push, so it must be
|
|
1323
|
-
// an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
|
|
1324
|
-
// exfiltrate the token exactly like a rogue primary clone URL.
|
|
1325
|
-
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
1326
|
-
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
|
|
1327
|
-
}
|
|
1328
|
-
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
1329
|
-
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
1330
|
-
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
1331
|
-
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
1332
|
-
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
|
|
1333
|
-
}
|
|
1334
|
-
return job
|
|
1335
1400
|
}
|