@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.
package/README.md CHANGED
@@ -55,7 +55,7 @@ subscription** sign-in (it runs OpenCode's browser login and extracts the token
55
55
  for you). `doctor` offers to run it whenever a credential is missing.
56
56
 
57
57
  In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
58
- `CODEX_OAUTH_REFRESH_TOKEN` for the mixed setup) — the scaffolded workflow
58
+ `CODEX_OAUTH_ACCESS_TOKEN` for the mixed setup) — the scaffolded workflow
59
59
  forwards them.
60
60
 
61
61
  **Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
@@ -218,16 +218,31 @@ your-monorepo/
218
218
  (e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
219
219
  root roster — a scope defining a same-id agent gets the root one, so a team can't
220
220
  shadow the enforced reviewer with a weaker version on its own subtree.
221
- - **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
222
- auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
223
- and routing globs for its own advisory review that only steers what the
224
- comment-only reviewer says about that PR. What a PR can **never** do is touch
225
- credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
226
- `ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
227
- against whatever ref is checked out. A `/review`-command workflow that checks out
228
- only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
229
- resolving config from the base ref on the auto path as well is on the
230
- [roadmap](./ROADMAP.md).
221
+ - **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
222
+ policy and reviewer configuration `config.jsonc`, `routing.jsonc`, prompts,
223
+ models, and the auth mapping load from the PR's immutable **base** commit,
224
+ materialized via the GitHub API. The PR head is untrusted data: it is
225
+ materialized separately (pinned to its immutable OID) purely as source content
226
+ to read and verify against. A PR editing rosters, prompts, or routing is
227
+ reviewed under the **previous** config; its changes activate after merge. If
228
+ the base commit can't be materialized, the run fails closed (one terminal
229
+ comment) it never falls back to the checkout. A scope config that is new in
230
+ a PR is reviewed with the root config until it merges.
231
+ - **The model runtime never sees PR-owned ambient config.** The head worktree the
232
+ agents read from is scrubbed of runtime configuration before the OpenCode
233
+ server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
234
+ `CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
235
+ A PR can't install a plugin, MCP server, instruction file, or `.env` into the
236
+ process that holds the model credential and the comment token. (Changes to
237
+ those files are still reviewed — their diffs are inlined in the prompt — but a
238
+ finding citing one can't be re-read during verification; that's the tradeoff.)
239
+ - **The scaffolded workflows check out only the base commit** with
240
+ `persist-credentials: false`; the CLI's own git fetches authenticate through
241
+ `gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
242
+ CLI enforces the trust model itself, so a custom workflow that checks out the
243
+ PR head still gets base-commit configuration. The temporary escape hatch
244
+ `ecr ci --unsafe-config-from-head` restores the old behavior with a loud
245
+ security warning and will be removed on a minor boundary.
231
246
 
232
247
  Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
233
248
  (the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
@@ -344,7 +359,7 @@ coordinator, and per-repo `noise.additionalIgnores`.
344
359
  {
345
360
  "model": "openai/gpt-5.5", // default model for the specialists
346
361
  "policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
347
- "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
362
+ "chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
348
363
  "noise": { "additionalIgnores": ["packages/*/build/**"] },
349
364
  "review": { "trigger": "all", // which PRs `ecr ci` reviews: "all"
350
365
  "label": "ai-review", // (default, except ai-review:skip) or
@@ -406,6 +421,15 @@ change which model reviewed your code. Use an explicit override instead.
406
421
  session, inside the same budget — instead of spending the whole cap on a dead
407
422
  request. Progress lines say how long a reply has been silent, so this is legible in
408
423
  the CI log.
424
+ - **Rate limits are detected and waited out, not fought.** The reviewer watches the
425
+ OpenCode server's own log for provider 429s (hard evidence, per run). A stall
426
+ *with* recent 429 evidence is throttling, not a wedge — the pass waits in 90s
427
+ beats (without consuming its one retry) instead of re-sending its whole context
428
+ into a limited account; explicit 429 errors retry on a slow 15s/45s/90s schedule.
429
+ Subscription (oauth) runs also default to `concurrency` 3 instead of 6, since one
430
+ account may be serving several PRs' reviews at once. Rate-limit events are
431
+ reported in the job log and the run log (`rateLimitEvents`), so throttling is a
432
+ visible fact about a run, never a mystery slowdown.
409
433
  - **Soft landing on timeout** — at either cap, the run is interrupted and the agent
410
434
  is asked to return the findings it already has, rather than discarding its work.
411
435
  Tools are disabled for that request, so the salvage step can't resume investigating
@@ -490,7 +514,7 @@ set in `config.auth` (credentials come from OpenCode):
490
514
 
491
515
  ```jsonc
492
516
  "auth": { "providers": {
493
- "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
517
+ "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
494
518
  "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
495
519
  } }
496
520
  ```
@@ -499,11 +523,14 @@ set in `config.auth` (credentials come from OpenCode):
499
523
  (`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
500
524
  in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
501
525
 
502
- - **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
503
- ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`)
504
- access tokens are short-lived, so the refresh token is the durable secret and
505
- OpenCode mints access tokens on demand. Refresh-token reuse across runs is
506
- verified, so a static CI secret works.
526
+ - **The oauth `tokenEnv` holds the ACCESS token** from an `opencode auth login`
527
+ ChatGPT sign-in (`ecr setup-auth` extracts it) a plain bearer, valid for
528
+ days, with no rotation involvement. Do **not** use the refresh token as a
529
+ shared secret: refresh tokens are single-use (rotation), so a static copy is
530
+ spent by its first use and the sign-in dies with it. Access tokens expire
531
+ (~10 days observed), so CI secrets need periodic re-minting — see the
532
+ token-rotator item in the [roadmap](./ROADMAP.md); `doctor` and the run
533
+ preflight warn before expiry.
507
534
  - **The API key needs exactly two permissions** — a *Restricted* key with
508
535
  *Model capabilities*: **Responses → Request** and **Chat completions →
509
536
  Request**; everything else (including *List models*) stays None. Create it
@@ -511,7 +538,7 @@ set in `config.auth` (credentials come from OpenCode):
511
538
  instructions too.)
512
539
  - **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
513
540
  comma-separated set of both env names
514
- (`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
541
+ (`CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
515
542
  workflow.
516
543
  - **Auditability**: every pass logs which provider/model answered it (job log,
517
544
  step summary, run log), so the subscription/API split is visible per run.
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
2
+ import path from "node:path";
3
+ import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
3
4
  import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
4
5
  import { repoRoot, run } from "../core/exec.js";
5
6
  import { errorMessage } from "../core/util.js";
@@ -32,6 +33,15 @@ For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
32
33
  via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
33
34
  and non-blocking (a reviewer failure never fails the PR's checks).
34
35
 
36
+ Trust model: review policy and reviewer configuration (config.jsonc, routing,
37
+ prompts, models, auth mapping) load from the PR's immutable BASE commit,
38
+ materialized via the GitHub API — never from the PR head — so a PR cannot change
39
+ the reviewer that evaluates it; config changes activate after merge. The PR head
40
+ is materialized separately (pinned to its immutable OID, scrubbed of ambient
41
+ runtime config) purely as source content to read and verify against. If the
42
+ trusted base cannot be materialized, the run fails closed with one terminal
43
+ comment; it never falls back to the checkout.
44
+
35
45
  Monorepos: when .expo-code-review/routing.jsonc exists, ci fans out INTERNALLY —
36
46
  it assigns each changed file to exactly one scope (last-match-wins) and reviews
37
47
  each active scope over only its files, then renders one aggregated comment (or one
@@ -42,8 +52,17 @@ Options:
42
52
  --route Let the router pick relevant agents from the diff
43
53
  --scopes <a,b> Limit the fan-out to these named scopes (routing only)
44
54
  --config-dir <dir> Load the ROOT config.jsonc + routing.jsonc from <dir>
45
- instead of .expo-code-review/ (also ECR_CONFIG_DIR). Scope
46
- subtrees stay repo-root-relative.
55
+ instead of .expo-code-review/ (also ECR_CONFIG_DIR). A
56
+ RELATIVE dir resolves beneath the trusted base commit; an
57
+ ABSOLUTE dir is an explicit operator trust decision. Scope
58
+ subtrees always resolve beneath the trusted base commit.
59
+ --unsafe-config-from-head
60
+ COMPATIBILITY ESCAPE HATCH: load configuration from the
61
+ current checkout instead of the PR's trusted base commit.
62
+ This lets a same-repo PR change the reviewer (policy,
63
+ prompts, model, auth mapping) that evaluates itself.
64
+ Never scaffolded; prints a security warning; will be
65
+ removed on a scheduled minor boundary.
47
66
  --comment <mode> Override manifest comment mode: single | per-scope
48
67
  --force Manual override: review even if the trigger policy (label
49
68
  trigger / ai-review:skip) would skip. Break-glass and the
@@ -78,6 +97,7 @@ export async function ciCommand(argv = []) {
78
97
  // A maintainer's explicit `/review` (comment command or --force) is a manual
79
98
  // escape hatch that bypasses the trigger-policy gate only (see passesTriggerGate).
80
99
  const bypassTriggerGate = shouldBypassTriggerGate(argv);
100
+ const unsafeConfigFromHead = argv.includes("--unsafe-config-from-head");
81
101
  const root = await repoRoot();
82
102
  if (root && root !== process.cwd()) {
83
103
  process.chdir(root);
@@ -89,35 +109,120 @@ export async function ciCommand(argv = []) {
89
109
  process.stderr.write("CI reviewer: could not determine repository or PR number from the environment. Skipping.\n");
90
110
  return;
91
111
  }
92
- // A malformed manifest is a loud, non-blocking error (never a silent fallback).
93
- let manifest;
94
- try {
95
- manifest = await loadRoutingManifest(cwd, { configDir });
112
+ // ONE source for the whole run: metadata (incl. immutable OIDs), the diff, and
113
+ // the PR-head read root are each fetched once and shared across scopes.
114
+ const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
115
+ const source = memoizeSource(ghSource);
116
+ // Trusted configuration root: review policy and reviewer config load from the
117
+ // PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
118
+ // when the base can't be materialized, post the one terminal comment and stop —
119
+ // silently reading the checkout would let a head checkout smuggle config in.
120
+ let trustedRoot = null;
121
+ let configRoot = cwd;
122
+ if (unsafeConfigFromHead) {
123
+ process.stderr.write("CI reviewer: ⚠ SECURITY — --unsafe-config-from-head is set: reviewer configuration " +
124
+ "(policy, prompts, models, auth mapping) is being loaded from the current checkout, so " +
125
+ "a same-repository PR can change the reviewer that evaluates it. This escape hatch will " +
126
+ "be removed in a future minor release.\n");
96
127
  }
97
- catch (error) {
98
- process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
99
- return;
128
+ else {
129
+ try {
130
+ trustedRoot = await ghSource.prepareTrustedConfigRootAsync();
131
+ configRoot = trustedRoot.dir;
132
+ }
133
+ catch (error) {
134
+ const reason = errorMessage(error);
135
+ process.stderr.write(`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
136
+ `(failing closed, not reviewing): ${reason}\n`);
137
+ await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${reason}). ` +
138
+ `This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`);
139
+ return;
140
+ }
100
141
  }
101
- if (manifest == null) {
102
- await runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir);
103
- return;
142
+ try {
143
+ // A malformed manifest is a loud, non-blocking error (never a silent fallback).
144
+ let manifest;
145
+ try {
146
+ manifest = await loadRoutingManifest(configRoot, { configDir });
147
+ }
148
+ catch (error) {
149
+ process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
150
+ return;
151
+ }
152
+ if (manifest == null) {
153
+ await runLegacyCi(source, repo, prNumber, cwd, configRoot, {
154
+ agents,
155
+ route,
156
+ bypassTriggerGate,
157
+ configDir,
158
+ });
159
+ return;
160
+ }
161
+ try {
162
+ await runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, {
163
+ agents,
164
+ route,
165
+ scopesFilter,
166
+ commentOverride,
167
+ bypassTriggerGate,
168
+ configDir,
169
+ });
170
+ }
171
+ catch (error) {
172
+ // Fan-out failures stay non-blocking (single-writer property is the point).
173
+ process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
174
+ }
104
175
  }
176
+ finally {
177
+ await source.dispose();
178
+ await trustedRoot?.cleanup();
179
+ }
180
+ }
181
+ /**
182
+ * The one terminal "this PR was NOT reviewed" comment for failures that happen
183
+ * before any configuration is loaded (trusted-root materialization). Uses the
184
+ * DEFAULT comment tag/break-glass marker because the config that could customize
185
+ * them is exactly what failed to load; a repo with a custom tag gets a fresh
186
+ * comment rather than an upsert, which is the acceptable degraded case.
187
+ */
188
+ async function postTerminalFailureNote(repo, prNumber, cwd, reason) {
105
189
  try {
106
- await runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir);
190
+ const reporter = new GitHubReporter({
191
+ prNumber,
192
+ repo,
193
+ commentTag: "expo-ai-code-reviewer",
194
+ breakGlassMarker: "/skip-review",
195
+ cwd,
196
+ });
197
+ await reporter.report({
198
+ decision: "approve_with_comments",
199
+ findings: [],
200
+ summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed: ${reason}.`,
201
+ incomplete: [],
202
+ couldNotComplete: true,
203
+ });
107
204
  }
