@expo/code-review-cli 0.5.1 → 0.6.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.
@@ -74,6 +74,31 @@ export function checkOauthTokenShape(provider, token, tokenEnv) {
74
74
  detail: `${tokenEnv} holds only ${token.length} characters, too short to be a real ${provider} token — it looks truncated. ${fix}`,
75
75
  };
76
76
  }
77
+ // A ChatGPT access token carries its own expiry — check it up front, so a
78
+ // lapsed credential is one clear message instead of N failed passes, and a
79
+ // nearly-lapsed one warns before it bites mid-run.
80
+ if (provider === "openai" && isJwtAccessToken(token)) {
81
+ const expires = jwtExpiryMs(token);
82
+ if (expires !== null) {
83
+ const remainingMs = expires - Date.now();
84
+ if (remainingMs <= 0) {
85
+ return {
86
+ ok: false,
87
+ detail: `${tokenEnv} holds a ChatGPT access token that EXPIRED ${Math.ceil(-remainingMs / 86_400_000)} day(s) ago. ` +
88
+ `Mint a fresh one (\`ecr setup-auth\`, or your token-rotator job) and update ${tokenEnv}.`,
89
+ };
90
+ }
91
+ if (remainingMs < 3 * 86_400_000) {
92
+ return {
93
+ ...ok,
94
+ detail: `oauth for ${provider}; token env ${tokenEnv} is set`,
95
+ warning: `${tokenEnv}'s ChatGPT access token expires in ${Math.max(1, Math.round(remainingMs / 3_600_000))}h — ` +
96
+ `re-mint it soon (\`ecr setup-auth\`, or your token-rotator job).`,
97
+ };
98
+ }
99
+ }
100
+ return { ...ok, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
101
+ }
77
102
  // Only anthropic's formats are known well enough to say anything about.
78
103
  if (provider !== "anthropic") {
79
104
  return ok;
@@ -241,20 +266,45 @@ export function checkProviderAuth(config, env = process.env) {
241
266
  ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
242
267
  };
243
268
  }
269
+ /** A ChatGPT access token is a JWT (three base64url segments); refresh tokens are opaque. */
270
+ export function isJwtAccessToken(token) {
271
+ return token.startsWith("eyJ") && token.split(".").length === 3;
272
+ }
273
+ /** A JWT's `exp` claim as epoch ms, decoded (not verified) — null when unreadable. */
274
+ export function jwtExpiryMs(token) {
275
+ const payload = token.split(".")[1];
276
+ if (!payload) {
277
+ return null;
278
+ }
279
+ try {
280
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
281
+ return typeof claims.exp === "number" ? claims.exp * 1000 : null;
282
+ }
283
+ catch {
284
+ return null;
285
+ }
286
+ }
244
287
  /**
245
- * The auth.json entry for one oauth credential. Provider-shaped:
246
- * - openai: the durable secret is the REFRESH token (a ChatGPT/Codex sign-in's
247
- * access tokens live ~1h, shorter than a worst-case run), so store it with
248
- * `expires: 0` and let OpenCode's codex plugin mint access tokens on demand.
288
+ * The auth.json entry for one oauth credential. Shaped by what the token IS:
289
+ * - a JWT (an ACCESS token, e.g. from `ecr setup-auth` or a rotator job): use it
290
+ * as-is and never refresh refresh tokens are SINGLE-USE (rotation), so a
291
+ * static/shared secret must not participate in rotation at all. Expiry comes
292
+ * from the JWT's own `exp` claim so OpenCode trusts it exactly as long as it
293
+ * is valid.
294
+ * - an opaque openai token (a REFRESH token): store it with `expires: 0` and let
295
+ * OpenCode's codex plugin mint the access token. Only safe when this run is
296
+ * the token's SOLE consumer — a value shared across runs/repos dies on first
297
+ * rotation (learned the hard way).
249
298
  * - everything else: the token IS the access credential (e.g. long-lived
250
299
  * setup-token style bearers), far-future expiry so OpenCode never tries to
251
300
  * refresh a credential that has no refresh half.
252
301
  */
253
302
  export function oauthAuthJsonEntry(provider, token) {
254
- if (provider === "openai") {
303
+ if (provider === "openai" && !isJwtAccessToken(token)) {
255
304
  return { type: "oauth", access: "", refresh: token, expires: 0 };
256
305
  }
257
- return { type: "oauth", access: token, refresh: "", expires: Date.now() + YEAR_MS };
306
+ const expires = isJwtAccessToken(token) ? jwtExpiryMs(token) : null;
307
+ return { type: "oauth", access: token, refresh: "", expires: expires ?? Date.now() + YEAR_MS };
258
308
  }
259
309
  /**
260
310
  * Prepare model credentials for the OpenCode server from the repo's auth entries
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import path from "node:path";
3
3
  import { createOpencode } from "@opencode-ai/sdk";
4
+ import { RateLimitWatch } from "./throttle.js";
4
5
  import { toolMap } from "./tools.js";
5
6
  import { errorMessage, sleep } from "./util.js";
6
7
  /** Sum token usage across attempts (for per-task/run totals). */
@@ -175,7 +176,9 @@ export async function startOpencode(config) {
175
176
  port: 0,
176
177
  config: config,
177
178
  });
178
- return { client, url: server.url, close: () => server.close() };
179
+ // Watch THIS server's log for provider throttle evidence (prepareAuth has already
180
+ // pointed XDG_DATA_HOME at the run's isolated dir when auth is injected).
181
+ return { client, url: server.url, close: () => server.close(), rateLimit: new RateLimitWatch() };
179
182
  }
