@cat-factory/executor-harness 1.110.0 → 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.
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 {
@@ -50,6 +51,7 @@ import {
50
51
  diagnosticsSuffix,
51
52
  resolveStructuredOutput,
52
53
  } from './structured-output.js'
54
+ import { extractJsonObject } from './json-reply.js'
53
55
  import type { RunOptions } from './runner.js'
54
56
  import { log, type Logger } from './logger.js'
55
57
 
@@ -263,23 +265,6 @@ async function resolveReplyCustom(
263
265
  return { value: resolved.value, diagnostics: resolved.diagnostics }
264
266
  }
265
267
 
266
- /** Extract the first JSON object from an agent's final message (tolerating fences/prose). */
267
- function extractJsonObject(text: string): unknown {
268
- const trimmed = text.trim()
269
- const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed)
270
- const body = fenced ? (fenced[1] ?? '') : trimmed
271
- try {
272
- return JSON.parse(body)
273
- } catch {
274
- const start = body.indexOf('{')
275
- const end = body.lastIndexOf('}')
276
- if (start === -1 || end === -1 || end <= start) {
277
- throw new Error('agent did not return a JSON object')
278
- }
279
- return JSON.parse(body.slice(start, end + 1))
280
- }
281
- }
282
-
283
268
  /**
284
269
  * The service work directory for a checkout at `dir`: the monorepo service subtree
285
270
  * (`repo.serviceDirectory`, created if missing) when the job is service-scoped, else the clone
@@ -333,9 +318,13 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
333
318
  // not the other would be an integration that works or 401s depending on how its step was
334
319
  // registered. Per-job env like everything else here — never `process.env`, which the shared
335
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.
336
324
  const scoped = withAgentEnv(opts, {
337
325
  ...registryEnv,
338
326
  ...secretEnv(job.generatorSecrets),
327
+ ...artifactUploadEnv(job.artifactUpload),
339
328
  })
340
329
  if (job.mode === 'preview') return await runPreviewMode(job, scoped)
341
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
+ }
@@ -0,0 +1,187 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import {
5
+ codexMcpConfigToml,
6
+ mcpServerSecretValues,
7
+ type McpServerSpec,
8
+ } from './agent-capabilities.js'
9
+ import {
10
+ GENERATED_BINARY_DIR,
11
+ stageCodexImages,
12
+ sweepCodexImages,
13
+ unstageCodexImages,
14
+ } from './codex-images.js'
15
+ import type { Logger } from './logger.js'
16
+ import { registerKnownSecrets } from './redact.js'
17
+ import { retainSessionTranscripts } from './transcript-retention.js'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // The PER-RUN `CODEX_HOME`: everything that is written for one codex job and torn down with it.
21
+ //
22
+ // Extracted from `runCodex`, which had grown past the file-size ratchet: the run loop's own job is
23
+ // streaming and reducing the CLI's events, and this is a distinct concern — a directory with a
24
+ // lifecycle, holding a credential, a config and (now) a redirect for the CLI's generated output.
25
+ //
26
+ // CRITICAL and the reason it is a temp dir rather than anything under the checkout: several
27
+ // handlers finish with `git add -A` + push, so a decrypted `auth.json` inside `opts.cwd` would be
28
+ // published to the PR branch.
29
+ //
30
+ // KNOWN LIMITATION, unchanged by the extraction: codex refreshes its OAuth access token in place by
31
+ // rewriting `auth.json` mid-run, and this home is wiped afterwards, so the refreshed credential is
32
+ // discarded and never written back to the pool. The stored bundle keeps working while its refresh
33
+ // token stays valid (ChatGPT refresh tokens are long-lived and reused, not rotated per refresh
34
+ // today); if that ever changes, a pooled codex token would need re-connecting by its owner. Claude
35
+ // OAuth tokens (from `claude setup-token`) are long-lived and unaffected.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** What one codex job needs written into its own home. */
39
+ export interface CodexHomeOptions {
40
+ /** The decrypted `auth.json` bundle. Required unless `ambientAuth`. */
41
+ subscriptionToken?: string
42
+ /** Run the developer's own CLI login instead: no isolated home, nothing written. */
43
+ ambientAuth?: boolean
44
+ /** Tool servers to scope to this job's config. */
45
+ mcpServers?: McpServerSpec[]
46
+ /** Enable the CLI's built-in image tool and redirect its output into the checkout. */
47
+ generateImages?: boolean
48
+ /** The checkout, which is where generated output is staged to. */
49
+ cwd: string
50
+ log?: Logger
51
+ }
52
+
53
+ /**
54
+ * What became of the image capability a job asked for, so the run can SAY so.
55
+ *
56
+ * Its own value rather than a boolean, because the two failures need different words and the
57
+ * teardown report needs to tell them apart: an image found in the home afterwards is a LATE
58
+ * arrival when the redirect was live, and a file that was never reachable at all when it was not.
59
+ */
60
+ export type CodexImageOutcome =
61
+ /** The redirect is in place: the agent reads what the tool writes, the moment it writes it. */
62
+ | { state: 'staged' }
63
+ /** Enabled nowhere: an ambient run has no per-run home to configure or redirect. */
64
+ | { state: 'unavailable'; reason: 'ambient-home' }
65
+ /** The tool is on and its output goes somewhere the agent cannot reach during the run. */
66
+ | { state: 'unavailable'; reason: 'redirect-refused' }
67
+
68
+ /** This job's `CODEX_HOME` (absent for an ambient run) and what its image capability came to. */
69
+ export interface CodexHomeSetup {
70
+ home?: string
71
+ /** Absent when the job asked for no image generation. */
72
+ images?: CodexImageOutcome
73
+ }
74
+
75
+ /**
76
+ * Create and populate this job's `CODEX_HOME`, or answer no home for an ambient run.
77
+ *
78
+ * Ambient mode writes nothing deliberately: there is no per-run home, so there is nowhere to put
79
+ * MCP servers (writing them into the developer's own `~/.codex/config.toml` would outlive the run
80
+ * and race a concurrent job) and nowhere to redirect generated images.
81
+ *
82
+ * What it does NOT do is drop the image capability quietly. The backend composed a brief naming
83
+ * the staging directory and told the agent to collect from it, so an ambient run with the tool
84
+ * silently off leaves the agent hunting for files nothing wrote and reporting a vendor problem
85
+ * for a configuration one. The outcome comes back so the caller can state it, which is what the
86
+ * brief's own "if the tool is unavailable, say so" instruction exists to be paired with.
87
+ */
88
+ export async function createCodexHome(opts: CodexHomeOptions): Promise<CodexHomeSetup> {
89
+ if (!opts.ambientAuth && !opts.subscriptionToken) {
90
+ throw new Error('codex harness requires a subscription token (or ambientAuth)')
91
+ }
92
+ if (opts.ambientAuth) {
93
+ return opts.generateImages ? { images: { state: 'unavailable', reason: 'ambient-home' } } : {}
94
+ }
95
+ const codexHome = await mkdtemp(join(tmpdir(), 'cf-codex-'))
96
+ await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
97
+ // Registered before the CLI starts, for the same reason the claude path does it: a server that
98
+ // fails to launch puts its own command line into the stderr tail we keep.
99
+ if (opts.mcpServers?.length) registerKnownSecrets(mcpServerSecretValues(opts.mcpServers))
100
+ const mcpToml = opts.mcpServers?.length ? codexMcpConfigToml(opts.mcpServers) : ''
101
+ // `image_generation` is OPT-IN per job rather than always-on: the tool bills the leased ChatGPT
102
+ // plan at 3-5x an ordinary turn, so every non-generating run would pay for a capability it was
103
+ // never asked for. Enabled only when the dispatch selected a harness-served generator, which is
104
+ // the one thing that knows the step exists to make pictures.
105
+ const imagesToml = opts.generateImages ? '\n[features]\nimage_generation = true\n' : ''
106
+ await writeFile(
107
+ join(codexHome, 'config.toml'),
108
+ `cli_auth_credentials_store = "file"\n${mcpToml ? `\n${mcpToml}` : ''}${imagesToml}`,
109
+ { encoding: 'utf8', mode: 0o600 },
110
+ )
111
+ if (!opts.generateImages) return { home: codexHome }
112
+ // Redirect the tool's output into the checkout BEFORE the CLI starts, so the agent never has to
113
+ // read this directory (which holds the decrypted credential) to find what it generated. The
114
+ // answer is KEPT rather than discarded: a refused redirect leaves the tool enabled and its
115
+ // output unreachable until the post-run sweep, which is a different fact from a live one and
116
+ // the difference the teardown report would otherwise get wrong.
117
+ const staged = await stageCodexImages(codexHome, opts.cwd, opts.log)
118
+ return {
119
+ home: codexHome,
120
+ images: staged ? { state: 'staged' } : { state: 'unavailable', reason: 'redirect-refused' },
121
+ }
122
+ }
123
+
124
+ /**
125
+ * What to TELL THE AGENT when the image capability it was briefed on is not there, or undefined
126
+ * when there is nothing to say.
127
+ *
128
+ * Appended to the user prompt rather than left to the backend, because only this half knows: the
129
+ * backend resolved a harness-served generator and composed a brief naming the staging directory,
130
+ * and whether that directory can be written to is decided here, one process later. Silence is the
131
+ * one answer that is never right — it reads to the agent exactly like a working tool that returned
132
+ * nothing.
133
+ */
134
+ export function codexImageGapNote(images: CodexImageOutcome | undefined): string | undefined {
135
+ if (!images || images.state === 'staged') return undefined
136
+ const shared =
137
+ `Nothing will appear in \`${GENERATED_BINARY_DIR}/\` while you are working. Report the ` +
138
+ `artifacts you could not produce, exactly as your instructions for an unavailable generation ` +
139
+ `tool describe, and do not substitute another generator or describe an image you did not make.`
140
+ return images.reason === 'ambient-home'
141
+ ? `NOTE: this run's built-in image generation tool could NOT be enabled. It needs an isolated ` +
142
+ `per-run CLI home, and this run uses the host's own CLI login, which is not reconfigured ` +
143
+ `for a job. ${shared}`
144
+ : `NOTE: this run's built-in image generation tool is enabled, but its output could NOT be ` +
145
+ `redirected into the checkout, so anything it writes lands somewhere you cannot read. ` +
146
+ `${shared}`
147
+ }
148
+
149
+ /**
150
+ * Tear the home down: rescue anything generated, keep the transcripts, delete the credential.
151
+ *
152
+ * ORDER is load-bearing. The image sweep runs first because the files are about to be deleted with
153
+ * the home; the redirect is unlinked next so the recursive delete cannot follow it into the
154
+ * checkout; the transcripts are lifted before the delete (the credential lives at the home ROOT,
155
+ * never in `sessions/`, which is what makes that safe); and the delete is last, because nothing
156
+ * else may leave a decrypted credential on disk past the run.
157
+ */
158
+ export async function disposeCodexHome(
159
+ codexHome: string,
160
+ opts: CodexHomeOptions,
161
+ images?: CodexImageOutcome,
162
+ ): Promise<void> {
163
+ if (opts.generateImages) {
164
+ const stranded = await sweepCodexImages(codexHome, opts.cwd, opts.log)
165
+ if (stranded.length > 0) {
166
+ // REPORTED rather than quietly rescued: an image that arrived too late for the agent to
167
+ // store is a different fact from a run that generated none.
168
+ //
169
+ // And WHICH fact depends on what the setup came to, which is why the outcome is threaded in
170
+ // rather than inferred here. With a live redirect these really are late arrivals (the CLI
171
+ // wrote after the agent's last turn); with a refused one they were never reachable at all,
172
+ // and calling those "late" points the next reader at the model instead of at the filesystem.
173
+ opts.log?.warn(
174
+ images?.state === 'unavailable'
175
+ ? 'generated images were rescued after the run: the output redirect was never in place'
176
+ : 'generated images were staged after the agent finished',
177
+ { count: stranded.length, dir: GENERATED_BINARY_DIR },
178
+ )
179
+ }
180
+ await unstageCodexImages(codexHome, opts.log)
181
+ }
182
+ await retainSessionTranscripts(codexHome, ['sessions'], {
183
+ label: 'codex',
184
+ ...(opts.log ? { log: opts.log } : {}),
185
+ })
186
+ await rm(codexHome, { recursive: true, force: true }).catch(() => {})
187
+ }
@@ -0,0 +1,167 @@
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
+ import type { Logger } from './logger.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // CODEX'S OWN IMAGE GENERATION, staged somewhere the agent can actually reach.
8
+ //
9
+ // Codex CLI carries a built-in `image_gen` tool (gpt-image-2) that is available ONLY on ChatGPT
10
+ // subscription auth — an `OPENAI_API_KEY` session routes to the Images API instead and does not
11
+ // get the tool at all. That makes it the one generative path the platform can offer with no
12
+ // vendor API key anywhere, which is exactly what a `harness`-transport binary generator is.
13
+ //
14
+ // Two facts make this more than a feature flag.
15
+ //
16
+ // 1. Codex writes generated images to `$CODEX_HOME/generated_images/`, and does not reliably tell
17
+ // anyone where they landed: the tool result exposes no path, no URL and no artifact id
18
+ // (openai/codex#28887, #28898, #28873, #28849, all open), and `codex exec --json` never
19
+ // surfaces structured tool bodies at all. So the PLATFORM has to know where the file is; asking
20
+ // the model is the thing that does not work.
21
+ //
22
+ // 2. `$CODEX_HOME` is also where the decrypted subscription `auth.json` lives. Telling the agent to
23
+ // go and look there would point a prompt-injectable process at the run's own credential — the
24
+ // same exposure `runCodex` already keeps the home OUTSIDE the checkout to avoid.
25
+ //
26
+ // So the harness redirects the output instead: `generated_images` is created as a SYMLINK into the
27
+ // checkout's context directory before the CLI starts, and codex writes through it. The agent reads
28
+ // one stable, credential-free path, with no polling and no race between generating and uploading —
29
+ // the file is simply there the moment the tool returns. `$CODEX_HOME` stays unreadable to it.
30
+ //
31
+ // A post-run sweep backs that up for the case where the symlink could not be made (a filesystem
32
+ // that refuses one) or was replaced: anything sitting in a REAL `generated_images` directory is
33
+ // moved into the same staging path, so a run never silently loses an image it paid to generate.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Codex's own output directory name, relative to `CODEX_HOME`. Chosen by the CLI, not by us. */
37
+ const CODEX_OUTPUT_DIRNAME = 'generated_images'
38
+
39
+ /**
40
+ * Where a harness-generated binary artifact is staged for the agent, relative to the checkout.
41
+ *
42
+ * Under {@link CONTEXT_DIR} so it inherits that directory's git exclude: an image the agent has
43
+ * not uploaded yet must never be swept into a commit by the `git add -A` a coding run ends with.
44
+ * Part of the backend↔harness path contract (`HARNESS_SENTINEL_PATHS.generatedBinaries`), because
45
+ * the agent's brief has to NAME this path and the two halves are written independently.
46
+ */
47
+ export const GENERATED_BINARY_SUBDIR = 'binary-output/generated'
48
+
49
+ /** The staging directory's repo-relative path, as the prompt names it. */
50
+ export const GENERATED_BINARY_DIR = `${CONTEXT_DIR}/${GENERATED_BINARY_SUBDIR}`
51
+
52
+ /**
53
+ * Point codex's image output at the checkout, before the CLI starts.
54
+ *
55
+ * Returns whether the redirect is in place. FALSE is a real and reportable answer rather than a
56
+ * throw: a run whose images cannot be staged is still a run worth doing (the agent may have plenty
57
+ * of non-generating work), and the caller states the gap instead of failing the job. The sweep
58
+ * below is what keeps that case from losing files outright.
59
+ *
60
+ * Best-effort by construction, and deliberately NOT idempotent-by-overwrite: an existing
61
+ * `generated_images` is left exactly as it is. On the per-run home this is always a fresh
62
+ * directory, so anything already there on the ambient path is the DEVELOPER's own history, and
63
+ * replacing it with a symlink into a throwaway checkout would destroy it.
64
+ */
65
+ export async function stageCodexImages(
66
+ codexHome: string,
67
+ cwd: string,
68
+ log?: Logger,
69
+ ): Promise<boolean> {
70
+ const target = join(cwd, CONTEXT_DIR, GENERATED_BINARY_SUBDIR)
71
+ try {
72
+ await mkdir(target, { recursive: true })
73
+ // The context directory's own exclude, applied here rather than assumed: a codex run that was
74
+ // handed no context files never reaches the materialiser that normally writes it, and this is
75
+ // the one writer whose output is BINARY and would otherwise be committed as such.
76
+ await excludeContextDir(cwd)
77
+ // `junction` is ignored off Windows and is what makes the same call work on a developer's
78
+ // machine, where a plain directory symlink needs a privilege the shell usually lacks.
79
+ await symlink(target, join(codexHome, CODEX_OUTPUT_DIRNAME), 'junction')
80
+ return true
81
+ } catch (error) {
82
+ log?.warn('codex image staging unavailable; falling back to a post-run sweep', {
83
+ error: error instanceof Error ? error.message : String(error),
84
+ })
85
+ return false
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Move anything codex wrote into a REAL `generated_images` directory across to the staging path.
91
+ *
92
+ * The backstop for a redirect that did not take. Returns the file names moved, so the caller can
93
+ * say what arrived after the agent had already finished — those are images the run generated and
94
+ * the agent never had a chance to upload, which is a different fact from generating none, and the
95
+ * kind of distinction this codebase refuses to let collapse into silence.
96
+ *
97
+ * A live redirect yields nothing here, detected by `lstat` on the directory itself: reading THROUGH
98
+ * the link would list files that are already where they belong and report every one of them as
99
+ * stranded.
100
+ */
101
+ export async function sweepCodexImages(
102
+ codexHome: string,
103
+ cwd: string,
104
+ log?: Logger,
105
+ ): Promise<string[]> {
106
+ const source = join(codexHome, CODEX_OUTPUT_DIRNAME)
107
+ const target = join(cwd, CONTEXT_DIR, GENERATED_BINARY_SUBDIR)
108
+ // A LIVE REDIRECT has nothing to sweep, and it must be detected by asking the filesystem rather
109
+ // than by comparing paths: `readdir` through the link yields the staging directory's own files
110
+ // under the SOURCE prefix, so every path pair looks distinct and every file the run generated is
111
+ // "moved" onto itself and then reported as stranded. That is a false alarm on every successful
112
+ // generating run — precisely inverting what this report is for.
113
+ try {
114
+ if ((await lstat(source)).isSymbolicLink()) return []
115
+ } catch {
116
+ // No directory at all is the normal case: the run generated nothing.
117
+ return []
118
+ }
119
+ let names: string[]
120
+ try {
121
+ names = await readdir(source)
122
+ } catch {
123
+ return []
124
+ }
125
+ const moved: string[] = []
126
+ // Loop-invariant: made ONCE here rather than per rescued file. This is the path where
127
+ // `stageCodexImages` did not create it, so it cannot be assumed to exist. A failure needs no
128
+ // report of its own — every rename below then fails and names its own file in the warning it
129
+ // already emits, which is the more useful line anyway.
130
+ await mkdir(target, { recursive: true }).catch(() => {})
131
+ for (const name of names) {
132
+ const from = join(source, name)
133
+ const to = join(target, name)
134
+ try {
135
+ await rename(from, to)
136
+ moved.push(name)
137
+ } catch (error) {
138
+ log?.warn('could not stage a generated image', {
139
+ name,
140
+ error: error instanceof Error ? error.message : String(error),
141
+ })
142
+ }
143
+ }
144
+ return moved
145
+ }
146
+
147
+ /**
148
+ * Remove the redirect before the per-run home is torn down.
149
+ *
150
+ * Only ever the LINK: `rm` on a symlink unlinks it and leaves the target alone, which is what must
151
+ * happen here — the target is inside the checkout and holds the run's actual output. Passed the
152
+ * home rather than the link path so a caller cannot accidentally hand it the staging directory.
153
+ *
154
+ * A failure is SWALLOWED (teardown must not take a completed run down with it) and REPORTED,
155
+ * because this unlink is the property that stops the recursive delete that follows from reaching
156
+ * the checkout. Dropping it silently would forfeit that with no line anywhere saying so, and the
157
+ * evidence would be missing artifacts nobody could trace back to this call.
158
+ */
159
+ export async function unstageCodexImages(codexHome: string, log?: Logger): Promise<void> {
160
+ try {
161
+ await rm(join(codexHome, CODEX_OUTPUT_DIRNAME), { recursive: false, force: true })
162
+ } catch (error) {
163
+ log?.warn('could not remove the codex image redirect before tearing the home down', {
164
+ error: error instanceof Error ? error.message : String(error),
165
+ })
166
+ }
167
+ }
package/src/job.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  type ImageFileSpec,
30
30
  type ImageManifestSpec,
31
31
  } from './context-manifests.js'
32
+ import { parseArtifactUpload, type ArtifactUploadSpec } from './artifact-upload.js'
32
33
 
33
34
  // Re-exported so a handler describing a job keeps ONE import site (the env-pair shape is a job
34
35
  // body field like any other; only its VALIDATION moved out).
@@ -41,6 +42,9 @@ export type { McpServerSpec, SkillResourceSpec, SkillSpec }
41
42
  // `context-manifests.ts`, but they remain job body fields, so this stays the import site.
42
43
  export type { ContextFileSpec, ImageFileSpec, ImageManifestSpec }
43
44
 
45
+ // The return leg of the same seam, for the same reason (`artifact-upload.ts`).
46
+ export type { ArtifactUploadSpec }
47
+
44
48
  // The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
45
49
  // types with a hand-rolled validator so the image needs no schema dependency.
46
50
  // `ghToken`, `sessionToken` and `subscriptionToken` are secrets: they are
@@ -737,6 +741,17 @@ export interface AgentJob extends HarnessAuthFields {
737
741
  * works from the textual design description (its prompt says which).
738
742
  */
739
743
  designImages?: ImageManifestSpec
744
+ /**
745
+ * Where this job uploads the artifacts it PRODUCES (see {@link ArtifactUploadSpec}) — the
746
+ * outbound leg of the seam {@link AgentJob.referenceScreenshots} and {@link
747
+ * AgentJob.designImages} are the inbound legs of. Surfaced to the agent as
748
+ * `ARTIFACT_UPLOAD_URL` / `ARTIFACT_UPLOAD_TOKEN`, which the capturing prompts already name.
749
+ *
750
+ * Sent only for a kind the backend gave a browser image to, so absent is the NORMAL case and
751
+ * means this run produces no platform-held bytes. SECRET-BEARING (`token` is the run's container
752
+ * session token), so it is registered for redaction before it reaches any child.
753
+ */
754
+ artifactUpload?: ArtifactUploadSpec
740
755
  /**
741
756
  * Private package-registry auth (npm private orgs, GitHub Packages), rendered into
742
757
  * `~/.npmrc` before the run so the checkout's installs — the agent's own and the
@@ -760,6 +775,16 @@ export interface AgentJob extends HarnessAuthFields {
760
775
  * built-in tools only.
761
776
  */
762
777
  mcpServers?: McpServerSpec[]
778
+ /**
779
+ * Enable the codex CLI's own `image_gen` tool for this job, and stage what it writes into
780
+ * `.cat-context/binary-output/generated/` where the agent can reach it.
781
+ *
782
+ * Set when the dispatch resolved a HARNESS-transport binary generator served by codex. Opt-in
783
+ * per job because the tool bills the leased ChatGPT plan at several times an ordinary turn, so
784
+ * an always-on image capability would charge every run for one it never uses. Ignored by the
785
+ * Pi and claude-code runners, neither of which has such a tool. Absent ⇒ no image tool.
786
+ */
787
+ generateImages?: boolean
763
788
  /**
764
789
  * Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
765
790
  * band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
@@ -1226,6 +1251,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1226
1251
  contextFiles: parseContextFiles(o.contextFiles),
1227
1252
  referenceScreenshots: parseImageManifest(o.referenceScreenshots),
1228
1253
  designImages: parseImageManifest(o.designImages),
1254
+ artifactUpload: parseArtifactUpload(o.artifactUpload),
1229
1255
  packageRegistries: parsePackageRegistries(o.packageRegistries),
1230
1256
  skills: parseSkillSpecs(o.skills),
1231
1257
  mcpServers: parseMcpServerSpecs(o.mcpServers),
@@ -1270,6 +1296,7 @@ interface ParsedAgentJobParts {
1270
1296
  contextFiles: ReturnType<typeof parseContextFiles>
1271
1297
  referenceScreenshots: ReturnType<typeof parseImageManifest>
1272
1298
  designImages: ReturnType<typeof parseImageManifest>
1299
+ artifactUpload: ReturnType<typeof parseArtifactUpload>
1273
1300
  packageRegistries: ReturnType<typeof parsePackageRegistries>
1274
1301
  skills: ReturnType<typeof parseSkillSpecs>
1275
1302
  mcpServers: ReturnType<typeof parseMcpServerSpecs>
@@ -1329,6 +1356,7 @@ function assembleAgentJob(
1329
1356
  contextFiles,
1330
1357
  referenceScreenshots,
1331
1358
  designImages,
1359
+ artifactUpload,
1332
1360
  packageRegistries,
1333
1361
  skills,
1334
1362
  mcpServers,
@@ -1358,6 +1386,7 @@ function assembleAgentJob(
1358
1386
  ...(contextFiles.length ? { contextFiles } : {}),
1359
1387
  ...(referenceScreenshots ? { referenceScreenshots } : {}),
1360
1388
  ...(designImages ? { designImages } : {}),
1389
+ ...(artifactUpload ? { artifactUpload } : {}),
1361
1390
  ...(packageRegistries.length ? { packageRegistries } : {}),
1362
1391
  ...(skills ? { skills } : {}),
1363
1392
  ...(mcpServers ? { mcpServers } : {}),
@@ -1390,6 +1419,7 @@ function collectOptionalRequestFields(o: Record<string, unknown>): Partial<Agent
1390
1419
  ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
1391
1420
  ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
1392
1421
  ...(o.webSearch === true ? { webSearch: true } : {}),
1422
+ ...(o.generateImages === true ? { generateImages: true } : {}),
1393
1423
  ...(o.full === true ? { full: true } : {}),
1394
1424
  ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
1395
1425
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),