108
- catch (error) {
109
- // Fan-out failures stay non-blocking (single-writer property is the point).
110
- process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
205
+ catch (postError) {
206
+ process.stderr.write(`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`);
111
207
  }
112
208
  }
209
+ /**
210
+ * Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
211
+ * (temporary, removed-on-exit) trusted config root — the workflow uploads
212
+ * `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
213
+ */
214
+ function workspaceRunsDir(cwd) {
215
+ return path.join(cwd, CONFIG_DIRNAME, ".runs");
216
+ }
113
217
  /**
114
218
  * The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
115
219
  * routing.jsonc the CLI behaves exactly as before (backcompat invariant).
116
220
  */
117
- async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir) {
221
+ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
222
+ const { agents, route, bypassTriggerGate, configDir } = options;
118
223
  let config;
119
224
  try {
120
- config = await loadReviewConfig(cwd, { configDir });
225
+ config = await loadReviewConfig(configRoot, { configDir });
121
226
  }
122
227
  catch (error) {
123
228
  process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
@@ -158,11 +263,12 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
158
263
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
159
264
  }
160
265
  try {
161
- const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd }), {
266
+ const review = await runReview(source, {
162
267
  config,
163
268
  mode: "ci",
164
269
  agents,
165
270
  route,
271
+ runsDir: workspaceRunsDir(cwd),
166
272
  onProgress: (message) => process.stderr.write(`${message}\n`),
167
273
  });
168
274
  await reporter.report(review);
@@ -198,10 +304,12 @@ function failureReview(scopeName, reason) {
198
304
  };
199
305
  }
