@cat-factory/executor-harness 1.32.0 → 1.34.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 +6 -0
- package/dist/job.js +59 -0
- package/dist/package-registries.js +51 -0
- package/dist/redact.js +17 -4
- package/package.json +3 -3
- package/src/agent.ts +6 -0
- package/src/job.ts +93 -0
- package/src/package-registries.ts +58 -0
- package/src/redact.ts +18 -4
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
|
|
36
|
-
* safe to call on
|
|
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-
|
|
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.
|
|
3
|
+
"version": "1.34.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/
|
|
30
|
-
"@cat-factory/
|
|
29
|
+
"@cat-factory/spend": "0.10.78",
|
|
30
|
+
"@cat-factory/server": "0.69.1"
|
|
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
|
|
41
|
-
* safe to call on
|
|
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-
|
|
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
|
}
|