@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/README.md +64 -49
- package/dist/agent-capabilities.d.ts +20 -3
- package/dist/agent-capabilities.js +32 -4
- package/dist/agent.d.ts +7 -0
- package/dist/agent.js +19 -1
- package/dist/job-env.d.ts +44 -0
- package/dist/job-env.js +108 -0
- package/dist/job.d.ts +14 -17
- package/dist/job.js +11 -97
- package/dist/progress-guard.d.ts +29 -0
- package/dist/progress-guard.js +82 -8
- package/package.json +4 -4
- package/src/agent-capabilities.ts +34 -4
- package/src/agent.ts +20 -1
- package/src/job-env.ts +121 -0
- package/src/job.ts +27 -112
- package/src/progress-guard.ts +129 -10
package/dist/job.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { type ValidationChecksSpec, type ValidationReport } from './validation-c
|
|
|
6
6
|
import { type ReproductionReport, type ReproductionSpec } from './reproduction-proof.js';
|
|
7
7
|
import { type DependencyInstallSpec } from './dependency-install.js';
|
|
8
8
|
import { type McpServerSpec, type SkillResourceSpec, type SkillSpec } from './agent-capabilities.js';
|
|
9
|
+
import { type TestSecretSpec } from './job-env.js';
|
|
10
|
+
export type { TestSecretSpec };
|
|
9
11
|
export type { McpServerSpec, SkillResourceSpec, SkillSpec };
|
|
10
12
|
/**
|
|
11
13
|
* Per-job auth fields, shared across every job shape. The Pi harness carries the
|
|
@@ -130,23 +132,6 @@ export interface PackageRegistrySpec {
|
|
|
130
132
|
export declare function allowedNpmRegistryHosts(env?: NodeJS.ProcessEnv): Set<string>;
|
|
131
133
|
/** Validate the optional `packageRegistries` list (see {@link PackageRegistrySpec}). */
|
|
132
134
|
export declare function parsePackageRegistries(value: unknown, env?: NodeJS.ProcessEnv): PackageRegistrySpec[];
|
|
133
|
-
/**
|
|
134
|
-
* One sensitive test credential the tester receives: an env-var name + its (secret) value.
|
|
135
|
-
* The backend seals these at rest and decrypts them at dispatch; the harness injects each as an
|
|
136
|
-
* environment variable the tester's shell can read (out of band — the value is NEVER in the
|
|
137
|
-
* prompt/telemetry). See {@link parseTestSecrets}.
|
|
138
|
-
*/
|
|
139
|
-
export interface TestSecretSpec {
|
|
140
|
-
key: string;
|
|
141
|
-
value: string;
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Validate the optional tester `testSecrets` list — `{ key, value }` env pairs the harness
|
|
145
|
-
* injects into the run environment. Keys must be valid env-var names; toolchain-critical /
|
|
146
|
-
* reserved names ({@link isReservedEnvName}) and duplicates are dropped so a drifted body can't
|
|
147
|
-
* clobber PATH/NODE_OPTIONS/etc. Absent ⇒ no secrets injected.
|
|
148
|
-
*/
|
|
149
|
-
export declare function parseTestSecrets(value: unknown): TestSecretSpec[];
|
|
150
135
|
/** The new repository a repo-bootstrap run force-pushes its fresh history to. */
|
|
151
136
|
export interface BootstrapTargetSpec {
|
|
152
137
|
owner: string;
|
|
@@ -372,6 +357,16 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
372
357
|
* Absent ⇒ no secrets injected.
|
|
373
358
|
*/
|
|
374
359
|
testSecrets?: TestSecretSpec[];
|
|
360
|
+
/**
|
|
361
|
+
* The resolved credentials of the step's GENERATIVE BINARY INTEGRATIONS (the image / music /
|
|
362
|
+
* video generation APIs its `binaryOutput` selection named), as env pairs the harness injects
|
|
363
|
+
* into the agent's own process — where the agent's brief has already told it to read them from.
|
|
364
|
+
* Distinct from {@link testSecrets} because the two have different producers and different
|
|
365
|
+
* lifetimes: tester secrets are workspace state a human stored, these are a deployment's
|
|
366
|
+
* registration resolved per dispatch. Absent ⇒ no integration declared a credential, or none
|
|
367
|
+
* resolved (which the agent is told to report rather than work around).
|
|
368
|
+
*/
|
|
369
|
+
generatorSecrets?: TestSecretSpec[];
|
|
375
370
|
/**
|
|
376
371
|
* Explore mode: stand the service's dependencies up before the agent runs (the
|
|
377
372
|
* tester). Brings the docker-compose infra up on localhost for the duration of the
|
|
@@ -498,6 +493,8 @@ export interface GuardLimitsSpec {
|
|
|
498
493
|
maxToolCallsWithoutEdit?: number;
|
|
499
494
|
maxConsecutiveErrors?: number;
|
|
500
495
|
maxConsecutiveWebCalls?: number;
|
|
496
|
+
maxConsecutiveMcpCalls?: number;
|
|
497
|
+
maxConsecutiveNonActionCalls?: number;
|
|
501
498
|
}
|
|
502
499
|
/**
|
|
503
500
|
* The record of standing the service's docker-compose dependencies up before a tester
|
package/dist/job.js
CHANGED
|
@@ -2,12 +2,7 @@ import { parseValidationChecksSpec, } from './validation-checks.js';
|
|
|
2
2
|
import { parseReproductionSpec, } from './reproduction-proof.js';
|
|
3
3
|
import { parseDependencyInstallSpec } from './dependency-install.js';
|
|
4
4
|
import { parseMcpServerSpecs, parseSkillSpecs, } from './agent-capabilities.js';
|
|
5
|
-
|
|
6
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
7
|
-
throw new Error(`Invalid job: '${path}' must be a non-empty string`);
|
|
8
|
-
}
|
|
9
|
-
return value;
|
|
10
|
-
}
|
|
5
|
+
import { parseInfraEnv, parseSecretEnvPairs, str } from './job-env.js';
|
|
11
6
|
/** A positive finite integer, or undefined for any other input (silently ignored). */
|
|
12
7
|
function posInt(value) {
|
|
13
8
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
@@ -41,12 +36,18 @@ function parseGuardLimits(value) {
|
|
|
41
36
|
const noEdit = posInt(o.maxToolCallsWithoutEdit);
|
|
42
37
|
const errors = posInt(o.maxConsecutiveErrors);
|
|
43
38
|
const web = posInt(o.maxConsecutiveWebCalls);
|
|
39
|
+
const mcp = posInt(o.maxConsecutiveMcpCalls);
|
|
40
|
+
const nonAction = posInt(o.maxConsecutiveNonActionCalls);
|
|
44
41
|
if (noEdit !== undefined)
|
|
45
42
|
spec.maxToolCallsWithoutEdit = noEdit;
|
|
46
43
|
if (errors !== undefined)
|
|
47
44
|
spec.maxConsecutiveErrors = errors;
|
|
48
45
|
if (web !== undefined)
|
|
49
46
|
spec.maxConsecutiveWebCalls = web;
|
|
47
|
+
if (mcp !== undefined)
|
|
48
|
+
spec.maxConsecutiveMcpCalls = mcp;
|
|
49
|
+
if (nonAction !== undefined)
|
|
50
|
+
spec.maxConsecutiveNonActionCalls = nonAction;
|
|
50
51
|
return Object.keys(spec).length > 0 ? spec : undefined;
|
|
51
52
|
}
|
|
52
53
|
/**
|
|
@@ -332,37 +333,6 @@ export function parsePackageRegistries(value, env = process.env) {
|
|
|
332
333
|
}
|
|
333
334
|
return entries;
|
|
334
335
|
}
|
|
335
|
-
/** A valid POSIX shell variable name (letters, digits, underscore; not starting with a digit). */
|
|
336
|
-
const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
337
|
-
/**
|
|
338
|
-
* Validate the optional tester `testSecrets` list — `{ key, value }` env pairs the harness
|
|
339
|
-
* injects into the run environment. Keys must be valid env-var names; toolchain-critical /
|
|
340
|
-
* reserved names ({@link isReservedEnvName}) and duplicates are dropped so a drifted body can't
|
|
341
|
-
* clobber PATH/NODE_OPTIONS/etc. Absent ⇒ no secrets injected.
|
|
342
|
-
*/
|
|
343
|
-
export function parseTestSecrets(value) {
|
|
344
|
-
if (value === undefined || value === null)
|
|
345
|
-
return [];
|
|
346
|
-
if (!Array.isArray(value))
|
|
347
|
-
throw new Error("Invalid job: 'testSecrets' must be an array");
|
|
348
|
-
const entries = [];
|
|
349
|
-
const seen = new Set();
|
|
350
|
-
for (const [i, raw] of value.entries()) {
|
|
351
|
-
if (typeof raw !== 'object' || raw === null) {
|
|
352
|
-
throw new Error(`Invalid job: 'testSecrets[${i}]' must be an object`);
|
|
353
|
-
}
|
|
354
|
-
const entry = raw;
|
|
355
|
-
const key = str(entry.key, `testSecrets[${i}].key`).trim();
|
|
356
|
-
if (!ENV_VAR_NAME_PATTERN.test(key)) {
|
|
357
|
-
throw new Error(`Invalid job: 'testSecrets[${i}].key' must be a valid environment variable name`);
|
|
358
|
-
}
|
|
359
|
-
if (isReservedEnvName(key) || seen.has(key))
|
|
360
|
-
continue;
|
|
361
|
-
seen.add(key);
|
|
362
|
-
entries.push({ key, value: str(entry.value, `testSecrets[${i}].value`) });
|
|
363
|
-
}
|
|
364
|
-
return entries;
|
|
365
|
-
}
|
|
366
336
|
/** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
|
|
367
337
|
function parseAgentBootstrapSpec(value) {
|
|
368
338
|
if (typeof value !== 'object' || value === null)
|
|
@@ -453,64 +423,6 @@ function parseStringMap(value) {
|
|
|
453
423
|
}
|
|
454
424
|
return Object.keys(out).length ? out : undefined;
|
|
455
425
|
}
|
|
456
|
-
/**
|
|
457
|
-
* Env-var names never injected from a frontend binding: spread over `process.env` at build
|
|
458
|
-
* time, so any of these would break the toolchain (or enable code execution / cert overrides)
|
|
459
|
-
* rather than name an upstream URL. Matched exactly (Linux env is case-sensitive); the
|
|
460
|
-
* {@link RESERVED_ENV_PREFIXES} below cover whole families (`npm_config_*`, `GIT_*`, …).
|
|
461
|
-
*/
|
|
462
|
-
const RESERVED_ENV_NAMES = new Set([
|
|
463
|
-
'PATH',
|
|
464
|
-
'HOME',
|
|
465
|
-
'NODE_OPTIONS',
|
|
466
|
-
'NODE_PATH',
|
|
467
|
-
'NODE_EXTRA_CA_CERTS',
|
|
468
|
-
'LD_PRELOAD',
|
|
469
|
-
'LD_LIBRARY_PATH',
|
|
470
|
-
'BASH_ENV',
|
|
471
|
-
'ENV',
|
|
472
|
-
'SHELL',
|
|
473
|
-
'IFS',
|
|
474
|
-
]);
|
|
475
|
-
/**
|
|
476
|
-
* Env-var name PREFIXES never injected from a frontend binding. `npm_config_*` reconfigures the
|
|
477
|
-
* package manager (registry, scripts, prefix), and `GIT_*` reconfigures git — both run during a
|
|
478
|
-
* frontend install/build, so a binding in either family is toolchain control, not an upstream URL.
|
|
479
|
-
* Compared case-INSENSITIVELY (lower-cased here, matched lower-cased below): npm reads its config
|
|
480
|
-
* env with a case-insensitive `/^npm_config_/i`, so `NPM_CONFIG_REGISTRY` is honoured just like
|
|
481
|
-
* `npm_config_registry` — a case-sensitive prefix match would let the upper-cased form slip through.
|
|
482
|
-
*/
|
|
483
|
-
const RESERVED_ENV_PREFIXES = ['npm_config_', 'git_'];
|
|
484
|
-
/**
|
|
485
|
-
* Whether an env-var name is reserved (an exact name, or a reserved family prefix). The exact
|
|
486
|
-
* names are canonical upper-case env vars matched verbatim (Linux env is case-sensitive, so a
|
|
487
|
-
* distinct lower-cased `home` is a different, harmless var); the family PREFIXES are matched
|
|
488
|
-
* case-insensitively because npm interprets `npm_config_*` regardless of case (see above).
|
|
489
|
-
*/
|
|
490
|
-
function isReservedEnvName(key) {
|
|
491
|
-
if (RESERVED_ENV_NAMES.has(key))
|
|
492
|
-
return true;
|
|
493
|
-
const lower = key.toLowerCase();
|
|
494
|
-
return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p));
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
|
|
498
|
-
* malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
|
|
499
|
-
* names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
|
|
500
|
-
* dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
|
|
501
|
-
* replace it with a URL and the build would no longer find its tools. Extracted from the infra
|
|
502
|
-
* parsers to keep their cyclomatic complexity down.
|
|
503
|
-
*/
|
|
504
|
-
function parseInfraEnv(raw) {
|
|
505
|
-
const env = {};
|
|
506
|
-
if (typeof raw === 'object' && raw !== null) {
|
|
507
|
-
for (const [key, val] of Object.entries(raw)) {
|
|
508
|
-
if (key && !isReservedEnvName(key) && typeof val === 'string')
|
|
509
|
-
env[key] = val;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
return env;
|
|
513
|
-
}
|
|
514
426
|
/** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
|
|
515
427
|
function parseFrontendInfraSpec(o) {
|
|
516
428
|
const packageManager = o.packageManager === 'pnpm' || o.packageManager === 'npm' || o.packageManager === 'yarn'
|
|
@@ -607,7 +519,8 @@ export function parseAgentJob(input) {
|
|
|
607
519
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
608
520
|
skills: parseSkillSpecs(o.skills),
|
|
609
521
|
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
610
|
-
testSecrets:
|
|
522
|
+
testSecrets: parseSecretEnvPairs(o.testSecrets, 'testSecrets'),
|
|
523
|
+
generatorSecrets: parseSecretEnvPairs(o.generatorSecrets, 'generatorSecrets'),
|
|
611
524
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
612
525
|
validation: parseValidationSpec(o.validation),
|
|
613
526
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
@@ -669,7 +582,7 @@ function parseAgentPrSpec(raw) {
|
|
|
669
582
|
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
670
583
|
*/
|
|
671
584
|
function assembleAgentJob(o, mode, agentField, parts) {
|
|
672
|
-
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, dependencyInstall, reviewPrNumber, } = parts;
|
|
585
|
+
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, dependencyInstall, reviewPrNumber, generatorSecrets, } = parts;
|
|
673
586
|
const repo = (o.repo ?? {});
|
|
674
587
|
return {
|
|
675
588
|
jobId: str(o.jobId, 'jobId'),
|
|
@@ -689,6 +602,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
689
602
|
...(skills ? { skills } : {}),
|
|
690
603
|
...(mcpServers ? { mcpServers } : {}),
|
|
691
604
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
605
|
+
...(generatorSecrets.length ? { generatorSecrets } : {}),
|
|
692
606
|
...(infra ? { infra } : {}),
|
|
693
607
|
...(pr ? { pr } : {}),
|
|
694
608
|
...(peerRepos.length ? { peerRepos } : {}),
|
package/dist/progress-guard.d.ts
CHANGED
|
@@ -35,11 +35,38 @@ export interface ProgressGuardLimits {
|
|
|
35
35
|
* without it.
|
|
36
36
|
*/
|
|
37
37
|
maxConsecutiveWebCalls?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Abort after this many consecutive MCP tool-server calls (`mcp__*`) with no other
|
|
40
|
+
* tool call in between: the tool-server analogue of `maxConsecutiveWebCalls`, and
|
|
41
|
+
* present for the same reason. An `mcp__*` call is exempt from the no-edit bound (see
|
|
42
|
+
* `isMcpToolCall`), so without a streak of its own a run could query a tool server
|
|
43
|
+
* indefinitely without tripping any guard. Any non-MCP tool call resets the streak.
|
|
44
|
+
* Optional: defaults to {@link DEFAULT_PROGRESS_GUARD_LIMITS}.
|
|
45
|
+
*/
|
|
46
|
+
maxConsecutiveMcpCalls?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Abort after this many consecutive calls that are EXEMPT from the no-edit bound
|
|
49
|
+
* (planning, read-only exploration, subagent dispatch, `mcp__*`) with no action call
|
|
50
|
+
* in between. The backstop that makes each individual exemption mean "not counted"
|
|
51
|
+
* rather than "unbounded": every per-family streak above resets on any call outside
|
|
52
|
+
* its own family, so a run alternating `web_search` with `mcp__issues__search` (or
|
|
53
|
+
* with `read`) trips none of them and, having never made an action call, never
|
|
54
|
+
* reaches `maxToolCallsWithoutEdit` either. Only the job's wall-clock ceiling
|
|
55
|
+
* bounded that.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately far above every family cap, because it is not a research bound and
|
|
58
|
+
* must not become one: reading a hundred files before the first edit is legitimate
|
|
59
|
+
* work-up, and any `bash`/edit/action call resets the streak. Optional: defaults to
|
|
60
|
+
* {@link DEFAULT_PROGRESS_GUARD_LIMITS}.
|
|
61
|
+
*/
|
|
62
|
+
maxConsecutiveNonActionCalls?: number;
|
|
38
63
|
}
|
|
39
64
|
export declare const DEFAULT_PROGRESS_GUARD_LIMITS: {
|
|
40
65
|
maxToolCallsWithoutEdit: number;
|
|
41
66
|
maxConsecutiveErrors: number;
|
|
42
67
|
maxConsecutiveWebCalls: number;
|
|
68
|
+
maxConsecutiveMcpCalls: number;
|
|
69
|
+
maxConsecutiveNonActionCalls: number;
|
|
43
70
|
};
|
|
44
71
|
/** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
|
|
45
72
|
export declare function progressGuardLimitsFromEnv(env?: NodeJS.ProcessEnv): ProgressGuardLimits;
|
|
@@ -69,6 +96,8 @@ export declare class ProgressGuard {
|
|
|
69
96
|
private edits;
|
|
70
97
|
private consecutiveErrors;
|
|
71
98
|
private consecutiveWebCalls;
|
|
99
|
+
private consecutiveMcpCalls;
|
|
100
|
+
private consecutiveNonActionCalls;
|
|
72
101
|
constructor(limits: ProgressGuardLimits,
|
|
73
102
|
/** When false (assess-only runs like the merger), the no-edit bound is skipped. */
|
|
74
103
|
expectsEdits?: boolean);
|
package/dist/progress-guard.js
CHANGED
|
@@ -28,6 +28,16 @@ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
|
|
|
28
28
|
// A genuine research burst is a handful of searches; an uninterrupted run of this
|
|
29
29
|
// many web calls (with no read/edit/bash between) is a search loop, not progress.
|
|
30
30
|
maxConsecutiveWebCalls: 25,
|
|
31
|
+
// Looser than the web cap: a tool server is usually the agent's route to the SYSTEM OF
|
|
32
|
+
// RECORD (the issue tracker, the advisory database, the design source), and reading a
|
|
33
|
+
// list and then each of its items is a normal opening move, not a rabbit-hole. A run
|
|
34
|
+
// that makes this many in a row with no read, edit or bash between is looping.
|
|
35
|
+
maxConsecutiveMcpCalls: 40,
|
|
36
|
+
// Well clear of every family cap above, and of any plausible read-up: a run that makes
|
|
37
|
+
// this many exempt calls with not one action call between them has stopped converging,
|
|
38
|
+
// whatever mix of reads, searches and lookups it is cycling through. Sized as a
|
|
39
|
+
// backstop rather than a judgement, because the families are where judgement belongs.
|
|
40
|
+
maxConsecutiveNonActionCalls: 200,
|
|
31
41
|
};
|
|
32
42
|
// Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
|
|
33
43
|
// broad on purpose: different models/extensions name the same capability differently
|
|
@@ -98,6 +108,28 @@ const EXPLORATION_TOOLS = new Set([
|
|
|
98
108
|
// call between) can be caught as a search loop — see `maxConsecutiveWebCalls`. Covers both
|
|
99
109
|
// Pi's `web_search`/`web_fetch` and Claude Code's `WebSearch`/`WebFetch`.
|
|
100
110
|
const WEB_TOOLS = new Set(['web_search', 'web_fetch', 'websearch', 'webfetch']);
|
|
111
|
+
// A call to a tool server (MCP). Every MCP client names these `mcp__<server>__<tool>`, and
|
|
112
|
+
// the prefix is the ONLY thing the harness can know about them: what a given server's tools
|
|
113
|
+
// do is a backend registration this image has never seen, so the guard classifies by shape.
|
|
114
|
+
//
|
|
115
|
+
// Matched, rather than enumerated in EXPLORATION_TOOLS, because the set is open: it is
|
|
116
|
+
// whatever tool servers the running kind was wired with. A prefix test is also why this is a
|
|
117
|
+
// function, `name.startsWith` on the already-lower-cased name, so `MCP__Issues__search`
|
|
118
|
+
// classifies the same as `mcp__issues__search`.
|
|
119
|
+
function isMcpToolCall(loweredName) {
|
|
120
|
+
return loweredName.startsWith('mcp__');
|
|
121
|
+
}
|
|
122
|
+
// Whether a call is EXEMPT from the no-edit bound: planning and bookkeeping, read-only
|
|
123
|
+
// exploration, a subagent dispatch, or a tool-server call. One predicate rather than the
|
|
124
|
+
// four tests inlined at the branch, because the combined non-action streak and the no-edit
|
|
125
|
+
// exemption must be the SAME set: a family exempted in one place and missed in the other is
|
|
126
|
+
// either an unbounded loop or a run killed for a call the bound says it may make.
|
|
127
|
+
function isNonActionToolCall(loweredName) {
|
|
128
|
+
return (PLANNING_TOOLS.has(loweredName) ||
|
|
129
|
+
EXPLORATION_TOOLS.has(loweredName) ||
|
|
130
|
+
SUBAGENT_DISPATCH_TOOLS.has(loweredName) ||
|
|
131
|
+
isMcpToolCall(loweredName));
|
|
132
|
+
}
|
|
101
133
|
/** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
|
|
102
134
|
export function progressGuardLimitsFromEnv(env = process.env) {
|
|
103
135
|
const num = (raw, fallback) => {
|
|
@@ -108,6 +140,8 @@ export function progressGuardLimitsFromEnv(env = process.env) {
|
|
|
108
140
|
maxToolCallsWithoutEdit: num(env.JOB_MAX_TOOLCALLS_WITHOUT_EDIT, DEFAULT_PROGRESS_GUARD_LIMITS.maxToolCallsWithoutEdit),
|
|
109
141
|
maxConsecutiveErrors: num(env.JOB_MAX_CONSECUTIVE_TOOL_ERRORS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveErrors),
|
|
110
142
|
maxConsecutiveWebCalls: num(env.JOB_MAX_CONSECUTIVE_WEB_CALLS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls),
|
|
143
|
+
maxConsecutiveMcpCalls: num(env.JOB_MAX_CONSECUTIVE_MCP_CALLS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls),
|
|
144
|
+
maxConsecutiveNonActionCalls: num(env.JOB_MAX_CONSECUTIVE_NON_ACTION_CALLS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls),
|
|
111
145
|
};
|
|
112
146
|
}
|
|
113
147
|
/**
|
|
@@ -127,9 +161,12 @@ export function mergeGuardLimits(base, overrides) {
|
|
|
127
161
|
return {
|
|
128
162
|
maxToolCallsWithoutEdit: loosen(base.maxToolCallsWithoutEdit, overrides.maxToolCallsWithoutEdit),
|
|
129
163
|
maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
|
|
130
|
-
//
|
|
131
|
-
//
|
|
164
|
+
// The streak knobs are optional on the interface (callers may omit them), so fall back
|
|
165
|
+
// to the default before loosening: it keeps `loosen`'s base a concrete number.
|
|
132
166
|
maxConsecutiveWebCalls: loosen(base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls, overrides.maxConsecutiveWebCalls),
|
|
167
|
+
maxConsecutiveMcpCalls: loosen(base.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls, overrides.maxConsecutiveMcpCalls),
|
|
168
|
+
maxConsecutiveNonActionCalls: loosen(base.maxConsecutiveNonActionCalls ??
|
|
169
|
+
DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls, overrides.maxConsecutiveNonActionCalls),
|
|
133
170
|
};
|
|
134
171
|
}
|
|
135
172
|
/**
|
|
@@ -146,6 +183,8 @@ export class ProgressGuard {
|
|
|
146
183
|
edits = 0;
|
|
147
184
|
consecutiveErrors = 0;
|
|
148
185
|
consecutiveWebCalls = 0;
|
|
186
|
+
consecutiveMcpCalls = 0;
|
|
187
|
+
consecutiveNonActionCalls = 0;
|
|
149
188
|
constructor(limits,
|
|
150
189
|
/** When false (assess-only runs like the merger), the no-edit bound is skipped. */
|
|
151
190
|
expectsEdits = true) {
|
|
@@ -189,14 +228,49 @@ export class ProgressGuard {
|
|
|
189
228
|
else {
|
|
190
229
|
this.consecutiveWebCalls = 0;
|
|
191
230
|
}
|
|
192
|
-
//
|
|
193
|
-
// no-edit bound
|
|
194
|
-
//
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
231
|
+
// Tool-server (MCP) calls: bounded as their own streak for exactly the reason the web
|
|
232
|
+
// streak exists. They are exempt from the no-edit bound below, and an exemption with no
|
|
233
|
+
// counter-bound is a loop the guard cannot see. Any non-MCP call resets it.
|
|
234
|
+
if (isMcpToolCall(name)) {
|
|
235
|
+
this.consecutiveMcpCalls++;
|
|
236
|
+
const mcpCap = this.limits.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls;
|
|
237
|
+
if (this.consecutiveMcpCalls >= mcpCap) {
|
|
238
|
+
return (`no progress: ${this.consecutiveMcpCalls} consecutive tool-server (MCP) calls without ` +
|
|
239
|
+
`any other action. The agent is stuck querying its tools instead of doing the work. ` +
|
|
240
|
+
`Aborting.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
this.consecutiveMcpCalls = 0;
|
|
245
|
+
}
|
|
246
|
+
// Planning, read-only exploration, subagent-dispatch and tool-server calls don't count
|
|
247
|
+
// toward the no-edit bound (see `isNonActionToolCall`): only "action" calls without an
|
|
248
|
+
// edit do.
|
|
249
|
+
//
|
|
250
|
+
// An `mcp__*` call is exempt for the same reason a `read` is: the bound targets the
|
|
251
|
+
// credential rabbit-hole (endless `bash` probing with nothing implemented), and reaching
|
|
252
|
+
// a registered tool server is the platform TELLING the agent to look something up
|
|
253
|
+
// ("prefer them over guessing"). Counting them would abort an edits-expected kind for
|
|
254
|
+
// consulting the issue tracker the deployment wired for it, punishing the run for
|
|
255
|
+
// following its own prompt. They are neutral rather than edit-satisfying, exactly like a
|
|
256
|
+
// subagent dispatch: a read-only lookup must not clear the suspicion the bound holds.
|
|
257
|
+
//
|
|
258
|
+
// The exempt calls carry ONE streak of their own, and it is what keeps every exemption
|
|
259
|
+
// above from adding up to an unbounded run: each per-family cap resets on any call
|
|
260
|
+
// outside its family, so alternating two exempt families trips neither, and a run that
|
|
261
|
+
// never makes an action call never reaches the no-edit bound either.
|
|
262
|
+
if (isNonActionToolCall(name)) {
|
|
263
|
+
this.consecutiveNonActionCalls++;
|
|
264
|
+
const nonActionCap = this.limits.maxConsecutiveNonActionCalls ??
|
|
265
|
+
DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls;
|
|
266
|
+
if (this.consecutiveNonActionCalls >= nonActionCap) {
|
|
267
|
+
return (`no progress: ${this.consecutiveNonActionCalls} consecutive read-only calls (searching, ` +
|
|
268
|
+
`reading, tool-server lookups, subagent dispatches) with no action call between them. ` +
|
|
269
|
+
`The agent is cycling through research instead of doing the work. Aborting.`);
|
|
270
|
+
}
|
|
198
271
|
return null;
|
|
199
272
|
}
|
|
273
|
+
this.consecutiveNonActionCalls = 0;
|
|
200
274
|
this.toolCalls++;
|
|
201
275
|
if (FILE_EDIT_TOOLS.has(name))
|
|
202
276
|
this.edits++;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.90.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",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"hono": "^4.12.33",
|
|
31
31
|
"typescript": "7.0.2",
|
|
32
32
|
"vitest": "^4.1.10",
|
|
33
|
-
"@cat-factory/kernel": "0.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.
|
|
33
|
+
"@cat-factory/kernel": "0.232.0",
|
|
34
|
+
"@cat-factory/server": "0.210.0",
|
|
35
|
+
"@cat-factory/spend": "0.14.7"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
|
@@ -178,6 +178,20 @@ function sanitizeServerId(value: unknown): string | undefined {
|
|
|
178
178
|
return MCP_SERVER_ID_PATTERN.test(value) ? value : undefined
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A tool name an `allowedTools` entry may name. Kept byte-identical to kernel's
|
|
183
|
+
* `MCP_TOOL_NAME_PATTERN` for the same reason {@link MCP_SERVER_ID_PATTERN} is a copy, and pinned
|
|
184
|
+
* against it by `test/agent-capabilities.conformity.test.ts`.
|
|
185
|
+
*
|
|
186
|
+
* The comma is the reason the rule exists on THIS side of the boundary too:
|
|
187
|
+
* {@link claudeAllowedToolPatterns} builds the list that the runner joins into one
|
|
188
|
+
* `--allowedTools` argument with commas, so an entry carrying one splits into two patterns of which
|
|
189
|
+
* the second matches no tool the CLI has. Dropped rather than passed through, because the entries
|
|
190
|
+
* that survive are what narrows the session: a bad one would silently take the run's whole MCP
|
|
191
|
+
* surface with it.
|
|
192
|
+
*/
|
|
193
|
+
export const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
194
|
+
|
|
181
195
|
/**
|
|
182
196
|
* Whether an HTTP tool server's URL may be started. Mirrors kernel's `isAllowedMcpHttpUrl` (see
|
|
183
197
|
* {@link MCP_SERVER_ID_PATTERN} for why it is a copy, and the conformity suite that pins it):
|
|
@@ -219,13 +233,25 @@ function parseStringArray(value: unknown): string[] | undefined {
|
|
|
219
233
|
return out.length ? out : undefined
|
|
220
234
|
}
|
|
221
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The `allowedTools` list: string entries that are single tool NAMES (see
|
|
238
|
+
* {@link MCP_TOOL_NAME_PATTERN}). Undefined when nothing survives, which is the same answer as an
|
|
239
|
+
* absent field (every tool the server exposes) and the right one: the alternative is a list whose
|
|
240
|
+
* only surviving entries are the platform's own built-in tool names, i.e. a run narrowed to no MCP
|
|
241
|
+
* tools at all. The backend refuses these at registration; this is the boundary check.
|
|
242
|
+
*/
|
|
243
|
+
function parseAllowedTools(value: unknown): string[] | undefined {
|
|
244
|
+
const names = parseStringArray(value)?.filter((name) => MCP_TOOL_NAME_PATTERN.test(name))
|
|
245
|
+
return names?.length ? names : undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
222
248
|
/** Validate one `mcpServers` entry, or undefined when malformed for its transport. */
|
|
223
249
|
function parseMcpServerSpec(value: unknown): McpServerSpec | undefined {
|
|
224
250
|
if (typeof value !== 'object' || value === null) return undefined
|
|
225
251
|
const o = value as Record<string, unknown>
|
|
226
252
|
const id = sanitizeServerId(o.id)
|
|
227
253
|
if (!id) return undefined
|
|
228
|
-
const allowedTools =
|
|
254
|
+
const allowedTools = parseAllowedTools(o.allowedTools)
|
|
229
255
|
const secretKeys = parseStringArray(o.secretKeys)
|
|
230
256
|
if (o.transport === 'http') {
|
|
231
257
|
// https anywhere, plain http only on loopback: the CLI would happily be pointed at a
|
|
@@ -370,9 +396,13 @@ function tomlString(value: string): string {
|
|
|
370
396
|
|
|
371
397
|
/**
|
|
372
398
|
* The `[mcp_servers.<id>]` TOML block Codex reads from its `CODEX_HOME/config.toml`. Codex's MCP
|
|
373
|
-
* client is stdio-only, so an `http` server is skipped here
|
|
374
|
-
*
|
|
375
|
-
*
|
|
399
|
+
* client is stdio-only, so an `http` server is skipped here.
|
|
400
|
+
*
|
|
401
|
+
* The skip is now a BACKSTOP rather than the decision: the backend knows which transports each
|
|
402
|
+
* harness reaches (`MCP_HARNESS_TRANSPORTS`) and drops an `http` server from a Codex dispatch under
|
|
403
|
+
* its own `transport_unsupported` reason, so the prompt states the gap instead of advertising a tool
|
|
404
|
+
* this writer then silently omitted. It stays because a body that reached the container by any other
|
|
405
|
+
* route must still produce a valid config rather than a malformed one.
|
|
376
406
|
*/
|
|
377
407
|
export function codexMcpConfigToml(servers: McpServerSpec[]): string {
|
|
378
408
|
const blocks: string[] = []
|
package/src/agent.ts
CHANGED
|
@@ -330,7 +330,16 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
|
|
|
330
330
|
job.packageRegistries,
|
|
331
331
|
scopeDir ? { isolatedDir: scopeDir } : {},
|
|
332
332
|
)
|
|
333
|
-
|
|
333
|
+
// The credentials of this job's GENERATIVE BINARY INTEGRATIONS, layered on for EVERY mode
|
|
334
|
+
// rather than inside one of them: the kinds that carry the `binary-output` trait are a
|
|
335
|
+
// deployment's own and may be explore or coding agents, and a key delivered to one mode and
|
|
336
|
+
// not the other would be an integration that works or 401s depending on how its step was
|
|
337
|
+
// registered. Per-job env like everything else here — never `process.env`, which the shared
|
|
338
|
+
// native host process makes a cross-job leak.
|
|
339
|
+
const scoped = withAgentEnv(opts, {
|
|
340
|
+
...registryEnv,
|
|
341
|
+
...secretEnv(job.generatorSecrets),
|
|
342
|
+
})
|
|
334
343
|
if (job.mode === 'preview') return await runPreviewMode(job, scoped)
|
|
335
344
|
return job.mode === 'coding'
|
|
336
345
|
? await runCodingMode(job, scoped)
|
|
@@ -454,6 +463,16 @@ async function runPreviewMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
454
463
|
* the restore step entirely.
|
|
455
464
|
*/
|
|
456
465
|
export function testSecretEnv(secrets: TestSecretSpec[] | undefined): Record<string, string> {
|
|
466
|
+
return secretEnv(secrets)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The shared `{ key, value }[]` → child-env projection behind {@link testSecretEnv} and the
|
|
471
|
+
* generative integrations' credentials. One implementation because both channels owe the same two
|
|
472
|
+
* things — the values registered for redaction, and the env returned rather than written to
|
|
473
|
+
* `process.env` — and a second copy is a second place to forget the redaction.
|
|
474
|
+
*/
|
|
475
|
+
export function secretEnv(secrets: TestSecretSpec[] | undefined): Record<string, string> {
|
|
457
476
|
if (!secrets?.length) return {}
|
|
458
477
|
registerKnownSecrets(secrets.map((s) => s.value))
|
|
459
478
|
return Object.fromEntries(secrets.map(({ key, value }) => [key, value]))
|
package/src/job-env.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The primitive VALUE rules of the untrusted job body: what counts as a required string, which
|
|
2
|
+
// environment-variable names a body may never set, and how the two env-bearing fields (the
|
|
3
|
+
// tester's `testSecrets`, a generative integration's `generatorSecrets`, and a frontend binding's
|
|
4
|
+
// `env`) are parsed against those rules.
|
|
5
|
+
//
|
|
6
|
+
// Extracted from `job.ts` when the generative-integration credentials arrived (the file-size
|
|
7
|
+
// ratchet: split along a cohesive seam, never raise the budget). The seam is a real one — every
|
|
8
|
+
// rule here answers "may this raw value become part of a child process's environment", which is
|
|
9
|
+
// the harness's sharpest untrusted-input boundary, and it is now shared by three parsers rather
|
|
10
|
+
// than being one parser's private business.
|
|
11
|
+
|
|
12
|
+
/** A required non-empty string field of the job body. */
|
|
13
|
+
export function str(value: unknown, path: string): string {
|
|
14
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
15
|
+
throw new Error(`Invalid job: '${path}' must be a non-empty string`)
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Env-var names never injected from a frontend binding: spread over `process.env` at build
|
|
22
|
+
* time, so any of these would break the toolchain (or enable code execution / cert overrides)
|
|
23
|
+
* rather than name an upstream URL. Matched exactly (Linux env is case-sensitive); the
|
|
24
|
+
* {@link RESERVED_ENV_PREFIXES} below cover whole families (`npm_config_*`, `GIT_*`, …).
|
|
25
|
+
*/
|
|
26
|
+
export const RESERVED_ENV_NAMES = new Set([
|
|
27
|
+
'PATH',
|
|
28
|
+
'HOME',
|
|
29
|
+
'NODE_OPTIONS',
|
|
30
|
+
'NODE_PATH',
|
|
31
|
+
'NODE_EXTRA_CA_CERTS',
|
|
32
|
+
'LD_PRELOAD',
|
|
33
|
+
'LD_LIBRARY_PATH',
|
|
34
|
+
'BASH_ENV',
|
|
35
|
+
'ENV',
|
|
36
|
+
'SHELL',
|
|
37
|
+
'IFS',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Env-var name PREFIXES never injected from a frontend binding. `npm_config_*` reconfigures the
|
|
42
|
+
* package manager (registry, scripts, prefix), and `GIT_*` reconfigures git — both run during a
|
|
43
|
+
* frontend install/build, so a binding in either family is toolchain control, not an upstream URL.
|
|
44
|
+
* Compared case-INSENSITIVELY (lower-cased here, matched lower-cased below): npm reads its config
|
|
45
|
+
* env with a case-insensitive `/^npm_config_/i`, so `NPM_CONFIG_REGISTRY` is honoured just like
|
|
46
|
+
* `npm_config_registry` — a case-sensitive prefix match would let the upper-cased form slip through.
|
|
47
|
+
*/
|
|
48
|
+
const RESERVED_ENV_PREFIXES = ['npm_config_', 'git_']
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether an env-var name is reserved (an exact name, or a reserved family prefix). The exact
|
|
52
|
+
* names are canonical upper-case env vars matched verbatim (Linux env is case-sensitive, so a
|
|
53
|
+
* distinct lower-cased `home` is a different, harmless var); the family PREFIXES are matched
|
|
54
|
+
* case-insensitively because npm interprets `npm_config_*` regardless of case (see above).
|
|
55
|
+
*/
|
|
56
|
+
export function isReservedEnvName(key: string): boolean {
|
|
57
|
+
if (RESERVED_ENV_NAMES.has(key)) return true
|
|
58
|
+
const lower = key.toLowerCase()
|
|
59
|
+
return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
|
|
64
|
+
* malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
|
|
65
|
+
* names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
|
|
66
|
+
* dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
|
|
67
|
+
* replace it with a URL and the build would no longer find its tools. Extracted from the infra
|
|
68
|
+
* parsers to keep their cyclomatic complexity down.
|
|
69
|
+
*/
|
|
70
|
+
export function parseInfraEnv(raw: unknown): Record<string, string> {
|
|
71
|
+
const env: Record<string, string> = {}
|
|
72
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
73
|
+
for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
74
|
+
if (key && !isReservedEnvName(key) && typeof val === 'string') env[key] = val
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return env
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One sensitive test credential the tester receives: an env-var name + its (secret) value.
|
|
82
|
+
* The backend seals these at rest and decrypts them at dispatch; the harness injects each as an
|
|
83
|
+
* environment variable the tester's shell can read (out of band — the value is NEVER in the
|
|
84
|
+
* prompt/telemetry). See {@link parseSecretEnvPairs}.
|
|
85
|
+
*/
|
|
86
|
+
export interface TestSecretSpec {
|
|
87
|
+
key: string
|
|
88
|
+
value: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A valid POSIX shell variable name (letters, digits, underscore; not starting with a digit). */
|
|
92
|
+
const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a `{ key, value }` env-pair list under `field`. Shared by the tester's `testSecrets`
|
|
96
|
+
* and by `generatorSecrets` (the credentials of a step's generative binary integrations), because
|
|
97
|
+
* both are secret values the harness turns into environment variables of the agent's own process
|
|
98
|
+
* and both owe the same guarantees: valid env-var names, no toolchain-critical
|
|
99
|
+
* ({@link isReservedEnvName}) names, no duplicates. A second copy of these rules would be a second
|
|
100
|
+
* place for a drifted body to clobber PATH.
|
|
101
|
+
*/
|
|
102
|
+
export function parseSecretEnvPairs(value: unknown, field: string): TestSecretSpec[] {
|
|
103
|
+
if (value === undefined || value === null) return []
|
|
104
|
+
if (!Array.isArray(value)) throw new Error(`Invalid job: '${field}' must be an array`)
|
|
105
|
+
const entries: TestSecretSpec[] = []
|
|
106
|
+
const seen = new Set<string>()
|
|
107
|
+
for (const [i, raw] of value.entries()) {
|
|
108
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
109
|
+
throw new Error(`Invalid job: '${field}[${i}]' must be an object`)
|
|
110
|
+
}
|
|
111
|
+
const entry = raw as Record<string, unknown>
|
|
112
|
+
const key = str(entry.key, `${field}[${i}].key`).trim()
|
|
113
|
+
if (!ENV_VAR_NAME_PATTERN.test(key)) {
|
|
114
|
+
throw new Error(`Invalid job: '${field}[${i}].key' must be a valid environment variable name`)
|
|
115
|
+
}
|
|
116
|
+
if (isReservedEnvName(key) || seen.has(key)) continue
|
|
117
|
+
seen.add(key)
|
|
118
|
+
entries.push({ key, value: str(entry.value, `${field}[${i}].value`) })
|
|
119
|
+
}
|
|
120
|
+
return entries
|
|
121
|
+
}
|