@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,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 } : {}),
|
package/src/pi-workspace.ts
CHANGED
|
@@ -241,6 +241,15 @@ export interface AgentRunSpec {
|
|
|
241
241
|
* re-deciding. Absent ⇒ the CLI's built-in tools only.
|
|
242
242
|
*/
|
|
243
243
|
mcpServers?: McpServerSpec[]
|
|
244
|
+
/**
|
|
245
|
+
* Enable the codex CLI's built-in `image_gen` tool and stage its output into the checkout.
|
|
246
|
+
*
|
|
247
|
+
* Forwarded rather than decided here, exactly like {@link mcpServers}: the BACKEND is the half
|
|
248
|
+
* that resolved a harness-served binary generator for this step and knows the run is meant to
|
|
249
|
+
* generate. A run that simply asks nicely gets nothing, which is the point — the tool bills the
|
|
250
|
+
* leased plan at several times an ordinary turn.
|
|
251
|
+
*/
|
|
252
|
+
generateImages?: boolean
|
|
244
253
|
/**
|
|
245
254
|
* Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
|
|
246
255
|
* backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
|
|
@@ -342,6 +351,11 @@ export async function runAgentInWorkspace(
|
|
|
342
351
|
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
343
352
|
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
344
353
|
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
354
|
+
// Codex's own image tool. Passed for both subscription harnesses because the option lives on
|
|
355
|
+
// the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
|
|
356
|
+
// (unlike an MCP server) there is nothing to report as unservable — the backend never
|
|
357
|
+
// resolves a codex-served generator onto a claude-code step, because admission refuses it.
|
|
358
|
+
...(spec.generateImages ? { generateImages: true } : {}),
|
|
345
359
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
346
360
|
signal: opts.signal,
|
|
347
361
|
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
package/src/pi.ts
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'
|
|
@@ -337,12 +337,22 @@ export async function materializeContextFiles(
|
|
|
337
337
|
* One helper rather than a copy per materialiser: every writer into that directory owes the same
|
|
338
338
|
* exclude, and a new one that forgot it would leak the platform's own files into a customer's
|
|
339
339
|
* repository with nothing failing.
|
|
340
|
+
*
|
|
341
|
+
* IDEMPOTENT BY CONTENT, because that is the only shape that survives its own design: several
|
|
342
|
+
* materialisers legitimately run in one job (context files, skill resources, the codex image
|
|
343
|
+
* staging), so a blind append writes the same line two or three times per run, and on a persistent
|
|
344
|
+
* checkout it accretes one copy per run forever. Re-read and skip rather than tracking who called
|
|
345
|
+
* first, which would be a second piece of state to keep right.
|
|
340
346
|
*/
|
|
341
347
|
export async function excludeContextDir(cwd: string): Promise<void> {
|
|
342
348
|
const gitRoot = await findGitRoot(cwd)
|
|
343
349
|
if (!gitRoot) return
|
|
350
|
+
const excludeFile = join(gitRoot, '.git', 'info', 'exclude')
|
|
351
|
+
const entry = `${CONTEXT_DIR}/`
|
|
344
352
|
try {
|
|
345
|
-
await
|
|
353
|
+
const current = await readFile(excludeFile, 'utf8').catch(() => '')
|
|
354
|
+
if (current.split('\n').some((line) => line.trim() === entry)) return
|
|
355
|
+
await appendFile(excludeFile, `\n${entry}\n`, 'utf8')
|
|
346
356
|
} catch {
|
|
347
357
|
// No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
|
|
348
358
|
}
|