@cat-factory/executor-harness 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, opendir, rm } from 'node:fs/promises';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
7
+ import { configurePackageRegistries } from './package-registries.js';
7
8
  import { captureRedactedOutput, redactSecrets } from './redact.js';
8
9
  import { cloneRepo, commitAll, conflictDiff, hasAgentChanges, headCommit, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
9
10
  import { noChangesReason, runCodingAgent } from './coding-agent.js';
@@ -196,6 +197,11 @@ async function cloneServiceCheckout(dir, job, signal) {
196
197
  }
197
198
  /** Run one generic agent job end to end, dispatching on `mode`. */
198
199
  export async function handleAgent(job, opts = {}) {
200
+ // Private-registry auth first, before any mode runs: every mode with a checkout may
201
+ // install dependencies (the agent's own shell and the frontend-infra stand-up both
202
+ // inherit `HOME`, so they all read the written ~/.npmrc). A job with no entries
203
+ // clears any stale ~/.npmrc from a prior job on a reused (warm-pool) container.
204
+ await configurePackageRegistries(job.packageRegistries);
199
205
  if (job.mode === 'preview')
200
206
  return runPreviewMode(job, opts);
201
207
  return job.mode === 'coding' ? runCodingMode(job, opts) : runExploreMode(job, opts);
package/dist/job.js CHANGED
@@ -165,6 +165,63 @@ function assertAllowedHost(rawUrl, path, env = process.env) {
165
165
  throw new Error(`Invalid job: '${path}' host '${host}' is not an allowed GitHub host`);
166
166
  }
167
167
  }
168
+ /** npm registry hosts the harness is willing to send a registry token to. */
169
+ export function allowedNpmRegistryHosts(env = process.env) {
170
+ const hosts = new Set(['registry.npmjs.org', 'npm.pkg.github.com']);
171
+ // Optional extra allowlist (comma-separated) for tests / bespoke deployments.
172
+ for (const h of (env.NPM_ALLOWED_REGISTRY_HOSTS ?? '').split(',')) {
173
+ const t = h.trim().toLowerCase();
174
+ if (t)
175
+ hosts.add(t);
176
+ }
177
+ return hosts;
178
+ }
179
+ /** An npm scope (`@org`) — same shape the backend validates at the write boundary. */
180
+ const NPM_SCOPE_PATTERN = /^@[a-z0-9~-][a-z0-9._~-]*$/i;
181
+ // A registry token is a single opaque string. Reject any whitespace / control
182
+ // character: a newline in the token would inject arbitrary lines into the rendered
183
+ // `~/.npmrc` (a second, forged registry/_authToken line). Mirrors the backend's
184
+ // write-boundary constraint so a drifted body can't slip a multiline token past.
185
+ const NPM_TOKEN_PATTERN = /^[\x21-\x7e]+$/;
186
+ /** Validate the optional `packageRegistries` list (see {@link PackageRegistrySpec}). */
187
+ export function parsePackageRegistries(value, env = process.env) {
188
+ if (value === undefined || value === null)
189
+ return [];
190
+ if (!Array.isArray(value))
191
+ throw new Error("Invalid job: 'packageRegistries' must be an array");
192
+ const allowed = allowedNpmRegistryHosts(env);
193
+ const entries = [];
194
+ for (const [i, raw] of value.entries()) {
195
+ if (typeof raw !== 'object' || raw === null) {
196
+ throw new Error(`Invalid job: 'packageRegistries[${i}]' must be an object`);
197
+ }
198
+ const entry = raw;
199
+ // Unknown ecosystems are additive: a newer backend may send pip/maven entries an
200
+ // older image doesn't understand yet — skip them rather than failing the job.
201
+ if (entry.ecosystem !== 'npm')
202
+ continue;
203
+ const host = str(entry.host, `packageRegistries[${i}].host`).trim().toLowerCase();
204
+ if (!allowed.has(host)) {
205
+ throw new Error(`Invalid job: 'packageRegistries[${i}].host' '${host}' is not an allowed npm registry host`);
206
+ }
207
+ if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) {
208
+ throw new Error(`Invalid job: 'packageRegistries[${i}].scopes' must be a non-empty array`);
209
+ }
210
+ const scopes = entry.scopes.map((scope, j) => {
211
+ const s = str(scope, `packageRegistries[${i}].scopes[${j}]`).trim();
212
+ if (!NPM_SCOPE_PATTERN.test(s)) {
213
+ throw new Error(`Invalid job: 'packageRegistries[${i}].scopes[${j}]' must look like @org`);
214
+ }
215
+ return s;
216
+ });
217
+ const token = str(entry.token, `packageRegistries[${i}].token`);
218
+ if (!NPM_TOKEN_PATTERN.test(token)) {
219
+ throw new Error(`Invalid job: 'packageRegistries[${i}].token' must not contain spaces or control characters`);
220
+ }
221
+ entries.push({ ecosystem: 'npm', host, scopes, token });
222
+ }
223
+ return entries;
224
+ }
168
225
  /** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
169
226
  function parseAgentBootstrapSpec(value) {
170
227
  if (typeof value !== 'object' || value === null)
@@ -372,6 +429,7 @@ export function parseAgentJob(input) {
372
429
  const infra = parseAgentInfraSpec(o.infra);
373
430
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
374
431
  const contextFiles = parseContextFiles(o.contextFiles);
432
+ const packageRegistries = parsePackageRegistries(o.packageRegistries);
375
433
  const guardLimits = parseGuardLimits(o.guardLimits);
376
434
  const job = {
377
435
  jobId: str(o.jobId, 'jobId'),
@@ -391,6 +449,7 @@ export function parseAgentJob(input) {
391
449
  ...(bootstrap ? { bootstrap } : {}),
392
450
  ...(output ? { output } : {}),
393
451
  ...(contextFiles.length ? { contextFiles } : {}),
452
+ ...(packageRegistries.length ? { packageRegistries } : {}),
394
453
  ...(infra ? { infra } : {}),
395
454
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
396
455
  ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
@@ -0,0 +1,51 @@
1
+ import { chmod, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { registerKnownSecrets } from './redact.js';
5
+ // Private package-registry auth for the checkout's installs (npm private orgs,
6
+ // GitHub Packages). The job's allowlisted entries are rendered into the USER
7
+ // `~/.npmrc` — read by npm, pnpm and yarn v1 alike, and inherited by every child
8
+ // process (the agent's own shell installs and the frontend-infra stand-up's) — so
9
+ // the token never rides argv or the checkout. Written per job; a job with NO
10
+ // entries removes any stale file, because warm-pool containers are reused across
11
+ // jobs and must not leak a prior workspace's token.
12
+ /** Where the per-job npm auth lands (the user npmrc, outside any checkout). */
13
+ export function npmrcPath() {
14
+ return join(homedir(), '.npmrc');
15
+ }
16
+ /**
17
+ * Render the job's registry entries as npmrc lines: each scope routed to its
18
+ * registry, plus one `_authToken` credential line per distinct host.
19
+ */
20
+ export function renderNpmrc(entries) {
21
+ const lines = [];
22
+ const hosts = new Map();
23
+ for (const entry of entries) {
24
+ for (const scope of entry.scopes) {
25
+ lines.push(`${scope}:registry=https://${entry.host}/`);
26
+ }
27
+ // Last entry wins per host — entries for the same host carry the same vendor
28
+ // token in practice (the backend stores one token per entry).
29
+ hosts.set(entry.host, entry.token);
30
+ }
31
+ for (const [host, token] of hosts) {
32
+ lines.push(`//${host}/:_authToken=${token}`);
33
+ }
34
+ return `${lines.join('\n')}\n`;
35
+ }
36
+ /**
37
+ * Write (or clear) the per-job `~/.npmrc` before the agent runs. Tokens are
38
+ * registered for output redaction so a token echoed in an npm error never reaches
39
+ * logs or stored output.
40
+ */
41
+ export async function configurePackageRegistries(entries) {
42
+ const path = npmrcPath();
43
+ if (!entries || entries.length === 0) {
44
+ await rm(path, { force: true });
45
+ return;
46
+ }
47
+ registerKnownSecrets(entries.map((entry) => entry.token));
48
+ await writeFile(path, renderNpmrc(entries), { mode: 0o600 });
49
+ // writeFile's mode only applies on create — tighten an existing file too.
50
+ await chmod(path, 0o600);
51
+ }
package/dist/redact.js CHANGED
@@ -28,12 +28,25 @@ const MIN_HARVEST_LEN = 12;
28
28
  // `DB_ACCESS_KEY`/`api_key` are covered; `auth` is deliberately excluded so it can't
