@cat-factory/executor-harness 1.86.2 → 1.90.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/src/job.ts CHANGED
@@ -20,6 +20,11 @@ import {
20
20
  type SkillResourceSpec,
21
21
  type SkillSpec,
22
22
  } from './agent-capabilities.js'
23
+ import { type TestSecretSpec, parseInfraEnv, parseSecretEnvPairs, str } from './job-env.js'
24
+
25
+ // Re-exported so a handler describing a job keeps ONE import site (the env-pair shape is a job
26
+ // body field like any other; only its VALIDATION moved out).
27
+ export type { TestSecretSpec }
23
28
 
24
29
  // Re-exported so the job body stays the one import site for a harness handler describing a job.
25
30
  export type { McpServerSpec, SkillResourceSpec, SkillSpec }
@@ -142,13 +147,6 @@ export interface ReferenceRepoSpec {
142
147
  ghToken?: string
143
148
  }
144
149
 
145
- function str(value: unknown, path: string): string {
146
- if (typeof value !== 'string' || value.length === 0) {
147
- throw new Error(`Invalid job: '${path}' must be a non-empty string`)
148
- }
149
- return value
150
- }
151
-
152
150
  /** A positive finite integer, or undefined for any other input (silently ignored). */
153
151
  function posInt(value: unknown): number | undefined {
154
152
  return typeof value === 'number' && Number.isFinite(value) && value > 0
@@ -183,9 +181,13 @@ function parseGuardLimits(value: unknown): GuardLimitsSpec | undefined {
183
181
  const noEdit = posInt(o.maxToolCallsWithoutEdit)
184
182
  const errors = posInt(o.maxConsecutiveErrors)
185
183
  const web = posInt(o.maxConsecutiveWebCalls)
184
+ const mcp = posInt(o.maxConsecutiveMcpCalls)
185
+ const nonAction = posInt(o.maxConsecutiveNonActionCalls)
186
186
  if (noEdit !== undefined) spec.maxToolCallsWithoutEdit = noEdit
187
187
  if (errors !== undefined) spec.maxConsecutiveErrors = errors
188
188
  if (web !== undefined) spec.maxConsecutiveWebCalls = web
189
+ if (mcp !== undefined) spec.maxConsecutiveMcpCalls = mcp
190
+ if (nonAction !== undefined) spec.maxConsecutiveNonActionCalls = nonAction
189
191
  return Object.keys(spec).length > 0 ? spec : undefined
190
192
  }
191
193
 
@@ -494,49 +496,6 @@ export function parsePackageRegistries(
494
496
  return entries
495
497
  }
496
498
 
497
- /**
498
- * One sensitive test credential the tester receives: an env-var name + its (secret) value.
499
- * The backend seals these at rest and decrypts them at dispatch; the harness injects each as an
500
- * environment variable the tester's shell can read (out of band — the value is NEVER in the
501
- * prompt/telemetry). See {@link parseTestSecrets}.
502
- */
503
- export interface TestSecretSpec {
504
- key: string
505
- value: string
506
- }
507
-
508
- /** A valid POSIX shell variable name (letters, digits, underscore; not starting with a digit). */
509
- const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
510
-
511
- /**
512
- * Validate the optional tester `testSecrets` list — `{ key, value }` env pairs the harness
513
- * injects into the run environment. Keys must be valid env-var names; toolchain-critical /
514
- * reserved names ({@link isReservedEnvName}) and duplicates are dropped so a drifted body can't
515
- * clobber PATH/NODE_OPTIONS/etc. Absent ⇒ no secrets injected.
516
- */
517
- export function parseTestSecrets(value: unknown): TestSecretSpec[] {
518
- if (value === undefined || value === null) return []
519
- if (!Array.isArray(value)) throw new Error("Invalid job: 'testSecrets' must be an array")
520
- const entries: TestSecretSpec[] = []
521
- const seen = new Set<string>()
522
- for (const [i, raw] of value.entries()) {
523
- if (typeof raw !== 'object' || raw === null) {
524
- throw new Error(`Invalid job: 'testSecrets[${i}]' must be an object`)
525
- }
526
- const entry = raw as Record<string, unknown>
527
- const key = str(entry.key, `testSecrets[${i}].key`).trim()
528
- if (!ENV_VAR_NAME_PATTERN.test(key)) {
529
- throw new Error(
530
- `Invalid job: 'testSecrets[${i}].key' must be a valid environment variable name`,
531
- )
532
- }
533
- if (isReservedEnvName(key) || seen.has(key)) continue
534
- seen.add(key)
535
- entries.push({ key, value: str(entry.value, `testSecrets[${i}].value`) })
536
- }
537
- return entries
538
- }
539
-
540
499
  // ---- Shared repo-bootstrap target ---------------------------------------
541
500
 
542
501
  /** The new repository a repo-bootstrap run force-pushes its fresh history to. */
@@ -782,6 +741,16 @@ export interface AgentJob extends HarnessAuthFields {
782
741
  * Absent ⇒ no secrets injected.
783
742
  */
784
743
  testSecrets?: TestSecretSpec[]
744
+ /**
745
+ * The resolved credentials of the step's GENERATIVE BINARY INTEGRATIONS (the image / music /
746
+ * video generation APIs its `binaryOutput` selection named), as env pairs the harness injects
747
+ * into the agent's own process — where the agent's brief has already told it to read them from.
748
+ * Distinct from {@link testSecrets} because the two have different producers and different
749
+ * lifetimes: tester secrets are workspace state a human stored, these are a deployment's
750
+ * registration resolved per dispatch. Absent ⇒ no integration declared a credential, or none
751
+ * resolved (which the agent is told to report rather than work around).
752
+ */
753
+ generatorSecrets?: TestSecretSpec[]
785
754
  /**
786
755
  * Explore mode: stand the service's dependencies up before the agent runs (the
787
756
  * tester). Brings the docker-compose infra up on localhost for the duration of the
@@ -909,6 +878,8 @@ export interface GuardLimitsSpec {
909
878
  maxToolCallsWithoutEdit?: number
910
879
  maxConsecutiveErrors?: number
911
880
  maxConsecutiveWebCalls?: number
881
+ maxConsecutiveMcpCalls?: number
882
+ maxConsecutiveNonActionCalls?: number
912
883
  }
913
884
 
914
885
  /**
@@ -1110,66 +1081,6 @@ function parseStringMap(value: unknown): Record<string, string> | undefined {
1110
1081
  return Object.keys(out).length ? out : undefined
1111
1082
  }
1112
1083
 
1113
- /**
1114
- * Env-var names never injected from a frontend binding: spread over `process.env` at build
1115
- * time, so any of these would break the toolchain (or enable code execution / cert overrides)
1116
- * rather than name an upstream URL. Matched exactly (Linux env is case-sensitive); the
1117
- * {@link RESERVED_ENV_PREFIXES} below cover whole families (`npm_config_*`, `GIT_*`, …).
1118
- */
1119
- const RESERVED_ENV_NAMES = new Set([
1120
- 'PATH',
1121
- 'HOME',
1122
- 'NODE_OPTIONS',
1123
- 'NODE_PATH',
1124
- 'NODE_EXTRA_CA_CERTS',
1125
- 'LD_PRELOAD',
1126
- 'LD_LIBRARY_PATH',
1127
- 'BASH_ENV',
1128
- 'ENV',
1129
- 'SHELL',
1130
- 'IFS',
1131
- ])
1132
-
1133
- /**
1134
- * Env-var name PREFIXES never injected from a frontend binding. `npm_config_*` reconfigures the
1135
- * package manager (registry, scripts, prefix), and `GIT_*` reconfigures git — both run during a
1136
- * frontend install/build, so a binding in either family is toolchain control, not an upstream URL.
1137
- * Compared case-INSENSITIVELY (lower-cased here, matched lower-cased below): npm reads its config
1138
- * env with a case-insensitive `/^npm_config_/i`, so `NPM_CONFIG_REGISTRY` is honoured just like
1139
- * `npm_config_registry` — a case-sensitive prefix match would let the upper-cased form slip through.
1140
- */
1141
- const RESERVED_ENV_PREFIXES = ['npm_config_', 'git_']
1142
-
1143
- /**
1144
- * Whether an env-var name is reserved (an exact name, or a reserved family prefix). The exact
1145
- * names are canonical upper-case env vars matched verbatim (Linux env is case-sensitive, so a
1146
- * distinct lower-cased `home` is a different, harmless var); the family PREFIXES are matched
1147
- * case-insensitively because npm interprets `npm_config_*` regardless of case (see above).
1148
- */
1149
- function isReservedEnvName(key: string): boolean {
1150
- if (RESERVED_ENV_NAMES.has(key)) return true
1151
- const lower = key.toLowerCase()
1152
- return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p))
1153
- }
1154
-
1155
- /**
1156
- * Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
1157
- * malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
1158
- * names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
1159
- * dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
1160
- * replace it with a URL and the build would no longer find its tools. Extracted from the infra
1161
- * parsers to keep their cyclomatic complexity down.
1162
- */
1163
- function parseInfraEnv(raw: unknown): Record<string, string> {
1164
- const env: Record<string, string> = {}
1165
- if (typeof raw === 'object' && raw !== null) {
1166
- for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
1167
- if (key && !isReservedEnvName(key) && typeof val === 'string') env[key] = val
1168
- }
1169
- }
1170
- return env
1171
- }
1172
-
1173
1084
  /** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
1174
1085
  function parseFrontendInfraSpec(o: Record<string, unknown>): FrontendInfraSpec {
1175
1086
  const packageManager =
@@ -1321,7 +1232,8 @@ export function parseAgentJob(input: unknown): AgentJob {
1321
1232
  packageRegistries: parsePackageRegistries(o.packageRegistries),
1322
1233
  skills: parseSkillSpecs(o.skills),
1323
1234
  mcpServers: parseMcpServerSpecs(o.mcpServers),
1324
- testSecrets: parseTestSecrets(o.testSecrets),
1235
+ testSecrets: parseSecretEnvPairs(o.testSecrets, 'testSecrets'),
1236
+ generatorSecrets: parseSecretEnvPairs(o.generatorSecrets, 'generatorSecrets'),
1325
1237
  guardLimits: parseGuardLimits(o.guardLimits),
1326
1238
  validation: parseValidationSpec(o.validation),
1327
1239
  validationChecks: parseValidationChecksSpec(o.validationChecks),
@@ -1362,7 +1274,8 @@ interface ParsedAgentJobParts {
1362
1274
  packageRegistries: ReturnType<typeof parsePackageRegistries>
1363
1275
  skills: ReturnType<typeof parseSkillSpecs>
1364
1276
  mcpServers: ReturnType<typeof parseMcpServerSpecs>
1365
- testSecrets: ReturnType<typeof parseTestSecrets>
1277
+ testSecrets: ReturnType<typeof parseSecretEnvPairs>
1278
+ generatorSecrets: ReturnType<typeof parseSecretEnvPairs>
1366
1279
  guardLimits: ReturnType<typeof parseGuardLimits>
1367
1280
  validation: ReturnType<typeof parseValidationSpec>
1368
1281
  validationChecks: ReturnType<typeof parseValidationChecksSpec>
@@ -1425,6 +1338,7 @@ function assembleAgentJob(
1425
1338
  reproduction,
1426
1339
  dependencyInstall,
1427
1340
  reviewPrNumber,
1341
+ generatorSecrets,
1428
1342
  } = parts
1429
1343
  const repo = (o.repo ?? {}) as Record<string, unknown>
1430
1344
  return {
@@ -1445,6 +1359,7 @@ function assembleAgentJob(
1445
1359
  ...(skills ? { skills } : {}),
1446
1360
  ...(mcpServers ? { mcpServers } : {}),
1447
1361
  ...(testSecrets.length ? { testSecrets } : {}),
1362
+ ...(generatorSecrets.length ? { generatorSecrets } : {}),
1448
1363
  ...(infra ? { infra } : {}),
1449
1364
  ...(pr ? { pr } : {}),
1450
1365
  ...(peerRepos.length ? { peerRepos } : {}),
@@ -50,6 +50,31 @@ export interface ProgressGuardLimits {
50
50
  * without it.
51
51
  */
