@cat-factory/executor-harness 1.86.2 → 1.88.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.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 +12 -17
- package/dist/job.js +5 -97
- package/package.json +4 -4
- package/src/agent.ts +20 -1
- package/src/job-env.ts +121 -0
- package/src/job.ts +21 -112
package/dist/agent.d.ts
CHANGED
|
@@ -46,6 +46,13 @@ export declare function buildPreviewOutcome(standUp: {
|
|
|
46
46
|
* the restore step entirely.
|
|
47
47
|
*/
|
|
48
48
|
export declare function testSecretEnv(secrets: TestSecretSpec[] | undefined): Record<string, string>;
|
|
49
|
+
/**
|
|
50
|
+
* The shared `{ key, value }[]` → child-env projection behind {@link testSecretEnv} and the
|
|
51
|
+
* generative integrations' credentials. One implementation because both channels owe the same two
|
|
52
|
+
* things — the values registered for redaction, and the env returned rather than written to
|
|
53
|
+
* `process.env` — and a second copy is a second place to forget the redaction.
|
|
54
|
+
*/
|
|
55
|
+
export declare function secretEnv(secrets: TestSecretSpec[] | undefined): Record<string, string>;
|
|
49
56
|
/**
|
|
50
57
|
* Whether a Ralph iteration ({@link AgentJob.validation} set) landed on a MULTI-REPO job (writable
|
|
51
58
|
* peer repos or read-only reference repos). The post-commit validation command is only wired into
|
package/dist/agent.js
CHANGED
|
@@ -248,7 +248,16 @@ export async function handleAgent(job, opts = {}) {
|
|
|
248
248
|
// inherit this env, so they all read the written npmrc). In a container a job with no
|
|
249
249
|
// entries clears any stale ~/.npmrc from a prior job on a reused (warm-pool) container.
|
|
250
250
|
const registryEnv = await configurePackageRegistries(job.packageRegistries, scopeDir ? { isolatedDir: scopeDir } : {});
|
|
251
|
-
|
|
251
|
+
// The credentials of this job's GENERATIVE BINARY INTEGRATIONS, layered on for EVERY mode
|
|
252
|
+
// rather than inside one of them: the kinds that carry the `binary-output` trait are a
|
|
253
|
+
// deployment's own and may be explore or coding agents, and a key delivered to one mode and
|
|
254
|
+
// not the other would be an integration that works or 401s depending on how its step was
|
|
255
|
+
// registered. Per-job env like everything else here — never `process.env`, which the shared
|
|
256
|
+
// native host process makes a cross-job leak.
|
|
257
|
+
const scoped = withAgentEnv(opts, {
|
|
258
|
+
...registryEnv,
|
|
259
|
+
...secretEnv(job.generatorSecrets),
|
|
260
|
+
});
|
|
252
261
|
if (job.mode === 'preview')
|
|
253
262
|
return await runPreviewMode(job, scoped);
|
|
254
263
|
return job.mode === 'coding'
|
|
@@ -369,6 +378,15 @@ async function runPreviewMode(job, opts) {
|
|
|
369
378
|
* the restore step entirely.
|
|
370
379
|
*/
|
|
371
380
|
export function testSecretEnv(secrets) {
|
|
381
|
+
return secretEnv(secrets);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The shared `{ key, value }[]` → child-env projection behind {@link testSecretEnv} and the
|
|
385
|
+
* generative integrations' credentials. One implementation because both channels owe the same two
|
|
386
|
+
* things — the values registered for redaction, and the env returned rather than written to
|
|
387
|
+
* `process.env` — and a second copy is a second place to forget the redaction.
|
|
388
|
+
*/
|
|
389
|
+
export function secretEnv(secrets) {
|
|
372
390
|
if (!secrets?.length)
|
|
373
391
|
return {};
|
|
374
392
|
registerKnownSecrets(secrets.map((s) => s.value));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A required non-empty string field of the job body. */
|
|
2
|
+
export declare function str(value: unknown, path: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Env-var names never injected from a frontend binding: spread over `process.env` at build
|
|
5
|
+
* time, so any of these would break the toolchain (or enable code execution / cert overrides)
|
|
6
|
+
* rather than name an upstream URL. Matched exactly (Linux env is case-sensitive); the
|
|
7
|
+
* {@link RESERVED_ENV_PREFIXES} below cover whole families (`npm_config_*`, `GIT_*`, …).
|
|
8
|
+
*/
|
|
9
|
+
export declare const RESERVED_ENV_NAMES: Set<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Whether an env-var name is reserved (an exact name, or a reserved family prefix). The exact
|
|
12
|
+
* names are canonical upper-case env vars matched verbatim (Linux env is case-sensitive, so a
|
|
13
|
+
* distinct lower-cased `home` is a different, harmless var); the family PREFIXES are matched
|
|
14
|
+
* case-insensitively because npm interprets `npm_config_*` regardless of case (see above).
|
|
15
|
+
*/
|
|
16
|
+
export declare function isReservedEnvName(key: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
|
|
19
|
+
* malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
|
|
20
|
+
* names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
|
|
21
|
+
* dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
|
|
22
|
+
* replace it with a URL and the build would no longer find its tools. Extracted from the infra
|
|
23
|
+
* parsers to keep their cyclomatic complexity down.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseInfraEnv(raw: unknown): Record<string, string>;
|
|
26
|
+
/**
|
|
27
|
+
* One sensitive test credential the tester receives: an env-var name + its (secret) value.
|
|
28
|
+
* The backend seals these at rest and decrypts them at dispatch; the harness injects each as an
|
|
29
|
+
* environment variable the tester's shell can read (out of band — the value is NEVER in the
|
|
30
|
+
* prompt/telemetry). See {@link parseSecretEnvPairs}.
|
|
31
|
+
*/
|
|
32
|
+
export interface TestSecretSpec {
|
|
33
|
+
key: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Validate a `{ key, value }` env-pair list under `field`. Shared by the tester's `testSecrets`
|
|
38
|
+
* and by `generatorSecrets` (the credentials of a step's generative binary integrations), because
|
|
39
|
+
* both are secret values the harness turns into environment variables of the agent's own process
|
|
40
|
+
* and both owe the same guarantees: valid env-var names, no toolchain-critical
|
|
41
|
+
* ({@link isReservedEnvName}) names, no duplicates. A second copy of these rules would be a second
|
|
42
|
+
* place for a drifted body to clobber PATH.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseSecretEnvPairs(value: unknown, field: string): TestSecretSpec[];
|
package/dist/job-env.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
/** A required non-empty string field of the job body. */
|
|
12
|
+
export function str(value, path) {
|
|
13
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
14
|
+
throw new Error(`Invalid job: '${path}' must be a non-empty string`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Env-var names never injected from a frontend binding: spread over `process.env` at build
|
|
20
|
+
* time, so any of these would break the toolchain (or enable code execution / cert overrides)
|
|
21
|
+
* rather than name an upstream URL. Matched exactly (Linux env is case-sensitive); the
|
|
22
|
+
* {@link RESERVED_ENV_PREFIXES} below cover whole families (`npm_config_*`, `GIT_*`, …).
|
|
23
|
+
*/
|
|
24
|
+
export const RESERVED_ENV_NAMES = new Set([
|
|
25
|
+
'PATH',
|
|
26
|
+
'HOME',
|
|
27
|
+
'NODE_OPTIONS',
|
|
28
|
+
'NODE_PATH',
|
|
29
|
+
'NODE_EXTRA_CA_CERTS',
|
|
30
|
+
'LD_PRELOAD',
|
|
31
|
+
'LD_LIBRARY_PATH',
|
|
32
|
+
'BASH_ENV',
|
|
33
|
+
'ENV',
|
|
34
|
+
'SHELL',
|
|
35
|
+
'IFS',
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* Env-var name PREFIXES never injected from a frontend binding. `npm_config_*` reconfigures the
|
|
39
|
+
* package manager (registry, scripts, prefix), and `GIT_*` reconfigures git — both run during a
|
|
40
|
+
* frontend install/build, so a binding in either family is toolchain control, not an upstream URL.
|
|
41
|
+
* Compared case-INSENSITIVELY (lower-cased here, matched lower-cased below): npm reads its config
|
|
42
|
+
* env with a case-insensitive `/^npm_config_/i`, so `NPM_CONFIG_REGISTRY` is honoured just like
|
|
43
|
+
* `npm_config_registry` — a case-sensitive prefix match would let the upper-cased form slip through.
|
|
44
|
+
*/
|
|
45
|
+
const RESERVED_ENV_PREFIXES = ['npm_config_', 'git_'];
|
|
46
|
+
/**
|
|
47
|
+
* Whether an env-var name is reserved (an exact name, or a reserved family prefix). The exact
|
|
48
|
+
* names are canonical upper-case env vars matched verbatim (Linux env is case-sensitive, so a
|
|
49
|
+
* distinct lower-cased `home` is a different, harmless var); the family PREFIXES are matched
|
|
50
|
+
* case-insensitively because npm interprets `npm_config_*` regardless of case (see above).
|
|
51
|
+
*/
|
|
52
|
+
export function isReservedEnvName(key) {
|
|
53
|
+
if (RESERVED_ENV_NAMES.has(key))
|
|
54
|
+
return true;
|
|
55
|
+
const lower = key.toLowerCase();
|
|
56
|
+
return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
|
|
60
|
+
* malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
|
|
61
|
+
* names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
|
|
62
|
+
* dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
|
|
63
|
+
* replace it with a URL and the build would no longer find its tools. Extracted from the infra
|
|
64
|
+
* parsers to keep their cyclomatic complexity down.
|
|
65
|
+
*/
|
|
66
|
+
export function parseInfraEnv(raw) {
|
|
67
|
+
const env = {};
|
|
68
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
69
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
70
|
+
if (key && !isReservedEnvName(key) && typeof val === 'string')
|
|
71
|
+
env[key] = val;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return env;
|
|
75
|
+
}
|
|
76
|
+
/** A valid POSIX shell variable name (letters, digits, underscore; not starting with a digit). */
|
|
77
|
+
const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
78
|
+
/**
|
|
79
|
+
* Validate a `{ key, value }` env-pair list under `field`. Shared by the tester's `testSecrets`
|
|
80
|
+
* and by `generatorSecrets` (the credentials of a step's generative binary integrations), because
|
|
81
|
+
* both are secret values the harness turns into environment variables of the agent's own process
|
|
82
|
+
* and both owe the same guarantees: valid env-var names, no toolchain-critical
|
|
83
|
+
* ({@link isReservedEnvName}) names, no duplicates. A second copy of these rules would be a second
|
|
84
|
+
* place for a drifted body to clobber PATH.
|
|
85
|
+
*/
|
|
86
|
+
export function parseSecretEnvPairs(value, field) {
|
|
87
|
+
if (value === undefined || value === null)
|
|
88
|
+
return [];
|
|
89
|
+
if (!Array.isArray(value))
|
|
90
|
+
throw new Error(`Invalid job: '${field}' must be an array`);
|
|
91
|
+
const entries = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const [i, raw] of value.entries()) {
|
|
94
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
95
|
+
throw new Error(`Invalid job: '${field}[${i}]' must be an object`);
|
|
96
|
+
}
|
|
97
|
+
const entry = raw;
|
|
98
|
+
const key = str(entry.key, `${field}[${i}].key`).trim();
|
|
99
|
+
if (!ENV_VAR_NAME_PATTERN.test(key)) {
|
|
100
|
+
throw new Error(`Invalid job: '${field}[${i}].key' must be a valid environment variable name`);
|
|
101
|
+
}
|
|
102
|
+
if (isReservedEnvName(key) || seen.has(key))
|
|
103
|
+
continue;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
entries.push({ key, value: str(entry.value, `${field}[${i}].value`) });
|
|
106
|
+
}
|
|
107
|
+
return entries;
|
|
108
|
+
}
|
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
|
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
|
|
@@ -332,37 +327,6 @@ export function parsePackageRegistries(value, env = process.env) {
|
|
|
332
327
|
}
|
|
333
328
|
return entries;
|
|
334
329
|
}
|
|
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
330
|
/** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
|
|
367
331
|
function parseAgentBootstrapSpec(value) {
|
|
368
332
|
if (typeof value !== 'object' || value === null)
|
|
@@ -453,64 +417,6 @@ function parseStringMap(value) {
|
|
|
453
417
|
}
|
|
454
418
|
return Object.keys(out).length ? out : undefined;
|
|
455
419
|
}
|
|
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
420
|
/** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
|
|
515
421
|
function parseFrontendInfraSpec(o) {
|
|
516
422
|
const packageManager = o.packageManager === 'pnpm' || o.packageManager === 'npm' || o.packageManager === 'yarn'
|
|
@@ -607,7 +513,8 @@ export function parseAgentJob(input) {
|
|
|
607
513
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
608
514
|
skills: parseSkillSpecs(o.skills),
|
|
609
515
|
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
610
|
-
testSecrets:
|
|
516
|
+
testSecrets: parseSecretEnvPairs(o.testSecrets, 'testSecrets'),
|
|
517
|
+
generatorSecrets: parseSecretEnvPairs(o.generatorSecrets, 'generatorSecrets'),
|
|
611
518
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
612
519
|
validation: parseValidationSpec(o.validation),
|
|
613
520
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
@@ -669,7 +576,7 @@ function parseAgentPrSpec(raw) {
|
|
|
669
576
|
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
670
577
|
*/
|
|
671
578
|
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;
|
|
579
|
+
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, dependencyInstall, reviewPrNumber, generatorSecrets, } = parts;
|
|
673
580
|
const repo = (o.repo ?? {});
|
|
674
581
|
return {
|
|
675
582
|
jobId: str(o.jobId, 'jobId'),
|
|
@@ -689,6 +596,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
689
596
|
...(skills ? { skills } : {}),
|
|
690
597
|
...(mcpServers ? { mcpServers } : {}),
|
|
691
598
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
599
|
+
...(generatorSecrets.length ? { generatorSecrets } : {}),
|
|
692
600
|
...(infra ? { infra } : {}),
|
|
693
601
|
...(pr ? { pr } : {}),
|
|
694
602
|
...(peerRepos.length ? { peerRepos } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.88.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.13.
|
|
33
|
+
"@cat-factory/kernel": "0.215.0",
|
|
34
|
+
"@cat-factory/server": "0.195.0",
|
|
35
|
+
"@cat-factory/spend": "0.13.2"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
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
|
+
}
|
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
|
|
@@ -494,49 +492,6 @@ export function parsePackageRegistries(
|
|
|
494
492
|
return entries
|
|
495
493
|
}
|
|
496
494
|
|
|
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
495
|
// ---- Shared repo-bootstrap target ---------------------------------------
|
|
541
496
|
|
|
542
497
|
/** The new repository a repo-bootstrap run force-pushes its fresh history to. */
|
|
@@ -782,6 +737,16 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
782
737
|
* Absent ⇒ no secrets injected.
|
|
783
738
|
*/
|
|
784
739
|
testSecrets?: TestSecretSpec[]
|
|
740
|
+
/**
|
|
741
|
+
* The resolved credentials of the step's GENERATIVE BINARY INTEGRATIONS (the image / music /
|
|
742
|
+
* video generation APIs its `binaryOutput` selection named), as env pairs the harness injects
|
|
743
|
+
* into the agent's own process — where the agent's brief has already told it to read them from.
|
|
744
|
+
* Distinct from {@link testSecrets} because the two have different producers and different
|
|
745
|
+
* lifetimes: tester secrets are workspace state a human stored, these are a deployment's
|
|
746
|
+
* registration resolved per dispatch. Absent ⇒ no integration declared a credential, or none
|
|
747
|
+
* resolved (which the agent is told to report rather than work around).
|
|
748
|
+
*/
|
|
749
|
+
generatorSecrets?: TestSecretSpec[]
|
|
785
750
|
/**
|
|
786
751
|
* Explore mode: stand the service's dependencies up before the agent runs (the
|
|
787
752
|
* tester). Brings the docker-compose infra up on localhost for the duration of the
|
|
@@ -1110,66 +1075,6 @@ function parseStringMap(value: unknown): Record<string, string> | undefined {
|
|
|
1110
1075
|
return Object.keys(out).length ? out : undefined
|
|
1111
1076
|
}
|
|
1112
1077
|
|
|
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
1078
|
/** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
|
|
1174
1079
|
function parseFrontendInfraSpec(o: Record<string, unknown>): FrontendInfraSpec {
|
|
1175
1080
|
const packageManager =
|
|
@@ -1321,7 +1226,8 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1321
1226
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1322
1227
|
skills: parseSkillSpecs(o.skills),
|
|
1323
1228
|
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
1324
|
-
testSecrets:
|
|
1229
|
+
testSecrets: parseSecretEnvPairs(o.testSecrets, 'testSecrets'),
|
|
1230
|
+
generatorSecrets: parseSecretEnvPairs(o.generatorSecrets, 'generatorSecrets'),
|
|
1325
1231
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1326
1232
|
validation: parseValidationSpec(o.validation),
|
|
1327
1233
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
@@ -1362,7 +1268,8 @@ interface ParsedAgentJobParts {
|
|
|
1362
1268
|
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1363
1269
|
skills: ReturnType<typeof parseSkillSpecs>
|
|
1364
1270
|
mcpServers: ReturnType<typeof parseMcpServerSpecs>
|
|
1365
|
-
testSecrets: ReturnType<typeof
|
|
1271
|
+
testSecrets: ReturnType<typeof parseSecretEnvPairs>
|
|
1272
|
+
generatorSecrets: ReturnType<typeof parseSecretEnvPairs>
|
|
1366
1273
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1367
1274
|
validation: ReturnType<typeof parseValidationSpec>
|
|
1368
1275
|
validationChecks: ReturnType<typeof parseValidationChecksSpec>
|
|
@@ -1425,6 +1332,7 @@ function assembleAgentJob(
|
|
|
1425
1332
|
reproduction,
|
|
1426
1333
|
dependencyInstall,
|
|
1427
1334
|
reviewPrNumber,
|
|
1335
|
+
generatorSecrets,
|
|
1428
1336
|
} = parts
|
|
1429
1337
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
1430
1338
|
return {
|
|
@@ -1445,6 +1353,7 @@ function assembleAgentJob(
|
|
|
1445
1353
|
...(skills ? { skills } : {}),
|
|
1446
1354
|
...(mcpServers ? { mcpServers } : {}),
|
|
1447
1355
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
1356
|
+
...(generatorSecrets.length ? { generatorSecrets } : {}),
|
|
1448
1357
|
...(infra ? { infra } : {}),
|
|
1449
1358
|
...(pr ? { pr } : {}),
|
|
1450
1359
|
...(peerRepos.length ? { peerRepos } : {}),
|