@cat-factory/executor-harness 1.52.0 → 1.54.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/src/agent.ts CHANGED
@@ -155,8 +155,7 @@ async function manageInfra(
155
155
  dir: string,
156
156
  workDir: string,
157
157
  infra: AgentInfraSpec,
158
- signal: AbortSignal | undefined,
159
- onActivity: (() => void) | undefined,
158
+ opts: RunOptions,
160
159
  logger: Logger,
161
160
  ): Promise<{
162
161
  note?: string
@@ -168,7 +167,7 @@ async function manageInfra(
168
167
  // `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
169
168
  // which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
170
169
  // Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
171
- const fe = await standUpFrontend(workDir, infra, signal, onActivity, logger)
170
+ const fe = await standUpFrontend(workDir, infra, opts, logger)
172
171
  return {
173
172
  ...(fe.note ? { note: fe.note } : {}),
174
173
  ...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
@@ -176,7 +175,7 @@ async function manageInfra(
176
175
  cleanup: () => tearDownFrontend(fe.processes, logger),
177
176
  }
178
177
  }
179
- const standUp = await standUpInfra(dir, infra, signal, logger)
178
+ const standUp = await standUpInfra(dir, infra, opts.signal, logger)
180
179
  return {
181
180
  ...(standUp.note ? { note: standUp.note } : {}),
182
181
  ...(standUp.record ? { record: standUp.record } : {}),
@@ -324,13 +323,39 @@ function mergeEffort(result: AgentResult, effortReport: EffortReport | undefined
324
323
 
325
324
  /** Run one generic agent job end to end, dispatching on `mode`. */
326
325
  export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
327
- // Private-registry auth first, before any mode runs: every mode with a checkout may
328
- // install dependencies (the agent's own shell and the frontend-infra stand-up both
329
- // inherit `HOME`, so they all read the written ~/.npmrc). A job with no entries
330
- // clears any stale ~/.npmrc from a prior job on a reused (warm-pool) container.
331
- await configurePackageRegistries(job.packageRegistries)
332
- if (job.mode === 'preview') return runPreviewMode(job, opts)
333
- return job.mode === 'coding' ? runCodingMode(job, opts) : runExploreMode(job, opts)
326
+ // An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
327
+ // (see `LocalProcessRunnerTransport`), so anything this job would otherwise write to a
328
+ // process- or HOME-global gets a per-job directory instead it can't corrupt the
329
+ // developer's files, and concurrent jobs can't race on them.
330
+ const scopeDir = job.ambientAuth ? await mkdtemp(join(tmpdir(), 'cf-jobenv-')) : undefined
331
+ try {
332
+ // Private-registry auth first, before any mode runs: every mode with a checkout may
333
+ // install dependencies (the agent's own shell and the frontend-infra stand-up both
334
+ // inherit this env, so they all read the written npmrc). In a container a job with no
335
+ // entries clears any stale ~/.npmrc from a prior job on a reused (warm-pool) container.
336
+ const registryEnv = await configurePackageRegistries(
337
+ job.packageRegistries,
338
+ scopeDir ? { isolatedDir: scopeDir } : {},
339
+ )
340
+ const scoped = withAgentEnv(opts, registryEnv)
341
+ if (job.mode === 'preview') return await runPreviewMode(job, scoped)
342
+ return job.mode === 'coding'
343
+ ? await runCodingMode(job, scoped)
344
+ : await runExploreMode(job, scoped)
345
+ } finally {
346
+ if (scopeDir) await rm(scopeDir, { recursive: true, force: true }).catch(() => {})
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Layer extra child-process env onto a job's {@link RunOptions}. The agent CLI is spawned with
352
+ * `{...process.env, ...agentEnv}`, so this is how per-job values reach the agent (and the shell
353
+ * tools it spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every
354
+ * concurrent job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
355
+ */
356
+ function withAgentEnv(opts: RunOptions, env: Record<string, string>): RunOptions {
357
+ if (Object.keys(env).length === 0) return opts
358
+ return { ...opts, agentEnv: { ...opts.agentEnv, ...env } }
334
359
  }
335
360
 
336
361
  /**
@@ -391,7 +416,7 @@ async function runPreviewMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
391
416
  logger.info('agent(preview): building + serving', {
392
417
  serviceDirectory: job.repo.serviceDirectory,
393
418
  })
394
- const fe = await standUpFrontend(workDir, infra, opts.signal, opts.onActivity, logger)
419
+ const fe = await standUpFrontend(workDir, infra, opts, logger)
395
420
  const infraSetupFields: { infraSetup?: InfraSetupRecord } = fe.record
396
421
  ? { infraSetup: fe.record }
397
422
  : {}
@@ -423,27 +448,22 @@ async function runPreviewMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
423
448
  }
424
449
 
425
450
  /**
426
- * Inject the tester's sensitive secrets into the PROCESS environment so the agent's shell tools
427
- * (spawned as child processes that inherit this env) can read `$KEY` — the out-of-band delivery
428
- * channel. Each value is registered for redaction so it can't leak into captured output/logs.
429
- * Returns a restore closure that puts the environment back afterward (warm-pool hygiene, so a
430
- * later job on a reused container never inherits a prior run's secrets). Reserved/toolchain env
431
- * names were already dropped at parse. A no-op when there are no secrets.
451
+ * Build the env carrying the tester's sensitive secrets, so the agent's shell tools (spawned as
452
+ * child processes that inherit it) can read `$KEY` — the out-of-band delivery channel. Each value
453
+ * is registered for redaction so it can't leak into captured output/logs. Reserved/toolchain env
454
+ * names were already dropped at parse. No secrets an empty env.
455
+ *
456
+ * Returned as EXPLICIT child env rather than written onto `process.env`: a process-global
457
+ * set/restore is only safe when the process runs one job, which the native host-process transport
458
+ * breaks (it serves every concurrent ambient job from one process). There, two overlapping tester
459
+ * runs would read each other's secrets, and whichever finished first would delete the other's
460
+ * mid-run. Scoping them to the spawn env makes the delivery correct under concurrency and drops
461
+ * the restore step entirely.
432
462
  */
433
- function applyTestSecrets(secrets: TestSecretSpec[] | undefined): () => void {
434
- if (!secrets?.length) return () => {}
463
+ export function testSecretEnv(secrets: TestSecretSpec[] | undefined): Record<string, string> {
464
+ if (!secrets?.length) return {}
435
465
  registerKnownSecrets(secrets.map((s) => s.value))
436
- const previous = new Map<string, string | undefined>()
437
- for (const { key, value } of secrets) {
438
- previous.set(key, process.env[key])
439
- process.env[key] = value
440
- }
441
- return () => {
442
- for (const [key, prior] of previous) {
443
- if (prior === undefined) delete process.env[key]
444
- else process.env[key] = prior
445
- }
446
- }
466
+ return Object.fromEntries(secrets.map(({ key, value }) => [key, value]))
447
467
  }
448
468
 
449
469
  /**
@@ -536,9 +556,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
536
556
  // The run-mode guidance itself lives in the backend-composed system/user prompt; the
537
557
  // harness only manages the lifecycle + this dynamic stand-up note.
538
558
  const infra = job.infra
539
- const managed = infra
540
- ? await manageInfra(dir, workDir, infra, opts.signal, opts.onActivity, logger)
541
- : undefined
559
+ const managed = infra ? await manageInfra(dir, workDir, infra, opts, logger) : undefined
542
560
  // Fold the stand-up outcome into the agent prompt: a stand-up problem (build/compose
543
561
  // failure) is flagged as a concern; a frontend serve URL points the UI tester at the
544
562
  // app it just built + served (the backend env resolution already reached the harness).
@@ -553,10 +571,10 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
553
571
  ? { infraSetup: managed.record }
554
572
  : {}
555
573
 
556
- // Inject the tester's sensitive secrets into the environment (out of band) so the agent's
557
- // shell can read them as `$KEY`; restore afterwards so a reused (warm-pool) container never
558
- // leaks them to a later job. A no-op for non-tester runs (no `testSecrets`).
559
- const restoreSecrets = applyTestSecrets(job.testSecrets)
574
+ // Hand the tester's sensitive secrets to the agent's child process (out of band) so its
575
+ // shell can read them as `$KEY`. Scoped to this job's env, so a concurrent job in the same
576
+ // harness process never sees them. A no-op for non-tester runs (no `testSecrets`).
577
+ const agentOpts = withAgentEnv(opts, testSecretEnv(job.testSecrets))
560
578
 
561
579
  try {
562
580
  opts.onPhase?.('agent')
@@ -590,7 +608,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
590
608
  contextFiles: job.contextFiles,
591
609
  guardLimits: job.guardLimits,
592
610
  },
593
- opts,
611
+ agentOpts,
594
612
  )
595
613
 
596
614
  return mergeEffort(
@@ -602,7 +620,6 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
602
620
  effortReport,
603
621
  )
604
622
  } finally {
605
- restoreSecrets()
606
623
  if (managed) await managed.cleanup()
607
624
  }
608
625
  },
@@ -105,8 +105,9 @@ export interface CodingAgentSpec extends HarnessAuthFields {
105
105
  validation?: { command: string; iteration?: number }
106
106
  /**
107
107
  * A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
108
- * into {@link runAgentInWorkspace}, which installs it harness-aware (native `~/.claude/skills`
109
- * for claude-code, `.cat-context/skill/` for Pi/codex). Absent ⇒ no skill.
108
+ * into {@link runAgentInWorkspace}, which installs it harness-aware: natively under the ISOLATED
109
+ * `CLAUDE_CONFIG_DIR` for a leased-credential claude-code run, `.cat-context/skill/` for everything
110
+ * else (Pi, codex, and ambient claude-code, which has no isolated config dir). Absent ⇒ no skill.
110
111
  */
111
112
  skill?: SkillSpec
112
113
  }
@@ -602,6 +603,10 @@ async function runRalphValidation(
602
603
  cwd,
603
604
  detached: spawnDetached,
604
605
  stdio: ['ignore', 'pipe', 'pipe'],
606
+ // The job's own env (see `RunOptions.agentEnv`): a validation command typically installs
607
+ // before it tests, and this is spawned by the HARNESS rather than the agent, so it does not
608
+ // otherwise inherit the job's private-registry npmrc pointer on the native path.
609
+ env: { ...process.env, ...opts.agentEnv },
605
610
  })
606
611
  // Keep only the tail; guard against unbounded buffering on a chatty command.
607
612
  const capture = (chunk: Buffer): void => {
@@ -3,6 +3,7 @@ import { promisify } from 'node:util'
3
3
  import { writeFile } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import type { FrontendInfraSpec, InfraSetupRecord } from './job.js'
6
+ import type { RunOptions } from './runner.js'
6
7
  import { killChildProcess } from './process.js'
7
8
  import { pathExists } from './fs-utils.js'
8
9
  import { captureRedactedOutput, redactSecrets } from './redact.js'
@@ -77,10 +78,10 @@ function guardProcess(child: ChildProcess, label: string, logger: Logger): Child
77
78
  export async function standUpFrontend(
78
79
  dir: string,
79
80
  infra: FrontendInfraSpec,
80
- signal: AbortSignal | undefined,
81
- onActivity: (() => void) | undefined,
81
+ run: Pick<RunOptions, 'signal' | 'onActivity' | 'agentEnv'>,
82
82
  logger: Logger = log,
83
83
  ): Promise<FrontendStandUp> {
84
+ const { signal, onActivity } = run
84
85
  const startedAt = Date.now()
85
86
  const processes: ChildProcess[] = []
86
87
  // The frontend app's directory: the checkout root, or a monorepo subdirectory when the config
@@ -118,6 +119,11 @@ export async function standUpFrontend(
118
119
 
119
120
  const buildEnv =
120
121
  (infra.envInjection ?? DEFAULTS.envInjection) === 'build' ? (infra.env ?? {}) : {}
122
+ // The job's own env (see `RunOptions.agentEnv`) — today the private-registry npmrc pointer.
123
+ // The stand-up is spawned by the HARNESS, not by the agent, so it does not inherit whatever the
124
+ // agent's CLI child was given: without this the install here would miss the job's registry auth
125
+ // on the native path, where the npmrc is per-job rather than the process's `~/.npmrc`.
126
+ const jobEnv = run.agentEnv ?? {}
121
127
 
122
128
  try {
123
129
  // 1) Install dependencies.
@@ -128,6 +134,7 @@ export async function standUpFrontend(
128
134
  signal,
129
135
  timeout: 8 * 60_000,
130
136
  maxBuffer: 16 * 1024 * 1024,
137
+ env: { ...process.env, ...jobEnv },
131
138
  })
132
139
  pushOutput(installed.stdout, installed.stderr)
133
140
 
@@ -140,7 +147,7 @@ export async function standUpFrontend(
140
147
  signal,
141
148
  timeout: 12 * 60_000,
142
149
  maxBuffer: 16 * 1024 * 1024,
143
- env: { ...process.env, ...buildEnv },
150
+ env: { ...process.env, ...jobEnv, ...buildEnv },
144
151
  })
145
152
  pushOutput(built.stdout, built.stderr)
146
153
 
@@ -1,22 +1,47 @@
1
- import { chmod, rm, writeFile } from 'node:fs/promises'
1
+ import { chmod, readFile, rm, writeFile } from 'node:fs/promises'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import type { PackageRegistrySpec } from './job.js'
5
5
  import { registerKnownSecrets } from './redact.js'
6
6
 
7
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.
8
+ // GitHub Packages). The job's allowlisted entries are rendered into an npmrc — read by
9
+ // npm, pnpm and yarn v1 alike, and inherited by every child process (the agent's own
10
+ // shell installs and the frontend-infra stand-up's) — so the token never rides argv or
11
+ // the checkout.
12
+ //
13
+ // WHERE that npmrc lands depends on whether the harness process owns its HOME:
14
+ // - container (the default): the user `~/.npmrc`. HOME belongs to that one container, so
15
+ // writing it is safe and a job with NO entries CLEARS it — warm-pool containers are
16
+ // reused across jobs and must not leak a prior workspace's token.
17
+ // - shared native host process (`ambientAuth`, the local native transport): HOME is the
18
+ // DEVELOPER's. Writing there would overwrite their own npm config, clearing there would
19
+ // DELETE it, and concurrent jobs in the one process would race on the single file. Such a
20
+ // job gets its own npmrc under a per-job directory instead, pointed at by
21
+ // `npm_config_userconfig`; the developer's file is never written and never removed.
22
+ //
23
+ // Note the isolated path trades a little reach for that safety: `~/.npmrc` is read by npm, pnpm
24
+ // and yarn v1 alike, whereas `npm_config_userconfig` is honoured by npm and pnpm but NOT by yarn
25
+ // (v1 or Berry). A yarn-based checkout on the native path therefore sees only the developer's own
26
+ // registries, not the job's. Since the alternative is overwriting the file they actually use, the
27
+ // limitation stands — a yarn repo needing private-registry auth wants the container path.
14
28
 
15
- /** Where the per-job npm auth lands (the user npmrc, outside any checkout). */
29
+ /** Where the per-job npm auth lands in a container (the user npmrc, outside any checkout). */
16
30
  export function npmrcPath(): string {
17
31
  return join(homedir(), '.npmrc')
18
32
  }
19
33
 
34
+ /**
35
+ * Per-job isolation for the rendered npmrc. Set `isolatedDir` when the harness process is
36
+ * SHARED across concurrent jobs and its HOME is the developer's own — i.e. the local native
37
+ * host-process transport, which is exactly the set of jobs carrying `ambientAuth`. Absent ⇒
38
+ * the container default (`~/.npmrc`).
39
+ */
40
+ export interface PackageRegistryScope {
41
+ /** A per-job directory (removed with the job) to hold this job's npmrc. */
42
+ isolatedDir?: string
43
+ }
44
+
20
45
  /**
21
46
  * Render the job's registry entries as npmrc lines: each scope routed to its
22
47
  * registry, plus one `_authToken` credential line per distinct host.
@@ -39,20 +64,75 @@ export function renderNpmrc(entries: readonly PackageRegistrySpec[]): string {
39
64
  }
40
65
 
41
66
  /**
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
67
+ * Write (or clear) the job's npmrc before the agent runs, and return the env the agent's child
68
+ * process needs to find it (empty for the container default, which npm picks up from HOME).
69
+ * Tokens are registered for output redaction so a token echoed in an npm error never reaches
44
70
  * logs or stored output.
45
71
  */
46
72
  export async function configurePackageRegistries(
47
73
  entries: readonly PackageRegistrySpec[] | undefined,
48
- ): Promise<void> {
74
+ scope: PackageRegistryScope = {},
75
+ ): Promise<Record<string, string>> {
76
+ const hasEntries = Boolean(entries?.length)
77
+ if (scope.isolatedDir) {
78
+ // A job with no entries needs no file at all: emitting no override leaves the developer's
79
+ // own `~/.npmrc` in effect (their private registries keep working) — and, crucially, leaves
80
+ // it ALONE. Clearing a stale file is a container concern; here nothing stale can exist,
81
+ // because the per-job dir is created and removed with the job.
82
+ if (!hasEntries) return {}
83
+ const path = join(scope.isolatedDir, '.npmrc')
84
+ await writeIsolatedNpmrc(path, entries!)
85
+ return { npm_config_userconfig: path }
86
+ }
49
87
  const path = npmrcPath()
50
- if (!entries || entries.length === 0) {
88
+ if (!hasEntries) {
51
89
  await rm(path, { force: true })
52
- return
90
+ return {}
53
91
  }
54
- registerKnownSecrets(entries.map((entry) => entry.token))
55
- await writeFile(path, renderNpmrc(entries), { mode: 0o600 })
92
+ registerKnownSecrets(entries!.map((entry) => entry.token))
93
+ await writeFile(path, renderNpmrc(entries!), { mode: 0o600 })
56
94
  // writeFile's mode only applies on create — tighten an existing file too.
57
95
  await chmod(path, 0o600)
96
+ return {}
97
+ }
98
+
99
+ /**
100
+ * Write the per-job npmrc, seeded from the developer's own `~/.npmrc` when they have one so
101
+ * their unrelated settings (a corporate registry, a proxy) keep working for this run. The job's
102
+ * lines are APPENDED, and npm resolves the last occurrence of a key, so the job's entries win on
103
+ * any host they both configure. Copying their file into a 0600 temp adds no exposure: an ambient
104
+ * run already has the developer's full file access by definition.
105
+ *
106
+ * The seeded credentials are registered for redaction alongside the job's own. The job's tokens
107
+ * were always registered; the developer's were not, because before this path existed their file
108
+ * was overwritten and no credential of theirs was in play during the run. Now that theirs is in
109
+ * effect, an npm error echoing one must be scrubbed on exactly the same terms.
110
+ */
111
+ async function writeIsolatedNpmrc(
112
+ path: string,
113
+ entries: readonly PackageRegistrySpec[],
114
+ ): Promise<void> {
115
+ registerKnownSecrets(entries.map((entry) => entry.token))
116
+ // Best-effort: no personal npmrc (or an unreadable one) just means the job's entries stand alone.
117
+ const inherited = await readFile(npmrcPath(), 'utf8').catch(() => '')
118
+ registerKnownSecrets(npmrcCredentials(inherited))
119
+ const prefix = inherited && !inherited.endsWith('\n') ? `${inherited}\n` : inherited
120
+ await writeFile(path, `${prefix}${renderNpmrc(entries)}`, { mode: 0o600 })
121
+ await chmod(path, 0o600)
122
+ }
123
+
124
+ /**
125
+ * The credential VALUES in npmrc content: the three keys npm accepts a secret under, on any host
126
+ * line. Used to register a seeded (developer-owned) file's tokens for redaction. An `${ENV_VAR}`
127
+ * reference is not itself a secret — npm expands it at read time — so it is skipped rather than
128
+ * registered as a literal to scrub.
129
+ */
130
+ export function npmrcCredentials(content: string): string[] {
131
+ const found: string[] = []
132
+ for (const line of content.split(/\r?\n/)) {
133
+ const match = /^\s*(?:.*:)?_(?:authToken|auth|password)\s*=\s*(.+?)\s*$/.exec(line)
134
+ const value = match?.[1]?.replace(/^["']|["']$/g, '')
135
+ if (value && !/^\$\{.*\}$/.test(value)) found.push(value)
136
+ }
137
+ return found
58
138
  }
@@ -241,11 +241,13 @@ export async function runAgentInWorkspace(
241
241
  // harness paths; kept out of the agent's commits via a local git exclude entry.
242
242
  const contextFiles = spec.contextFiles ?? []
243
243
  await materializeContextFiles(spec.dir, contextFiles)
244
- // Repo-sourced skill (slice 2): claude-code installs it natively (written by the runner into the
245
- // config dir), so it reads from there. Every other harness (Pi/codex) reads the checkout, so
246
- // materialise the skill's resources under `.cat-context/skill/` (its instructions are folded
247
- // into the prompt by the backend). A resource-free skill is a no-op here.
248
- if (spec.skill && spec.harness !== 'claude-code') {
244
+ // Repo-sourced skill (slice 2): claude-code installs it natively into its ISOLATED config dir,
245
+ // so it reads from there. Everything else reads the checkout, so materialise the skill's
246
+ // resources under `.cat-context/skill/` (its instructions are folded into the prompt by the
247
+ // backend) Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install
248
+ // into (the runner refuses to write a repo's skill into the developer's own `~/.claude`; see
249
+ // `runClaudeCode`). A resource-free skill is a no-op here.
250
+ if (spec.skill && !installsSkillNatively(spec)) {
249
251
  await materializeSkillResources(spec.dir, spec.skill)
250
252
  }
251
253
 
@@ -268,6 +270,7 @@ export async function runAgentInWorkspace(
268
270
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
269
271
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
270
272
  ...(spec.skill ? { skill: spec.skill } : {}),
273
+ ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
271
274
  signal: opts.signal,
272
275
  onActivity: opts.onActivity,
273
276
  onProgress: opts.onProgress,
@@ -293,9 +296,11 @@ export async function runAgentInWorkspace(
293
296
  // container env, which `webSearchConfigFromEnv` autodetects.
294
297
  // The proxy vars are handed to Pi's child via `extraEnv` (not the harness's own
295
298
  // process.env), so detection runs against the same merged view the extension sees.
296
- const extraEnv: Record<string, string> = spec.webSearchProxy
297
- ? webSearchProxyEnv(proxyBaseUrl, sessionToken)
298
- : {}
299
+ const extraEnv: Record<string, string> = {
300
+ ...(spec.webSearchProxy ? webSearchProxyEnv(proxyBaseUrl, sessionToken) : {}),
301
+ // Per-job env (tester secrets, a private-registry npmrc pointer) — see `RunOptions.agentEnv`.
302
+ ...opts.agentEnv,
303
+ }
299
304
  const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv })
300
305
  if (webSearch) await writeWebToolsConfig(webSearch)
301
306
  await writeAgentsContext(spec.systemPrompt, {
@@ -325,6 +330,20 @@ export async function runAgentInWorkspace(
325
330
  return withEffortReport(spec.dir, piOutcome)
326
331
  }
327
332
 
333
+ /**
334
+ * Whether the claude-code runner will install this run's repo-sourced skill natively (into the
335
+ * CLI's config dir) rather than the caller materialising it into the checkout. True ONLY for a
336
+ * leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
337
+ * uses the developer's own `~/.claude`, which the runner will not write a repo's skill into —
338
+ * it would outlive the run in their personal setup, and two concurrent jobs carrying same-named
339
+ * skills from different repos would overwrite each other's.
340
+ */
341
+ export function installsSkillNatively(
342
+ spec: Pick<AgentRunSpec, 'harness' | 'ambientAuth'>,
343
+ ): boolean {
344
+ return spec.harness === 'claude-code' && !spec.ambientAuth
345
+ }
346
+
328
347
  /**
329
348
  * Lift the agent's effort self-assessment off its sentinel file in `dir` and fold it onto the
330
349
  * run outcome. Shared by both harness paths so EVERY container agent's effort report is captured
package/src/pi.ts CHANGED
@@ -250,9 +250,10 @@ export const SKILL_CONTEXT_SUBDIR = 'skill'
250
250
 
251
251
  /**
252
252
  * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
253
- * (repo-sourced Claude Skills, slice 2) — the Pi/codex path, whose agents read the checkout rather
254
- * than a native `~/.claude/skills` dir (the skill's instructions are folded into their prompt by
255
- * the backend). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
253
+ * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
254
+ * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
255
+ * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
256
+ * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
256
257
  * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
257
258
  * exclude entry. A skill with no resource bodies is a no-op.
258
259
  */