@cat-factory/executor-harness 1.31.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/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/agent-runner.js +389 -0
- package/dist/agent.js +810 -0
- package/dist/blueprint.js +367 -0
- package/dist/bootstrap.js +99 -0
- package/dist/ci-fixer.js +46 -0
- package/dist/coding-agent.js +285 -0
- package/dist/conflict-resolver.js +138 -0
- package/dist/embed.js +8 -0
- package/dist/explore.js +74 -0
- package/dist/failure.js +47 -0
- package/dist/fixer.js +44 -0
- package/dist/follow-ups.js +103 -0
- package/dist/frontend-infra.js +283 -0
- package/dist/fs-utils.js +11 -0
- package/dist/git.js +778 -0
- package/dist/job.js +409 -0
- package/dist/logger.js +27 -0
- package/dist/merger.js +135 -0
- package/dist/on-call.js +126 -0
- package/dist/pi-workspace.js +237 -0
- package/dist/pi.js +971 -0
- package/dist/process.js +25 -0
- package/dist/redact.js +109 -0
- package/dist/runner.js +228 -0
- package/dist/server.js +135 -0
- package/dist/spec.js +754 -0
- package/dist/structured-output.js +431 -0
- package/dist/tester.js +191 -0
- package/package.json +35 -0
- package/src/agent-runner.ts +484 -0
- package/src/agent.ts +948 -0
- package/src/coding-agent.ts +393 -0
- package/src/embed.ts +32 -0
- package/src/failure.ts +73 -0
- package/src/follow-ups.ts +106 -0
- package/src/frontend-infra.ts +340 -0
- package/src/fs-utils.ts +11 -0
- package/src/git.ts +955 -0
- package/src/job.ts +766 -0
- package/src/logger.ts +45 -0
- package/src/pi-workspace.ts +348 -0
- package/src/pi.ts +1236 -0
- package/src/process.ts +33 -0
- package/src/redact.ts +109 -0
- package/src/runner.ts +384 -0
- package/src/server.ts +153 -0
- package/src/structured-output.ts +524 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
|
|
6
|
+
import { killChildProcess } from './process.js'
|
|
7
|
+
import { redact, secretsToRedact } from './redact.js'
|
|
8
|
+
|
|
9
|
+
// The alternate (subscription) harness runners. The Pi harness reaches models
|
|
10
|
+
// through the LLM proxy with a model-locked session token; the Claude Code and
|
|
11
|
+
// Codex harnesses instead authenticate with a stored subscription OAuth token and
|
|
12
|
+
// talk DIRECT to the vendor. Everything around the inner loop — the HTTP job
|
|
13
|
+
// server, JobRegistry watchdogs, git clone/push, the handlers — is harness-
|
|
14
|
+
// agnostic, so only this inner "run the CLI" step differs.
|
|
15
|
+
//
|
|
16
|
+
// Each runner mirrors `runPi`'s contract: stream the CLI's JSON events, feed
|
|
17
|
+
// `onActivity` (inactivity watchdog) and `onProgress` (subtask counts) the way Pi
|
|
18
|
+
// does, and return a {@link PiRunOutcome}. Because the proxy never sees this
|
|
19
|
+
// traffic, the runners also lift per-turn token usage out of the CLI event stream
|
|
20
|
+
// onto the outcome, which the backend uses for usage-aware token rotation and
|
|
21
|
+
// telemetry. Event-schema details vary by CLI version, so the extractors below are
|
|
22
|
+
// deliberately defensive and degrade gracefully when a field is absent.
|
|
23
|
+
|
|
24
|
+
/** Which subscription harness to run (the Pi harness uses `runPi` directly). */
|
|
25
|
+
export type SubscriptionHarness = 'claude-code' | 'codex'
|
|
26
|
+
|
|
27
|
+
export interface SubscriptionRunOptions {
|
|
28
|
+
/** Prepared working directory (cloned/scaffolded by the caller). */
|
|
29
|
+
cwd: string
|
|
30
|
+
/** Real vendor model id, e.g. `claude-opus-4-8` / `gpt-5.5-codex`. */
|
|
31
|
+
model: string
|
|
32
|
+
/** Composed role + best-practice fragments, supplied as the system prompt. */
|
|
33
|
+
systemPrompt: string
|
|
34
|
+
/** The concrete task prompt handed to the CLI over stdin. */
|
|
35
|
+
userPrompt: string
|
|
36
|
+
/**
|
|
37
|
+
* The decrypted subscription credential: an OAuth token (claude) or auth.json blob
|
|
38
|
+
* (codex). Omitted when `ambientAuth` is set — the CLI uses the developer's own login.
|
|
39
|
+
*/
|
|
40
|
+
subscriptionToken?: string
|
|
41
|
+
/**
|
|
42
|
+
* Anthropic-compatible base URL for a non-Anthropic Claude-Code vendor (GLM/Kimi).
|
|
43
|
+
* Present ⇒ ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN; absent ⇒ CLAUDE_CODE_OAUTH_TOKEN.
|
|
44
|
+
*/
|
|
45
|
+
subscriptionBaseUrl?: string
|
|
46
|
+
/**
|
|
47
|
+
* Native local execution: run the developer's ALREADY-INSTALLED CLI with its OWN
|
|
48
|
+
* ambient login (`~/.claude` / `~/.codex`) — no leased credential, no isolated config
|
|
49
|
+
* home. Set ONLY by the local native transport (which runs the harness as a host
|
|
50
|
+
* process); a no-op everywhere else. The agent then runs with the user's personal
|
|
51
|
+
* subscription, unsandboxed, on their own machine — the explicit trade for skipping the
|
|
52
|
+
* container.
|
|
53
|
+
*/
|
|
54
|
+
ambientAuth?: boolean
|
|
55
|
+
/** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
|
|
56
|
+
signal?: AbortSignal
|
|
57
|
+
/** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
|
|
58
|
+
onActivity?: () => void
|
|
59
|
+
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
60
|
+
onProgress?: (progress: TodoProgress) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
64
|
+
return typeof value === 'object' && value !== null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Drive one CLI subprocess to completion, streaming LF-framed JSONL from stdout
|
|
69
|
+
* through `onEvent`. Mirrors `runPi`'s lifecycle: prompt over stdin (out-of-band,
|
|
70
|
+
* never argv), `onActivity` on every chunk, abort kills the child, and the close
|
|
71
|
+
* handler resolves/rejects. The caller's `onEvent` accumulates the outcome.
|
|
72
|
+
*
|
|
73
|
+
* `prompt` is fed over stdin: for Claude Code that is just the task prompt (the
|
|
74
|
+
* system prompt rides `--append-system-prompt`); for Codex — which has no
|
|
75
|
+
* system-prompt flag — the caller prepends the composed system prompt to it so
|
|
76
|
+
* the role + best-practice context is not lost.
|
|
77
|
+
*/
|
|
78
|
+
function streamCli(
|
|
79
|
+
command: string,
|
|
80
|
+
args: string[],
|
|
81
|
+
prompt: string,
|
|
82
|
+
opts: SubscriptionRunOptions,
|
|
83
|
+
env: Record<string, string>,
|
|
84
|
+
secrets: string[],
|
|
85
|
+
onEvent: (event: Record<string, unknown>) => void,
|
|
86
|
+
): Promise<{ stderrTail: string }> {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
if (opts.signal?.aborted) {
|
|
89
|
+
reject(new Error(`${command} aborted before start`))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const child = spawn(command, args, {
|
|
93
|
+
cwd: opts.cwd,
|
|
94
|
+
env: { ...process.env, ...env },
|
|
95
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
96
|
+
})
|
|
97
|
+
child.stdin.on('error', () => {})
|
|
98
|
+
child.stdin.end(prompt)
|
|
99
|
+
|
|
100
|
+
let stderr = ''
|
|
101
|
+
let aborted = false
|
|
102
|
+
let lineBuffer = ''
|
|
103
|
+
|
|
104
|
+
const killChild = (): void => killChildProcess(child)
|
|
105
|
+
|
|
106
|
+
const processLine = (line: string): void => {
|
|
107
|
+
if (!line.startsWith('{')) return
|
|
108
|
+
let event: Record<string, unknown>
|
|
109
|
+
try {
|
|
110
|
+
event = JSON.parse(line) as Record<string, unknown>
|
|
111
|
+
} catch {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
onEvent(event)
|
|
116
|
+
} catch {
|
|
117
|
+
// A faulty observer must never break the run.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const consumeStdout = (text: string): void => {
|
|
122
|
+
lineBuffer += text
|
|
123
|
+
let nl = lineBuffer.indexOf('\n')
|
|
124
|
+
while (nl !== -1) {
|
|
125
|
+
const line = lineBuffer.slice(0, nl).trim()
|
|
126
|
+
lineBuffer = lineBuffer.slice(nl + 1)
|
|
127
|
+
nl = lineBuffer.indexOf('\n')
|
|
128
|
+
processLine(line)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const onAbort = (): void => {
|
|
133
|
+
aborted = true
|
|
134
|
+
killChild()
|
|
135
|
+
}
|
|
136
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
137
|
+
|
|
138
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
139
|
+
opts.onActivity?.()
|
|
140
|
+
consumeStdout(chunk.toString())
|
|
141
|
+
})
|
|
142
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
143
|
+
opts.onActivity?.()
|
|
144
|
+
stderr += chunk.toString()
|
|
145
|
+
if (stderr.length > 8_000) stderr = stderr.slice(-8_000)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
child.on('error', (err) => {
|
|
149
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
150
|
+
reject(err)
|
|
151
|
+
})
|
|
152
|
+
child.on('close', (code) => {
|
|
153
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
154
|
+
if (lineBuffer.trim()) processLine(lineBuffer.trim())
|
|
155
|
+
const stderrTail = redact(stderr, secrets).slice(-700)
|
|
156
|
+
if (aborted) {
|
|
157
|
+
reject(new Error('agent run aborted by watchdog'))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (code !== 0) {
|
|
161
|
+
reject(new Error(`${command} exited with code ${code}: ${stderrTail}`))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
resolve({ stderrTail })
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Claude Code
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the Claude Code CLI headlessly against `opts.cwd`, authenticated with the
|
|
175
|
+
* leased subscription OAuth token (CLAUDE_CODE_OAUTH_TOKEN), talking direct to
|
|
176
|
+
* api.anthropic.com. Streams `--output-format stream-json`, mapping the
|
|
177
|
+
* `TodoWrite` tool calls onto subtask progress and the terminal `result` event
|
|
178
|
+
* onto the summary + usage.
|
|
179
|
+
*/
|
|
180
|
+
export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
181
|
+
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
182
|
+
let summary = ''
|
|
183
|
+
let usage: { inputTokens: number; outputTokens: number } | undefined
|
|
184
|
+
|
|
185
|
+
const onEvent = (event: Record<string, unknown>): void => {
|
|
186
|
+
const type = event.type
|
|
187
|
+
if (type === 'assistant' && isObject(event.message)) {
|
|
188
|
+
const content = (event.message as Record<string, unknown>).content
|
|
189
|
+
if (Array.isArray(content)) {
|
|
190
|
+
for (const block of content) {
|
|
191
|
+
if (!isObject(block)) continue
|
|
192
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
193
|
+
stats.assistantChars += block.text.length
|
|
194
|
+
}
|
|
195
|
+
if (block.type === 'tool_use') {
|
|
196
|
+
stats.toolCalls += 1
|
|
197
|
+
if (block.name === 'TodoWrite' && opts.onProgress) {
|
|
198
|
+
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
199
|
+
if (progress) opts.onProgress(progress)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else if (type === 'result') {
|
|
205
|
+
if (typeof event.result === 'string') summary = event.result
|
|
206
|
+
usage = claudeUsage(event.usage) ?? usage
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Native (ambient) mode: run the developer's installed `claude` with its OWN login —
|
|
211
|
+
// no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
|
|
212
|
+
// Claude Code persists user config/credentials under its config dir; point that at an
|
|
213
|
+
// isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
|
|
214
|
+
// agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
|
|
215
|
+
// stray `.claude/` directory — and any cached credential in it — into the pushed branch.
|
|
216
|
+
// Mirrors the Codex CODEX_HOME isolation below; removed in `finally`.
|
|
217
|
+
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
218
|
+
throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
|
|
219
|
+
}
|
|
220
|
+
const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
|
|
221
|
+
|
|
222
|
+
// The config dir is brand-new every run, so Claude Code would otherwise treat this
|
|
223
|
+
// as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
|
|
224
|
+
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
225
|
+
// hanging the job until the watchdog kills it. Pre-seed the config that marks those
|
|
226
|
+
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
227
|
+
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
228
|
+
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
229
|
+
if (configHome) {
|
|
230
|
+
await writeFile(
|
|
231
|
+
join(configHome, '.claude.json'),
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
hasCompletedOnboarding: true,
|
|
234
|
+
bypassPermissionsModeAccepted: true,
|
|
235
|
+
hasTrustDialogAccepted: true,
|
|
236
|
+
}),
|
|
237
|
+
{ mode: 0o600 },
|
|
238
|
+
).catch(() => {})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Anthropic itself authenticates with the subscription OAuth token; a
|
|
242
|
+
// non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
|
|
243
|
+
// points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
|
|
244
|
+
// Ambient mode injects neither — the CLI uses the developer's logged-in `~/.claude`.
|
|
245
|
+
const env: Record<string, string> = opts.ambientAuth
|
|
246
|
+
? {}
|
|
247
|
+
: {
|
|
248
|
+
CLAUDE_CONFIG_DIR: configHome!,
|
|
249
|
+
...(opts.subscriptionBaseUrl
|
|
250
|
+
? {
|
|
251
|
+
ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
|
|
252
|
+
ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken!,
|
|
253
|
+
}
|
|
254
|
+
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const { stderrTail } = await streamCli(
|
|
259
|
+
'claude',
|
|
260
|
+
[
|
|
261
|
+
'-p',
|
|
262
|
+
'--output-format',
|
|
263
|
+
'stream-json',
|
|
264
|
+
'--verbose',
|
|
265
|
+
// The per-run container IS the sandbox, and the run is fully headless (no one
|
|
266
|
+
// to approve a tool call) — so bypass permissions entirely. `acceptEdits`
|
|
267
|
+
// would auto-accept file edits but still gate Bash, which in `-p` mode is then
|
|
268
|
+
// denied, leaving the agent unable to run builds/tests/git to verify its work.
|
|
269
|
+
'--permission-mode',
|
|
270
|
+
'bypassPermissions',
|
|
271
|
+
'--model',
|
|
272
|
+
opts.model,
|
|
273
|
+
'--append-system-prompt',
|
|
274
|
+
opts.systemPrompt,
|
|
275
|
+
],
|
|
276
|
+
opts.userPrompt,
|
|
277
|
+
opts,
|
|
278
|
+
env,
|
|
279
|
+
opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
|
|
280
|
+
onEvent,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return { summary, stats, stderrTail, ...(usage ? { usage } : {}) }
|
|
284
|
+
} finally {
|
|
285
|
+
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
286
|
+
if (configHome) await rm(configHome, { recursive: true, force: true }).catch(() => {})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
291
|
+
function todosToProgress(todos: unknown): TodoProgress | undefined {
|
|
292
|
+
if (!Array.isArray(todos)) return undefined
|
|
293
|
+
const items = todos.filter(isObject).map((t) => ({
|
|
294
|
+
label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
|
|
295
|
+
status: normalizeStatus(t.status),
|
|
296
|
+
}))
|
|
297
|
+
const completed = items.filter((i) => i.status === 'completed').length
|
|
298
|
+
const inProgress = items.filter((i) => i.status === 'in_progress').length
|
|
299
|
+
return { completed, inProgress, total: items.length, items }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' {
|
|
303
|
+
if (status === 'completed') return 'completed'
|
|
304
|
+
if (status === 'in_progress') return 'in_progress'
|
|
305
|
+
return 'pending'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number } | undefined {
|
|
309
|
+
if (!isObject(raw)) return undefined
|
|
310
|
+
// Count every input bucket Anthropic bills: fresh input plus BOTH cache reads and
|
|
311
|
+
// cache writes (cache_creation_input_tokens), which are real consumed tokens — and
|
|
312
|
+
// are the dominant share on a long agent run. Omitting them under-weights a token's
|
|
313
|
+
// true load in the usage-aware rotation window.
|
|
314
|
+
const input =
|
|
315
|
+
numberOf(raw.input_tokens) +
|
|
316
|
+
numberOf(raw.cache_read_input_tokens) +
|
|
317
|
+
numberOf(raw.cache_creation_input_tokens)
|
|
318
|
+
const output = numberOf(raw.output_tokens)
|
|
319
|
+
if (input === 0 && output === 0) return undefined
|
|
320
|
+
return { inputTokens: input, outputTokens: output }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Codex
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Run the Codex CLI headlessly against `opts.cwd`, authenticated with the leased
|
|
329
|
+
* ChatGPT `auth.json` bundle written to an isolated CODEX_HOME, talking direct to
|
|
330
|
+
* the ChatGPT backend. Streams `codex exec --json`, mapping plan/todo updates onto
|
|
331
|
+
* subtask progress and the running cumulative token usage onto the outcome.
|
|
332
|
+
*/
|
|
333
|
+
export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
334
|
+
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
335
|
+
let summary = ''
|
|
336
|
+
let usage: { inputTokens: number; outputTokens: number } | undefined
|
|
337
|
+
|
|
338
|
+
// Codex reads its credentials from $CODEX_HOME/auth.json with file-backed
|
|
339
|
+
// storage. CRITICAL: this home must live OUTSIDE the cloned checkout (`opts.cwd`)
|
|
340
|
+
// — the blueprint/requirements/conflict-resolver handlers finish with
|
|
341
|
+
// `git add -A` + push, which would otherwise stage and publish the decrypted
|
|
342
|
+
// subscription `auth.json` (access + refresh tokens) to the PR branch. An
|
|
343
|
+
// isolated, per-run temp dir keeps the credential out of the working tree and is
|
|
344
|
+
// removed in `finally`.
|
|
345
|
+
//
|
|
346
|
+
// KNOWN LIMITATION: Codex refreshes its OAuth access token in-place by rewriting
|
|
347
|
+
// this `auth.json` mid-run. Because the home is a per-run temp dir wiped in
|
|
348
|
+
// `finally`, that refreshed credential is discarded and never written back to the
|
|
349
|
+
// pool — there is no write-back path. The stored bundle keeps working as long as
|
|
350
|
+
// its refresh token stays valid (ChatGPT refresh tokens are long-lived and reused,
|
|
351
|
+
// not rotated per refresh today), so each run re-refreshes from the same stored
|
|
352
|
+
// copy; if OpenAI ever rotates refresh tokens on use, a pooled Codex token would
|
|
353
|
+
// eventually need to be re-connected by the user. Claude OAuth tokens (from
|
|
354
|
+
// `claude setup-token`) are long-lived and unaffected.
|
|
355
|
+
// Native (ambient) mode: run the developer's installed `codex` with its OWN login —
|
|
356
|
+
// no isolated CODEX_HOME, no injected auth.json. Otherwise write the leased credential
|
|
357
|
+
// to a per-run temp home kept OUTSIDE the checkout (and removed in `finally`).
|
|
358
|
+
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
359
|
+
throw new Error('codex harness requires a subscription token (or ambientAuth)')
|
|
360
|
+
}
|
|
361
|
+
const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'))
|
|
362
|
+
if (codexHome) {
|
|
363
|
+
await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
|
|
364
|
+
await writeFile(join(codexHome, 'config.toml'), 'cli_auth_credentials_store = "file"\n', 'utf8')
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const onEvent = (event: Record<string, unknown>): void => {
|
|
368
|
+
const type = typeof event.type === 'string' ? event.type : ''
|
|
369
|
+
if (type.includes('agent_message') || type === 'item.completed') {
|
|
370
|
+
const text = extractText(event)
|
|
371
|
+
if (text) {
|
|
372
|
+
stats.assistantChars += text.length
|
|
373
|
+
summary = text
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (type.includes('tool') || type.includes('command') || type.includes('exec')) {
|
|
377
|
+
stats.toolCalls += 1
|
|
378
|
+
}
|
|
379
|
+
const progress = codexPlanProgress(event)
|
|
380
|
+
if (progress && opts.onProgress) opts.onProgress(progress)
|
|
381
|
+
const turnUsage = codexUsage(event)
|
|
382
|
+
if (turnUsage) usage = turnUsage
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Codex has no system-prompt flag, so fold the composed role + best-practice
|
|
386
|
+
// context into the prompt itself (Claude Code instead rides --append-system-prompt).
|
|
387
|
+
const prompt = opts.systemPrompt
|
|
388
|
+
? `${opts.systemPrompt}\n\n---\n\n${opts.userPrompt}`
|
|
389
|
+
: opts.userPrompt
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const { stderrTail } = await streamCli(
|
|
393
|
+
'codex',
|
|
394
|
+
[
|
|
395
|
+
'exec',
|
|
396
|
+
'--json',
|
|
397
|
+
'--skip-git-repo-check',
|
|
398
|
+
// The per-run container IS the sandbox; let Codex write files and reach the
|
|
399
|
+
// vendor unrestricted, with no approval prompts (the run is headless).
|
|
400
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
401
|
+
'--model',
|
|
402
|
+
opts.model,
|
|
403
|
+
'-',
|
|
404
|
+
],
|
|
405
|
+
prompt,
|
|
406
|
+
opts,
|
|
407
|
+
codexHome ? { CODEX_HOME: codexHome } : {},
|
|
408
|
+
opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
|
|
409
|
+
onEvent,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return { summary, stats, stderrTail, ...(usage ? { usage } : {}) }
|
|
413
|
+
} finally {
|
|
414
|
+
// Never leave the decrypted credential on disk past the run.
|
|
415
|
+
if (codexHome) await rm(codexHome, { recursive: true, force: true }).catch(() => {})
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Best-effort: pull a textual message out of a Codex event. */
|
|
420
|
+
function extractText(event: Record<string, unknown>): string | undefined {
|
|
421
|
+
if (typeof event.message === 'string') return event.message
|
|
422
|
+
if (typeof event.text === 'string') return event.text
|
|
423
|
+
if (isObject(event.item)) {
|
|
424
|
+
const item = event.item as Record<string, unknown>
|
|
425
|
+
if (typeof item.text === 'string') return item.text
|
|
426
|
+
if (typeof item.message === 'string') return item.message
|
|
427
|
+
}
|
|
428
|
+
return undefined
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Best-effort: map a Codex `update_plan`/plan event onto subtask counts. */
|
|
432
|
+
function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undefined {
|
|
433
|
+
const plan =
|
|
434
|
+
(isObject(event.plan) ? event.plan : undefined) ??
|
|
435
|
+
(isObject(event.item) && Array.isArray((event.item as Record<string, unknown>).plan)
|
|
436
|
+
? { steps: (event.item as Record<string, unknown>).plan }
|
|
437
|
+
: undefined)
|
|
438
|
+
const steps = isObject(plan) ? plan.steps : Array.isArray(event.steps) ? event.steps : undefined
|
|
439
|
+
if (!Array.isArray(steps)) return undefined
|
|
440
|
+
const items = steps.filter(isObject).map((s) => ({
|
|
441
|
+
label: typeof s.step === 'string' ? s.step : String(s.step ?? s.content ?? ''),
|
|
442
|
+
status: normalizeStatus(s.status),
|
|
443
|
+
}))
|
|
444
|
+
if (items.length === 0) return undefined
|
|
445
|
+
const completed = items.filter((i) => i.status === 'completed').length
|
|
446
|
+
const inProgress = items.filter((i) => i.status === 'in_progress').length
|
|
447
|
+
return { completed, inProgress, total: items.length, items }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
|
|
452
|
+
* reports a running CUMULATIVE total on `token_count` events under
|
|
453
|
+
* `info.total_token_usage` (it also carries the per-turn `last_token_usage`); older /
|
|
454
|
+
* other shapes put it on `usage` / `info.usage` directly. We read the cumulative
|
|
455
|
+
* total when present so the caller can simply overwrite (not sum) — summing
|
|
456
|
+
* cumulative totals across events would multiply-count. Checked most-likely first.
|
|
457
|
+
*/
|
|
458
|
+
function codexUsage(
|
|
459
|
+
event: Record<string, unknown>,
|
|
460
|
+
): { inputTokens: number; outputTokens: number } | undefined {
|
|
461
|
+
const info = isObject(event.info) ? (event.info as Record<string, unknown>) : undefined
|
|
462
|
+
const raw =
|
|
463
|
+
(info && isObject(info.total_token_usage) ? info.total_token_usage : undefined) ??
|
|
464
|
+
(isObject(event.total_token_usage) ? event.total_token_usage : undefined) ??
|
|
465
|
+
(isObject(event.usage) ? event.usage : undefined) ??
|
|
466
|
+
(info && isObject(info.usage) ? info.usage : undefined)
|
|
467
|
+
if (!isObject(raw)) return undefined
|
|
468
|
+
const input = numberOf(raw.input_tokens) + numberOf(raw.cached_input_tokens)
|
|
469
|
+
const output = numberOf(raw.output_tokens)
|
|
470
|
+
if (input === 0 && output === 0) return undefined
|
|
471
|
+
return { inputTokens: input, outputTokens: output }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function numberOf(value: unknown): number {
|
|
475
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Dispatch to the configured subscription harness runner. */
|
|
479
|
+
export function runSubscriptionHarness(
|
|
480
|
+
harness: SubscriptionHarness,
|
|
481
|
+
opts: SubscriptionRunOptions,
|
|
482
|
+
): Promise<PiRunOutcome> {
|
|
483
|
+
return harness === 'claude-code' ? runClaudeCode(opts) : runCodex(opts)
|
|
484
|
+
}
|