52
52
  maxConsecutiveWebCalls?: number
53
+ /**
54
+ * Abort after this many consecutive MCP tool-server calls (`mcp__*`) with no other
55
+ * tool call in between: the tool-server analogue of `maxConsecutiveWebCalls`, and
56
+ * present for the same reason. An `mcp__*` call is exempt from the no-edit bound (see
57
+ * `isMcpToolCall`), so without a streak of its own a run could query a tool server
58
+ * indefinitely without tripping any guard. Any non-MCP tool call resets the streak.
59
+ * Optional: defaults to {@link DEFAULT_PROGRESS_GUARD_LIMITS}.
60
+ */
61
+ maxConsecutiveMcpCalls?: number
62
+ /**
63
+ * Abort after this many consecutive calls that are EXEMPT from the no-edit bound
64
+ * (planning, read-only exploration, subagent dispatch, `mcp__*`) with no action call
65
+ * in between. The backstop that makes each individual exemption mean "not counted"
66
+ * rather than "unbounded": every per-family streak above resets on any call outside
67
+ * its own family, so a run alternating `web_search` with `mcp__issues__search` (or
68
+ * with `read`) trips none of them and, having never made an action call, never
69
+ * reaches `maxToolCallsWithoutEdit` either. Only the job's wall-clock ceiling
70
+ * bounded that.
71
+ *
72
+ * Deliberately far above every family cap, because it is not a research bound and
73
+ * must not become one: reading a hundred files before the first edit is legitimate
74
+ * work-up, and any `bash`/edit/action call resets the streak. Optional: defaults to
75
+ * {@link DEFAULT_PROGRESS_GUARD_LIMITS}.
76
+ */
77
+ maxConsecutiveNonActionCalls?: number
53
78
  }
