@cat-factory/executor-harness 1.34.10 → 1.35.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/dist/agent.js +5 -5
- package/dist/coding-agent.js +37 -0
- package/dist/job.js +32 -0
- package/package.json +4 -4
- package/src/agent.ts +8 -7
- package/src/coding-agent.ts +52 -1
- package/src/job.ts +53 -0
package/dist/agent.js
CHANGED
|
@@ -603,11 +603,11 @@ async function runCodingMode(job, opts) {
|
|
|
603
603
|
// commit + push (no PR). Keyed off job DATA (`mergeBase`), not the agent kind.
|
|
604
604
|
if (job.mergeBase)
|
|
605
605
|
return runConflictResolution(job, opts);
|
|
606
|
-
// Multi-repo coding
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
const result = job.peerRepos?.length
|
|
606
|
+
// Multi-repo coding: clone every additional repo as a sibling and run the agent once across
|
|
607
|
+
// all of them. Keyed off job DATA, not the agent kind — set for the implementer's writable
|
|
608
|
+
// peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
|
|
609
|
+
// reference repos (`referenceRepos`, cloned but never pushed).
|
|
610
|
+
const result = job.peerRepos?.length || job.referenceRepos?.length
|
|
611
611
|
? await runMultiRepoCoding(job, opts)
|
|
612
612
|
: await runSingleRepoCoding(job, opts);
|
|
613
613
|
// Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
|
package/dist/coding-agent.js
CHANGED
|
@@ -326,6 +326,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
326
326
|
const { signal } = opts;
|
|
327
327
|
const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId });
|
|
328
328
|
const peers = job.peerRepos ?? [];
|
|
329
|
+
const references = job.referenceRepos ?? [];
|
|
329
330
|
const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
330
331
|
// Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
|
|
331
332
|
// matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
|
|
@@ -358,6 +359,21 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
358
359
|
baseSha: '',
|
|
359
360
|
resumed: false,
|
|
360
361
|
})),
|
|
362
|
+
// Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
|
|
363
|
+
// `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
|
|
364
|
+
// pushes (guarded by `readOnly` in both the clone and push phases below).
|
|
365
|
+
...references.map((reference) => ({
|
|
366
|
+
repo: reference.repo,
|
|
367
|
+
dirName: claimDir(reference.repo),
|
|
368
|
+
dir: '',
|
|
369
|
+
cloneBranch: reference.repo.baseBranch,
|
|
370
|
+
workBranch: reference.repo.baseBranch,
|
|
371
|
+
ghToken: reference.ghToken ?? job.ghToken,
|
|
372
|
+
primary: false,
|
|
373
|
+
readOnly: true,
|
|
374
|
+
baseSha: '',
|
|
375
|
+
resumed: false,
|
|
376
|
+
})),
|
|
361
377
|
];
|
|
362
378
|
return withWorkspace('multi', async (root) => {
|
|
363
379
|
// Clone phase: every repo into its sibling dir under the workspace root. Resume an
|
|
@@ -366,6 +382,23 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
366
382
|
for (const leg of legs) {
|
|
367
383
|
const dir = join(root, leg.dirName);
|
|
368
384
|
await mkdir(dir, { recursive: true });
|
|
385
|
+
// A read-only reference leg: clone its base branch for the agent to read, and stop there —
|
|
386
|
+
// no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
|
|
387
|
+
// never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
|
|
388
|
+
if (leg.readOnly) {
|
|
389
|
+
logger.info('multi-repo: cloning read-only reference', {
|
|
390
|
+
repo: leg.dirName,
|
|
391
|
+
cloneBranch: leg.cloneBranch,
|
|
392
|
+
});
|
|
393
|
+
await cloneRepo({
|
|
394
|
+
repo: { ...leg.repo, baseBranch: leg.cloneBranch },
|
|
395
|
+
ghToken: leg.ghToken,
|
|
396
|
+
dir,
|
|
397
|
+
signal,
|
|
398
|
+
});
|
|
399
|
+
leg.dir = dir;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
369
402
|
leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal);
|
|
370
403
|
if (leg.resumed) {
|
|
371
404
|
logger.info('multi-repo: resuming existing branch', {
|
|
@@ -439,6 +472,10 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
439
472
|
let primaryPrUrl;
|
|
440
473
|
const peerPullRequests = [];
|
|
441
474
|
for (const leg of legs) {
|
|
475
|
+
// A read-only reference leg is never committed or pushed — the third layer of the read-only
|
|
476
|
+
// guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
|
|
477
|
+
if (leg.readOnly)
|
|
478
|
+
continue;
|
|
442
479
|
await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
|
|
443
480
|
const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
|
|
444
481
|
let hasWork = advanced || leg.resumed;
|
package/dist/job.js
CHANGED
|
@@ -150,6 +150,30 @@ function parsePeerRepos(value) {
|
|
|
150
150
|
return spec;
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Parse the optional read-only reference-repo list (document-authoring runs). Each entry carries
|
|
155
|
+
* a full {@link RepoSpec} (validated + sanitised like the primary) and an optional per-repo token.
|
|
156
|
+
* Any branch/PR fields on the wire are IGNORED — a reference repo is never pushed, so the parsed
|
|
157
|
+
* shape has none to carry. A malformed list throws; an absent one yields `[]`.
|
|
158
|
+
*/
|
|
159
|
+
function parseReferenceRepos(value) {
|
|
160
|
+
if (value === undefined || value === null)
|
|
161
|
+
return [];
|
|
162
|
+
if (!Array.isArray(value))
|
|
163
|
+
throw new Error("Invalid job: 'referenceRepos' must be an array");
|
|
164
|
+
return value.map((entry, i) => {
|
|
165
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
166
|
+
throw new Error(`Invalid job: 'referenceRepos[${i}]' must be an object`);
|
|
167
|
+
}
|
|
168
|
+
const e = entry;
|
|
169
|
+
const spec = {
|
|
170
|
+
repo: parseRepoSpec((e.repo ?? {})),
|
|
171
|
+
};
|
|
172
|
+
if (typeof e.ghToken === 'string' && e.ghToken)
|
|
173
|
+
spec.ghToken = e.ghToken;
|
|
174
|
+
return spec;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
153
177
|
/** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
|
|
154
178
|
function parseVcsProvider(value) {
|
|
155
179
|
if (value === undefined || value === null)
|
|
@@ -479,6 +503,7 @@ export function parseAgentJob(input) {
|
|
|
479
503
|
: undefined;
|
|
480
504
|
const infra = parseAgentInfraSpec(o.infra);
|
|
481
505
|
const peerRepos = parsePeerRepos(o.peerRepos);
|
|
506
|
+
const referenceRepos = parseReferenceRepos(o.referenceRepos);
|
|
482
507
|
const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
|
|
483
508
|
const contextFiles = parseContextFiles(o.contextFiles);
|
|
484
509
|
const packageRegistries = parsePackageRegistries(o.packageRegistries);
|
|
@@ -510,6 +535,7 @@ export function parseAgentJob(input) {
|
|
|
510
535
|
: {}),
|
|
511
536
|
...(pr ? { pr } : {}),
|
|
512
537
|
...(peerRepos.length ? { peerRepos } : {}),
|
|
538
|
+
...(referenceRepos.length ? { referenceRepos } : {}),
|
|
513
539
|
...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
|
|
514
540
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
515
541
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
@@ -528,5 +554,11 @@ export function parseAgentJob(input) {
|
|
|
528
554
|
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
529
555
|
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
|
|
530
556
|
}
|
|
557
|
+
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
558
|
+
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
559
|
+
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
560
|
+
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
561
|
+
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
|
|
562
|
+
}
|
|
531
563
|
return job;
|
|
532
564
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
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",
|
|
@@ -21,13 +21,13 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@hono/node-server": "^2.0.
|
|
24
|
+
"@hono/node-server": "^2.0.8",
|
|
25
25
|
"@types/node": "^26.0.0",
|
|
26
26
|
"hono": "^4.12.27",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
28
|
"vitest": "^4.1.9",
|
|
29
|
-
"@cat-factory/spend": "0.10.
|
|
30
|
-
"@cat-factory/server": "0.
|
|
29
|
+
"@cat-factory/spend": "0.10.104",
|
|
30
|
+
"@cat-factory/server": "0.88.0"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent.ts
CHANGED
|
@@ -739,13 +739,14 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
|
|
|
739
739
|
// clone full, merge the base in to surface the conflicts, then complete the merge
|
|
740
740
|
// commit + push (no PR). Keyed off job DATA (`mergeBase`), not the agent kind.
|
|
741
741
|
if (job.mergeBase) return runConflictResolution(job, opts)
|
|
742
|
-
// Multi-repo coding
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
const result =
|
|
747
|
-
|
|
748
|
-
|
|
742
|
+
// Multi-repo coding: clone every additional repo as a sibling and run the agent once across
|
|
743
|
+
// all of them. Keyed off job DATA, not the agent kind — set for the implementer's writable
|
|
744
|
+
// peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
|
|
745
|
+
// reference repos (`referenceRepos`, cloned but never pushed).
|
|
746
|
+
const result =
|
|
747
|
+
job.peerRepos?.length || job.referenceRepos?.length
|
|
748
|
+
? await runMultiRepoCoding(job, opts)
|
|
749
|
+
: await runSingleRepoCoding(job, opts)
|
|
749
750
|
|
|
750
751
|
// Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
|
|
751
752
|
// backend post-completion resolver records the outcome. Skipped on a failed run (its `error`
|
package/src/coding-agent.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AgentJob,
|
|
5
|
+
AgentResult,
|
|
6
|
+
HarnessAuthFields,
|
|
7
|
+
PeerRepoSpec,
|
|
8
|
+
ReferenceRepoSpec,
|
|
9
|
+
RepoSpec,
|
|
10
|
+
} from './job.js'
|
|
4
11
|
import {
|
|
5
12
|
branchAheadOfBase,
|
|
6
13
|
branchHasCommitsSince,
|
|
@@ -435,6 +442,12 @@ interface RepoLeg {
|
|
|
435
442
|
pr?: { title: string; body: string }
|
|
436
443
|
frameId?: string
|
|
437
444
|
primary: boolean
|
|
445
|
+
/**
|
|
446
|
+
* A READ-ONLY reference checkout (doc-writer's `referenceRepos`): cloned at its base branch for
|
|
447
|
+
* the agent to read, but NEVER given a work branch, committed, or pushed. Skipped entirely in the
|
|
448
|
+
* push phase, so it is structurally impossible for the run to write to it. Absent ⇒ a writable leg.
|
|
449
|
+
*/
|
|
450
|
+
readOnly?: boolean
|
|
438
451
|
/** The branch tip before the run — work iff the branch advances past it. */
|
|
439
452
|
baseSha: string
|
|
440
453
|
/** Whether an existing remote work branch was resumed (already carries prior work). */
|
|
@@ -461,6 +474,7 @@ export async function runMultiRepoCoding(
|
|
|
461
474
|
const { signal } = opts
|
|
462
475
|
const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId })
|
|
463
476
|
const peers: PeerRepoSpec[] = job.peerRepos ?? []
|
|
477
|
+
const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
|
|
464
478
|
const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
465
479
|
|
|
466
480
|
// Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
|
|
@@ -496,6 +510,23 @@ export async function runMultiRepoCoding(
|
|
|
496
510
|
resumed: false,
|
|
497
511
|
}),
|
|
498
512
|
),
|
|
513
|
+
// Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
|
|
514
|
+
// `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
|
|
515
|
+
// pushes (guarded by `readOnly` in both the clone and push phases below).
|
|
516
|
+
...references.map(
|
|
517
|
+
(reference): RepoLeg => ({
|
|
518
|
+
repo: reference.repo,
|
|
519
|
+
dirName: claimDir(reference.repo),
|
|
520
|
+
dir: '',
|
|
521
|
+
cloneBranch: reference.repo.baseBranch,
|
|
522
|
+
workBranch: reference.repo.baseBranch,
|
|
523
|
+
ghToken: reference.ghToken ?? job.ghToken,
|
|
524
|
+
primary: false,
|
|
525
|
+
readOnly: true,
|
|
526
|
+
baseSha: '',
|
|
527
|
+
resumed: false,
|
|
528
|
+
}),
|
|
529
|
+
),
|
|
499
530
|
]
|
|
500
531
|
|
|
501
532
|
return withWorkspace('multi', async (root) => {
|
|
@@ -505,6 +536,23 @@ export async function runMultiRepoCoding(
|
|
|
505
536
|
for (const leg of legs) {
|
|
506
537
|
const dir = join(root, leg.dirName)
|
|
507
538
|
await mkdir(dir, { recursive: true })
|
|
539
|
+
// A read-only reference leg: clone its base branch for the agent to read, and stop there —
|
|
540
|
+
// no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
|
|
541
|
+
// never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
|
|
542
|
+
if (leg.readOnly) {
|
|
543
|
+
logger.info('multi-repo: cloning read-only reference', {
|
|
544
|
+
repo: leg.dirName,
|
|
545
|
+
cloneBranch: leg.cloneBranch,
|
|
546
|
+
})
|
|
547
|
+
await cloneRepo({
|
|
548
|
+
repo: { ...leg.repo, baseBranch: leg.cloneBranch },
|
|
549
|
+
ghToken: leg.ghToken,
|
|
550
|
+
dir,
|
|
551
|
+
signal,
|
|
552
|
+
})
|
|
553
|
+
leg.dir = dir
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
508
556
|
leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
|
|
509
557
|
if (leg.resumed) {
|
|
510
558
|
logger.info('multi-repo: resuming existing branch', {
|
|
@@ -587,6 +635,9 @@ export async function runMultiRepoCoding(
|
|
|
587
635
|
let primaryPrUrl: string | undefined
|
|
588
636
|
const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
|
|
589
637
|
for (const leg of legs) {
|
|
638
|
+
// A read-only reference leg is never committed or pushed — the third layer of the read-only
|
|
639
|
+
// guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
|
|
640
|
+
if (leg.readOnly) continue
|
|
590
641
|
await commitTrackedEdits(
|
|
591
642
|
leg.dir,
|
|
592
643
|
job.commitMessage ?? leg.pr?.title ?? 'Agent changes',
|
package/src/job.ts
CHANGED
|
@@ -84,6 +84,21 @@ export interface PeerRepoSpec {
|
|
|
84
84
|
ghToken?: string
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* A repository checked out READ-ONLY as a sibling alongside the primary during a
|
|
89
|
+
* document-authoring coding run — the doc-writer reads it (to reuse existing solutions as a
|
|
90
|
+
* reference) but the harness never creates a branch, commits, or opens a PR for it. Deliberately
|
|
91
|
+
* carries NO branch/PR fields (unlike {@link PeerRepoSpec}), so it is structurally impossible to
|
|
92
|
+
* push: the read-only guarantee is enforced by the shape itself, by cloning at the repo's own
|
|
93
|
+
* base branch with no work branch, and by skipping the leg in the push phase. The clone URL is
|
|
94
|
+
* host-allowlisted exactly like the primary `repo.cloneUrl`.
|
|
95
|
+
*/
|
|
96
|
+
export interface ReferenceRepoSpec {
|
|
97
|
+
repo: RepoSpec
|
|
98
|
+
/** Per-repo GitHub token; defaults to the job's `ghToken` (one installation per workspace today). */
|
|
99
|
+
ghToken?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
function str(value: unknown, path: string): string {
|
|
88
103
|
if (typeof value !== 'string' || value.length === 0) {
|
|
89
104
|
throw new Error(`Invalid job: '${path}' must be a non-empty string`)
|
|
@@ -234,6 +249,28 @@ function parsePeerRepos(value: unknown): PeerRepoSpec[] {
|
|
|
234
249
|
})
|
|
235
250
|
}
|
|
236
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Parse the optional read-only reference-repo list (document-authoring runs). Each entry carries
|
|
254
|
+
* a full {@link RepoSpec} (validated + sanitised like the primary) and an optional per-repo token.
|
|
255
|
+
* Any branch/PR fields on the wire are IGNORED — a reference repo is never pushed, so the parsed
|
|
256
|
+
* shape has none to carry. A malformed list throws; an absent one yields `[]`.
|
|
257
|
+
*/
|
|
258
|
+
function parseReferenceRepos(value: unknown): ReferenceRepoSpec[] {
|
|
259
|
+
if (value === undefined || value === null) return []
|
|
260
|
+
if (!Array.isArray(value)) throw new Error("Invalid job: 'referenceRepos' must be an array")
|
|
261
|
+
return value.map((entry, i) => {
|
|
262
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
263
|
+
throw new Error(`Invalid job: 'referenceRepos[${i}]' must be an object`)
|
|
264
|
+
}
|
|
265
|
+
const e = entry as Record<string, unknown>
|
|
266
|
+
const spec: ReferenceRepoSpec = {
|
|
267
|
+
repo: parseRepoSpec((e.repo ?? {}) as Record<string, unknown>),
|
|
268
|
+
}
|
|
269
|
+
if (typeof e.ghToken === 'string' && e.ghToken) spec.ghToken = e.ghToken
|
|
270
|
+
return spec
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
237
274
|
/** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
|
|
238
275
|
function parseVcsProvider(value: unknown): 'github' | 'gitlab' | undefined {
|
|
239
276
|
if (value === undefined || value === null) return undefined
|
|
@@ -591,6 +628,14 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
591
628
|
* peer repo it actually changed — in addition to the primary. Absent ⇒ single-repo run.
|
|
592
629
|
*/
|
|
593
630
|
peerRepos?: PeerRepoSpec[]
|
|
631
|
+
/**
|
|
632
|
+
* Coding mode (doc-writer): repositories to clone READ-ONLY as SIBLINGS for the agent to
|
|
633
|
+
* reference while it drafts the document. When present the agent works at the workspace ROOT
|
|
634
|
+
* (all checkouts are siblings under it); the harness clones each reference at its own base
|
|
635
|
+
* branch and NEVER creates a branch, commits, or opens a PR for it. Only the primary is
|
|
636
|
+
* pushed. Absent ⇒ single-repo run. Independent of {@link peerRepos} (those are writable).
|
|
637
|
+
*/
|
|
638
|
+
referenceRepos?: ReferenceRepoSpec[]
|
|
594
639
|
/**
|
|
595
640
|
* Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
|
|
596
641
|
* fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
|
|
@@ -926,6 +971,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
926
971
|
: undefined
|
|
927
972
|
const infra = parseAgentInfraSpec(o.infra)
|
|
928
973
|
const peerRepos = parsePeerRepos(o.peerRepos)
|
|
974
|
+
const referenceRepos = parseReferenceRepos(o.referenceRepos)
|
|
929
975
|
const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
|
|
930
976
|
const contextFiles = parseContextFiles(o.contextFiles)
|
|
931
977
|
const packageRegistries = parsePackageRegistries(o.packageRegistries)
|
|
@@ -957,6 +1003,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
957
1003
|
: {}),
|
|
958
1004
|
...(pr ? { pr } : {}),
|
|
959
1005
|
...(peerRepos.length ? { peerRepos } : {}),
|
|
1006
|
+
...(referenceRepos.length ? { referenceRepos } : {}),
|
|
960
1007
|
...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
|
|
961
1008
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
962
1009
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
@@ -973,5 +1020,11 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
973
1020
|
for (const [i, peer] of (job.peerRepos ?? []).entries()) {
|
|
974
1021
|
assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
|
|
975
1022
|
}
|
|
1023
|
+
// Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
|
|
1024
|
+
// never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
|
|
1025
|
+
// attacker host would exfiltrate the token exactly like a rogue peer clone URL.
|
|
1026
|
+
for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
|
|
1027
|
+
assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
|
|
1028
|
+
}
|
|
976
1029
|
return job
|
|
977
1030
|
}
|