@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 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
 
@@ -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(command, args, prompt, opts, env, secrets, onEvent) {
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('claude', [
274
- '-p',
275
- '--output-format',
276
- 'stream-json',
277
- '--verbose',
278
- // The per-run container IS the sandbox, and the run is fully headless (no one
279
- // to approve a tool call) — so bypass permissions entirely. `acceptEdits`
280
- // would auto-accept file edits but still gate Bash, which in `-p` mode is then
281
- // denied, leaving the agent unable to run builds/tests/git to verify its work.
282
- '--permission-mode',
283
- 'bypassPermissions',
284
- '--model',
285
- opts.model,
286
- '--append-system-prompt',
287
- opts.systemPrompt,
288
- ], opts.userPrompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
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
- // Never leave the config dir (and any cached credential) on disk past the run.
300
- if (configHome)
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('codex', [
467
- 'exec',
468
- '--json',
469
- '--skip-git-repo-check',
470
- // The per-run container IS the sandbox; let Codex write files and reach the
471
- // vendor unrestricted, with no approval prompts (the run is headless).
472
- '--dangerously-bypass-approvals-and-sandbox',
473
- '--model',
474
- opts.model,
475
- '-',
476
- ], prompt, opts, codexHome ? { CODEX_HOME: codexHome } : {}, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
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
- // Never leave the decrypted credential on disk past the run.
502
- if (codexHome)
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
  /**
@@ -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.48.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.137.2",
30
- "@cat-factory/spend": "0.12.52"
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",
@@ -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
- 'claude',
371
- [
372
- '-p',
373
- '--output-format',
374
- 'stream-json',
375
- '--verbose',
376
- // The per-run container IS the sandbox, and the run is fully headless (no one
377
- // to approve a tool call) so bypass permissions entirely. `acceptEdits`
378
- // would auto-accept file edits but still gate Bash, which in `-p` mode is then
379
- // denied, leaving the agent unable to run builds/tests/git to verify its work.
380
- '--permission-mode',
381
- 'bypassPermissions',
382
- '--model',
383
- opts.model,
384
- '--append-system-prompt',
385
- opts.systemPrompt,
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
- // Never leave the config dir (and any cached credential) on disk past the run.
404
- if (configHome) await rm(configHome, { recursive: true, force: true }).catch(() => {})
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
- 'codex',
582
- [
583
- 'exec',
584
- '--json',
585
- '--skip-git-repo-check',
586
- // The per-run container IS the sandbox; let Codex write files and reach the
587
- // vendor unrestricted, with no approval prompts (the run is headless).
588
- '--dangerously-bypass-approvals-and-sandbox',
589
- '--model',
590
- opts.model,
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
- // Never leave the decrypted credential on disk past the run.
624
- if (codexHome) await rm(codexHome, { recursive: true, force: true }).catch(() => {})
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
 
@@ -270,6 +270,7 @@ export async function runAgentInWorkspace(
270
270
  signal: opts.signal,
271
271
  onActivity: opts.onActivity,
272
272
  onProgress: opts.onProgress,
273
+ ...(opts.log ? { log: opts.log } : {}),
273
274
  })
274
275
  }
275
276
  if (!spec.proxyBaseUrl || !spec.sessionToken) {
@@ -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
+ }