200
306
  /** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
201
- async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir) {
307
+ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, options) {
308
+ const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
202
309
  // The root config + manifest follow the override; scope configs stay
203
- // repo-root-relative (loadScopeConfig reads <root>/<scope.config>/.expo-code-review).
204
- const rootConfig = await loadReviewConfig(cwd, { configDir });
310
+ // relative to the TRUSTED root (loadScopeConfig reads
311
+ // <configRoot>/<scope.config>/.expo-code-review).
312
+ const rootConfig = await loadReviewConfig(configRoot, { configDir });
205
313
  // The root/aggregate marker is the ACTUAL root-owned comment tag so the
206
314
  // pre-routing comment and its dismissal state upsert in place, not stranded
207
315
  // under a new marker (risk 8/9). manifest.defaults.commentTag is the
@@ -240,7 +348,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
240
348
  if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), rootConfig.review, bypassTriggerGate)) {
241
349
  return;
242
350
  }
243
- const source = memoizeSource(new GitHubPRSource({ prNumber, repo, cwd }));
244
351
  const changed = await source.getChangedFiles();
245
352
  const resolution = resolveScopes(manifest, changed.map((file) => file.path));
246
353
  process.stderr.write("CI reviewer: scope ownership —\n");
@@ -276,7 +383,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
276
383
  if (await bgReporter.checkBreakGlass()) {
277
384
  process.stderr.write(`CI reviewer: ${rootConfig.breakGlassMarker} detected; skipping.\n`);
278
385
  await bgReporter.postSkipNote();
279
- await source.dispose();
280
386
  return;
281
387
  }
282
388
  }
@@ -284,17 +390,17 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
284
390
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
285
391
  }
286
392
  // Build ONE link context for all scopes (rate-limit hygiene): diff lines from the
287
- // already-fetched changed files, base SHA via a single `gh pr view`.
393
+ // already-fetched changed files, base OID from the memoized PR metadata (the same
394
+ // immutable OID the trusted config root was materialized from).
288
395
  const link = {
289
396
  repo,
290
397
  prNumber,
291
398
  diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
292
399
  };
293
400
  try {
294
- const { stdout } = await run("gh", ["pr", "view", String(prNumber), "--repo", repo, "--json", "baseRefOid"], { cwd });
295
- const oid = JSON.parse(stdout).baseRefOid;
296
- if (oid) {
297
- link.baseSha = oid;
401
+ const { baseOid } = await source.getMetadata();
402
+ if (baseOid) {
403
+ link.baseSha = baseOid;
298
404
  }
299
405
  }
300
406
  catch {
@@ -317,7 +423,18 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
317
423
  const isDefault = scope.configDir === ".";
318
424
  let review;
319
425
  try {
320
- const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
426
+ // A scope whose config dir doesn't exist at the TRUSTED base commit is a
427
+ // scope this PR introduces: review it with the root config rather than
428
+ // failing the run on exactly that PR. The scope's own config (PR-owned,
429
+ // untrusted for this run) activates once it merges.
430
+ let effectiveScopeDef = scopeDef;
431
+ if (!hasScopeConfig(configRoot, scopeDef)) {
432
+ process.stderr.write(`CI reviewer: [${scope.name}] no config at "${scopeDef.config}" in the PR's base ` +
433
+ `commit (new in this PR?); reviewing with the root config — the scope's config ` +
434
+ `takes effect after merge.\n`);
435
+ effectiveScopeDef = { ...scopeDef, config: "." };
436
+ }
437
+ const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
321
438
  review = await runReview(source, {
322
439
  config,
323
440
  mode: "ci",
@@ -325,6 +442,7 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
325
442
  route,
326
443
  includePaths: scope.files,
327
444
  passesBudgetMs: budget,
445
+ runsDir: workspaceRunsDir(cwd),
328
446
  onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
329
447
  });
330
448
  }
@@ -335,7 +453,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
335
453
  }
336
454
  results.push({ scope: scope.name, isDefault, review });
337
455
  }
338
- await source.dispose();
339
456
  const reporterFor = (tag, withLink = false) => new GitHubReporter({
340
457
  prNumber,
341
458
  repo,
@@ -38,9 +38,11 @@ Options:
38
38
  --no-fail always exit 0, even on request-changes
39
39
  -h, --help show this help
40
40
 
41
- Note: agents read the local working tree for surrounding context, so --pr uses the
42
- PR's diff (authoritative) but your checked-out files for context. For full fidelity
43
- on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
41
+ Note: with --repo (or in CI), --pr materializes the PR-head tree (pinned to its
42
+ immutable commit, scrubbed of ambient runtime config) so reads match the PR; if
43
+ that isn't possible, it falls back to your checked-out files with a warning.
44
+ Config always loads from YOUR checkout in local runs — you are the trust
45
+ principal here. In \`ecr ci\`, config loads from the PR's trusted base commit.
44
46
 
45
47
  Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
46
48
  `;
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline/promises";
6
6
  import { hasConfig, loadReviewConfig } from "../config/load.js";
7
+ import { jwtExpiryMs } from "../core/auth.js";
7
8
  import { opencodeBinSource } from "../core/opencode.js";
8
9
  import { errorMessage } from "../core/util.js";
9
10
  const USAGE = `ecr setup-auth — set up model credentials for local runs
@@ -48,13 +49,25 @@ export function opencodeAuthJsonPath(env = process.env) {
48
49
  const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
49
50
  return path.join(dataHome, "opencode", "auth.json");
50
51
  }
51
- /** The stored ChatGPT sign-in's refresh token, if OpenCode has one. */
52
- async function readStoredRefreshToken() {
52
+ /**
53
+ * The stored ChatGPT sign-in's ACCESS token, if OpenCode has a live one. The
54
+ * refresh token deliberately never leaves OpenCode's store: refresh tokens are
55
+ * SINGLE-USE (rotation) and OpenCode is their sole legitimate consumer — a copy
56
+ * in a shell config or CI secret dies on the next rotation and can take the
57
+ * whole sign-in with it. The access token is a plain bearer that stays valid for
58
+ * days and never touches rotation.
59
+ */
60
+ async function readStoredAccessToken() {
53
61
  try {
54
62
  const raw = await readFile(opencodeAuthJsonPath(), "utf8");
55
63
  const parsed = JSON.parse(raw);
56
64
  const openai = parsed.openai;
57
- return openai?.type === "oauth" && openai.refresh ? openai.refresh : null;
65
+ if (openai?.type !== "oauth" || !openai.access) {
66
+ return null;
67
+ }
68
+ const expiresMs = jwtExpiryMs(openai.access) ?? 0;
69
+ // An expired stored token means the sign-in needs redoing anyway.
70
+ return expiresMs > Date.now() ? { token: openai.access, expiresMs } : null;
58
71
  }
59
72
  catch {
60
73
  return null;
@@ -96,7 +109,7 @@ export async function setupAuthCommand(argv = []) {
96
109
  else {
97
110
  err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
98
111
  plan = planFromAuth([
99
- { provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" },
112
+ { provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
100
113
  ]);
101
114
  }
102
115
  if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
@@ -110,14 +123,15 @@ export async function setupAuthCommand(argv = []) {
110
123
  err(`✓ ${tokenEnv} is already set in this shell — skipping the ChatGPT sign-in.`);
111
124
  }
112
125
  else {
113
- let refresh = await readStoredRefreshToken();
114
- if (refresh) {
115
- err("Found an existing ChatGPT sign-in in OpenCode.");
116
- if (!(await confirm(`Reuse it for ${tokenEnv}?`, yes))) {
117
- refresh = null;
126
+ let stored = await readStoredAccessToken();
127
+ if (stored) {
128
+ err(`Found a live ChatGPT sign-in in OpenCode (access token valid ` +
129
+ `${Math.max(1, Math.round((stored.expiresMs - Date.now()) / 86_400_000))} more day(s)).`);
130
+ if (!(await confirm(`Use it for ${tokenEnv}?`, yes))) {
131
+ stored = null;
118
132
  }
119
133
  }
120
- if (!refresh) {
134
+ if (!stored) {
121
135
  err("This will run the bundled `opencode auth login` (interactive).");
122
136
  err("When it prompts:");
123
137
  err(" 1. select the provider: OpenAI");
@@ -133,17 +147,20 @@ export async function setupAuthCommand(argv = []) {
133
147
  if (result.status !== 0) {
134
148
  throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
135
149
  }
136
- refresh = await readStoredRefreshToken();
137
- if (!refresh) {
138
- throw new Error("The login finished but no ChatGPT sign-in was stored — did you select " +
150
+ stored = await readStoredAccessToken();
151
+ if (!stored) {
152
+ throw new Error("The login finished but no live ChatGPT sign-in was stored — did you select " +
139
153
  'OpenAI → "Sign in with ChatGPT"? Re-run `ecr setup-auth` to try again.');
140
154
  }
141
155
  }
142
156
  }
143
- if (refresh) {
144
- // The REFRESH token is the durable secret: access tokens are short-lived,
145
- // and OpenCode mints them from this on demand.
146
- exports.push(exportLine(tokenEnv, refresh));
157
+ if (stored) {
158
+ // The ACCESS token: a plain bearer, valid for days, no rotation involved.
159
+ // (The refresh token stays in OpenCode's store it is single-use, and
160
+ // copying it anywhere kills it on the next rotation.)
161
+ exports.push(exportLine(tokenEnv, stored.token));
162
+ err(`Note: this access token expires in ~${Math.max(1, Math.round((stored.expiresMs - Date.now()) / 86_400_000))} day(s); ` +
163
+ `re-run \`ecr setup-auth\` then to refresh it (your OpenCode sign-in stays valid).`);
147
164
  }
148
165
  }
149
166
  }
@@ -177,6 +177,21 @@ export function loadAuthFromRoot(rootConfig, manifest) {
177
177
  }
178
178
  return rootConfig.auth;
179
179
  }
180
+ /**
181
+ * Whether a scope's own config dir exists under `root` — the same path
182
+ * `loadScopeConfig` reads (deliberately NOT via resolveConfigDir: ECR_CONFIG_DIR
183
+ * must never redirect scope subtrees). `ecr ci` uses this against the TRUSTED
184
+ * BASE root to give scopes that are new in a PR a defined miss behavior (review
185
+ * with the root config; the scope config activates after merge) instead of
186
+ * failing the run on exactly the PR that introduces the scope.
187
+ */
188
+ export function hasScopeConfig(root, scope) {
189
+ if (scope.config === ".") {
190
+ return true;
191
+ }
192
+ const dir = path.join(root, scope.config, CONFIG_DIRNAME);
193
+ return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
194
+ }
180
195
  /**
181
196
  * Load one scope's fully-resolved config. The default scope (config '.') reuses
182
197
  * the root config unchanged except auth; a nested scope reads its own
@@ -38,10 +38,15 @@ export const ReviewConfigSchema = z.object({
38
38
  maxChangedLines: z.number().int().positive().default(1000),
39
39
  // Secondary guard so a chunk isn't an absurd number of tiny-diff files.
40
40
  maxFiles: z.number().int().positive().default(20),
41
- // Max concurrent reviewer calls across all agents/chunks.
42
- concurrency: z.number().int().positive().default(6),
41
+ // Max concurrent reviewer calls across all agents/chunks. Unset ⇒ resolved
42
+ // from the auth mode: 6 for API-key runs, 3 when a subscription (oauth)
43
+ // credential is configured — one ChatGPT account handles six parallel
44
+ // streams poorly (requests get parked = the stall signature), and several
45
+ // PRs may be reviewing on the same credential at once. An explicit value
46
+ // here always wins. See effectiveConcurrency in core/review.ts.
47
+ concurrency: z.number().int().positive().optional(),
43
48
  })
44
- .default({ maxChangedLines: 1000, maxFiles: 20, concurrency: 6 }),
49
+ .default({ maxChangedLines: 1000, maxFiles: 20 }),
45
50
  noise: z
46
51
  .object({
47
52
  additionalIgnores: z.array(z.string()).default([]),