@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.
- package/README.md +48 -0
- package/dist/agent-capabilities.js +6 -1
- package/dist/agent-runner.d.ts +15 -0
- package/dist/agent-runner.js +14 -51
- package/dist/agent-shared.d.ts +1 -0
- package/dist/agent-shared.js +1 -0
- package/dist/agent.js +5 -0
- package/dist/artifact-upload.d.ts +38 -0
- package/dist/artifact-upload.js +39 -0
- package/dist/codex-home.d.ts +79 -0
- package/dist/codex-home.js +109 -0
- package/dist/codex-images.d.ts +52 -0
- package/dist/codex-images.js +157 -0
- package/dist/job.d.ts +23 -0
- package/dist/job.js +5 -1
- package/dist/pi-workspace.d.ts +9 -0
- package/dist/pi-workspace.js +5 -0
- package/dist/pi.d.ts +6 -0
- package/dist/pi.js +13 -2
- package/package.json +4 -4
- package/src/agent-capabilities.ts +6 -1
- package/src/agent-runner.ts +30 -54
- package/src/agent-shared.ts +2 -0
- package/src/agent.ts +5 -0
- package/src/artifact-upload.ts +71 -0
- package/src/codex-home.ts +187 -0
- package/src/codex-images.ts +167 -0
- package/src/job.ts +30 -0
- package/src/pi-workspace.ts +14 -0
- package/src/pi.ts +12 -2
|
@@ -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 } : {}),
|
package/dist/pi-workspace.d.ts
CHANGED
|
@@ -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
|
package/dist/pi-workspace.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.15.
|
|
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[] = [
|
|
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.
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
|
|
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
|
-
|
|
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
|
|
package/src/agent-shared.ts
CHANGED
|
@@ -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
|
+
}
|