@cat-factory/executor-harness 1.39.0 → 1.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -5,7 +5,7 @@ import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
7
7
  import { configurePackageRegistries } from './package-registries.js';
8
- import { captureRedactedOutput, redactSecrets } from './redact.js';
8
+ import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
9
9
  import { cloneRepo, commitAll, conflictDiff, hasAgentChanges, headCommit, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
10
10
  import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
11
11
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
@@ -323,6 +323,32 @@ async function runPreviewMode(job, opts) {
323
323
  throw err;
324
324
  }
325
325
  }
326
+ /**
327
+ * Inject the tester's sensitive secrets into the PROCESS environment so the agent's shell tools
328
+ * (spawned as child processes that inherit this env) can read `$KEY` — the out-of-band delivery
329
+ * channel. Each value is registered for redaction so it can't leak into captured output/logs.
330
+ * Returns a restore closure that puts the environment back afterward (warm-pool hygiene, so a
331
+ * later job on a reused container never inherits a prior run's secrets). Reserved/toolchain env
332
+ * names were already dropped at parse. A no-op when there are no secrets.
333
+ */
334
+ function applyTestSecrets(secrets) {
335
+ if (!secrets?.length)
336
+ return () => { };
337
+ registerKnownSecrets(secrets.map((s) => s.value));
338
+ const previous = new Map();
339
+ for (const { key, value } of secrets) {
340
+ previous.set(key, process.env[key]);
341
+ process.env[key] = value;
342
+ }
343
+ return () => {
344
+ for (const [key, prior] of previous) {
345
+ if (prior === undefined)
346
+ delete process.env[key];
347
+ else
348
+ process.env[key] = prior;
349
+ }
350
+ };
351
+ }
326
352
  /**
327
353
  * Read-only exploration: clone `branch`, run the agent making no edits, and return its
328
354
  * prose report — or, when `output.kind==='structured'`, the parsed JSON object as
@@ -386,6 +412,10 @@ async function runExploreMode(job, opts) {
386
412
  const infraSetupFields = managed?.record
387
413
  ? { infraSetup: managed.record }
388
414
  : {};
415
+ // Inject the tester's sensitive secrets into the environment (out of band) so the agent's
416
+ // shell can read them as `$KEY`; restore afterwards so a reused (warm-pool) container never
417
+ // leaks them to a later job. A no-op for non-tester runs (no `testSecrets`).
418
+ const restoreSecrets = applyTestSecrets(job.testSecrets);
389
419
  try {
390
420
  opts.onPhase?.('agent');
391
421
  logger.info('agent(explore): running agent', { serviceDirectory });
@@ -412,6 +442,7 @@ async function runExploreMode(job, opts) {
412
442
  return await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal });
413
443
  }
414
444
  finally {
445
+ restoreSecrets();
415
446
  if (managed)
416
447
  await managed.cleanup();
417
448
  }
package/dist/job.js CHANGED
@@ -286,6 +286,37 @@ export function parsePackageRegistries(value, env = process.env) {
286
286
  }
287
287
  return entries;
288
288
  }
289
+ /** A valid POSIX shell variable name (letters, digits, underscore; not starting with a digit). */
290
+ const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
291
+ /**
292
+ * Validate the optional tester `testSecrets` list — `{ key, value }` env pairs the harness
293
+ * injects into the run environment. Keys must be valid env-var names; toolchain-critical /
294
+ * reserved names ({@link isReservedEnvName}) and duplicates are dropped so a drifted body can't
295
+ * clobber PATH/NODE_OPTIONS/etc. Absent ⇒ no secrets injected.
296
+ */
297
+ export function parseTestSecrets(value) {
298
+ if (value === undefined || value === null)
299
+ return [];
300
+ if (!Array.isArray(value))
301
+ throw new Error("Invalid job: 'testSecrets' must be an array");
302
+ const entries = [];
303
+ const seen = new Set();
304
+ for (const [i, raw] of value.entries()) {
305
+ if (typeof raw !== 'object' || raw === null) {
306
+ throw new Error(`Invalid job: 'testSecrets[${i}]' must be an object`);
307
+ }
308
+ const entry = raw;
309
+ const key = str(entry.key, `testSecrets[${i}].key`).trim();
310
+ if (!ENV_VAR_NAME_PATTERN.test(key)) {
311
+ throw new Error(`Invalid job: 'testSecrets[${i}].key' must be a valid environment variable name`);
312
+ }
313
+ if (isReservedEnvName(key) || seen.has(key))
314
+ continue;
315
+ seen.add(key);
316
+ entries.push({ key, value: str(entry.value, `testSecrets[${i}].value`) });
317
+ }
318
+ return entries;
319
+ }
289
320
  /** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
290
321
  function parseAgentBootstrapSpec(value) {
291
322
  if (typeof value !== 'object' || value === null)
@@ -540,6 +571,7 @@ export function parseAgentJob(input) {
540
571
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
541
572
  const contextFiles = parseContextFiles(o.contextFiles);
542
573
  const packageRegistries = parsePackageRegistries(o.packageRegistries);
574
+ const testSecrets = parseTestSecrets(o.testSecrets);
543
575
  const guardLimits = parseGuardLimits(o.guardLimits);
544
576
  const job = {
545
577
  jobId: str(o.jobId, 'jobId'),
@@ -560,6 +592,7 @@ export function parseAgentJob(input) {
560
592
  ...(output ? { output } : {}),
561
593
  ...(contextFiles.length ? { contextFiles } : {}),
562
594
  ...(packageRegistries.length ? { packageRegistries } : {}),
595
+ ...(testSecrets.length ? { testSecrets } : {}),
563
596
  ...(infra ? { infra } : {}),
564
597
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
565
598
  ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.39.0",
3
+ "version": "1.39.2",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.27",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.9",
29
- "@cat-factory/server": "0.104.0",
30
- "@cat-factory/spend": "0.11.21"
29
+ "@cat-factory/server": "0.105.0",
30
+ "@cat-factory/spend": "0.11.24"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -9,10 +9,11 @@ import type {
9
9
  AgentResult,
10
10
  InfraSetupRecord,
11
11
  ServiceInfraSpec,
12
+ TestSecretSpec,
12
13
  } from './job.js'
13
14
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js'
14
15
  import { configurePackageRegistries } from './package-registries.js'
15
- import { captureRedactedOutput, redactSecrets } from './redact.js'
16
+ import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js'
16
17
  import {
17
18
  cloneRepo,
18
19
  commitAll,
@@ -408,6 +409,30 @@ async function runPreviewMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
408
409
  }
409
410
  }
410
411
 
412
+ /**
413
+ * Inject the tester's sensitive secrets into the PROCESS environment so the agent's shell tools
414
+ * (spawned as child processes that inherit this env) can read `$KEY` — the out-of-band delivery
415
+ * channel. Each value is registered for redaction so it can't leak into captured output/logs.
416
+ * Returns a restore closure that puts the environment back afterward (warm-pool hygiene, so a
417
+ * later job on a reused container never inherits a prior run's secrets). Reserved/toolchain env
418
+ * names were already dropped at parse. A no-op when there are no secrets.
419
+ */
420
+ function applyTestSecrets(secrets: TestSecretSpec[] | undefined): () => void {
421
+ if (!secrets?.length) return () => {}
422
+ registerKnownSecrets(secrets.map((s) => s.value))
423
+ const previous = new Map<string, string | undefined>()
424
+ for (const { key, value } of secrets) {
425
+ previous.set(key, process.env[key])
426
+ process.env[key] = value
427
+ }
428
+ return () => {
429
+ for (const [key, prior] of previous) {
430
+ if (prior === undefined) delete process.env[key]
431
+ else process.env[key] = prior
432
+ }
433
+ }
434
+ }
435
+
411
436
  /**
412
437
  * Read-only exploration: clone `branch`, run the agent making no edits, and return its
413
438
  * prose report — or, when `output.kind==='structured'`, the parsed JSON object as
@@ -473,6 +498,11 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
473
498
  ? { infraSetup: managed.record }
474
499
  : {}
475
500
 
501
+ // Inject the tester's sensitive secrets into the environment (out of band) so the agent's
502
+ // shell can read them as `$KEY`; restore afterwards so a reused (warm-pool) container never
503
+ // leaks them to a later job. A no-op for non-tester runs (no `testSecrets`).
504
+ const restoreSecrets = applyTestSecrets(job.testSecrets)
505
+
476
506
  try {
477
507
  opts.onPhase?.('agent')
478
508
  logger.info('agent(explore): running agent', { serviceDirectory })
@@ -513,6 +543,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
513
543
  { infra, infraSetupFields, logger, signal: opts.signal },
514
544
  )
515
545
  } finally {
546
+ restoreSecrets()
516
547
  if (managed) await managed.cleanup()
517
548
  }
518
549
  },
package/src/job.ts CHANGED
@@ -418,6 +418,49 @@ export function parsePackageRegistries(
418
418
  return entries
419
419
  }
420
420
 
421
+ /**
422
+ * One sensitive test credential the tester receives: an env-var name + its (secret) value.
423
+ * The backend seals these at rest and decrypts them at dispatch; the harness injects each as an
424
+ * environment variable the tester's shell can read (out of band — the value is NEVER in the
425
+ * prompt/telemetry). See {@link parseTestSecrets}.
426
+ */
427
+ export interface TestSecretSpec {
428
+ key: string
429
+ value: string
430
+ }
431
+
432
+ /** A valid POSIX shell variable name (letters, digits, underscore; not starting with a digit). */
433
+ const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
434
+
435
+ /**
436
+ * Validate the optional tester `testSecrets` list — `{ key, value }` env pairs the harness
437
+ * injects into the run environment. Keys must be valid env-var names; toolchain-critical /
438
+ * reserved names ({@link isReservedEnvName}) and duplicates are dropped so a drifted body can't
439
+ * clobber PATH/NODE_OPTIONS/etc. Absent ⇒ no secrets injected.
440
+ */
441
+ export function parseTestSecrets(value: unknown): TestSecretSpec[] {
442
+ if (value === undefined || value === null) return []
443
+ if (!Array.isArray(value)) throw new Error("Invalid job: 'testSecrets' must be an array")
444
+ const entries: TestSecretSpec[] = []
445
+ const seen = new Set<string>()
446
+ for (const [i, raw] of value.entries()) {
447
+ if (typeof raw !== 'object' || raw === null) {
448
+ throw new Error(`Invalid job: 'testSecrets[${i}]' must be an object`)
449
+ }
450
+ const entry = raw as Record<string, unknown>
451
+ const key = str(entry.key, `testSecrets[${i}].key`).trim()
452
+ if (!ENV_VAR_NAME_PATTERN.test(key)) {
453
+ throw new Error(
454
+ `Invalid job: 'testSecrets[${i}].key' must be a valid environment variable name`,
455
+ )
456
+ }
457
+ if (isReservedEnvName(key) || seen.has(key)) continue
458
+ seen.add(key)
459
+ entries.push({ key, value: str(entry.value, `testSecrets[${i}].value`) })
460
+ }
461
+ return entries
462
+ }
463
+
421
464
  // ---- Shared repo-bootstrap target ---------------------------------------
