@cat-factory/executor-harness 1.34.12 → 1.37.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/dist/agent.js CHANGED
@@ -603,11 +603,11 @@ async function runCodingMode(job, opts) {
603
603
  // commit + push (no PR). Keyed off job DATA (`mergeBase`), not the agent kind.
604
604
  if (job.mergeBase)
605
605
  return runConflictResolution(job, opts);
606
- // Multi-repo coding (service-connections phase 3): clone every connected peer repo as a
607
- // sibling, run the agent once across all of them, and open one PR per changed repo. Keyed
608
- // off job DATA (`peerRepos`), not the agent kind — the implementer sets it when the task
609
- // has involved services in distinct repos.
610
- const result = job.peerRepos?.length
606
+ // Multi-repo coding: clone every additional repo as a sibling and run the agent once across
607
+ // all of them. Keyed off job DATA, not the agent kind set for the implementer's writable
608
+ // peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
609
+ // reference repos (`referenceRepos`, cloned but never pushed).
610
+ const result = job.peerRepos?.length || job.referenceRepos?.length
611
611
  ? await runMultiRepoCoding(job, opts)
612
612
  : await runSingleRepoCoding(job, opts);
613
613
  // Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
@@ -326,6 +326,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
326
326
  const { signal } = opts;
327
327
  const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId });
328
328
  const peers = job.peerRepos ?? [];
329
+ const references = job.referenceRepos ?? [];
329
330
  const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch;
330
331
  // Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
331
332
  // matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
@@ -358,6 +359,21 @@ export async function runMultiRepoCoding(job, opts = {}) {
358
359
  baseSha: '',
359
360
  resumed: false,
360
361
  })),
362
+ // Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
363
+ // `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
364
+ // pushes (guarded by `readOnly` in both the clone and push phases below).
365
+ ...references.map((reference) => ({
366
+ repo: reference.repo,
367
+ dirName: claimDir(reference.repo),
368
+ dir: '',
369
+ cloneBranch: reference.repo.baseBranch,
370
+ workBranch: reference.repo.baseBranch,
371
+ ghToken: reference.ghToken ?? job.ghToken,
372
+ primary: false,
373
+ readOnly: true,
374
+ baseSha: '',
375
+ resumed: false,
376
+ })),
361
377
  ];
