@cat-factory/executor-harness 1.110.2 → 1.112.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.
@@ -0,0 +1,157 @@
1
+ import { lstat, mkdir, readdir, rename, rm, symlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CONTEXT_DIR, excludeContextDir } from './pi.js';
4
+ // ---------------------------------------------------------------------------
5
+ // CODEX'S OWN IMAGE GENERATION, staged somewhere the agent can actually reach.
6
+ //
7
+ // Codex CLI carries a built-in `image_gen` tool (gpt-image-2) that is available ONLY on ChatGPT
8
+ // subscription auth — an `OPENAI_API_KEY` session routes to the Images API instead and does not
9
+ // get the tool at all. That makes it the one generative path the platform can offer with no
10
+ // vendor API key anywhere, which is exactly what a `harness`-transport binary generator is.
11
+ //
12
+ // Two facts make this more than a feature flag.
13
+ //
14
+ // 1. Codex writes generated images to `$CODEX_HOME/generated_images/`, and does not reliably tell
15
+ // anyone where they landed: the tool result exposes no path, no URL and no artifact id
16
+ // (openai/codex#28887, #28898, #28873, #28849, all open), and `codex exec --json` never
17
+ // surfaces structured tool bodies at all. So the PLATFORM has to know where the file is; asking
18
+ // the model is the thing that does not work.
19
+ //
20
+ // 2. `$CODEX_HOME` is also where the decrypted subscription `auth.json` lives. Telling the agent to
21
+ // go and look there would point a prompt-injectable process at the run's own credential — the
22
+ // same exposure `runCodex` already keeps the home OUTSIDE the checkout to avoid.
23
+ //
24
+ // So the harness redirects the output instead: `generated_images` is created as a SYMLINK into the
25
+ // checkout's context directory before the CLI starts, and codex writes through it. The agent reads
26
+ // one stable, credential-free path, with no polling and no race between generating and uploading —
27
+ // the file is simply there the moment the tool returns. `$CODEX_HOME` stays unreadable to it.
28
+ //
29
+ // A post-run sweep backs that up for the case where the symlink could not be made (a filesystem
30
+ // that refuses one) or was replaced: anything sitting in a REAL `generated_images` directory is
31
+ // moved into the same staging path, so a run never silently loses an image it paid to generate.
32
+ // ---------------------------------------------------------------------------
33
+ /** Codex's own output directory name, relative to `CODEX_HOME`. Chosen by the CLI, not by us. */
34
+ const CODEX_OUTPUT_DIRNAME = 'generated_images';
35
+ /**
36
+ * Where a harness-generated binary artifact is staged for the agent, relative to the checkout.
37
+ *
38
+ * Under {@link CONTEXT_DIR} so it inherits that directory's git exclude: an image the agent has
39
+ * not uploaded yet must never be swept into a commit by the `git add -A` a coding run ends with.
40
+ * Part of the backend↔harness path contract (`HARNESS_SENTINEL_PATHS.generatedBinaries`), because
41
+ * the agent's brief has to NAME this path and the two halves are written independently.
42
+ */
43
+ export const GENERATED_BINARY_SUBDIR = 'binary-output/generated';
44
+ /** The staging directory's repo-relative path, as the prompt names it. */
45
+ export const GENERATED_BINARY_DIR = `${CONTEXT_DIR}/${GENERATED_BINARY_SUBDIR}`;
46
+ /**
47
+ * Point codex's image output at the checkout, before the CLI starts.
48
+ *
49
+ * Returns whether the redirect is in place. FALSE is a real and reportable answer rather than a
50
+ * throw: a run whose images cannot be staged is still a run worth doing (the agent may have plenty
51
+ * of non-generating work), and the caller states the gap instead of failing the job. The sweep
52
+ * below is what keeps that case from losing files outright.
53
+ *
54
+ * Best-effort by construction, and deliberately NOT idempotent-by-overwrite: an existing
55
+ * `generated_images` is left exactly as it is. On the per-run home this is always a fresh
56
+ * directory, so anything already there on the ambient path is the DEVELOPER's own history, and
57
+ * replacing it with a symlink into a throwaway checkout would destroy it.
58
+ */
59
+ export async function stageCodexImages(codexHome, cwd, log) {
60
+ const target = join(cwd, CONTEXT_DIR, GENERATED_BINARY_SUBDIR);
61
+ try {
62
+ await mkdir(target, { recursive: true });
63
+ // The context directory's own exclude, applied here rather than assumed: a codex run that was
64
+ // handed no context files never reaches the materialiser that normally writes it, and this is
65
+ // the one writer whose output is BINARY and would otherwise be committed as such.
66
+ await excludeContextDir(cwd);
67
+ // `junction` is ignored off Windows and is what makes the same call work on a developer's
68
+ // machine, where a plain directory symlink needs a privilege the shell usually lacks.
69
+ await symlink(target, join(codexHome, CODEX_OUTPUT_DIRNAME), 'junction');
70
+ return true;
71
+ }
72
+ catch (error) {
73
+ log?.warn('codex image staging unavailable; falling back to a post-run sweep', {
74
+ error: error instanceof Error ? error.message : String(error),
75
+ });
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Move anything codex wrote into a REAL `generated_images` directory across to the staging path.
81
+ *
82
+ * The backstop for a redirect that did not take. Returns the file names moved, so the caller can
83
+ * say what arrived after the agent had already finished — those are images the run generated and
84
+ * the agent never had a chance to upload, which is a different fact from generating none, and the
85
+ * kind of distinction this codebase refuses to let collapse into silence.
86
+ *
87
+ * A live redirect yields nothing here, detected by `lstat` on the directory itself: reading THROUGH
88
+ * the link would list files that are already where they belong and report every one of them as
89
+ * stranded.
90
+ */
91
+ export async function sweepCodexImages(codexHome, cwd, log) {
92
+ const source = join(codexHome, CODEX_OUTPUT_DIRNAME);
93
+ const target = join(cwd, CONTEXT_DIR, GENERATED_BINARY_SUBDIR);
94
+ // A LIVE REDIRECT has nothing to sweep, and it must be detected by asking the filesystem rather
95
+ // than by comparing paths: `readdir` through the link yields the staging directory's own files
96
+ // under the SOURCE prefix, so every path pair looks distinct and every file the run generated is
97
+ // "moved" onto itself and then reported as stranded. That is a false alarm on every successful
98
+ // generating run — precisely inverting what this report is for.
99
+ try {
100
+ if ((await lstat(source)).isSymbolicLink())
101
+ return [];
102
+ }
103
+ catch {
104
+ // No directory at all is the normal case: the run generated nothing.
105
+ return [];
106
+ }
107
+ let names;
108
+ try {
109
+ names = await readdir(source);
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ const moved = [];
115
+ // Loop-invariant: made ONCE here rather than per rescued file. This is the path where
116
+ // `stageCodexImages` did not create it, so it cannot be assumed to exist. A failure needs no
117
+ // report of its own — every rename below then fails and names its own file in the warning it
118
+ // already emits, which is the more useful line anyway.
119
+ await mkdir(target, { recursive: true }).catch(() => { });
120
+ for (const name of names) {
121
+ const from = join(source, name);
122
+ const to = join(target, name);
123
+ try {
124
+ await rename(from, to);
125
+ moved.push(name);
126
+ }
127
+ catch (error) {
128
+ log?.warn('could not stage a generated image', {
129
+ name,
130
+ error: error instanceof Error ? error.message : String(error),
131
+ });
132
+ }
133
+ }
134
+ return moved;
135
+ }
136
+ /**
137
+ * Remove the redirect before the per-run home is torn down.
138
+ *
139
+ * Only ever the LINK: `rm` on a symlink unlinks it and leaves the target alone, which is what must
140
+ * happen here — the target is inside the checkout and holds the run's actual output. Passed the
141
+ * home rather than the link path so a caller cannot accidentally hand it the staging directory.
142
+ *
143
+ * A failure is SWALLOWED (teardown must not take a completed run down with it) and REPORTED,
144
+ * because this unlink is the property that stops the recursive delete that follows from reaching
145
+ * the checkout. Dropping it silently would forfeit that with no line anywhere saying so, and the
146
+ * evidence would be missing artifacts nobody could trace back to this call.
147
+ */
148
+ export async function unstageCodexImages(codexHome, log) {
149
+ try {
150
+ await rm(join(codexHome, CODEX_OUTPUT_DIRNAME), { recursive: false, force: true });
151
+ }
152
+ catch (error) {
153
+ log?.warn('could not remove the codex image redirect before tearing the home down', {
154
+ error: error instanceof Error ? error.message : String(error),
155
+ });
156
+ }
157
+ }
package/dist/job.d.ts CHANGED
@@ -9,9 +9,11 @@ import { type DependencyInstallSpec } from './dependency-install.js';
9
9
  import { type McpServerSpec, type SkillResourceSpec, type SkillSpec } from './agent-capabilities.js';
10
10
  import { type TestSecretSpec } from './job-env.js';
11
11
  import { type ContextFileSpec, type ImageFileSpec, type ImageManifestSpec } from './context-manifests.js';
12
+ import { type ArtifactUploadSpec } from './artifact-upload.js';
12
13
  export type { TestSecretSpec };
13
14
  export type { McpServerSpec, SkillResourceSpec, SkillSpec };
14
15
  export type { ContextFileSpec, ImageFileSpec, ImageManifestSpec };
16
+ export type { ArtifactUploadSpec };
15
17
  /**
16
18
  * Per-job auth fields, shared across every job shape. The Pi harness carries the
17
19
  * proxy base URL + a model-locked session token; the subscription harnesses
@@ -342,6 +344,17 @@ export interface AgentJob extends HarnessAuthFields {
342
344
  * works from the textual design description (its prompt says which).
343
345
  */
344
346
  designImages?: ImageManifestSpec;
347
+ /**
348
+ * Where this job uploads the artifacts it PRODUCES (see {@link ArtifactUploadSpec}) — the
349
+ * outbound leg of the seam {@link AgentJob.referenceScreenshots} and {@link
350
+ * AgentJob.designImages} are the inbound legs of. Surfaced to the agent as
351
+ * `ARTIFACT_UPLOAD_URL` / `ARTIFACT_UPLOAD_TOKEN`, which the capturing prompts already name.
352
+ *
353
+ * Sent only for a kind the backend gave a browser image to, so absent is the NORMAL case and
354
+ * means this run produces no platform-held bytes. SECRET-BEARING (`token` is the run's container
355
+ * session token), so it is registered for redaction before it reaches any child.
356
+ */
357
+ artifactUpload?: ArtifactUploadSpec;
345
358
  /**
346
359
  * Private package-registry auth (npm private orgs, GitHub Packages), rendered into
347
360
  * `~/.npmrc` before the run so the checkout's installs — the agent's own and the
@@ -365,6 +378,16 @@ export interface AgentJob extends HarnessAuthFields {
365
378
  * built-in tools only.
366
379
  */
367
380
  mcpServers?: McpServerSpec[];
381
+ /**
382
+ * Enable the codex CLI's own `image_gen` tool for this job, and stage what it writes into
383
+ * `.cat-context/binary-output/generated/` where the agent can reach it.
384
+ *
385
+ * Set when the dispatch resolved a HARNESS-transport binary generator served by codex. Opt-in
386
+ * per job because the tool bills the leased ChatGPT plan at several times an ordinary turn, so
387
+ * an always-on image capability would charge every run for one it never uses. Ignored by the
388
+ * Pi and claude-code runners, neither of which has such a tool. Absent ⇒ no image tool.
389
+ */
390
+ generateImages?: boolean;
368
391
  /**
369
392
  * Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
370
393
  * band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
package/dist/job.js CHANGED
@@ -4,6 +4,7 @@ import { parseDependencyInstallSpec } from './dependency-install.js';
4
4
  import { parseMcpServerSpecs, parseSkillSpecs, } from './agent-capabilities.js';
5
5
  import { parseInfraEnv, parseSecretEnvPairs, str } from './job-env.js';
6
6
  import { parseContextFiles, parseImageManifest, } from './context-manifests.js';
7
+ import { parseArtifactUpload } from './artifact-upload.js';
7
8
  /** A positive finite integer, or undefined for any other input (silently ignored). */
8
9
  function posInt(value) {
9
10
  return typeof value === 'number' && Number.isFinite(value) && value > 0
@@ -483,6 +484,7 @@ export function parseAgentJob(input) {
483
484
  contextFiles: parseContextFiles(o.contextFiles),
484
485
  referenceScreenshots: parseImageManifest(o.referenceScreenshots),
485
486
  designImages: parseImageManifest(o.designImages),
487
+ artifactUpload: parseArtifactUpload(o.artifactUpload),
486
488
  packageRegistries: parsePackageRegistries(o.packageRegistries),
487
489
  skills: parseSkillSpecs(o.skills),
488
490
  mcpServers: parseMcpServerSpecs(o.mcpServers),
@@ -549,7 +551,7 @@ function parseAgentPrSpec(raw) {
549
551
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
550
552
  */
551
553
  function assembleAgentJob(o, mode, agentField, parts) {
552
- const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, referenceScreenshots, designImages, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, dependencyInstall, reviewPrNumber, generatorSecrets, } = parts;
554
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, referenceScreenshots, designImages, artifactUpload, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, dependencyInstall, reviewPrNumber, generatorSecrets, } = parts;
553
555
  const repo = (o.repo ?? {});
554
556
  return {
555
557
  jobId: str(o.jobId, 'jobId'),
@@ -567,6 +569,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
567
569
  ...(contextFiles.length ? { contextFiles } : {}),
568
570
  ...(referenceScreenshots ? { referenceScreenshots } : {}),
569
571
  ...(designImages ? { designImages } : {}),
572
+ ...(artifactUpload ? { artifactUpload } : {}),
570
573
  ...(packageRegistries.length ? { packageRegistries } : {}),
571
574
  ...(skills ? { skills } : {}),
572
575
  ...(mcpServers ? { mcpServers } : {}),
@@ -598,6 +601,7 @@ function collectOptionalRequestFields(o) {
598
601
  ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
599
602
  ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
600
603
  ...(o.webSearch === true ? { webSearch: true } : {}),
604
+ ...(o.generateImages === true ? { generateImages: true } : {}),
601
605
  ...(o.full === true ? { full: true } : {}),
602
606
  ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
603
607
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
@@ -133,6 +133,15 @@ export interface AgentRunSpec {
133
133
  * re-deciding. Absent ⇒ the CLI's built-in tools only.
134
134
  */
135
135
  mcpServers?: McpServerSpec[];
136
+ /**
137
+ * Enable the codex CLI's built-in `image_gen` tool and stage its output into the checkout.
138
+ *
139
+ * Forwarded rather than decided here, exactly like {@link mcpServers}: the BACKEND is the half
140
+ * that resolved a harness-served binary generator for this step and knows the run is meant to
141
+ * generate. A run that simply asks nicely gets nothing, which is the point — the tool bills the
142
+ * leased plan at several times an ordinary turn.
143
+ */
144
+ generateImages?: boolean;
136
145
  /**
137
146
  * Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
138
147
  * backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
@@ -187,6 +187,11 @@ export async function runAgentInWorkspace(spec, opts = {}) {
187
187
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
188
188
  ...(spec.skills?.length ? { skills: spec.skills } : {}),
189
189
  ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
190
+ // Codex's own image tool. Passed for both subscription harnesses because the option lives on
191
+ // the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
192
+ // (unlike an MCP server) there is nothing to report as unservable — the backend never
193
+ // resolves a codex-served generator onto a claude-code step, because admission refuses it.
194
+ ...(spec.generateImages ? { generateImages: true } : {}),
190
195
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
191
196
  signal: opts.signal,
192
197
  // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
package/dist/pi.d.ts CHANGED
@@ -106,6 +106,12 @@ export declare function materializeContextFiles(cwd: string, files: ContextFileI
106
106
  * One helper rather than a copy per materialiser: every writer into that directory owes the same
107
107
  * exclude, and a new one that forgot it would leak the platform's own files into a customer's
108
108
  * repository with nothing failing.
109
+ *
110
+ * IDEMPOTENT BY CONTENT, because that is the only shape that survives its own design: several
111
+ * materialisers legitimately run in one job (context files, skill resources, the codex image
112
+ * staging), so a blind append writes the same line two or three times per run, and on a persistent
113
+ * checkout it accretes one copy per run forever. Re-read and skip rather than tracking who called
114
+ * first, which would be a second piece of state to keep right.
109
115
  */
110
116
  export declare function excludeContextDir(cwd: string): Promise<void>;
111
117
  /** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
package/dist/pi.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { appendFile, mkdir, writeFile } from 'node:fs/promises';
2
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { killChildProcess, spawnDetached } from './process.js';
@@ -264,13 +264,24 @@ export async function materializeContextFiles(cwd, files) {
264
264
  * One helper rather than a copy per materialiser: every writer into that directory owes the same
265
265
  * exclude, and a new one that forgot it would leak the platform's own files into a customer's
266
266
  * repository with nothing failing.
267
+ *
268
+ * IDEMPOTENT BY CONTENT, because that is the only shape that survives its own design: several
269
+ * materialisers legitimately run in one job (context files, skill resources, the codex image
270
+ * staging), so a blind append writes the same line two or three times per run, and on a persistent
271
+ * checkout it accretes one copy per run forever. Re-read and skip rather than tracking who called
272
+ * first, which would be a second piece of state to keep right.
267
273
  */
268
274
  export async function excludeContextDir(cwd) {
269
275
  const gitRoot = await findGitRoot(cwd);
270
276
  if (!gitRoot)
271
277
  return;
278
+ const excludeFile = join(gitRoot, '.git', 'info', 'exclude');
279
+ const entry = `${CONTEXT_DIR}/`;
272
280
  try {
273
- await appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8');
281
+ const current = await readFile(excludeFile, 'utf8').catch(() => '');
282
+ if (current.split('\n').some((line) => line.trim() === entry))
283
+ return;
284
+ await appendFile(excludeFile, `\n${entry}\n`, 'utf8');
274
285
  }
275
286
  catch {
276
287
  // No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.110.2",
3
+ "version": "1.112.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.1",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.292.1",
34
- "@cat-factory/server": "0.278.1",
35
- "@cat-factory/spend": "0.15.80"
33
+ "@cat-factory/kernel": "0.296.0",
34
+ "@cat-factory/server": "0.283.0",
35
+ "@cat-factory/spend": "0.15.86"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
@@ -343,7 +343,12 @@ export function parseSkillSpecs(value: unknown): SkillSpec[] | undefined {
343
343
  * A member is added here in the SAME change that teaches the parser the field, never ahead of it:
344
344
  * the whole value of the list is that it is the image's own honest answer.
345
345
  */
346
- export const HARNESS_BODY_CAPABILITIES: readonly string[] = ['mcpServers', 'skills', 'designImages']
346
+ export const HARNESS_BODY_CAPABILITIES: readonly string[] = [
347
+ 'mcpServers',
348
+ 'skills',
349
+ 'designImages',
350
+ 'generateImages',
351
+ ]
347
352
 
348
353
  /**
349
354
  * A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
@@ -23,7 +23,6 @@ import {
23
23
  import type { PiRunStats } from './pi-reduction.js'
24
24
  import {
25
25
  claudeAllowedToolPatterns,
26
- codexMcpConfigToml,
27
26
  mcpServerSecretValues,
28
27
  observeClaudeMcpInit,
29
28
  writeClaudeMcpConfig,
@@ -31,6 +30,7 @@ import {
31
30
  type ObservedMcpServer,
32
31
  type SkillSpec,
33
32
  } from './agent-capabilities.js'
33
+ import { codexImageGapNote, createCodexHome, disposeCodexHome } from './codex-home.js'
34
34
  import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
35
35
  import { BoundedTail, JsonlLineReader } from './jsonl-stream.js'
36
36
  import { killChildProcess, spawnDetached } from './process.js'
@@ -108,6 +108,21 @@ export interface SubscriptionRunOptions {
108
108
  * carries this job's credentials. Absent ⇒ the CLI's built-in tools only.
109
109
  */
110
110
  mcpServers?: McpServerSpec[]
111
+ /**
112
+ * CODEX ONLY: enable the CLI's built-in `image_gen` tool for this job, and redirect what it
113
+ * writes into the checkout (see `codex-images.ts`).
114
+ *
115
+ * Opt-in per job rather than a property of the image, because the tool bills against the leased
116
+ * ChatGPT plan at 3-5x an ordinary turn: every non-generating run would pay for a capability it
117
+ * was never asked for. Set when the dispatch resolved a HARNESS-transport binary generator whose
118
+ * `harness` is `codex`, which is the one signal that says this step exists to make pictures.
119
+ *
120
+ * A no-op under `ambientAuth`: there is no per-run `CODEX_HOME` to write a config into or
121
+ * redirect, and the alternative — reconfiguring the developer's own `~/.codex` and staging into
122
+ * their real output directory — is the HOME-global mutation this harness never makes. The
123
+ * backend states the capability as unavailable there rather than half-enabling it.
124
+ */
125
+ generateImages?: boolean
111
126
  /**
112
127
  * Extra environment for the CLI child, scoped to this job (the tester's secrets, a
113
128
  * private-registry npmrc pointer). Merged over the inherited `process.env` at spawn, so the
@@ -1181,52 +1196,23 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
1181
1196
  // this one value rather than one being reconstructed from the other.
1182
1197
  let cumulative: CodexCumulativeUsage | undefined
1183
1198
 
1184
- // Codex reads its credentials from $CODEX_HOME/auth.json with file-backed
1185
- // storage. CRITICAL: this home must live OUTSIDE the cloned checkout (`opts.cwd`)
1186
- // the blueprint/requirements/conflict-resolver handlers finish with
1187
- // `git add -A` + push, which would otherwise stage and publish the decrypted
1188
- // subscription `auth.json` (access + refresh tokens) to the PR branch. An
1189
- // isolated, per-run temp dir keeps the credential out of the working tree and is
1190
- // removed in `finally`.
1191
- //
1192
- // KNOWN LIMITATION: Codex refreshes its OAuth access token in-place by rewriting
1193
- // this `auth.json` mid-run. Because the home is a per-run temp dir wiped in
1194
- // `finally`, that refreshed credential is discarded and never written back to the
1195
- // pool — there is no write-back path. The stored bundle keeps working as long as
1196
- // its refresh token stays valid (ChatGPT refresh tokens are long-lived and reused,
1197
- // not rotated per refresh today), so each run re-refreshes from the same stored
1198
- // copy; if OpenAI ever rotates refresh tokens on use, a pooled Codex token would
1199
- // eventually need to be re-connected by the user. Claude OAuth tokens (from
1200
- // `claude setup-token`) are long-lived and unaffected.
1201
- // Native (ambient) mode: run the developer's installed `codex` with its OWN login —
1202
- // no isolated CODEX_HOME, no injected auth.json. Otherwise write the leased credential
1203
- // to a per-run temp home kept OUTSIDE the checkout (and removed in `finally`).
1204
- if (!opts.ambientAuth && !opts.subscriptionToken) {
1205
- throw new Error('codex harness requires a subscription token (or ambientAuth)')
1206
- }
1207
- const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'))
1208
- if (codexHome) {
1209
- await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
1210
- // Tool servers (MCP) ride the SAME per-run config.toml, so they are scoped to this job and
1211
- // torn down with the home. Under AMBIENT auth there is no per-run home — and writing servers
1212
- // into the developer's own `~/.codex/config.toml` would outlive the run and race a concurrent
1213
- // job — so an ambient codex run gets no MCP servers; the backend states them as unavailable
1214
- // the same way it does for a harness with no MCP client at all.
1215
- // Registered before the CLI starts, for the same reason the claude path does it: a server that
1216
- // fails to launch puts its own command line into the stderr tail we keep.
1217
- if (opts.mcpServers?.length) registerKnownSecrets(mcpServerSecretValues(opts.mcpServers))
1218
- const mcpToml = opts.mcpServers?.length ? codexMcpConfigToml(opts.mcpServers) : ''
1219
- await writeFile(
1220
- join(codexHome, 'config.toml'),
1221
- `cli_auth_credentials_store = "file"\n${mcpToml ? `\n${mcpToml}` : ''}`,
1222
- { encoding: 'utf8', mode: 0o600 },
1223
- )
1224
- }
1199
+ // The per-run `CODEX_HOME` the credential, the config and the generated-output redirect — is
1200
+ // a lifecycle of its own, in `codex-home.ts`. Ambient mode answers no home: the developer's
1201
+ // own CLI login, with nothing written and nothing to tear down.
1202
+ const { home: codexHome, images } = await createCodexHome(opts)
1225
1203
 
1226
1204
  // Codex has no system-prompt flag, so fold the composed role + best-practice
1227
1205
  // context into the prompt itself (Claude Code instead rides --append-system-prompt,
1228
1206
  // falling back to this same fold when the prompt overflows argv).
1229
- const prompt = foldSystemPrompt(opts.systemPrompt, opts.userPrompt)
1207
+ //
1208
+ // An image capability that could NOT be honoured is stated in the same fold, because the
1209
+ // backend's brief has already promised it and only this half knows it is missing. Absent for
1210
+ // every ordinary run, which is byte-for-byte the prompt it composed before.
1211
+ const gap = codexImageGapNote(images)
1212
+ const prompt = foldSystemPrompt(
1213
+ opts.systemPrompt,
1214
+ gap ? `${opts.userPrompt}\n\n${gap}` : opts.userPrompt,
1215
+ )
1230
1216
  // This stream's tool-silence window (see the claude runner for the shape); opened just before
1231
1217
  // the CLI starts and closed in the `finally` below.
1232
1218
  let toolWindow: ToolProgressWindow = NO_TOOL_WINDOW
@@ -1351,17 +1337,7 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
1351
1337
  throw withAgentReport(err, summary, secrets)
1352
1338
  } finally {
1353
1339
  toolWindow.close()
1354
- if (codexHome) {
1355
- // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
1356
- // home is deleted — the credential (`auth.json`) lives at the home root, never in
1357
- // `sessions/`, so this keeps the debugging artifact without leaking it. Best-effort.
1358
- await retainSessionTranscripts(codexHome, ['sessions'], {
1359
- label: 'codex',
1360
- ...(opts.log ? { log: opts.log } : {}),
1361
- })
1362
- // Never leave the decrypted credential on disk past the run.
1363
- await rm(codexHome, { recursive: true, force: true }).catch(() => {})
1364
- }
1340
+ if (codexHome) await disposeCodexHome(codexHome, opts, images)
1365
1341
  }
1366
1342
  }
1367
1343
 
@@ -27,12 +27,14 @@ export function mergeEffort(
27
27
  export function agentCapabilities(job: AgentJob): {
28
28
  skills?: SkillSpec[]
29
29
  mcpServers?: McpServerSpec[]
30
+ generateImages?: boolean
30
31
  referenceScreenshots?: ImageManifestSpec
31
32
  designImages?: ImageManifestSpec
32
33
  } {
33
34
  return {
34
35
  ...(job.skills?.length ? { skills: job.skills } : {}),
35
36
  ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
37
+ ...(job.generateImages ? { generateImages: true } : {}),
36
38
  ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
37
39
  ...(job.designImages ? { designImages: job.designImages } : {}),
38
40
  }
package/src/agent.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  TestSecretSpec,
13
13
  } from './job.js'
14
14
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js'
15
+ import { artifactUploadEnv } from './artifact-upload.js'
15
16
  import { configurePackageRegistries } from './package-registries.js'
16
17
  import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js'
17
18
  import {
@@ -317,9 +318,13 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
317
318
  // not the other would be an integration that works or 401s depending on how its step was
318
319
  // registered. Per-job env like everything else here — never `process.env`, which the shared
319
320
  // native host process makes a cross-job leak.
321
+ // The platform's own artifact ingest, layered on for EVERY mode for the same reason: which
322
+ // kinds get the seam is the backend's call (it keys off the kind's declared `ui` image), so a
323
+ // mode check here would be that decision made twice, in the half that cannot see the registry.
320
324
  const scoped = withAgentEnv(opts, {
321
325
  ...registryEnv,
322
326
  ...secretEnv(job.generatorSecrets),
327
+ ...artifactUploadEnv(job.artifactUpload),
323
328
  })
324
329
  if (job.mode === 'preview') return await runPreviewMode(job, scoped)
325
330
  return job.mode === 'coding'
@@ -0,0 +1,71 @@
1
+ import { registerKnownSecrets } from './redact.js'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The OUTBOUND half of the platform's own artifact seam: where a job that PRODUCES bytes sends
5
+ // them, as the two environment variables the producing prompt already names.
6
+ //
7
+ // The inbound half (`context-manifests.ts`) downloads the artifacts a run was handed. This is the
8
+ // return leg, and it stayed unwired long after its backend was done: `ContainerAgentExecutor` has
9
+ // injected `artifactUpload` into the job body and `harnessArtifactController` has served
10
+ // `POST <proxyBaseUrl>/artifacts/ingest` since the visual-confirmation work, while the harness
11
+ // parsed neither — so the `tester-ui` prompt referenced `ARTIFACT_UPLOAD_URL` at a container where
12
+ // nothing ever set it, and every screenshot a UI run captured was dropped without an error.
13
+ //
14
+ // Deliberately NOT a `switch (agentKind)`: which kinds get the seam is the BACKEND's decision (it
15
+ // keys off the kind's declared `ui` image), and the container's whole job is to pass through what
16
+ // the body carries. A harness-side kind list would be the same decision made twice, in the half
17
+ // that cannot see the registry.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Where this job uploads the artifacts it produces, and the credential to do it with.
22
+ *
23
+ * The token is the run's EXISTING container session token, not a second credential: the ingest
24
+ * route authenticates it the same way the LLM proxy does and scopes the stored bytes to that
25
+ * token's workspace + execution. So a body carrying this grants no reach the job did not already
26
+ * have, which is why it needs no allow-list of its own beyond the transport check below.
27
+ */
28
+ export interface ArtifactUploadSpec {
29
+ /** Absolute http(s) URL of the ingest endpoint. */
30
+ url: string
31
+ /** Bearer credential — the run's container session token. */
32
+ token: string
33
+ }
34
+
35
+ /** The env var naming the ingest endpoint, as the producing prompts already reference it. */
36
+ export const ARTIFACT_UPLOAD_URL_ENV = 'ARTIFACT_UPLOAD_URL'
37
+ /** The env var carrying the ingest credential. */
38
+ export const ARTIFACT_UPLOAD_TOKEN_ENV = 'ARTIFACT_UPLOAD_TOKEN'
39
+
40
+ /**
41
+ * Parse the job body's upload seam, or undefined when absent/unusable.
42
+ *
43
+ * The whole spec is dropped when either half is unusable, exactly as `parseImageManifest` drops a
44
+ * manifest whose transport half is: a URL with no token and a token with no URL are both
45
+ * an endpoint nothing can call, and the agent is told the capability is absent rather than handed
46
+ * half of it. Absent is the NORMAL case — only a kind the backend gave a browser image to ever
47
+ * receives one.
48
+ */
49
+ export function parseArtifactUpload(value: unknown): ArtifactUploadSpec | undefined {
50
+ if (typeof value !== 'object' || value === null) return undefined
51
+ const o = value as Record<string, unknown>
52
+ const url = typeof o.url === 'string' ? o.url.trim() : ''
53
+ const token = typeof o.token === 'string' ? o.token : ''
54
+ if (!url || !token || !/^https?:\/\//i.test(url)) return undefined
55
+ return { url, token }
56
+ }
57
+
58
+ /**
59
+ * Project the seam into the agent's child env, registering the credential for redaction first.
60
+ *
61
+ * Returns the env rather than writing `process.env`, for the reason every other per-job value here
62
+ * does: the native host transport serves every concurrent ambient job from ONE process, so a
63
+ * global would hand one job's ingest credential to a sibling. Absent spec ⇒ `{}`, which is what
64
+ * makes the capability's absence visible to the agent as an unset variable (the prompts that use
65
+ * it already branch on that) rather than as an endpoint that 401s.
66
+ */
67
+ export function artifactUploadEnv(spec: ArtifactUploadSpec | undefined): Record<string, string> {
68
+ if (!spec) return {}
69
+ registerKnownSecrets([spec.token])
70
+ return { [ARTIFACT_UPLOAD_URL_ENV]: spec.url, [ARTIFACT_UPLOAD_TOKEN_ENV]: spec.token }
71
+ }