@cat-factory/executor-harness 1.48.1 → 1.50.2
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 +4 -0
- package/dist/agent-runner.js +56 -32
- package/dist/pi-workspace.js +1 -0
- package/dist/transcript-retention.js +139 -0
- package/package.json +3 -3
- package/src/agent-runner.ts +64 -35
- package/src/pi-workspace.ts +1 -0
- package/src/transcript-retention.ts +150 -0
package/README.md
CHANGED
|
@@ -73,6 +73,8 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
|
|
|
73
73
|
| `src/bootstrap.ts` | The `/bootstrap` handler (clone-or-empty → adapt → reinit + force-push). |
|
|
74
74
|
| `src/blueprint.ts` | The `/blueprint` handler (decompose → render `blueprints/` → commit on branch). |
|
|
75
75
|
| `src/embed.ts` | Bundled assets/templates written into the workspace. |
|
|
76
|
+
| `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
|
|
77
|
+
| `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
|
|
76
78
|
| `src/logger.ts` | Structured logging. |
|
|
77
79
|
|
|
78
80
|
## Runner lifecycle knobs
|
|
@@ -85,6 +87,8 @@ runner):
|
|
|
85
87
|
| `PORT` | `8080` | HTTP port the harness listens on. |
|
|
86
88
|
| `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
|
|
87
89
|
| `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
|
|
90
|
+
| `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
|
|
91
|
+
| `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content — though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
|
|
88
92
|
|
|
89
93
|
## Build / test
|
|
90
94
|
|
package/dist/agent-runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
6
6
|
import { redact, secretsToRedact } from './redact.js';
|
|
7
|
+
import { retainSessionTranscripts } from './transcript-retention.js';
|
|
7
8
|
function isObject(value) {
|
|
8
9
|
return typeof value === 'object' && value !== null;
|
|
9
10
|
}
|
|
@@ -38,7 +39,8 @@ function attributeCumulativeUsage(calls, usage) {
|
|
|
38
39
|
* system-prompt flag — the caller prepends the composed system prompt to it so
|
|
39
40
|
* the role + best-practice context is not lost.
|
|
40
41
|
*/
|
|
41
|
-
function streamCli(
|
|
42
|
+
function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
43
|
+
const { command, args } = cli;
|
|
42
44
|
return new Promise((resolve, reject) => {
|
|
43
45
|
if (opts.signal?.aborted) {
|
|
44
46
|
reject(new Error(`${command} aborted before start`));
|
|
@@ -270,22 +272,25 @@ export async function runClaudeCode(opts) {
|
|
|
270
272
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
|
|
271
273
|
};
|
|
272
274
|
try {
|
|
273
|
-
const { stderrTail } = await streamCli(
|
|
274
|
-
'
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
275
|
+
const { stderrTail } = await streamCli({
|
|
276
|
+
command: 'claude',
|
|
277
|
+
args: [
|
|
278
|
+
'-p',
|
|
279
|
+
'--output-format',
|
|
280
|
+
'stream-json',
|
|
281
|
+
'--verbose',
|
|
282
|
+
// The per-run container IS the sandbox, and the run is fully headless (no one
|
|
283
|
+
// to approve a tool call) — so bypass permissions entirely. `acceptEdits`
|
|
284
|
+
// would auto-accept file edits but still gate Bash, which in `-p` mode is then
|
|
285
|
+
// denied, leaving the agent unable to run builds/tests/git to verify its work.
|
|
286
|
+
'--permission-mode',
|
|
287
|
+
'bypassPermissions',
|
|
288
|
+
'--model',
|
|
289
|
+
opts.model,
|
|
290
|
+
'--append-system-prompt',
|
|
291
|
+
opts.systemPrompt,
|
|
292
|
+
],
|
|
293
|
+
}, opts.userPrompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
289
294
|
attributeCumulativeUsage(calls, usage);
|
|
290
295
|
return {
|
|
291
296
|
summary,
|
|
@@ -296,9 +301,17 @@ export async function runClaudeCode(opts) {
|
|
|
296
301
|
};
|
|
297
302
|
}
|
|
298
303
|
finally {
|
|
299
|
-
|
|
300
|
-
|
|
304
|
+
if (configHome) {
|
|
305
|
+
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
306
|
+
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
307
|
+
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
308
|
+
await retainSessionTranscripts(configHome, ['projects'], {
|
|
309
|
+
label: 'claude-code',
|
|
310
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
311
|
+
});
|
|
312
|
+
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
301
313
|
await rm(configHome, { recursive: true, force: true }).catch(() => { });
|
|
314
|
+
}
|
|
302
315
|
}
|
|
303
316
|
}
|
|
304
317
|
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
@@ -463,17 +476,20 @@ export async function runCodex(opts) {
|
|
|
463
476
|
}
|
|
464
477
|
};
|
|
465
478
|
try {
|
|
466
|
-
const { stderrTail } = await streamCli(
|
|
467
|
-
'
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
479
|
+
const { stderrTail } = await streamCli({
|
|
480
|
+
command: 'codex',
|
|
481
|
+
args: [
|
|
482
|
+
'exec',
|
|
483
|
+
'--json',
|
|
484
|
+
'--skip-git-repo-check',
|
|
485
|
+
// The per-run container IS the sandbox; let Codex write files and reach the
|
|
486
|
+
// vendor unrestricted, with no approval prompts (the run is headless).
|
|
487
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
488
|
+
'--model',
|
|
489
|
+
opts.model,
|
|
490
|
+
'-',
|
|
491
|
+
],
|
|
492
|
+
}, prompt, opts, codexHome ? { CODEX_HOME: codexHome } : {}, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
477
493
|
// Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
|
|
478
494
|
// single call from the cumulative total + final text so the run is still observable.
|
|
479
495
|
if (calls.length === 0 && (usage || summary)) {
|
|
@@ -498,9 +514,17 @@ export async function runCodex(opts) {
|
|
|
498
514
|
};
|
|
499
515
|
}
|
|
500
516
|
finally {
|
|
501
|
-
|
|
502
|
-
|
|
517
|
+
if (codexHome) {
|
|
518
|
+
// Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
|
|
519
|
+
// home is deleted — the credential (`auth.json`) lives at the home root, never in
|
|
520
|
+
// `sessions/`, so this keeps the debugging artifact without leaking it. Best-effort.
|
|
521
|
+
await retainSessionTranscripts(codexHome, ['sessions'], {
|
|
522
|
+
label: 'codex',
|
|
523
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
524
|
+
});
|
|
525
|
+
// Never leave the decrypted credential on disk past the run.
|
|
503
526
|
await rm(codexHome, { recursive: true, force: true }).catch(() => { });
|
|
527
|
+
}
|
|
504
528
|
}
|
|
505
529
|
}
|
|
506
530
|
/**
|
package/dist/pi-workspace.js
CHANGED
|
@@ -146,6 +146,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
146
146
|
signal: opts.signal,
|
|
147
147
|
onActivity: opts.onActivity,
|
|
148
148
|
onProgress: opts.onProgress,
|
|
149
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
149
150
|
});
|
|
150
151
|
}
|
|
151
152
|
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
// Session-transcript retention for the subscription harnesses (Claude Code / Codex).
|
|
5
|
+
//
|
|
6
|
+
// Both runners create an ISOLATED, per-run config home so the leased OAuth credential never
|
|
7
|
+
// lands in the cloned checkout, then delete that home in `finally`. The CLIs also write their
|
|
8
|
+
// per-turn session transcripts INSIDE that home (`$CLAUDE_CONFIG_DIR/projects/…` for Claude
|
|
9
|
+
// Code, `$CODEX_HOME/sessions/…` for Codex), so deleting the home also erases the exact
|
|
10
|
+
// per-call detail needed to debug a finished run.
|
|
11
|
+
//
|
|
12
|
+
// This module lifts ONLY the transcript subtree out of the home BEFORE it is deleted, into a
|
|
13
|
+
// retention root, and prunes retained transcripts on a short TTL. The credential lives at the
|
|
14
|
+
// home ROOT (`.claude.json` / `auth.json`), never inside `projects/` / `sessions/`, so moving
|
|
15
|
+
// just those subdirs keeps the debugging artifact while the existing `rm(home)` still removes
|
|
16
|
+
// the credential — the credential-safety property is preserved.
|
|
17
|
+
//
|
|
18
|
+
// It is meaningful only where the container filesystem outlives the job (the reused local
|
|
19
|
+
// warm-pool container, whose next run's sweep honours the TTL); on a per-run cloud container
|
|
20
|
+
// torn down with the job it is a harmless no-op. Best-effort throughout: a retention failure
|
|
21
|
+
// must NEVER fail an otherwise-successful run.
|
|
22
|
+
/** Default retention window: 3 days. Overridable via `HARNESS_TRANSCRIPT_TTL_MS`. */
|
|
23
|
+
const DEFAULT_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
|
24
|
+
/**
|
|
25
|
+
* A marker file dropped into every retention dir THIS module creates. The pruner deletes ONLY
|
|
26
|
+
* dirs carrying it, so pointing `HARNESS_TRANSCRIPT_ROOT` at a shared (non-dedicated) directory
|
|
27
|
+
* can never `rm -rf` unrelated sibling content — we only ever sweep our own retained transcripts.
|
|
28
|
+
*/
|
|
29
|
+
export const RETENTION_MARKER = '.cf-retained';
|
|
30
|
+
/** The retention root (one dir per retained home underneath it). Overridable for operators. */
|
|
31
|
+
function retentionRoot() {
|
|
32
|
+
const override = process.env.HARNESS_TRANSCRIPT_ROOT?.trim();
|
|
33
|
+
return override && override.length > 0 ? override : join(tmpdir(), 'cf-agent-transcripts');
|
|
34
|
+
}
|
|
35
|
+
/** The retention TTL in ms, from the env override when it's a positive finite number. */
|
|
36
|
+
function ttlMs() {
|
|
37
|
+
const raw = Number(process.env.HARNESS_TRANSCRIPT_TTL_MS);
|
|
38
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Move the named transcript `subdirs` out of the credential-bearing config `home` into the
|
|
42
|
+
* retention root (so the caller's subsequent `rm(home)` can't take them), then prune retained
|
|
43
|
+
* transcripts older than the TTL. Both steps are best-effort: any failure is swallowed (logged
|
|
44
|
+
* at debug) so this can never fail an otherwise-successful run. Returns the destination dir when
|
|
45
|
+
* something was retained (for logging/tests), else `undefined`.
|
|
46
|
+
*/
|
|
47
|
+
export async function retainSessionTranscripts(home, subdirs, options = {}) {
|
|
48
|
+
const { label, log } = options;
|
|
49
|
+
const root = retentionRoot();
|
|
50
|
+
// A filesystem-safe, sortable per-home dir name: an ISO stamp (colons/dots → dashes) + the
|
|
51
|
+
// home's basename (already unique — `mkdtemp` seeded).
|
|
52
|
+
const dest = join(root, `${new Date().toISOString().replace(/[:.]/g, '-')}-${basename(home)}`);
|
|
53
|
+
let moved = 0;
|
|
54
|
+
try {
|
|
55
|
+
for (const sub of subdirs) {
|
|
56
|
+
const from = join(home, sub);
|
|
57
|
+
try {
|
|
58
|
+
await stat(from);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue; // the CLI never wrote this subdir this run — nothing to retain
|
|
62
|
+
}
|
|
63
|
+
if (moved === 0)
|
|
64
|
+
await ensureRetentionDir(dest);
|
|
65
|
+
await moveDir(from, join(dest, sub));
|
|
66
|
+
moved += 1;
|
|
67
|
+
}
|
|
68
|
+
if (moved > 0)
|
|
69
|
+
log?.info('retained session transcripts', { label, dest, subdirs: moved });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
log?.debug('failed to retain session transcripts', {
|
|
73
|
+
label,
|
|
74
|
+
err: err instanceof Error ? err.message : String(err),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Prune regardless of whether this run retained anything — a run that moved nothing still gets
|
|
78
|
+
// to sweep the backlog left by earlier runs on a reused container.
|
|
79
|
+
await pruneRetentionRoot(root, ttlMs(), log);
|
|
80
|
+
return moved > 0 ? dest : undefined;
|
|
81
|
+
}
|
|
82
|
+
/** Create a retention dir and stamp it with the ownership marker (see {@link RETENTION_MARKER}). */
|
|
83
|
+
async function ensureRetentionDir(dest) {
|
|
84
|
+
await mkdir(dest, { recursive: true });
|
|
85
|
+
await writeFile(join(dest, RETENTION_MARKER), '');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Move `from` → `to`, preferring a cheap same-filesystem `rename`. When the retention root is on
|
|
89
|
+
* a DIFFERENT device than the config home (an operator override of `HARNESS_TRANSCRIPT_ROOT`),
|
|
90
|
+
* `rename` fails with `EXDEV` — fall back to a recursive copy + remove so the transcript is still
|
|
91
|
+
* lifted out before the caller deletes the home, rather than being silently lost.
|
|
92
|
+
*/
|
|
93
|
+
async function moveDir(from, to) {
|
|
94
|
+
try {
|
|
95
|
+
await rename(from, to);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err?.code !== 'EXDEV')
|
|
99
|
+
throw err;
|
|
100
|
+
await cp(from, to, { recursive: true });
|
|
101
|
+
await rm(from, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Delete retained-transcript dirs whose mtime is older than `maxAgeMs`. Only dirs carrying the
|
|
106
|
+
* retention marker are candidates — foreign content under a shared retention root is never
|
|
107
|
+
* touched. Best-effort per entry (a concurrent sweep or a vanished dir must not abort the loop).
|
|
108
|
+
*/
|
|
109
|
+
async function pruneRetentionRoot(root, maxAgeMs, log) {
|
|
110
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = await readdir(root);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return; // the root doesn't exist yet (nothing ever retained) — nothing to prune
|
|
117
|
+
}
|
|
118
|
+
for (const name of entries) {
|
|
119
|
+
const path = join(root, name);
|
|
120
|
+
try {
|
|
121
|
+
const info = await stat(path);
|
|
122
|
+
if (!info.isDirectory() || info.mtimeMs >= cutoff)
|
|
123
|
+
continue;
|
|
124
|
+
// Ownership gate: only sweep dirs WE created (they carry the marker). This makes a
|
|
125
|
+
// shared/non-dedicated `HARNESS_TRANSCRIPT_ROOT` safe — unrelated dirs are left alone.
|
|
126
|
+
try {
|
|
127
|
+
await stat(join(path, RETENTION_MARKER));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
continue; // not one of ours — never delete it
|
|
131
|
+
}
|
|
132
|
+
await rm(path, { recursive: true, force: true });
|
|
133
|
+
log?.debug('pruned expired session transcripts', { path });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// best-effort per entry — a concurrent sweep or a vanished dir must not abort the loop
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.2",
|
|
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",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"hono": "^4.12.30",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.138.11",
|
|
30
|
+
"@cat-factory/spend": "0.12.63"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { spawn } from 'node:child_process'
|
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { homedir, tmpdir } from 'node:os'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
|
+
import type { Logger } from './logger.js'
|
|
5
6
|
import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
|
|
6
7
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
7
8
|
import { redact, secretsToRedact } from './redact.js'
|
|
9
|
+
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
8
10
|
|
|
9
11
|
// The alternate (subscription) harness runners. The Pi harness reaches models
|
|
10
12
|
// through the LLM proxy with a model-locked session token; the Claude Code and
|
|
@@ -70,6 +72,11 @@ export interface SubscriptionRunOptions {
|
|
|
70
72
|
onActivity?: () => void
|
|
71
73
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
72
74
|
onProgress?: (progress: TodoProgress) => void
|
|
75
|
+
/**
|
|
76
|
+
* The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
|
|
77
|
+
* session-transcript path is logged for the run when the isolated config home is torn down.
|
|
78
|
+
*/
|
|
79
|
+
log?: Logger
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -111,14 +118,14 @@ function attributeCumulativeUsage(
|
|
|
111
118
|
* the role + best-practice context is not lost.
|
|
112
119
|
*/
|
|
113
120
|
function streamCli(
|
|
114
|
-
command: string,
|
|
115
|
-
args: string[],
|
|
121
|
+
cli: { command: string; args: string[] },
|
|
116
122
|
prompt: string,
|
|
117
123
|
opts: SubscriptionRunOptions,
|
|
118
124
|
env: Record<string, string>,
|
|
119
125
|
secrets: string[],
|
|
120
126
|
onEvent: (event: Record<string, unknown>) => void,
|
|
121
127
|
): Promise<{ stderrTail: string }> {
|
|
128
|
+
const { command, args } = cli
|
|
122
129
|
return new Promise((resolve, reject) => {
|
|
123
130
|
if (opts.signal?.aborted) {
|
|
124
131
|
reject(new Error(`${command} aborted before start`))
|
|
@@ -367,23 +374,25 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
367
374
|
|
|
368
375
|
try {
|
|
369
376
|
const { stderrTail } = await streamCli(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
377
|
+
{
|
|
378
|
+
command: 'claude',
|
|
379
|
+
args: [
|
|
380
|
+
'-p',
|
|
381
|
+
'--output-format',
|
|
382
|
+
'stream-json',
|
|
383
|
+
'--verbose',
|
|
384
|
+
// The per-run container IS the sandbox, and the run is fully headless (no one
|
|
385
|
+
// to approve a tool call) — so bypass permissions entirely. `acceptEdits`
|
|
386
|
+
// would auto-accept file edits but still gate Bash, which in `-p` mode is then
|
|
387
|
+
// denied, leaving the agent unable to run builds/tests/git to verify its work.
|
|
388
|
+
'--permission-mode',
|
|
389
|
+
'bypassPermissions',
|
|
390
|
+
'--model',
|
|
391
|
+
opts.model,
|
|
392
|
+
'--append-system-prompt',
|
|
393
|
+
opts.systemPrompt,
|
|
394
|
+
],
|
|
395
|
+
},
|
|
387
396
|
opts.userPrompt,
|
|
388
397
|
opts,
|
|
389
398
|
env,
|
|
@@ -400,8 +409,17 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
400
409
|
...(calls.length ? { callMetrics: calls } : {}),
|
|
401
410
|
}
|
|
402
411
|
} finally {
|
|
403
|
-
|
|
404
|
-
|
|
412
|
+
if (configHome) {
|
|
413
|
+
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
414
|
+
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
415
|
+
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
416
|
+
await retainSessionTranscripts(configHome, ['projects'], {
|
|
417
|
+
label: 'claude-code',
|
|
418
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
419
|
+
})
|
|
420
|
+
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
421
|
+
await rm(configHome, { recursive: true, force: true }).catch(() => {})
|
|
422
|
+
}
|
|
405
423
|
}
|
|
406
424
|
}
|
|
407
425
|
|
|
@@ -578,18 +596,20 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
578
596
|
|
|
579
597
|
try {
|
|
580
598
|
const { stderrTail } = await streamCli(
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
599
|
+
{
|
|
600
|
+
command: 'codex',
|
|
601
|
+
args: [
|
|
602
|
+
'exec',
|
|
603
|
+
'--json',
|
|
604
|
+
'--skip-git-repo-check',
|
|
605
|
+
// The per-run container IS the sandbox; let Codex write files and reach the
|
|
606
|
+
// vendor unrestricted, with no approval prompts (the run is headless).
|
|
607
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
608
|
+
'--model',
|
|
609
|
+
opts.model,
|
|
610
|
+
'-',
|
|
611
|
+
],
|
|
612
|
+
},
|
|
593
613
|
prompt,
|
|
594
614
|
opts,
|
|
595
615
|
codexHome ? { CODEX_HOME: codexHome } : {},
|
|
@@ -620,8 +640,17 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
620
640
|
...(calls.length ? { callMetrics: calls } : {}),
|
|
621
641
|
}
|
|
622
642
|
} finally {
|
|
623
|
-
|
|
624
|
-
|
|
643
|
+
if (codexHome) {
|
|
644
|
+
// Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
|
|
645
|
+
// home is deleted — the credential (`auth.json`) lives at the home root, never in
|
|
646
|
+
// `sessions/`, so this keeps the debugging artifact without leaking it. Best-effort.
|
|
647
|
+
await retainSessionTranscripts(codexHome, ['sessions'], {
|
|
648
|
+
label: 'codex',
|
|
649
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
650
|
+
})
|
|
651
|
+
// Never leave the decrypted credential on disk past the run.
|
|
652
|
+
await rm(codexHome, { recursive: true, force: true }).catch(() => {})
|
|
653
|
+
}
|
|
625
654
|
}
|
|
626
655
|
}
|
|
627
656
|
|
package/src/pi-workspace.ts
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import type { Logger } from './logger.js'
|
|
5
|
+
|
|
6
|
+
// Session-transcript retention for the subscription harnesses (Claude Code / Codex).
|
|
7
|
+
//
|
|
8
|
+
// Both runners create an ISOLATED, per-run config home so the leased OAuth credential never
|
|
9
|
+
// lands in the cloned checkout, then delete that home in `finally`. The CLIs also write their
|
|
10
|
+
// per-turn session transcripts INSIDE that home (`$CLAUDE_CONFIG_DIR/projects/…` for Claude
|
|
11
|
+
// Code, `$CODEX_HOME/sessions/…` for Codex), so deleting the home also erases the exact
|
|
12
|
+
// per-call detail needed to debug a finished run.
|
|
13
|
+
//
|
|
14
|
+
// This module lifts ONLY the transcript subtree out of the home BEFORE it is deleted, into a
|
|
15
|
+
// retention root, and prunes retained transcripts on a short TTL. The credential lives at the
|
|
16
|
+
// home ROOT (`.claude.json` / `auth.json`), never inside `projects/` / `sessions/`, so moving
|
|
17
|
+
// just those subdirs keeps the debugging artifact while the existing `rm(home)` still removes
|
|
18
|
+
// the credential — the credential-safety property is preserved.
|
|
19
|
+
//
|
|
20
|
+
// It is meaningful only where the container filesystem outlives the job (the reused local
|
|
21
|
+
// warm-pool container, whose next run's sweep honours the TTL); on a per-run cloud container
|
|
22
|
+
// torn down with the job it is a harmless no-op. Best-effort throughout: a retention failure
|
|
23
|
+
// must NEVER fail an otherwise-successful run.
|
|
24
|
+
|
|
25
|
+
/** Default retention window: 3 days. Overridable via `HARNESS_TRANSCRIPT_TTL_MS`. */
|
|
26
|
+
const DEFAULT_TTL_MS = 3 * 24 * 60 * 60 * 1000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A marker file dropped into every retention dir THIS module creates. The pruner deletes ONLY
|
|
30
|
+
* dirs carrying it, so pointing `HARNESS_TRANSCRIPT_ROOT` at a shared (non-dedicated) directory
|
|
31
|
+
* can never `rm -rf` unrelated sibling content — we only ever sweep our own retained transcripts.
|
|
32
|
+
*/
|
|
33
|
+
export const RETENTION_MARKER = '.cf-retained'
|
|
34
|
+
|
|
35
|
+
/** The retention root (one dir per retained home underneath it). Overridable for operators. */
|
|
36
|
+
function retentionRoot(): string {
|
|
37
|
+
const override = process.env.HARNESS_TRANSCRIPT_ROOT?.trim()
|
|
38
|
+
return override && override.length > 0 ? override : join(tmpdir(), 'cf-agent-transcripts')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The retention TTL in ms, from the env override when it's a positive finite number. */
|
|
42
|
+
function ttlMs(): number {
|
|
43
|
+
const raw = Number(process.env.HARNESS_TRANSCRIPT_TTL_MS)
|
|
44
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RetainOptions {
|
|
48
|
+
/** A short label for the run/harness, folded into the retention log line. */
|
|
49
|
+
label?: string
|
|
50
|
+
/** The per-job child logger, so the retained path is logged with the run's correlation fields. */
|
|
51
|
+
log?: Logger
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Move the named transcript `subdirs` out of the credential-bearing config `home` into the
|
|
56
|
+
* retention root (so the caller's subsequent `rm(home)` can't take them), then prune retained
|
|
57
|
+
* transcripts older than the TTL. Both steps are best-effort: any failure is swallowed (logged
|
|
58
|
+
* at debug) so this can never fail an otherwise-successful run. Returns the destination dir when
|
|
59
|
+
* something was retained (for logging/tests), else `undefined`.
|
|
60
|
+
*/
|
|
61
|
+
export async function retainSessionTranscripts(
|
|
62
|
+
home: string,
|
|
63
|
+
subdirs: string[],
|
|
64
|
+
options: RetainOptions = {},
|
|
65
|
+
): Promise<string | undefined> {
|
|
66
|
+
const { label, log } = options
|
|
67
|
+
const root = retentionRoot()
|
|
68
|
+
// A filesystem-safe, sortable per-home dir name: an ISO stamp (colons/dots → dashes) + the
|
|
69
|
+
// home's basename (already unique — `mkdtemp` seeded).
|
|
70
|
+
const dest = join(root, `${new Date().toISOString().replace(/[:.]/g, '-')}-${basename(home)}`)
|
|
71
|
+
let moved = 0
|
|
72
|
+
try {
|
|
73
|
+
for (const sub of subdirs) {
|
|
74
|
+
const from = join(home, sub)
|
|
75
|
+
try {
|
|
76
|
+
await stat(from)
|
|
77
|
+
} catch {
|
|
78
|
+
continue // the CLI never wrote this subdir this run — nothing to retain
|
|
79
|
+
}
|
|
80
|
+
if (moved === 0) await ensureRetentionDir(dest)
|
|
81
|
+
await moveDir(from, join(dest, sub))
|
|
82
|
+
moved += 1
|
|
83
|
+
}
|
|
84
|
+
if (moved > 0) log?.info('retained session transcripts', { label, dest, subdirs: moved })
|
|
85
|
+
} catch (err) {
|
|
86
|
+
log?.debug('failed to retain session transcripts', {
|
|
87
|
+
label,
|
|
88
|
+
err: err instanceof Error ? err.message : String(err),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
// Prune regardless of whether this run retained anything — a run that moved nothing still gets
|
|
92
|
+
// to sweep the backlog left by earlier runs on a reused container.
|
|
93
|
+
await pruneRetentionRoot(root, ttlMs(), log)
|
|
94
|
+
return moved > 0 ? dest : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Create a retention dir and stamp it with the ownership marker (see {@link RETENTION_MARKER}). */
|
|
98
|
+
async function ensureRetentionDir(dest: string): Promise<void> {
|
|
99
|
+
await mkdir(dest, { recursive: true })
|
|
100
|
+
await writeFile(join(dest, RETENTION_MARKER), '')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Move `from` → `to`, preferring a cheap same-filesystem `rename`. When the retention root is on
|
|
105
|
+
* a DIFFERENT device than the config home (an operator override of `HARNESS_TRANSCRIPT_ROOT`),
|
|
106
|
+
* `rename` fails with `EXDEV` — fall back to a recursive copy + remove so the transcript is still
|
|
107
|
+
* lifted out before the caller deletes the home, rather than being silently lost.
|
|
108
|
+
*/
|
|
109
|
+
async function moveDir(from: string, to: string): Promise<void> {
|
|
110
|
+
try {
|
|
111
|
+
await rename(from, to)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if ((err as NodeJS.ErrnoException)?.code !== 'EXDEV') throw err
|
|
114
|
+
await cp(from, to, { recursive: true })
|
|
115
|
+
await rm(from, { recursive: true, force: true })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Delete retained-transcript dirs whose mtime is older than `maxAgeMs`. Only dirs carrying the
|
|
121
|
+
* retention marker are candidates — foreign content under a shared retention root is never
|
|
122
|
+
* touched. Best-effort per entry (a concurrent sweep or a vanished dir must not abort the loop).
|
|
123
|
+
*/
|
|
124
|
+
async function pruneRetentionRoot(root: string, maxAgeMs: number, log?: Logger): Promise<void> {
|
|
125
|
+
const cutoff = Date.now() - maxAgeMs
|
|
126
|
+
let entries: string[]
|
|
127
|
+
try {
|
|
128
|
+
entries = await readdir(root)
|
|
129
|
+
} catch {
|
|
130
|
+
return // the root doesn't exist yet (nothing ever retained) — nothing to prune
|
|
131
|
+
}
|
|
132
|
+
for (const name of entries) {
|
|
133
|
+
const path = join(root, name)
|
|
134
|
+
try {
|
|
135
|
+
const info = await stat(path)
|
|
136
|
+
if (!info.isDirectory() || info.mtimeMs >= cutoff) continue
|
|
137
|
+
// Ownership gate: only sweep dirs WE created (they carry the marker). This makes a
|
|
138
|
+
// shared/non-dedicated `HARNESS_TRANSCRIPT_ROOT` safe — unrelated dirs are left alone.
|
|
139
|
+
try {
|
|
140
|
+
await stat(join(path, RETENTION_MARKER))
|
|
141
|
+
} catch {
|
|
142
|
+
continue // not one of ours — never delete it
|
|
143
|
+
}
|
|
144
|
+
await rm(path, { recursive: true, force: true })
|
|
145
|
+
log?.debug('pruned expired session transcripts', { path })
|
|
146
|
+
} catch {
|
|
147
|
+
// best-effort per entry — a concurrent sweep or a vanished dir must not abort the loop
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|