180
183
  /** `provider/model` as the server reported it, or undefined if it reported neither. */
181
184
  export function formatModel(providerID, modelID) {
@@ -357,17 +360,26 @@ const FINALIZE_STALL_MS = 60 * 1000;
357
360
  // Breathing room before the retry: if the silence came from provider-side throttling
358
361
  // or backoff, reconnecting instantly is the worst move.
359
362
  const STALL_RETRY_BACKOFF_MS = 20 * 1000;
363
+ // When the account is provably rate-limited, wait in longer beats: re-sending the
364
+ // pass's whole context into a throttled account only deepens the limit. Several
365
+ // waits fit inside a pass budget, and each is long enough for a limit window to move.
366
+ const RATE_LIMIT_WAIT_MS = 90 * 1000;
360
367
  // Only retry when enough of the pass's budget remains for the fresh attempt to
361
368
  // plausibly finish; otherwise go straight to the soft landing.
362
369
  const STALL_RETRY_MIN_REMAINING_MS = STALL_MS + 60 * 1000;
363
370
  /**
364
- * What to do about a stalled attempt: start over from a clean session, or stop and
365
- * try to salvage findings. Exactly ONE retry, and only with enough budget left for it
366
- * to land a second wedged attempt would just spend the rest of the pass's window,
371
+ * What to do about a stalled attempt: WAIT (the account is provably rate-limited
372
+ * see core/throttle.ts so patience beats re-sending the context; waits don't
373
+ * consume the one retry), start over from a clean session, or stop and salvage
374
+ * findings. Exactly ONE wedged retry, and only with enough budget left for it to
375
+ * land — a second wedged attempt would just spend the rest of the pass's window,
367
376
  * which is the failure this whole mechanism exists to end. Exported for tests.
368
377
  */
369
- export function stallAction(attempt, remainingMs) {
370
- return attempt === 0 && remainingMs > STALL_RETRY_MIN_REMAINING_MS ? "retry" : "soft-land";
378
+ export function stallAction(wedgedRetries, remainingMs, rateLimited = false) {
379
+ if (rateLimited && remainingMs > STALL_RETRY_MIN_REMAINING_MS) {
380
+ return "wait";
381
+ }
382
+ return wedgedRetries === 0 && remainingMs > STALL_RETRY_MIN_REMAINING_MS ? "retry" : "soft-land";
371
383
  }
372
384
  const FINALIZE_PROMPT = "You have reached your time budget. STOP investigating now — do NOT read, grep, " +
373
385
  "glob, list, or open any more files, and do not call any tools. Based ONLY on " +
@@ -551,6 +563,7 @@ export async function promptAgent(handle, args) {
551
563
  throw finalizeError;
552
564
  }
553
565
  };
566
+ let wedgedRetries = 0;
554
567
  for (let attempt = 0;; attempt++) {
555
568
  const session = unwrap(await handle.client.session.create({
556
569
  body: { title: attempt === 0 ? args.title : `${args.title}-retry${attempt}` },
@@ -578,11 +591,27 @@ export async function promptAgent(handle, args) {
578
591
  // wedged request to answer. Exactly one retry, and only when enough budget
579
592
  // remains for it to land; after that the finalize is still worth a try as the
580
593
  // only remaining salvage (in eas-cli#4084 the session did respond once aborted).
594
+ //
595
+ // EXCEPT when the server log shows the account is rate-limited: then the
596
+ // silence is throttling, not a wedge, and the patient move is to wait —
597
+ // re-sending the pass's whole context would deepen the limit. Waits repeat
598
+ // (never consuming the one wedged retry) until the evidence goes stale or
599
+ // the pass runs out of room, both bounded by the pass deadline.
581
600
  if (error instanceof NoProgress) {
582
601
  await abortQuietly(handle, session.id);
583
602
  absorb(error);
603
+ await handle.rateLimit.check();
584
604
  const remaining = deadline - Date.now();
585
- if (stallAction(attempt, remaining) === "retry") {
605
+ const action = stallAction(wedgedRetries, remaining, handle.rateLimit.recentlyLimited());
606
+ if (action === "wait") {
607
+ args.onActivity?.(`provider is rate-limiting this account (429 in the server log; ` +
608
+ `${handle.rateLimit.events} so far) — waiting ${Math.round(RATE_LIMIT_WAIT_MS / 1000)}s ` +
609
+ `instead of retrying (${Math.round(remaining / 60000)}m of budget left)`);
610
+ await sleep(RATE_LIMIT_WAIT_MS);
611
+ continue;
612
+ }
613
+ if (action === "retry") {
614
+ wedgedRetries++;
586
615
  args.onActivity?.(`stalled — no output for ${Math.round(error.idleMs / 1000)}s; ` +
587
616
  `retrying once from a clean session (${Math.round(remaining / 60000)}m of budget left)`);
588
617
  await sleep(STALL_RETRY_BACKOFF_MS);
@@ -606,6 +635,18 @@ const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Repl
606
635
  const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
607
636
  /** Backoff (ms) before the 2nd and 3rd attempt of a transient-failing model call. */
608
637
  const TRANSIENT_BACKOFF_MS = [2_000, 8_000];
638
+ /**
639
+ * Rate limits need patience, not persistence: a limited account stays limited for
640
+ * tens of seconds to minutes, so the 2s/8s schedule just burns the retries. Shared
641
+ * subscription credentials (several PRs reviewing at once) make this the common
642
+ * transient, hence the dedicated, slower schedule.
643
+ */
644
+ const RATE_LIMIT_BACKOFF_MS = [15_000, 45_000, 90_000];
645
+ const RATE_LIMIT_ERROR = /\b429\b|rate.?limit|too many requests/i;
646
+ /** A transient error that is specifically a provider rate limit. */
647
+ export function isRateLimitError(error) {
648
+ return !(error instanceof AgentTimeoutError) && RATE_LIMIT_ERROR.test(errorMessage(error));
649
+ }
609
650
  /**
610
651
  * A transient, retryable API failure — a one-off rate-limit (429), server error
611
652
  * (5xx), or network blip — as opposed to a timeout (which means "abandon", see
@@ -649,11 +690,13 @@ async function withTransientRetry(label, onActivity, fn) {
649
690
  return await fn();
650
691
  }
651
692
  catch (error) {
652
- const waitMs = TRANSIENT_BACKOFF_MS[attempt];
693
+ // Rate limits get the slower, longer schedule — see RATE_LIMIT_BACKOFF_MS.
694
+ const schedule = isRateLimitError(error) ? RATE_LIMIT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
695
+ const waitMs = schedule[attempt];
653
696
  if (waitMs === undefined || !isTransientApiError(error)) {
654
697
  throw error;
655
698
  }
656
- onActivity?.(`${label}: transient API error (${errorMessage(error)}); retry ${attempt + 1}/${TRANSIENT_BACKOFF_MS.length} in ${Math.round(waitMs / 1000)}s`);
699
+ onActivity?.(`${label}: transient API error (${errorMessage(error)}); retry ${attempt + 1}/${schedule.length} in ${Math.round(waitMs / 1000)}s`);
657
700
  await sleep(waitMs);
658
701
  }
659
702
  }
@@ -108,7 +108,7 @@ export function renderMarkdown(review, tag, dismissed = [], link) {
108
108
  const kept = withFp.filter(({ fp }) => !dismissedByFp.has(fp));
109
109
  const dropped = withFp.filter(({ fp }) => dismissedByFp.has(fp));
110
110
  const lines = [commentMarker(tag), "## 🤖 AI code review", ""];
111
- lines.push(`**Decision:** ${decisionLabel(review.decision)}`, "", review.summary, "");
111
+ lines.push(`**Decision:** ${review.couldNotComplete ? "No review — every pass failed" : decisionLabel(review.decision)}`, "", review.summary, "");
112
112
  if (review.incomplete.length > 0) {
113
113
  lines.push("> ⏱️ **Coverage note:** coverage is partial — some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${note}`), "");
114
114
  }
@@ -230,7 +230,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
230
230
  "| --- | --- | --- |",
231
231
  ];
232
232
  for (const { result, kept } of perScope) {
233
- lines.push(`| ${result.scope} | ${decisionLabel(result.review.decision)} | ${kept.length} |`);
233
+ lines.push(`| ${result.scope} | ${result.review.couldNotComplete ? "No review — every pass failed" : decisionLabel(result.review.decision)} | ${kept.length} |`);
234
234
  }
235
235
  lines.push("");
236
236
  const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
@@ -32,12 +32,26 @@ function makeRunId() {
32
32
  * → coordinate → apply policy. Returns a CoordinatorOutput; the CLI commands are
33
33
  * thin wrappers that supply a Source and render the result.
34
34
  */
35
+ /**
36
+ * Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when a
37
+ * subscription (oauth) credential is configured, else 6. One ChatGPT account
38
+ * handles six parallel streams poorly — requests get parked server-side (the
39
+ * stall signature seen on eas-cli#4084), and several PRs may be reviewing on the
40
+ * same credential at once — so subscription runs trade a little wall-clock for a
41
+ * lot of reliability. Exported for tests.
42
+ */
43
+ export function effectiveConcurrency(config) {
44
+ if (config.chunk.concurrency) {
45
+ return config.chunk.concurrency;
46
+ }
47
+ return config.auth.some((entry) => entry.mode === "oauth") ? 3 : 6;
48
+ }
35
49
  export async function runReview(source, options) {
36
50
  const { config } = options;
37
51
  const started = Date.now();
38
52
  const runId = makeRunId();
39
53
  const progress = options.onProgress ?? (() => { });
40
- const runsRoot = path.join(config.configDir, ".runs");
54
+ const runsRoot = options.runsDir ?? path.join(config.configDir, ".runs");
41
55
  const runDir = path.join(runsRoot, runId);
42
56
  const logPath = path.join(runsRoot, "reviews.jsonl");
43
57
  // Fail fast on an invalid explicit selection before doing any work. Routing
@@ -83,18 +97,27 @@ export async function runReview(source, options) {
83
97
  });
84
98
  return output;
85
99
  }
86
- // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
87
- // and doing it first means nothing that can throw sits between the chdir and the
88
- // guarded blocks so a prepareAuth failure can't leak the worktree or leave cwd
89
- // pointing at it.
90
- const auth = await prepareAuth(config);
91
- // Read the PR-head tree (not the current checkout) when the source can materialize
92
- // it, so the agents' surrounding-source reads and the verifier's re-reads see the
93
- // versions that match the diff. Config is already fully loaded in memory, so the
94
- // chdir doesn't affect it; run-log/patch paths are absolute; gh/git calls already
95
- // ran above. Fails soft to the current directory.
100
+ // Materialize the PR-head tree (not the current checkout) when the source can, so
101
+ // the agents' surrounding-source reads and the verifier's re-reads see the versions
102
+ // that match the diff. Config is already fully loaded in memory, so the chdir below
103
+ // doesn't affect it; run-log/patch paths are absolute; gh/git calls already ran
104
+ // above. Failure policy is MODE-DEPENDENT (see resolveReadRoot): CI fails closed —
105
+ // with a base-SHA checkout the fallback tree is pre-PR content, and silently
106
+ // reviewing/verifying that drops real findings while a local run falls back to
107
+ // the user's own checkout with a warning.
96
108
  const originalCwd = process.cwd();
97
- const readRoot = (await source.prepareReadRootAsync?.()) ?? null;
109
+ const readRoot = await resolveReadRoot(source, options.mode, progress);
110
+ // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
111
+ // and doing it after readRoot but before chdir means a prepareAuth failure can't
112
+ // leave cwd pointing at the worktree — it only has the worktree itself to release.
113
+ let auth;
114
+ try {
115
+ auth = await prepareAuth(config);
116
+ }
117
+ catch (error) {
118
+ await readRoot?.cleanup();
119
+ throw error;
120
+ }
98
121
  const restoreCwd = async () => {
99
122
  if (readRoot) {
100
123
  process.chdir(originalCwd);
@@ -177,9 +200,10 @@ export async function runReview(source, options) {
177
200
  const chunks = chunkByLines(workspace.files, config.chunk.maxChangedLines, config.chunk.maxFiles);
178
201
  // Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
179
202
  const chunked = chunks.length > 1;
203
+ const concurrency = effectiveConcurrency(config);
180
204
  progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map((a) => a.id).join(", ")}] over ${chunks.length} chunk(s)` +
181
205
  `${chunked ? " + cross-cutting pass" : ""} ` +
182
- `(${kept.length} files, concurrency ${config.chunk.concurrency})…`);
206
+ `(${kept.length} files, concurrency ${concurrency})…`);
183
207
  for (const agent of selectedAgents) {
184
208
  agentFindings[agent.id] = [];
185
209
  agentCosts[agent.id] = 0;
@@ -313,7 +337,7 @@ export async function runReview(source, options) {
313
337
  // TIMEOUT, instead of dropping the work we break it into units that converge:
314
338
  // subdivide the chunk, then a fast no-tools pass, and only report a coverage gap
315
339
  // when even that can't finish inside the budget — so dropped work is never silent.
316
- await runGrowableQueue(tasks, config.chunk.concurrency, async (task, enqueue) => {
340
+ await runGrowableQueue(tasks, concurrency, async (task, enqueue) => {
317
341
  const minutes = Math.round(task.maxWaitMs / 60000);
318
342
  try {
319
343
  const { value, cost, truncated, tokens, model } = await promptAndParse(handle, {
@@ -443,6 +467,9 @@ export async function runReview(source, options) {
443
467
  summary: "⚠️ The AI review could not complete: every review pass failed or timed out, " +
444
468
  'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
445
469
  incomplete: coverageNotes,
470
+ // Presentation override: without it the comment header reads "Decision:
471
+ // Approve with comments" over a review that reviewed nothing (euxy#8).
472
+ couldNotComplete: true,
446
473
  };
447
474
  }
448
475
  else {
@@ -515,6 +542,14 @@ export async function runReview(source, options) {
515
542
  if (removedAfterChecks > 0) {
516
543
  output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
517
544
  }
545
+ // Surface provider throttling as a fact about the run: passes already waited or
546
+ // backed off, but the operator should still SEE that it happened (a run that
547
+ // was rate-limited is slower and may carry partial passes — that's the cause).
548
+ await handle.rateLimit.check();
549
+ if (handle.rateLimit.events > 0) {
550
+ progress(` ⚠ provider rate-limited this run ${handle.rateLimit.events} time(s) ` +
551
+ `(429s in the OpenCode server log) — passes waited it out rather than failing`);
552
+ }
518
553
  // Every pass says which model actually answered it — in the job log, the step
519
554
  // summary table, and the run log — so a wrong or substituted model is always
520
555
  // visible, not just when the substitution warning fires.
@@ -535,6 +570,7 @@ export async function runReview(source, options) {
535
570
  agentFindings,
536
571
  coverageNotes,
537
572
  verifierDropped,
573
+ ...(handle.rateLimit.events > 0 ? { rateLimitEvents: handle.rateLimit.events } : {}),
538
574
  durationMs: Date.now() - started,
539
575
  decision: output.decision,
540
576
  findingCount: output.findings.length,
@@ -641,6 +677,39 @@ export function reconcileSummary(summary, remaining) {
641
677
  "this summary was written, so it may mention issues no longer listed below._\n\n" +
642
678
  summary);
643
679
  }
680
+ /**
681
+ * Resolve the tree the review reads from, applying the mode's trust policy:
682
+ *
683
+ * - `null` from the source means "nothing to materialize" — reviewing the current
684
+ * checkout is intended (local diffs, or `--pr` without a repo). Never an error.
685
+ * - A materialization FAILURE (throw) is fatal in CI: the checkout there is the
686
+ * trusted BASE tree, and falling back to it would silently review and verify
687
+ * pre-PR file contents (dropping real findings with no trace in the output).
688
+ * The throw propagates to `ecr ci`'s catch, which posts the one terminal
689
+ * "not reviewed" comment.
690
+ * - The same failure in local mode degrades softly to the user's own checkout —
691
+ * the user is the trust principal there and sees the warning directly.
692
+ *
693
+ * Exported for tests.
694
+ */
695
+ export async function resolveReadRoot(source, mode, progress) {
696
+ if (!source.prepareReadRootAsync) {
697
+ return null;
698
+ }
699
+ try {
700
+ return await source.prepareReadRootAsync();
701
+ }
702
+ catch (error) {
703
+ if (mode === "ci") {
704
+ throw new Error(`Could not materialize the PR-head tree to review (and the CI checkout is the ` +
705
+ `trusted base, so reviewing it instead would silently review the wrong ` +
706
+ `contents): ${errorMessage(error)}`);
707
+ }
708
+ progress(`Could not materialize the PR-head tree (${errorMessage(error)}); ` +
709
+ `reading the current checkout instead — file contents may not match the PR.`);
710
+ return null;
711
+ }
712
+ }
644
713
  /** Capitalize the first letter (coverage notes read as sentences). */
645
714
  function capitalize(text) {
646
715
  return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
@@ -46,6 +46,14 @@ export const CoordinatorOutputSchema = z.object({
46
46
  * cut-short review is never presented as complete.
47
47
  */
48
48
  incomplete: z.array(z.string()).default([]),
49
+ /**
50
+ * True when EVERY pass failed — nothing was actually reviewed. Set by the
51
+ * engine, never the model. Reporters must not render an approving decision
52
+ * label for such a run (the decision enum has no "no review" member, and
53
+ * widening it would ripple through dismiss state and exit codes — this flag
54
+ * overrides the presentation instead).
55
+ */
56
+ couldNotComplete: z.boolean().optional(),
49
57
  });
50
58
  /** Minimum normalized evidence length to key a fingerprint on the code (below
51
59
  * this we fall back to the title). */
@@ -0,0 +1,62 @@
1
+ import { readdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * Ambient runtime configuration the OpenCode server (and the Claude-compatible
5
+ * loaders inside it) discovers from its project directory. The review core chdirs
6
+ * into a materialized PR-HEAD worktree before starting the server, so every one
7
+ * of these is attacker-writable in a PR: a plugin or MCP definition is arbitrary
8
+ * code execution in a process holding the model credential and a comment-capable
9
+ * GH_TOKEN; a `.env` can repoint a provider base URL; instruction files inject
10
+ * system-level prompts. OPENCODE_CONFIG_CONTENT (how ECR passes its own config)
11
+ * MERGES with project config rather than replacing it, so deleting these from the
12
+ * throwaway worktree is the only isolation that doesn't depend on OpenCode
13
+ * semantics.
14
+ *
15
+ * Exact-name entries match files or directories at any depth; `.env` is matched
16
+ * as a prefix (`.env`, `.env.local`, …). The PR's CHANGES to these files are
17
+ * still reviewed — their diffs are inlined in the task prompt — but the reviewer
18
+ * can no longer open their full head contents, and a finding citing one will
19
+ * fail verification (a documented tradeoff of the scrub approach).
20
+ */
21
+ export const AMBIENT_RUNTIME_CONFIG_NAMES = new Set([
22
+ "opencode.json",
23
+ "opencode.jsonc",
24
+ ".opencode",
25
+ "AGENTS.md",
26
+ "CLAUDE.md",
27
+ ".claude",
28
+ ".mcp.json",
29
+ ".cursor",
30
+ ".cursorrules",
31
+ ]);
32
+ /** Names never descended into (and never scrubbed as a unit — `.git` is the worktree link). */
33
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
34
+ /** Whether a directory entry is ambient runtime config that must not reach the model runtime. */
35
+ export function isAmbientRuntimeConfig(name) {
36
+ return AMBIENT_RUNTIME_CONFIG_NAMES.has(name) || name === ".env" || name.startsWith(".env.");
37
+ }
38
+ /**
39
+ * Remove ambient runtime config from a THROWAWAY materialized tree, at every
40
+ * depth. Must only ever run on a tree ECR created and will delete (a worktree or
41
+ * extracted archive) — never on the user's checkout. Returns the repo-relative
42
+ * paths removed so callers can log them.
43
+ */
44
+ export async function scrubAmbientRuntimeConfig(root) {
45
+ const removed = [];
46
+ const walk = async (dir) => {
47
+ const entries = await readdir(dir, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ const full = path.join(dir, entry.name);
50
+ if (isAmbientRuntimeConfig(entry.name)) {
51
+ await rm(full, { recursive: true, force: true });
52
+ removed.push(path.relative(root, full));
53
+ continue;
54
+ }
55
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
56
+ await walk(full);
57
+ }
58
+ }
59
+ };
60
+ await walk(root);
61
+ return removed.sort();
62
+ }
@@ -0,0 +1,94 @@
1
+ import { open } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * Rate-limit evidence, read from the OpenCode server's own log.
6
+ *
7
+ * Why this exists: provider throttling reaches us in two shapes. An EXPLICIT
8
+ * failure (HTTP 429 / "rate limit" stream error) surfaces in OpenCode's log as a
9
+ * structured ERROR line — that is proof. A SILENT one (the request is accepted
10
+ * and parked server-side) produces no error anywhere and is indistinguishable
11
+ * from a wedged request from the outside. So: the log watcher turns the explicit
12
+ * case into a hard signal, and the stall path treats "stall + recent explicit
13
+ * evidence" as throttling — the one situation where the right move is to WAIT
14
+ * (re-sending the whole context into a limited account only makes it worse).
15
+ *
16
+ * During oauth runs prepareAuth points XDG_DATA_HOME at an isolated temp dir, so
17
+ * the log we read belongs to exactly this run's server — no cross-talk with a
18
+ * developer's own OpenCode sessions.
19
+ */
20
+ /** The OpenCode server's log file under the active data dir. */
21
+ export function opencodeLogFile(env = process.env) {
22
+ const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
23
+ return path.join(dataHome, "opencode", "log", "opencode.log");
24
+ }
25
+ /**
26
+ * Provider throttle signatures in OpenCode log lines. Matched only against ERROR
27
+ * lines (a chatty INFO line mentioning "retry" must not count as evidence).
28
+ */
29
+ const RATE_LIMIT_PATTERN = /\b429\b|rate.?limit|too many requests|quota exceeded/i;
30
+ const ERROR_LINE = /\blevel=ERROR\b/;
31
+ /** Count rate-limit ERROR lines in a chunk of log text. Pure, for tests. */
32
+ export function countRateLimitLines(chunk) {
33
+ let count = 0;
34
+ for (const line of chunk.split("\n")) {
35
+ if (ERROR_LINE.test(line) && RATE_LIMIT_PATTERN.test(line)) {
36
+ count++;
37
+ }
38
+ }
39
+ return count;
40
+ }
41
+ /** How recent explicit evidence must be for a stall to be read as throttling. */
42
+ const EVIDENCE_WINDOW_MS = 5 * 60 * 1000;
43
+ /**
44
+ * Incremental watcher over the OpenCode server log. `check()` reads only what was
45
+ * appended since the last call (cheap enough for poll loops); `recentlyLimited()`
46
+ * is the signal the stall path consults. Fails soft everywhere: a missing or
47
+ * unreadable log yields "no evidence", never an error.
48
+ */
49
+ export class RateLimitWatch {
50
+ file;
51
+ /** Total rate-limit ERROR lines seen this run. */
52
+ events = 0;
53
+ /** Wall-clock time evidence was last SEEN (observation time, not log time). */
54
+ lastSeenAt = 0;
55
+ offset = 0;
56
+ constructor(file = opencodeLogFile()) {
57
+ this.file = file;
58
+ }
59
+ /** Scan newly-appended log lines for rate-limit evidence. */
60
+ async check() {
61
+ try {
62
+ const handle = await open(this.file, "r");
63
+ try {
64
+ const { size } = await handle.stat();
65
+ if (size < this.offset) {
66
+ this.offset = 0; // rotated/truncated — rescan from the top
67
+ }
68
+ if (size === this.offset) {
69
+ return this.events;
70
+ }
71
+ const length = size - this.offset;
72
+ const buffer = Buffer.alloc(length);
73
+ await handle.read(buffer, 0, length, this.offset);
74
+ this.offset = size;
75
+ const found = countRateLimitLines(buffer.toString("utf8"));
76
+ if (found > 0) {
77
+ this.events += found;
78
+ this.lastSeenAt = Date.now();
79
+ }
80
+ }
81
+ finally {
82
+ await handle.close();
83
+ }
84
+ }
85
+ catch {
86
+ // No log yet (server just started) or unreadable — no evidence, no error.
87
+ }
88
+ return this.events;
89
+ }
90
+ /** True when explicit throttle evidence appeared within the recency window. */
91
+ recentlyLimited(now = Date.now()) {
92
+ return this.lastSeenAt > 0 && now - this.lastSeenAt < EVIDENCE_WINDOW_MS;
93
+ }
94
+ }