29
29
  // clobber a git `Author:` line. The value is the first whitespace-delimited run.
30
30
  const CREDENTIAL_ASSIGNMENT = /\b([A-Za-z0-9_]*(?:password|passwd|pwd|secret|token|key|credential)[A-Za-z0-9_]*\s*[:=]\s*)\S+/gi;
31
+ // Known-secret values registered per JOB (e.g. the job's private-registry tokens),
32
+ // scrubbed on EVERY redaction — including the pattern-only `redactSecrets` call sites
33
+ // that carry no per-call secret list. Accumulating across jobs on a reused container
34
+ // is safe: redaction only ever widens.
35
+ const REGISTERED_SECRETS = new Set();
36
+ /** Register known secret values to scrub on every subsequent redaction. */
37
+ export function registerKnownSecrets(values) {
38
+ for (const value of values) {
39
+ if (value && value.length >= MIN_REDACT_LEN)
40
+ REGISTERED_SECRETS.add(value);
41
+ }
42
+ }
31
43
  /**
32
44
  * Strip credentials out of any string before it is logged or stored. Applies the
33
45
  * pattern rules (URL userinfo `https://user:pass@host`, `x-access-token:<token>`, bare
34
46
  * `ghs_`/`ghp_`/`gho_`/`github_pat_` shapes, and credential-named `KEY=value` / `KEY:
35
- * value` assignments) and then scrubs every supplied known-secret value. Idempotent
36
- * safe to call on already-redacted text.
47
+ * value` assignments) and then scrubs every supplied known-secret value plus the
48
+ * module-registered ones ({@link registerKnownSecrets}). Idempotent — safe to call on
49
+ * already-redacted text.
37
50
  */