54
79
 
55
80
  // `satisfies` (not a type annotation) so each property keeps its concrete `number`
@@ -63,6 +88,16 @@ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
63
88
  // A genuine research burst is a handful of searches; an uninterrupted run of this
64
89
  // many web calls (with no read/edit/bash between) is a search loop, not progress.
65
90
  maxConsecutiveWebCalls: 25,
91
+ // Looser than the web cap: a tool server is usually the agent's route to the SYSTEM OF
92
+ // RECORD (the issue tracker, the advisory database, the design source), and reading a
93
+ // list and then each of its items is a normal opening move, not a rabbit-hole. A run
94
+ // that makes this many in a row with no read, edit or bash between is looping.
95
+ maxConsecutiveMcpCalls: 40,
96
+ // Well clear of every family cap above, and of any plausible read-up: a run that makes
97
+ // this many exempt calls with not one action call between them has stopped converging,
98
+ // whatever mix of reads, searches and lookups it is cycling through. Sized as a
99
+ // backstop rather than a judgement, because the families are where judgement belongs.
100
+ maxConsecutiveNonActionCalls: 200,
66
101
  } satisfies ProgressGuardLimits
67
102
 
68
103
  // Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
@@ -139,6 +174,32 @@ const EXPLORATION_TOOLS = new Set([
139
174
  // Pi's `web_search`/`web_fetch` and Claude Code's `WebSearch`/`WebFetch`.
140
175
  const WEB_TOOLS = new Set(['web_search', 'web_fetch', 'websearch', 'webfetch'])
141
176
 
177
+ // A call to a tool server (MCP). Every MCP client names these `mcp__<server>__<tool>`, and
178
+ // the prefix is the ONLY thing the harness can know about them: what a given server's tools
179
+ // do is a backend registration this image has never seen, so the guard classifies by shape.
180
+ //
181
+ // Matched, rather than enumerated in EXPLORATION_TOOLS, because the set is open: it is
182
+ // whatever tool servers the running kind was wired with. A prefix test is also why this is a
183
+ // function, `name.startsWith` on the already-lower-cased name, so `MCP__Issues__search`
184
+ // classifies the same as `mcp__issues__search`.
185
+ function isMcpToolCall(loweredName: string): boolean {
186
+ return loweredName.startsWith('mcp__')
187
+ }
188
+
189
+ // Whether a call is EXEMPT from the no-edit bound: planning and bookkeeping, read-only
190
+ // exploration, a subagent dispatch, or a tool-server call. One predicate rather than the
191
+ // four tests inlined at the branch, because the combined non-action streak and the no-edit
192
+ // exemption must be the SAME set: a family exempted in one place and missed in the other is
193
+ // either an unbounded loop or a run killed for a call the bound says it may make.
194
+ function isNonActionToolCall(loweredName: string): boolean {
195
+ return (
196
+ PLANNING_TOOLS.has(loweredName) ||
197
+ EXPLORATION_TOOLS.has(loweredName) ||
198
+ SUBAGENT_DISPATCH_TOOLS.has(loweredName) ||
199
+ isMcpToolCall(loweredName)
200
+ )
201
+ }
202
+
142
203
  /** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
143
204
  export function progressGuardLimitsFromEnv(
144
205
  env: NodeJS.ProcessEnv = process.env,
@@ -160,6 +221,14 @@ export function progressGuardLimitsFromEnv(
160
221
  env.JOB_MAX_CONSECUTIVE_WEB_CALLS,
161
222
  DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
162
223
  ),
224
+ maxConsecutiveMcpCalls: num(
225
+ env.JOB_MAX_CONSECUTIVE_MCP_CALLS,
226
+ DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls,
227
+ ),
228
+ maxConsecutiveNonActionCalls: num(
229
+ env.JOB_MAX_CONSECUTIVE_NON_ACTION_CALLS,
230
+ DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls,
231
+ ),
163
232
  }
164
233
  }
165
234
 
@@ -186,12 +255,21 @@ export function mergeGuardLimits(
186
255
  overrides.maxToolCallsWithoutEdit,
187
256
  ),
188
257
  maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
189
- // `maxConsecutiveWebCalls` is optional on the interface (callers may omit it), so
190
- // fall back to the default before loosening keeps `loosen`'s base a concrete number.
258
+ // The streak knobs are optional on the interface (callers may omit them), so fall back
259
+ // to the default before loosening: it keeps `loosen`'s base a concrete number.
191
260
  maxConsecutiveWebCalls: loosen(
192
261
  base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls,
193
262
  overrides.maxConsecutiveWebCalls,
194
263
  ),
264
+ maxConsecutiveMcpCalls: loosen(
265
+ base.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls,
266
+ overrides.maxConsecutiveMcpCalls,
267
+ ),
268
+ maxConsecutiveNonActionCalls: loosen(
269
+ base.maxConsecutiveNonActionCalls ??
270
+ DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls,
271
+ overrides.maxConsecutiveNonActionCalls,
272
+ ),
195
273
  }
196
274
  }
197
275
 
@@ -207,6 +285,8 @@ export class ProgressGuard {
207
285
  private edits = 0
208
286
  private consecutiveErrors = 0
209
287
  private consecutiveWebCalls = 0
288
+ private consecutiveMcpCalls = 0
289
+ private consecutiveNonActionCalls = 0
210
290
 
211
291
  constructor(
212
292
  private readonly limits: ProgressGuardLimits,
@@ -257,16 +337,55 @@ export class ProgressGuard {
257
337
  this.consecutiveWebCalls = 0
258
338
  }
259
339
 
260
- // Planning, read-only exploration and subagent-dispatch calls don't count toward the
261
- // no-edit bound (see PLANNING_TOOLS / EXPLORATION_TOOLS / SUBAGENT_DISPATCH_TOOLS) —
262
- // only "action" calls without an edit do.
263
- if (
264
- PLANNING_TOOLS.has(name) ||
265
- EXPLORATION_TOOLS.has(name) ||
266
- SUBAGENT_DISPATCH_TOOLS.has(name)
267
- ) {
340
+ // Tool-server (MCP) calls: bounded as their own streak for exactly the reason the web
341
+ // streak exists. They are exempt from the no-edit bound below, and an exemption with no
342
+ // counter-bound is a loop the guard cannot see. Any non-MCP call resets it.
343
+ if (isMcpToolCall(name)) {
344
+ this.consecutiveMcpCalls++
345
+ const mcpCap =
346
+ this.limits.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls
347
+ if (this.consecutiveMcpCalls >= mcpCap) {
348
+ return (
349
+ `no progress: ${this.consecutiveMcpCalls} consecutive tool-server (MCP) calls without ` +
350
+ `any other action. The agent is stuck querying its tools instead of doing the work. ` +
351
+ `Aborting.`
352
+ )
353
+ }
354
+ } else {
355
+ this.consecutiveMcpCalls = 0
356
+ }
357
+
358
+ // Planning, read-only exploration, subagent-dispatch and tool-server calls don't count
359
+ // toward the no-edit bound (see `isNonActionToolCall`): only "action" calls without an
360
+ // edit do.
361
+ //
362
+ // An `mcp__*` call is exempt for the same reason a `read` is: the bound targets the
363
+ // credential rabbit-hole (endless `bash` probing with nothing implemented), and reaching
364
+ // a registered tool server is the platform TELLING the agent to look something up
365
+ // ("prefer them over guessing"). Counting them would abort an edits-expected kind for
366
+ // consulting the issue tracker the deployment wired for it, punishing the run for
367
+ // following its own prompt. They are neutral rather than edit-satisfying, exactly like a
368
+ // subagent dispatch: a read-only lookup must not clear the suspicion the bound holds.
369
+ //
370
+ // The exempt calls carry ONE streak of their own, and it is what keeps every exemption
371
+ // above from adding up to an unbounded run: each per-family cap resets on any call
372
+ // outside its family, so alternating two exempt families trips neither, and a run that
373
+ // never makes an action call never reaches the no-edit bound either.
374
+ if (isNonActionToolCall(name)) {
375
+ this.consecutiveNonActionCalls++
376
+ const nonActionCap =
377
+ this.limits.maxConsecutiveNonActionCalls ??
378
+ DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls
379
+ if (this.consecutiveNonActionCalls >= nonActionCap) {
380
+ return (
381
+ `no progress: ${this.consecutiveNonActionCalls} consecutive read-only calls (searching, ` +
382
+ `reading, tool-server lookups, subagent dispatches) with no action call between them. ` +
383
+ `The agent is cycling through research instead of doing the work. Aborting.`
384
+ )
385
+ }
268
386
  return null
269
387
  }
388
+ this.consecutiveNonActionCalls = 0
270
389
  this.toolCalls++
271
390
  if (FILE_EDIT_TOOLS.has(name)) this.edits++
272
391