422
465
 
423
466
  /** The new repository a repo-bootstrap run force-pushes its fresh history to. */
@@ -613,6 +656,13 @@ export interface AgentJob extends HarnessAuthFields {
613
656
  * job on a reused container is removed.
614
657
  */
615
658
  packageRegistries?: PackageRegistrySpec[]
659
+ /**
660
+ * Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
661
+ * band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
662
+ * ever appearing in the prompt or telemetry. Reserved/toolchain env names are dropped at parse.
663
+ * Absent ⇒ no secrets injected.
664
+ */
665
+ testSecrets?: TestSecretSpec[]
616
666
  /**
617
667
  * Explore mode: stand the service's dependencies up before the agent runs (the
618
668
  * tester). Brings the docker-compose infra up on localhost for the duration of the
@@ -1053,6 +1103,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1053
1103
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
1054
1104
  const contextFiles = parseContextFiles(o.contextFiles)
1055
1105
  const packageRegistries = parsePackageRegistries(o.packageRegistries)
1106
+ const testSecrets = parseTestSecrets(o.testSecrets)
1056
1107
  const guardLimits = parseGuardLimits(o.guardLimits)
1057
1108
  const job: AgentJob = {
1058
1109
  jobId: str(o.jobId, 'jobId'),
@@ -1073,6 +1124,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1073
1124
  ...(output ? { output } : {}),
1074
1125
  ...(contextFiles.length ? { contextFiles } : {}),
1075
1126
  ...(packageRegistries.length ? { packageRegistries } : {}),
1127
+ ...(testSecrets.length ? { testSecrets } : {}),
1076
1128
  ...(infra ? { infra } : {}),
1077
1129
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
1078
1130
  ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),