38
51
  export function redact(input, knownSecrets = []) {
39
52
  let out = input
@@ -41,14 +54,14 @@ export function redact(input, knownSecrets = []) {
41
54
  .replace(/x-access-token:[^@\s]+/gi, 'x-access-token:***')
42
55
  .replace(/\b(gh[pso]_|github_pat_)[A-Za-z0-9_]+/g, '$1***')
43
56
  .replace(CREDENTIAL_ASSIGNMENT, '$1***');
44
- for (const secret of knownSecrets) {
57
+ for (const secret of [...knownSecrets, ...REGISTERED_SECRETS]) {
45
58
  // Guard against scrubbing trivially-short values that would mangle output.
46
59
  if (secret.length >= MIN_REDACT_LEN)
47
60
  out = out.split(secret).join('***');
48
61
  }
49
62
  return out;
50
63
  }
51
- /** Pattern-only redaction (no known values). Kept for callers without a secret list. */
64
+ /** Pattern + registered-value redaction. Kept for callers without a per-call secret list. */
52
65
  export function redactSecrets(input) {
53
66
  return redact(input);
54
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.32.0",
3
+ "version": "1.34.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",
@@ -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.68.0",
30
- "@cat-factory/spend": "0.10.74"
29
+ "@cat-factory/server": "0.69.0",
30
+ "@cat-factory/spend": "0.10.77"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  ServiceInfraSpec,
12
12
  } from './job.js'
13
13
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js'
14
+ import { configurePackageRegistries } from './package-registries.js'
14
15
  import { captureRedactedOutput, redactSecrets } from './redact.js'
15
16
  import {
16
17
  cloneRepo,
@@ -263,6 +264,11 @@ async function cloneServiceCheckout(
263
264
 
264
265
  /** Run one generic agent job end to end, dispatching on `mode`. */
265
266
  export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
267
+ // Private-registry auth first, before any mode runs: every mode with a checkout may
268
+ // install dependencies (the agent's own shell and the frontend-infra stand-up both
269
+ // inherit `HOME`, so they all read the written ~/.npmrc). A job with no entries
270
+ // clears any stale ~/.npmrc from a prior job on a reused (warm-pool) container.
271
+ await configurePackageRegistries(job.packageRegistries)
266
272
  if (job.mode === 'preview') return runPreviewMode(job, opts)
267
273
  return job.mode === 'coding' ? runCodingMode(job, opts) : runExploreMode(job, opts)
268
274
  }
package/src/job.ts CHANGED
@@ -232,6 +232,89 @@ function assertAllowedHost(
232
232
  }
233
233
  }
234
234
 
235
+ // ---- Private package registries ------------------------------------------
236
+ // Workspace-configured private-registry auth (npm private orgs, GitHub Packages)
237
+ // so the checkout's installs resolve private dependencies. The backend derives the
238
+ // host from a fixed vendor set, so the harness hard-allowlists where a registry
239
+ // token may be sent — a body-supplied host outside the allowlist is treated as
240
+ // forgery (token exfiltration) and rejects the job. Ecosystem-discriminated:
241
+ // entries of an unknown ecosystem are DROPPED (not an error) so later ecosystems
242
+ // (pip/maven/cargo) stay additive for an older harness image.
243
+
244
+ /** One private-registry entry: rendered into `~/.npmrc` before the agent runs. */
245
+ export interface PackageRegistrySpec {
246
+ ecosystem: 'npm'
247
+ /** Registry host, e.g. `registry.npmjs.org` — allowlisted, never a full URL. */
248
+ host: string
249
+ /** npm scopes (`@org`) routed to this registry. */
250
+ scopes: string[]
251
+ token: string
252
+ }
253
+
254
+ /** npm registry hosts the harness is willing to send a registry token to. */
255
+ export function allowedNpmRegistryHosts(env: NodeJS.ProcessEnv = process.env): Set<string> {
256
+ const hosts = new Set(['registry.npmjs.org', 'npm.pkg.github.com'])
257
+ // Optional extra allowlist (comma-separated) for tests / bespoke deployments.
258
+ for (const h of (env.NPM_ALLOWED_REGISTRY_HOSTS ?? '').split(',')) {
259
+ const t = h.trim().toLowerCase()
260
+ if (t) hosts.add(t)
261
+ }
262
+ return hosts
263
+ }
264
+
265
+ /** An npm scope (`@org`) — same shape the backend validates at the write boundary. */
266
+ const NPM_SCOPE_PATTERN = /^@[a-z0-9~-][a-z0-9._~-]*$/i
267
+
268
+ // A registry token is a single opaque string. Reject any whitespace / control
269
+ // character: a newline in the token would inject arbitrary lines into the rendered
270
+ // `~/.npmrc` (a second, forged registry/_authToken line). Mirrors the backend's
271
+ // write-boundary constraint so a drifted body can't slip a multiline token past.
272
+ const NPM_TOKEN_PATTERN = /^[\x21-\x7e]+$/
273
+
274
+ /** Validate the optional `packageRegistries` list (see {@link PackageRegistrySpec}). */
275
+ export function parsePackageRegistries(
276
+ value: unknown,
277
+ env: NodeJS.ProcessEnv = process.env,
278
+ ): PackageRegistrySpec[] {
279
+ if (value === undefined || value === null) return []
280
+ if (!Array.isArray(value)) throw new Error("Invalid job: 'packageRegistries' must be an array")
281
+ const allowed = allowedNpmRegistryHosts(env)
282
+ const entries: PackageRegistrySpec[] = []
283
+ for (const [i, raw] of value.entries()) {
284
+ if (typeof raw !== 'object' || raw === null) {
285
+ throw new Error(`Invalid job: 'packageRegistries[${i}]' must be an object`)
286
+ }
287
+ const entry = raw as Record<string, unknown>
288
+ // Unknown ecosystems are additive: a newer backend may send pip/maven entries an
289
+ // older image doesn't understand yet — skip them rather than failing the job.
290
+ if (entry.ecosystem !== 'npm') continue
291
+ const host = str(entry.host, `packageRegistries[${i}].host`).trim().toLowerCase()
292
+ if (!allowed.has(host)) {
293
+ throw new Error(
294
+ `Invalid job: 'packageRegistries[${i}].host' '${host}' is not an allowed npm registry host`,
295
+ )
296
+ }
297
+ if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) {
298
+ throw new Error(`Invalid job: 'packageRegistries[${i}].scopes' must be a non-empty array`)
299
+ }
300
+ const scopes = entry.scopes.map((scope, j) => {
301
+ const s = str(scope, `packageRegistries[${i}].scopes[${j}]`).trim()
302
+ if (!NPM_SCOPE_PATTERN.test(s)) {
303
+ throw new Error(`Invalid job: 'packageRegistries[${i}].scopes[${j}]' must look like @org`)
304
+ }
305
+ return s
306
+ })
307
+ const token = str(entry.token, `packageRegistries[${i}].token`)
308
+ if (!NPM_TOKEN_PATTERN.test(token)) {
309
+ throw new Error(
310
+ `Invalid job: 'packageRegistries[${i}].token' must not contain spaces or control characters`,
311
+ )
312
+ }
313
+ entries.push({ ecosystem: 'npm', host, scopes, token })
314
+ }
315
+ return entries
316
+ }
317
+
235
318
  // ---- Shared repo-bootstrap target ---------------------------------------
236
319
 
237
320
  /** The new repository a repo-bootstrap run force-pushes its fresh history to. */
@@ -412,6 +495,14 @@ export interface AgentJob extends HarnessAuthFields {
412
495
  * The agent reads them on demand; they are kept out of any commit. Absent ⇒ none.
413
496
  */
414
497
  contextFiles?: ContextFileSpec[]
498
+ /**
499
+ * Private package-registry auth (npm private orgs, GitHub Packages), rendered into
500
+ * `~/.npmrc` before the run so the checkout's installs — the agent's own and the
501
+ * frontend-infra stand-up's — resolve private dependencies. Hosts are hard-allowlisted
502
+ * (see {@link allowedNpmRegistryHosts}). Absent ⇒ any stale `~/.npmrc` from a prior
503
+ * job on a reused container is removed.
504
+ */
505
+ packageRegistries?: PackageRegistrySpec[]
415
506
  /**
416
507
  * Explore mode: stand the service's dependencies up before the agent runs (the
417
508
  * tester). Brings the docker-compose infra up on localhost for the duration of the
@@ -746,6 +837,7 @@ export function parseAgentJob(input: unknown): AgentJob {
746
837
  const infra = parseAgentInfraSpec(o.infra)
747
838
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
748
839
  const contextFiles = parseContextFiles(o.contextFiles)
840
+ const packageRegistries = parsePackageRegistries(o.packageRegistries)
749
841
  const guardLimits = parseGuardLimits(o.guardLimits)
750
842
  const job: AgentJob = {
751
843
  jobId: str(o.jobId, 'jobId'),
@@ -765,6 +857,7 @@ export function parseAgentJob(input: unknown): AgentJob {
765
857
  ...(bootstrap ? { bootstrap } : {}),
766
858
  ...(output ? { output } : {}),
767
859
  ...(contextFiles.length ? { contextFiles } : {}),
860
+ ...(packageRegistries.length ? { packageRegistries } : {}),
768
861
  ...(infra ? { infra } : {}),
769
862
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
770
863
  ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
@@ -0,0 +1,58 @@
1
+ import { chmod, rm, writeFile } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import type { PackageRegistrySpec } from './job.js'
5
+ import { registerKnownSecrets } from './redact.js'
6
+
7
+ // Private package-registry auth for the checkout's installs (npm private orgs,
8
+ // GitHub Packages). The job's allowlisted entries are rendered into the USER
9
+ // `~/.npmrc` — read by npm, pnpm and yarn v1 alike, and inherited by every child
10
+ // process (the agent's own shell installs and the frontend-infra stand-up's) — so
11
+ // the token never rides argv or the checkout. Written per job; a job with NO
12
+ // entries removes any stale file, because warm-pool containers are reused across
13
+ // jobs and must not leak a prior workspace's token.
14
+
15
+ /** Where the per-job npm auth lands (the user npmrc, outside any checkout). */
16
+ export function npmrcPath(): string {
17
+ return join(homedir(), '.npmrc')
18
+ }
19
+
20
+ /**
21
+ * Render the job's registry entries as npmrc lines: each scope routed to its
22
+ * registry, plus one `_authToken` credential line per distinct host.
23
+ */
24
+ export function renderNpmrc(entries: readonly PackageRegistrySpec[]): string {
25
+ const lines: string[] = []
26
+ const hosts = new Map<string, string>()
27
+ for (const entry of entries) {
28
+ for (const scope of entry.scopes) {
29
+ lines.push(`${scope}:registry=https://${entry.host}/`)
30
+ }
31
+ // Last entry wins per host — entries for the same host carry the same vendor
32
+ // token in practice (the backend stores one token per entry).
33
+ hosts.set(entry.host, entry.token)
34
+ }
35
+ for (const [host, token] of hosts) {
36
+ lines.push(`//${host}/:_authToken=${token}`)
37
+ }
38
+ return `${lines.join('\n')}\n`
39
+ }
40
+
41
+ /**
42
+ * Write (or clear) the per-job `~/.npmrc` before the agent runs. Tokens are
43
+ * registered for output redaction so a token echoed in an npm error never reaches
44
+ * logs or stored output.
45
+ */
46
+ export async function configurePackageRegistries(
47
+ entries: readonly PackageRegistrySpec[] | undefined,
48
+ ): Promise<void> {
49
+ const path = npmrcPath()
50
+ if (!entries || entries.length === 0) {
51
+ await rm(path, { force: true })
52
+ return
53
+ }
54
+ registerKnownSecrets(entries.map((entry) => entry.token))
55
+ await writeFile(path, renderNpmrc(entries), { mode: 0o600 })
56
+ // writeFile's mode only applies on create — tighten an existing file too.
57
+ await chmod(path, 0o600)
58
+ }
package/src/redact.ts CHANGED
@@ -33,12 +33,26 @@ const MIN_HARVEST_LEN = 12
33
33
  const CREDENTIAL_ASSIGNMENT =
34
34
  /\b([A-Za-z0-9_]*(?:password|passwd|pwd|secret|token|key|credential)[A-Za-z0-9_]*\s*[:=]\s*)\S+/gi
35
35
 
36
+ // Known-secret values registered per JOB (e.g. the job's private-registry tokens),
37
+ // scrubbed on EVERY redaction — including the pattern-only `redactSecrets` call sites
38
+ // that carry no per-call secret list. Accumulating across jobs on a reused container
39
+ // is safe: redaction only ever widens.
40
+ const REGISTERED_SECRETS = new Set<string>()
41
+
42
+ /** Register known secret values to scrub on every subsequent redaction. */
43
+ export function registerKnownSecrets(values: readonly string[]): void {
44
+ for (const value of values) {
45
+ if (value && value.length >= MIN_REDACT_LEN) REGISTERED_SECRETS.add(value)
46
+ }
47
+ }
48
+
36
49
  /**
37
50
  * Strip credentials out of any string before it is logged or stored. Applies the
38
51
  * pattern rules (URL userinfo `https://user:pass@host`, `x-access-token:<token>`, bare
39
52
  * `ghs_`/`ghp_`/`gho_`/`github_pat_` shapes, and credential-named `KEY=value` / `KEY:
40
- * value` assignments) and then scrubs every supplied known-secret value. Idempotent
41
- * safe to call on already-redacted text.
53
+ * value` assignments) and then scrubs every supplied known-secret value plus the
54
+ * module-registered ones ({@link registerKnownSecrets}). Idempotent — safe to call on
55
+ * already-redacted text.
42
56
  */
43
57
  export function redact(input: string, knownSecrets: readonly string[] = []): string {
44
58
  let out = input
@@ -46,14 +60,14 @@ export function redact(input: string, knownSecrets: readonly string[] = []): str
46
60
  .replace(/x-access-token:[^@\s]+/gi, 'x-access-token:***')
47
61
  .replace(/\b(gh[pso]_|github_pat_)[A-Za-z0-9_]+/g, '$1***')
48
62
  .replace(CREDENTIAL_ASSIGNMENT, '$1***')
49
- for (const secret of knownSecrets) {
63
+ for (const secret of [...knownSecrets, ...REGISTERED_SECRETS]) {
50
64
  // Guard against scrubbing trivially-short values that would mangle output.
51
65
  if (secret.length >= MIN_REDACT_LEN) out = out.split(secret).join('***')
52
66
  }
53
67
  return out
54
68
  }
55
69
 
56
- /** Pattern-only redaction (no known values). Kept for callers without a secret list. */
70
+ /** Pattern + registered-value redaction. Kept for callers without a per-call secret list. */
57
71
  export function redactSecrets(input: string): string {
58
72
  return redact(input)
59
73
  }