362
378
  return withWorkspace('multi', async (root) => {
363
379
  // Clone phase: every repo into its sibling dir under the workspace root. Resume an
@@ -366,6 +382,23 @@ export async function runMultiRepoCoding(job, opts = {}) {
366
382
  for (const leg of legs) {
367
383
  const dir = join(root, leg.dirName);
368
384
  await mkdir(dir, { recursive: true });
385
+ // A read-only reference leg: clone its base branch for the agent to read, and stop there —
386
+ // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
387
+ // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
388
+ if (leg.readOnly) {
389
+ logger.info('multi-repo: cloning read-only reference', {
390
+ repo: leg.dirName,
391
+ cloneBranch: leg.cloneBranch,
392
+ });
393
+ await cloneRepo({
394
+ repo: { ...leg.repo, baseBranch: leg.cloneBranch },
395
+ ghToken: leg.ghToken,
396
+ dir,
397
+ signal,
398
+ });
399
+ leg.dir = dir;
400
+ continue;
401
+ }
369
402
  leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal);
370
403
  if (leg.resumed) {
371
404
  logger.info('multi-repo: resuming existing branch', {
@@ -439,6 +472,10 @@ export async function runMultiRepoCoding(job, opts = {}) {
439
472
  let primaryPrUrl;
440
473
  const peerPullRequests = [];
441
474
  for (const leg of legs) {
475
+ // A read-only reference leg is never committed or pushed — the third layer of the read-only
476
+ // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
477
+ if (leg.readOnly)
478
+ continue;
442
479
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
443
480
  const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
444
481
  let hasWork = advanced || leg.resumed;
package/dist/inline.js ADDED
@@ -0,0 +1,58 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { runSubscriptionHarness } from './agent-runner.js';
5
+ // The `inline` job handler: one-shot LLM completion through a subscription harness CLI
6
+ // (Claude Code / Codex) with NO checkout. It is the container analogue of the local
7
+ // host-CLI inline runner (runtimes/local `harnessInline.ts`) — it exists so the inline
8
+ // LLM steps (requirements reviewer, brainstorm, task-estimator, inline document kinds) can
9
+ // run on a subscription model even when the host has no `claude`/`codex` binary (and in
10
+ // mothership mode without touching the host), at warm-pool latency. It reuses
11
+ // `runSubscriptionHarness`'s credential-env setup verbatim (the single site that turns a
12
+ // leased subscription token into `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_*` / a Codex
13
+ // `auth.json`), so the container and coding paths can never disagree on how a credential
14
+ // is injected.
15
+ /**
16
+ * Map the harness CLI's terminal stop reason (lifted onto the last call metric) to the
17
+ * inline `finishReason` the reviewer keys off. Only Claude Code reports it (`max_tokens` on
18
+ * a `--output-format stream-json` result); Codex's thinner stream exposes none, so it reads
19
+ * as `stop` — the same one-shot limitation the host-CLI runner has.
20
+ */
21
+ function deriveFinishReason(calls) {
22
+ const last = calls?.[calls.length - 1];
23
+ const reason = last?.finishReason?.toLowerCase() ?? '';
24
+ return reason === 'max_tokens' || reason === 'length' ? 'length' : 'stop';
25
+ }
26
+ /**
27
+ * Run one inline completion in a throwaway temp cwd and return the reply text + lifted
28
+ * usage/telemetry. The CLI clones/pushes nothing — the empty cwd only gives it a working
29
+ * directory. The job's watchdog (inactivity + max-duration, see {@link JobRegistry}) bounds
30
+ * it through `opts.signal`; `opts.onActivity` keeps the inactivity timer alive while the CLI
31
+ * streams. The temp cwd is always removed.
32
+ */
33
+ export async function handleInline(job, opts) {
34
+ opts.onPhase?.('agent');
35
+ const cwd = await mkdtemp(join(tmpdir(), 'cf-inline-'));
36
+ try {
37
+ const outcome = await runSubscriptionHarness(job.harness, {
38
+ cwd,
39
+ model: job.model,
40
+ systemPrompt: job.systemPrompt,
41
+ userPrompt: job.userPrompt,
42
+ ...(job.subscriptionToken ? { subscriptionToken: job.subscriptionToken } : {}),
43
+ ...(job.subscriptionBaseUrl ? { subscriptionBaseUrl: job.subscriptionBaseUrl } : {}),
44
+ ...(job.ambientAuth ? { ambientAuth: true } : {}),
45
+ ...(opts.signal ? { signal: opts.signal } : {}),
46
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
47
+ });
48
+ return {
49
+ text: outcome.summary,
50
+ finishReason: deriveFinishReason(outcome.callMetrics),
51
+ ...(outcome.usage ? { usage: outcome.usage } : {}),
52
+ ...(outcome.callMetrics ? { callMetrics: outcome.callMetrics } : {}),
53
+ };
54
+ }
55
+ finally {
56
+ await rm(cwd, { recursive: true, force: true }).catch(() => { });
57
+ }
58
+ }
package/dist/job.js CHANGED
@@ -150,6 +150,30 @@ function parsePeerRepos(value) {
150
150
  return spec;
151
151
  });
152
152
  }
153
+ /**
154
+ * Parse the optional read-only reference-repo list (document-authoring runs). Each entry carries
155
+ * a full {@link RepoSpec} (validated + sanitised like the primary) and an optional per-repo token.
156
+ * Any branch/PR fields on the wire are IGNORED — a reference repo is never pushed, so the parsed
157
+ * shape has none to carry. A malformed list throws; an absent one yields `[]`.
158
+ */
159
+ function parseReferenceRepos(value) {
160
+ if (value === undefined || value === null)
161
+ return [];
162
+ if (!Array.isArray(value))
163
+ throw new Error("Invalid job: 'referenceRepos' must be an array");
164
+ return value.map((entry, i) => {
165
+ if (typeof entry !== 'object' || entry === null) {
166
+ throw new Error(`Invalid job: 'referenceRepos[${i}]' must be an object`);
167
+ }
168
+ const e = entry;
169
+ const spec = {
170
+ repo: parseRepoSpec((e.repo ?? {})),
171
+ };
172
+ if (typeof e.ghToken === 'string' && e.ghToken)
173
+ spec.ghToken = e.ghToken;
174
+ return spec;
175
+ });
176
+ }
153
177
  /** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
154
178
  function parseVcsProvider(value) {
155
179
  if (value === undefined || value === null)
@@ -431,6 +455,35 @@ function parseFrontendInfraSpec(o) {
431
455
  ...(wiremockPort !== undefined ? { wiremockPort } : {}),
432
456
  };
433
457
  }
458
+ /**
459
+ * Validate + narrow an untrusted body into an {@link InlineJob}. The harness MUST be a
460
+ * subscription harness (`claude-code` / `codex`) — the inline path never runs Pi (that goes
461
+ * through the LLM proxy inline, not a container CLI). Reuses {@link parseHarnessAuth}, so a
462
+ * non-ambient job requires `subscriptionToken`.
463
+ */
464
+ export function parseInlineJob(input) {
465
+ if (typeof input !== 'object' || input === null) {
466
+ throw new Error('Invalid job: body must be an object');
467
+ }
468
+ const o = input;
469
+ // Validate the harness FIRST (before parseHarnessAuth, whose `pi` branch demands a proxy
470
+ // base URL): the inline path only ever runs a subscription CLI, so a Pi/absent harness is a
471
+ // clear inline-specific rejection rather than a confusing "proxyBaseUrl required".
472
+ if (o.harness !== 'claude-code' && o.harness !== 'codex') {
473
+ throw new Error("Invalid inline job: 'harness' must be 'claude-code' or 'codex'");
474
+ }
475
+ const auth = parseHarnessAuth(o);
476
+ const maxOutputTokens = posInt(o.maxOutputTokens);
477
+ return {
478
+ jobId: str(o.jobId, 'jobId'),
479
+ model: str(o.model, 'model'),
480
+ // The system prompt is optional (an empty role is valid); the user prompt is required.
481
+ systemPrompt: typeof o.systemPrompt === 'string' ? o.systemPrompt : '',
482
+ userPrompt: str(o.userPrompt, 'userPrompt'),
483
+ ...auth,
484
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
485
+ };
486
+ }
434
487
  /** Validate + narrow an untrusted body into an {@link AgentJob}, throwing on bad input. */
435
488
  export function parseAgentJob(input) {
436
489
  if (typeof input !== 'object' || input === null) {
@@ -479,6 +532,7 @@ export function parseAgentJob(input) {
479
532
  : undefined;
480
533
  const infra = parseAgentInfraSpec(o.infra);
481
534
  const peerRepos = parsePeerRepos(o.peerRepos);
535
+ const referenceRepos = parseReferenceRepos(o.referenceRepos);
482
536
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
483
537
  const contextFiles = parseContextFiles(o.contextFiles);
484
538
  const packageRegistries = parsePackageRegistries(o.packageRegistries);
@@ -510,6 +564,7 @@ export function parseAgentJob(input) {
510
564
  : {}),
511
565
  ...(pr ? { pr } : {}),
512
566
  ...(peerRepos.length ? { peerRepos } : {}),
567
+ ...(referenceRepos.length ? { referenceRepos } : {}),
513
568
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
514
569
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
515
570
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
@@ -528,5 +583,11 @@ export function parseAgentJob(input) {
528
583
  for (const [i, peer] of (job.peerRepos ?? []).entries()) {
529
584
  assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
530
585
  }
586
+ // Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
587
+ // never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
588
+ // attacker host would exfiltrate the token exactly like a rogue peer clone URL.
589
+ for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
590
+ assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
591
+ }
531
592
  return job;
532
593
  }
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
- import { parseAgentJob } from './job.js';
3
+ import { parseAgentJob, parseInlineJob } from './job.js';
4
4
  import { handleAgent } from './agent.js';
5
+ import { handleInline } from './inline.js';
5
6
  import { redactSecrets } from './git.js';
6
7
  import { JobRegistry, loadRunnerLimits } from './runner.js';
7
8
  import { log } from './logger.js';
@@ -67,6 +68,13 @@ const KINDS = {
67
68
  repo: `${job.repo.owner}/${job.repo.name}`,
68
69
  branch: job.branch,
69
70
  })),
71
+ // The one-shot, no-checkout inline completion (requirements reviewer / brainstorm /
72
+ // task-estimator / inline document kinds) on a leased subscription credential — the
73
+ // container analogue of the local host-CLI inline runner. See inline.ts.
74
+ inline: defineKind(parseInlineJob, handleInline, (job) => ({
75
+ harness: job.harness,
76
+ model: job.model,
77
+ })),
70
78
  };
71
79
  async function readBody(req) {
72
80
  const chunks = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.34.12",
3
+ "version": "1.37.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.27",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.9",
29
- "@cat-factory/server": "0.85.0",
30
- "@cat-factory/spend": "0.10.102"
29
+ "@cat-factory/server": "0.99.4",
30
+ "@cat-factory/spend": "0.11.13"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -739,13 +739,14 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
739
739
  // clone full, merge the base in to surface the conflicts, then complete the merge
740
740
  // commit + push (no PR). Keyed off job DATA (`mergeBase`), not the agent kind.
741
741
  if (job.mergeBase) return runConflictResolution(job, opts)
742
- // Multi-repo coding (service-connections phase 3): clone every connected peer repo as a
743
- // sibling, run the agent once across all of them, and open one PR per changed repo. Keyed
744
- // off job DATA (`peerRepos`), not the agent kind — the implementer sets it when the task
745
- // has involved services in distinct repos.
746
- const result = job.peerRepos?.length
747
- ? await runMultiRepoCoding(job, opts)
748
- : await runSingleRepoCoding(job, opts)
742
+ // Multi-repo coding: clone every additional repo as a sibling and run the agent once across
743
+ // all of them. Keyed off job DATA, not the agent kind set for the implementer's writable
744
+ // peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
745
+ // reference repos (`referenceRepos`, cloned but never pushed).
746
+ const result =
747
+ job.peerRepos?.length || job.referenceRepos?.length
748
+ ? await runMultiRepoCoding(job, opts)
749
+ : await runSingleRepoCoding(job, opts)
749
750
 
750
751
  // Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
751
752
  // backend post-completion resolver records the outcome. Skipped on a failed run (its `error`
@@ -1,6 +1,13 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import type { AgentJob, AgentResult, HarnessAuthFields, PeerRepoSpec, RepoSpec } from './job.js'
3
+ import type {
4
+ AgentJob,
5
+ AgentResult,
6
+ HarnessAuthFields,
7
+ PeerRepoSpec,
8
+ ReferenceRepoSpec,
9
+ RepoSpec,
10
+ } from './job.js'
4
11
  import {
5
12
  branchAheadOfBase,
6
13
  branchHasCommitsSince,
@@ -435,6 +442,12 @@ interface RepoLeg {
435
442
  pr?: { title: string; body: string }
436
443
  frameId?: string
437
444
  primary: boolean
445
+ /**
446
+ * A READ-ONLY reference checkout (doc-writer's `referenceRepos`): cloned at its base branch for
447
+ * the agent to read, but NEVER given a work branch, committed, or pushed. Skipped entirely in the
448
+ * push phase, so it is structurally impossible for the run to write to it. Absent ⇒ a writable leg.
449
+ */
450
+ readOnly?: boolean
438
451
  /** The branch tip before the run — work iff the branch advances past it. */
439
452
  baseSha: string
440
453
  /** Whether an existing remote work branch was resumed (already carries prior work). */
@@ -461,6 +474,7 @@ export async function runMultiRepoCoding(
461
474
  const { signal } = opts
462
475
  const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId })
463
476
  const peers: PeerRepoSpec[] = job.peerRepos ?? []
477
+ const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
464
478
  const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
465
479
 
466
480
  // Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
@@ -496,6 +510,23 @@ export async function runMultiRepoCoding(
496
510
  resumed: false,
497
511
  }),
498
512
  ),
513
+ // Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
514
+ // `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
515
+ // pushes (guarded by `readOnly` in both the clone and push phases below).
516
+ ...references.map(
517
+ (reference): RepoLeg => ({
518
+ repo: reference.repo,
519
+ dirName: claimDir(reference.repo),
520
+ dir: '',
521
+ cloneBranch: reference.repo.baseBranch,
522
+ workBranch: reference.repo.baseBranch,
523
+ ghToken: reference.ghToken ?? job.ghToken,
524
+ primary: false,
525
+ readOnly: true,
526
+ baseSha: '',
527
+ resumed: false,
528
+ }),
529
+ ),
499
530
  ]
500
531
 
501
532
  return withWorkspace('multi', async (root) => {
@@ -505,6 +536,23 @@ export async function runMultiRepoCoding(
505
536
  for (const leg of legs) {
506
537
  const dir = join(root, leg.dirName)
507
538
  await mkdir(dir, { recursive: true })
539
+ // A read-only reference leg: clone its base branch for the agent to read, and stop there —
540
+ // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
541
+ // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
542
+ if (leg.readOnly) {
543
+ logger.info('multi-repo: cloning read-only reference', {
544
+ repo: leg.dirName,
545
+ cloneBranch: leg.cloneBranch,
546
+ })
547
+ await cloneRepo({
548
+ repo: { ...leg.repo, baseBranch: leg.cloneBranch },
549
+ ghToken: leg.ghToken,
550
+ dir,
551
+ signal,
552
+ })
553
+ leg.dir = dir
554
+ continue
555
+ }
508
556
  leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
509
557
  if (leg.resumed) {
510
558
  logger.info('multi-repo: resuming existing branch', {
@@ -587,6 +635,9 @@ export async function runMultiRepoCoding(
587
635
  let primaryPrUrl: string | undefined
588
636
  const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
589
637
  for (const leg of legs) {
638
+ // A read-only reference leg is never committed or pushed — the third layer of the read-only
639
+ // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
640
+ if (leg.readOnly) continue
590
641
  await commitTrackedEdits(
591
642
  leg.dir,
592
643
  job.commitMessage ?? leg.pr?.title ?? 'Agent changes',
package/src/inline.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { runSubscriptionHarness, type SubscriptionHarness } from './agent-runner.js'
5
+ import type { HarnessCallMetric } from './pi.js'
6
+ import type { InlineJob, InlineResult } from './job.js'
7
+ import type { RunOptions } from './runner.js'
8
+
9
+ // The `inline` job handler: one-shot LLM completion through a subscription harness CLI
10
+ // (Claude Code / Codex) with NO checkout. It is the container analogue of the local
11
+ // host-CLI inline runner (runtimes/local `harnessInline.ts`) — it exists so the inline
12
+ // LLM steps (requirements reviewer, brainstorm, task-estimator, inline document kinds) can
13
+ // run on a subscription model even when the host has no `claude`/`codex` binary (and in
14
+ // mothership mode without touching the host), at warm-pool latency. It reuses
15
+ // `runSubscriptionHarness`'s credential-env setup verbatim (the single site that turns a
16
+ // leased subscription token into `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_*` / a Codex
17
+ // `auth.json`), so the container and coding paths can never disagree on how a credential
18
+ // is injected.
19
+
20
+ /**
21
+ * Map the harness CLI's terminal stop reason (lifted onto the last call metric) to the
22
+ * inline `finishReason` the reviewer keys off. Only Claude Code reports it (`max_tokens` on
23
+ * a `--output-format stream-json` result); Codex's thinner stream exposes none, so it reads
24
+ * as `stop` — the same one-shot limitation the host-CLI runner has.
25
+ */
26
+ function deriveFinishReason(calls: HarnessCallMetric[] | undefined): 'stop' | 'length' {
27
+ const last = calls?.[calls.length - 1]
28
+ const reason = last?.finishReason?.toLowerCase() ?? ''
29
+ return reason === 'max_tokens' || reason === 'length' ? 'length' : 'stop'
30
+ }
31
+
32
+ /**
33
+ * Run one inline completion in a throwaway temp cwd and return the reply text + lifted
34
+ * usage/telemetry. The CLI clones/pushes nothing — the empty cwd only gives it a working
35
+ * directory. The job's watchdog (inactivity + max-duration, see {@link JobRegistry}) bounds
36
+ * it through `opts.signal`; `opts.onActivity` keeps the inactivity timer alive while the CLI
37
+ * streams. The temp cwd is always removed.
38
+ */
39
+ export async function handleInline(job: InlineJob, opts: RunOptions): Promise<InlineResult> {
40
+ opts.onPhase?.('agent')
41
+ const cwd = await mkdtemp(join(tmpdir(), 'cf-inline-'))
42
+ try {
43
+ const outcome = await runSubscriptionHarness(job.harness as SubscriptionHarness, {
44
+ cwd,
45
+ model: job.model,
46
+ systemPrompt: job.systemPrompt,
47
+ userPrompt: job.userPrompt,
48
+ ...(job.subscriptionToken ? { subscriptionToken: job.subscriptionToken } : {}),
49
+ ...(job.subscriptionBaseUrl ? { subscriptionBaseUrl: job.subscriptionBaseUrl } : {}),
50
+ ...(job.ambientAuth ? { ambientAuth: true } : {}),
51
+ ...(opts.signal ? { signal: opts.signal } : {}),
52
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
53
+ })
54
+ return {
55
+ text: outcome.summary,
56
+ finishReason: deriveFinishReason(outcome.callMetrics),
57
+ ...(outcome.usage ? { usage: outcome.usage } : {}),
58
+ ...(outcome.callMetrics ? { callMetrics: outcome.callMetrics } : {}),
59
+ }
60
+ } finally {
61
+ await rm(cwd, { recursive: true, force: true }).catch(() => {})
62
+ }
63
+ }
package/src/job.ts CHANGED
@@ -84,6 +84,21 @@ export interface PeerRepoSpec {
84
84
  ghToken?: string
85
85
  }
86
86
 
87
+ /**
88
+ * A repository checked out READ-ONLY as a sibling alongside the primary during a
89
+ * document-authoring coding run — the doc-writer reads it (to reuse existing solutions as a
90
+ * reference) but the harness never creates a branch, commits, or opens a PR for it. Deliberately
91
+ * carries NO branch/PR fields (unlike {@link PeerRepoSpec}), so it is structurally impossible to
92
+ * push: the read-only guarantee is enforced by the shape itself, by cloning at the repo's own
93
+ * base branch with no work branch, and by skipping the leg in the push phase. The clone URL is
94
+ * host-allowlisted exactly like the primary `repo.cloneUrl`.
95
+ */
96
+ export interface ReferenceRepoSpec {
97
+ repo: RepoSpec
98
+ /** Per-repo GitHub token; defaults to the job's `ghToken` (one installation per workspace today). */
99
+ ghToken?: string
100
+ }
101
+
87
102
  function str(value: unknown, path: string): string {
88
103
  if (typeof value !== 'string' || value.length === 0) {
89
104
  throw new Error(`Invalid job: '${path}' must be a non-empty string`)
@@ -234,6 +249,28 @@ function parsePeerRepos(value: unknown): PeerRepoSpec[] {
234
249
  })
235
250
  }
236
251
 
252
+ /**
253
+ * Parse the optional read-only reference-repo list (document-authoring runs). Each entry carries
254
+ * a full {@link RepoSpec} (validated + sanitised like the primary) and an optional per-repo token.
255
+ * Any branch/PR fields on the wire are IGNORED — a reference repo is never pushed, so the parsed
256
+ * shape has none to carry. A malformed list throws; an absent one yields `[]`.
257
+ */
258
+ function parseReferenceRepos(value: unknown): ReferenceRepoSpec[] {
259
+ if (value === undefined || value === null) return []
260
+ if (!Array.isArray(value)) throw new Error("Invalid job: 'referenceRepos' must be an array")
261
+ return value.map((entry, i) => {
262
+ if (typeof entry !== 'object' || entry === null) {
263
+ throw new Error(`Invalid job: 'referenceRepos[${i}]' must be an object`)
264
+ }
265
+ const e = entry as Record<string, unknown>
266
+ const spec: ReferenceRepoSpec = {
267
+ repo: parseRepoSpec((e.repo ?? {}) as Record<string, unknown>),
268
+ }
269
+ if (typeof e.ghToken === 'string' && e.ghToken) spec.ghToken = e.ghToken
270
+ return spec
271
+ })
272
+ }
273
+
237
274
  /** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
238
275
  function parseVcsProvider(value: unknown): 'github' | 'gitlab' | undefined {
239
276
  if (value === undefined || value === null) return undefined
@@ -591,6 +628,14 @@ export interface AgentJob extends HarnessAuthFields {
591
628
  * peer repo it actually changed — in addition to the primary. Absent ⇒ single-repo run.
592
629
  */
593
630
  peerRepos?: PeerRepoSpec[]
631
+ /**
632
+ * Coding mode (doc-writer): repositories to clone READ-ONLY as SIBLINGS for the agent to
633
+ * reference while it drafts the document. When present the agent works at the workspace ROOT
634
+ * (all checkouts are siblings under it); the harness clones each reference at its own base
635
+ * branch and NEVER creates a branch, commits, or opens a PR for it. Only the primary is
636
+ * pushed. Absent ⇒ single-repo run. Independent of {@link peerRepos} (those are writable).
637
+ */
638
+ referenceRepos?: ReferenceRepoSpec[]
594
639
  /**
595
640
  * Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
596
641
  * fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
@@ -878,6 +923,73 @@ function parseFrontendInfraSpec(o: Record<string, unknown>): FrontendInfraSpec {
878
923
  }
879
924
  }
880
925
 
926
+ // ---- Inline job (POST /jobs, kind=inline) --------------------------------
927
+ //
928
+ // A ONE-SHOT, no-checkout LLM completion run through a subscription harness CLI
929
+ // (Claude Code / Codex) on a leased subscription credential — the container analogue of
930
+ // the local host-CLI inline runner. It exists so a deployment that can't run the ambient
931
+ // CLI on the host (no `claude`/`codex` binary, or mothership mode) can still serve the
932
+ // inline LLM steps (requirements reviewer, brainstorm, task-estimator, inline document
933
+ // kinds) on a subscription model, at warm-pool latency. It clones NOTHING and pushes
934
+ // NOTHING: the CLI runs in a throwaway temp cwd and only the completion text + token usage
935
+ // come back. Auth is the SAME `HarnessAuthFields` the coding path uses (subscriptionToken +
936
+ // optional subscriptionBaseUrl, or ambientAuth), so the credential-env setup is shared.
937
+
938
+ /** The one-shot inline completion job. `harness` must be a subscription harness. */
939
+ export interface InlineJob extends HarnessAuthFields {
940
+ jobId: string
941
+ /** Real vendor model id, e.g. `claude-opus-4-8` / `gpt-5.5-codex`. */
942
+ model: string
943
+ /** Composed role + best-practice fragments (Claude: `--append-system-prompt`; Codex: prepended). */
944
+ systemPrompt: string
945
+ /** The concrete task/user prompt fed to the CLI over stdin. */
946
+ userPrompt: string
947
+ /** Advisory output cap, forwarded for parity; the one-shot CLIs don't all honour it. */
948
+ maxOutputTokens?: number
949
+ }
950
+
951
+ /** The inline completion result: the reply text plus lifted token usage / per-call telemetry. */
952
+ export interface InlineResult {
953
+ text: string
954
+ /** `length` when the model hit its output cap (the reviewer rejects a truncated doc). */
955
+ finishReason?: 'stop' | 'length'
956
+ usage?: { inputTokens: number; outputTokens: number }
957
+ /** Per-model-call telemetry lifted from the CLI stream (recorded into `llm_call_metrics`). */
958
+ callMetrics?: HarnessCallMetric[]
959
+ /** A structured failure marks a job-level failure even on a clean HTTP exit (see JobResultBase). */
960
+ error?: string
961
+ }
962
+
963
+ /**
964
+ * Validate + narrow an untrusted body into an {@link InlineJob}. The harness MUST be a
965
+ * subscription harness (`claude-code` / `codex`) — the inline path never runs Pi (that goes
966
+ * through the LLM proxy inline, not a container CLI). Reuses {@link parseHarnessAuth}, so a
967
+ * non-ambient job requires `subscriptionToken`.
968
+ */
969
+ export function parseInlineJob(input: unknown): InlineJob {
970
+ if (typeof input !== 'object' || input === null) {
971
+ throw new Error('Invalid job: body must be an object')
972
+ }
973
+ const o = input as Record<string, unknown>
974
+ // Validate the harness FIRST (before parseHarnessAuth, whose `pi` branch demands a proxy
975
+ // base URL): the inline path only ever runs a subscription CLI, so a Pi/absent harness is a
976
+ // clear inline-specific rejection rather than a confusing "proxyBaseUrl required".
977
+ if (o.harness !== 'claude-code' && o.harness !== 'codex') {
978
+ throw new Error("Invalid inline job: 'harness' must be 'claude-code' or 'codex'")
979
+ }
980
+ const auth = parseHarnessAuth(o)
981
+ const maxOutputTokens = posInt(o.maxOutputTokens)
982
+ return {
983
+ jobId: str(o.jobId, 'jobId'),
984
+ model: str(o.model, 'model'),
985
+ // The system prompt is optional (an empty role is valid); the user prompt is required.
986
+ systemPrompt: typeof o.systemPrompt === 'string' ? o.systemPrompt : '',
987
+ userPrompt: str(o.userPrompt, 'userPrompt'),
988
+ ...auth,
989
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
990
+ }
991
+ }
992
+
881
993
  /** Validate + narrow an untrusted body into an {@link AgentJob}, throwing on bad input. */
882
994
  export function parseAgentJob(input: unknown): AgentJob {
883
995
  if (typeof input !== 'object' || input === null) {
@@ -926,6 +1038,7 @@ export function parseAgentJob(input: unknown): AgentJob {
926
1038
  : undefined
927
1039
  const infra = parseAgentInfraSpec(o.infra)
928
1040
  const peerRepos = parsePeerRepos(o.peerRepos)
1041
+ const referenceRepos = parseReferenceRepos(o.referenceRepos)
929
1042
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
930
1043
  const contextFiles = parseContextFiles(o.contextFiles)
931
1044
  const packageRegistries = parsePackageRegistries(o.packageRegistries)
@@ -957,6 +1070,7 @@ export function parseAgentJob(input: unknown): AgentJob {
957
1070
  : {}),
958
1071
  ...(pr ? { pr } : {}),
959
1072
  ...(peerRepos.length ? { peerRepos } : {}),
1073
+ ...(referenceRepos.length ? { referenceRepos } : {}),
960
1074
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
961
1075
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
962
1076
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
@@ -973,5 +1087,11 @@ export function parseAgentJob(input: unknown): AgentJob {
973
1087
  for (const [i, peer] of (job.peerRepos ?? []).entries()) {
974
1088
  assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
975
1089
  }
1090
+ // Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
1091
+ // never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
1092
+ // attacker host would exfiltrate the token exactly like a rogue peer clone URL.
1093
+ for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
1094
+ assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
1095
+ }
976
1096
  return job
977
1097
  }
package/src/server.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { timingSafeEqual } from 'node:crypto'
2
2
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
3
- import { parseAgentJob } from './job.js'
3
+ import { parseAgentJob, parseInlineJob } from './job.js'
4
4
  import { handleAgent } from './agent.js'
5
+ import { handleInline } from './inline.js'
5
6
  import { redactSecrets } from './git.js'
6
7
  import { JobRegistry, loadRunnerLimits, type JobResultBase, type RunOptions } from './runner.js'
7
8
  import { log } from './logger.js'
@@ -84,6 +85,13 @@ const KINDS: Record<string, KindEntry> = {
84
85
  repo: `${job.repo.owner}/${job.repo.name}`,
85
86
  branch: job.branch,
86
87
  })),
88
+ // The one-shot, no-checkout inline completion (requirements reviewer / brainstorm /
89
+ // task-estimator / inline document kinds) on a leased subscription credential — the
90
+ // container analogue of the local host-CLI inline runner. See inline.ts.
91
+ inline: defineKind(parseInlineJob, handleInline, (job) => ({
92
+ harness: job.harness,
93
+ model: job.model,
94
+ })),
87
95
  }
88
96
 
89
97
  async function readBody(req: IncomingMessage): Promise<string> {