@expo/code-review-cli 0.4.0 → 0.5.1

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.
@@ -0,0 +1,200 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import readline from "node:readline/promises";
6
+ import { hasConfig, loadReviewConfig } from "../config/load.js";
7
+ import { opencodeBinSource } from "../core/opencode.js";
8
+ import { errorMessage } from "../core/util.js";
9
+ const USAGE = `ecr setup-auth — set up model credentials for local runs
10
+
11
+ Reads this repo's .expo-code-review/config.jsonc auth entries and walks through
12
+ getting each credential:
13
+ • a ChatGPT/Codex subscription (oauth/openai): runs the bundled
14
+ \`opencode auth login\` (interactive; opens your browser), then prints the
15
+ \`export <tokenEnv>=…\` line to add to your shell config. An existing
16
+ OpenCode ChatGPT sign-in is reused instead of re-authenticating.
17
+ • an API key (api-key entries): prints where to create the key, the exact
18
+ permissions it needs, and the export line to fill in.
19
+
20
+ Without a repo config, it offers the recommended ChatGPT/Codex subscription flow
21
+ with the default env name.
22
+
23
+ Options:
24
+ --yes Skip confirmation prompts (still interactive during the login itself).
25
+ `;
26
+ export function planFromAuth(auth) {
27
+ const plan = { manualKeys: [], unsupported: [] };
28
+ for (const entry of auth) {
29
+ if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
30
+ plan.chatgptLogin = { tokenEnv: entry.tokenEnv };
31
+ }
32
+ else if (entry.mode === "api-key" && entry.tokenEnv) {
33
+ plan.manualKeys.push({
34
+ provider: entry.provider,
35
+ tokenEnv: entry.tokenEnv,
36
+ upstream: entry.upstream,
37
+ });
38
+ }
39
+ else if (entry.mode === "oauth") {
40
+ plan.unsupported.push(entry);
41
+ }
42
+ // api-key without tokenEnv relies on OpenCode's own login — nothing to set up.
43
+ }
44
+ return plan;
45
+ }
46
+ /** Where OpenCode's own (non-isolated) auth.json lives. */
47
+ export function opencodeAuthJsonPath(env = process.env) {
48
+ const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
49
+ return path.join(dataHome, "opencode", "auth.json");
50
+ }
51
+ /** The stored ChatGPT sign-in's refresh token, if OpenCode has one. */
52
+ async function readStoredRefreshToken() {
53
+ try {
54
+ const raw = await readFile(opencodeAuthJsonPath(), "utf8");
55
+ const parsed = JSON.parse(raw);
56
+ const openai = parsed.openai;
57
+ return openai?.type === "oauth" && openai.refresh ? openai.refresh : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ async function confirm(question, skip) {
64
+ if (skip) {
65
+ return true;
66
+ }
67
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
68
+ try {
69
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
70
+ return answer === "" || answer === "y" || answer === "yes";
71
+ }
72
+ finally {
73
+ rl.close();
74
+ }
75
+ }
76
+ /** The line to paste into a shell config. Single-quoted: tokens never contain '. */
77
+ export function exportLine(tokenEnv, value) {
78
+ return `export ${tokenEnv}='${value}'`;
79
+ }
80
+ export async function setupAuthCommand(argv = []) {
81
+ if (argv.includes("-h") || argv.includes("--help")) {
82
+ process.stdout.write(USAGE);
83
+ return;
84
+ }
85
+ const yes = argv.includes("--yes");
86
+ const out = (line = "") => process.stdout.write(`${line}\n`);
87
+ const err = (line = "") => process.stderr.write(`${line}\n`);
88
+ try {
89
+ // Plan from the repo config when there is one; otherwise offer the
90
+ // recommended subscription flow with the default env name.
91
+ let plan;
92
+ if (hasConfig(process.cwd())) {
93
+ const config = await loadReviewConfig(process.cwd());
94
+ plan = planFromAuth(config.auth);
95
+ }
96
+ else {
97
+ err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
98
+ plan = planFromAuth([
99
+ { provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_REFRESH_TOKEN" },
100
+ ]);
101
+ }
102
+ if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
103
+ out("This repo's auth config needs no local credential setup (OpenCode's own login covers it).");
104
+ return;
105
+ }
106
+ const exports = [];
107
+ if (plan.chatgptLogin) {
108
+ const { tokenEnv } = plan.chatgptLogin;
109
+ if (process.env[tokenEnv]) {
110
+ err(`✓ ${tokenEnv} is already set in this shell — skipping the ChatGPT sign-in.`);
111
+ }
112
+ 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;
118
+ }
119
+ }
120
+ if (!refresh) {
121
+ err("This will run the bundled `opencode auth login` (interactive).");
122
+ err("When it prompts:");
123
+ err(" 1. select the provider: OpenAI");
124
+ err(" 2. select the method: Sign in with ChatGPT (Codex subscription)");
125
+ err(" 3. your browser opens — sign in and authorize.");
126
+ if (!(await confirm("Run it now?", yes))) {
127
+ err("Skipped the ChatGPT sign-in.");
128
+ }
129
+ else {
130
+ const binDir = opencodeBinSource().dir;
131
+ const opencode = binDir ? path.join(binDir, "opencode") : "opencode";
132
+ const result = spawnSync(opencode, ["auth", "login"], { stdio: "inherit" });
133
+ if (result.status !== 0) {
134
+ throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
135
+ }
136
+ refresh = await readStoredRefreshToken();
137
+ if (!refresh) {
138
+ throw new Error("The login finished but no ChatGPT sign-in was stored — did you select " +
139
+ 'OpenAI → "Sign in with ChatGPT"? Re-run `ecr setup-auth` to try again.');
140
+ }
141
+ }
142
+ }
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));
147
+ }
148
+ }
149
+ }
150
+ for (const key of plan.manualKeys) {
151
+ if (process.env[key.tokenEnv]) {
152
+ err(`✓ ${key.tokenEnv} is already set in this shell — skipping.`);
153
+ continue;
154
+ }
155
+ const upstream = key.upstream ?? key.provider;
156
+ err("");
157
+ err(`${key.tokenEnv} (${key.provider}) is an API key — create it by hand:`);
158
+ if (upstream === "openai") {
159
+ err(" https://platform.openai.com/api-keys — in a dedicated project (set a");
160
+ err(" monthly budget), as a RESTRICTED key with exactly two permissions, both");
161
+ err(" under Model capabilities: Responses → Request, Chat completions → Request.");
162
+ err(" Everything else (including List models) stays None.");
163
+ }
164
+ else if (upstream === "anthropic") {
165
+ err(" https://console.anthropic.com/settings/keys — a workspace-scoped key");
166
+ err(" with a spend limit is all the reviewer needs.");
167
+ }
168
+ else {
169
+ err(` mint a key for the "${upstream}" provider.`);
170
+ }
171
+ exports.push(exportLine(key.tokenEnv, "<paste the key here>"));
172
+ }
173
+ for (const entry of plan.unsupported) {
174
+ err("");
175
+ err(`auth for "${entry.provider}" is mode "oauth", which has no automated setup flow here` +
176
+ (entry.provider === "anthropic"
177
+ ? " — and cannot work: Anthropic prohibits subscription tokens in third-party tools. Use an API key instead."
178
+ : `. Set ${entry.tokenEnv ?? "its token env"} manually.`));
179
+ }
180
+ if (exports.length > 0) {
181
+ const rc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "your shell config";
182
+ err("");
183
+ err(`Add ${exports.length === 1 ? "this line" : "these lines"} to ${rc}:`);
184
+ out("");
185
+ for (const line of exports) {
186
+ out(` ${line}`);
187
+ }
188
+ out("");
189
+ err(`Then restart your shell (or \`source ${rc}\`) and run \`ecr doctor\` to verify.`);
190
+ }
191
+ else {
192
+ err("");
193
+ err("Nothing to add — run `ecr doctor` to verify your setup.");
194
+ }
195
+ }
196
+ catch (error) {
197
+ err(`setup-auth failed: ${errorMessage(error)}`);
198
+ process.exitCode = 1;
199
+ }
200
+ }
@@ -14,15 +14,17 @@ The canonical pre-review guard (ships with the CLI). It sweeps EVERY
14
14
  plain recursive walk (skipping node_modules/.git, so a staged-but-unreferenced
15
15
  config can't hide from git's index), parses each with the real comment-aware JSONC
16
16
  parser (never regex-scraping), and refuses to run (exit 1) when:
17
- • auth.tokenEnv (config) / defaults.auth.tokenEnv (routing.jsonc) appears more
18
- than once, or in a non-root file, or with --expected / ECR_EXPECTED_TOKEN_ENV
19
- set differs from the expected name or is absent (count must be exactly one);
17
+ a tokenEnv (auth.tokenEnv, or any auth.providers.<id>.tokenEnv; routing.jsonc:
18
+ same under defaults.auth) appears in a non-root file, in more than one root
19
+ file, twice under the same name, or with --expected / ECR_EXPECTED_TOKEN_ENV
20
+ set (comma-separated) — the declared set differs from the expected set;
20
21
  • a non-root config declares auth, breakGlass, or commentTag (root-locked keys);
21
22
  • any file fails to parse (fail-closed), reporting the parse error.
22
23
  Exit 0 = safe to run the review.
23
24
 
24
25
  Options:
25
- --expected <ENV_NAME> Require tokenEnv to equal this (else ECR_EXPECTED_TOKEN_ENV).
26
+ --expected <ENVS> Require the declared tokenEnv set to equal this
27
+ comma-separated set (else ECR_EXPECTED_TOKEN_ENV).
26
28
  --json Emit {ok, findings:[{file, problem}]} on stdout.
27
29
  `;
28
30
  const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
@@ -63,22 +65,38 @@ function asObject(value) {
63
65
  ? value
64
66
  : undefined;
65
67
  }
68
+ /** Every tokenEnv an auth block names — legacy single, or one per providers entry. */
69
+ function collectTokenEnvs(auth) {
70
+ if (!auth) {
71
+ return [];
72
+ }
73
+ const found = [];
74
+ if (typeof auth.tokenEnv === "string") {
75
+ found.push(auth.tokenEnv);
76
+ }
77
+ const providers = asObject(auth.providers);
78
+ for (const entry of Object.values(providers ?? {})) {
79
+ const tokenEnv = asObject(entry)?.tokenEnv;
80
+ if (typeof tokenEnv === "string") {
81
+ found.push(tokenEnv);
82
+ }
83
+ }
84
+ return found;
85
+ }
66
86
  /** Read the security-relevant declarations from a parsed config/routing object. */
67
87
  function extractFacts(file, parsed) {
68
88
  if (path.basename(file) === ROUTING_FILENAME) {
69
89
  // routing.jsonc locks auth under defaults.auth (defaults.auth.tokenEnv).
70
90
  const defaults = asObject(parsed.defaults);
71
- const auth = asObject(defaults?.auth);
72
91
  return {
73
- tokenEnv: typeof auth?.tokenEnv === "string" ? auth.tokenEnv : undefined,
92
+ tokenEnvs: collectTokenEnvs(asObject(defaults?.auth)),
74
93
  declaresAuth: Boolean(defaults) && "auth" in defaults,
75
94
  declaresBreakGlass: false, // routing.jsonc has no breakGlass concept
76
95
  declaresCommentTag: Boolean(defaults) && "commentTag" in defaults,
77
96
  };
78
97
  }
79
- const auth = asObject(parsed.auth);
80
98
  return {
81
- tokenEnv: typeof auth?.tokenEnv === "string" ? auth.tokenEnv : undefined,
99
+ tokenEnvs: collectTokenEnvs(asObject(parsed.auth)),
82
100
  declaresAuth: "auth" in parsed,
83
101
  declaresBreakGlass: "breakGlass" in parsed,
84
102
  declaresCommentTag: "commentTag" in parsed,
@@ -113,8 +131,8 @@ export async function verifyConfig(root, options = {}) {
113
131
  continue;
114
132
  }
115
133
  const facts = extractFacts(file, object);
116
- if (facts.tokenEnv !== undefined) {
117
- tokenEnvOccurrences.push({ file: rel(file), value: facts.tokenEnv, isRoot });
134
+ for (const value of facts.tokenEnvs) {
135
+ tokenEnvOccurrences.push({ file: rel(file), value, isRoot });
118
136
  }
119
137
  if (!isRoot) {
120
138
  const locked = [];
@@ -135,38 +153,58 @@ export async function verifyConfig(root, options = {}) {
135
153
  }
136
154
  }
137
155
  }
138
- // tokenEnv must appear at most once, only in a root-owned file.
156
+ // tokenEnvs may only be declared in root-owned files…
139
157
  for (const occurrence of tokenEnvOccurrences.filter((o) => !o.isRoot)) {
140
158
  findings.push({
141
159
  file: occurrence.file,
142
160
  problem: `tokenEnv "${occurrence.value}" is declared outside the root config; only a root-owned config.jsonc/config.json or routing.jsonc may name the forwarded credential`,
143
161
  });
144
162
  }
145
- if (tokenEnvOccurrences.length > 1) {
163
+ // …and all in ONE root file (multiple entries in one auth block are fine —
164
+ // that's the multi-provider map — but split across files there is no single
165
+ // honored source and a stale/staged second file could smuggle a credential).
166
+ const rootOccurrences = tokenEnvOccurrences.filter((o) => o.isRoot);
167
+ const rootFiles = [...new Set(rootOccurrences.map((o) => o.file))];
168
+ if (rootFiles.length > 1) {
146
169
  findings.push({
147
- file: tokenEnvOccurrences.map((o) => o.file).join(", "),
148
- problem: `tokenEnv is declared in ${tokenEnvOccurrences.length} files; it must appear exactly once, in a root-owned config`,
170
+ file: rootFiles.join(", "),
171
+ problem: `tokenEnv is declared in ${rootFiles.length} root files; all credential env names must live in ONE root-owned config`,
149
172
  });
150
173
  }
151
- // With an expectation set, exactly one root occurrence equal to it is required.
174
+ // Duplicate names within a file are a config bug worth failing on too: two auth
175
+ // entries forwarding the same env var means one of them is misconfigured.
176
+ const seen = new Set();
177
+ for (const occurrence of rootOccurrences) {
178
+ if (seen.has(occurrence.value)) {
179
+ findings.push({
180
+ file: occurrence.file,
181
+ problem: `tokenEnv "${occurrence.value}" is declared more than once; each credential env name must appear exactly once`,
182
+ });
183
+ }
184
+ seen.add(occurrence.value);
185
+ }
186
+ // With an expectation set, the declared names must equal the expected SET
187
+ // exactly (comma-separated; order-insensitive). A missing name is as much a
188
+ // finding as an extra one — a PR must not add, drop, or repoint credentials.
152
189
  const expected = options.expected;
153
190
  if (expected) {
154
- const rootOccurrences = tokenEnvOccurrences.filter((o) => o.isRoot);
155
- if (rootOccurrences.length === 0) {
191
+ const expectedSet = expected
192
+ .split(",")
193
+ .map((name) => name.trim())
194
+ .filter(Boolean)
195
+ .sort();
196
+ const declared = [...seen].sort();
197
+ if (declared.length === 0) {
156
198
  findings.push({
157
199
  file: path.join(CONFIG_DIRNAME, "config.jsonc"),
158
- problem: `no tokenEnv found, but an expected value "${expected}" is set exactly one root-owned tokenEnv is required`,
200
+ problem: `no tokenEnv found, but expected "${expectedSet.join(", ")}" — the root-owned config must name exactly those credential env(s)`,
159
201
  });
160
202
  }
161
- else {
162
- for (const occurrence of rootOccurrences) {
163
- if (occurrence.value !== expected) {
164
- findings.push({
165
- file: occurrence.file,
166
- problem: `tokenEnv "${occurrence.value}" != expected "${expected}" — a PR must not repoint which secret is forwarded to the model provider`,
167
- });
168
- }
169
- }
203
+ else if (JSON.stringify(declared) !== JSON.stringify(expectedSet)) {
204
+ findings.push({
205
+ file: rootFiles.join(", ") || path.join(CONFIG_DIRNAME, "config.jsonc"),
206
+ problem: `declared tokenEnv set [${declared.join(", ")}] != expected [${expectedSet.join(", ")}] — a PR must not add, drop, or repoint which secrets are forwarded to model providers`,
207
+ });
170
208
  }
171
209
  }
172
210
  return { ok: findings.length === 0, findings };
@@ -54,7 +54,14 @@ async function loadConfigDir(dir, schema) {
54
54
  const raw = await readFile(configPath, "utf8");
55
55
  const rawObject = JSON.parse(stripTrailingCommas(stripJsonComments(raw)));
56
56
  const parsed = schema.parse(rawObject);
57
- const override = process.env.REVIEWER_MODEL;
57
+ // An EMPTY REVIEWER_MODEL means "not set", not "use the empty model". GitHub Actions
58
+ // passes `${{ vars.REVIEWER_MODEL }}` as an empty string whenever that repo variable
59
+ // doesn't exist — which both scaffolded workflows do — so `??` (which only falls
60
+ // through on null/undefined) silently replaced every configured model with "". Every
61
+ // agent and the coordinator then ran on whatever OpenCode picked by default, so a
62
+ // config saying `anthropic/claude-sonnet-5` reviewed with something else entirely and
63
+ // nothing anywhere said so. Trim too: a stray newline is the same class of accident.
64
+ const override = process.env.REVIEWER_MODEL?.trim() || undefined;
58
65
  const defaultModel = override ?? parsed.model;
59
66
  const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
60
67
  const resolveTemp = (value, fallback) => {
@@ -112,15 +119,51 @@ async function loadConfigDir(dir, schema) {
112
119
  // Scope configs can't declare commentTag (scope schema rejects it);
113
120
  // loadScopeConfig overwrites this placeholder with the manifest default.
114
121
  commentTag: parsed.commentTag ?? "expo-ai-code-reviewer",
115
- auth: {
116
- mode: parsed.auth?.mode ?? "api-key",
117
- provider: parsed.auth?.provider ?? "anthropic",
118
- tokenEnv: parsed.auth?.tokenEnv,
119
- },
122
+ auth: normalizeAuth(parsed.auth),
120
123
  review: parsed.review,
121
124
  };
122
125
  return { config, raw: rawObject };
123
126
  }
127
+ /**
128
+ * Normalize either accepted `auth` shape (legacy single object, or the
129
+ * per-provider `{ providers }` map) into the canonical entry list. Absent auth
130
+ * means the schema default (api-key/openai, no tokenEnv).
131
+ */
132
+ export function normalizeAuth(auth) {
133
+ if (!auth) {
134
+ return [{ provider: "openai", mode: "api-key" }];
135
+ }
136
+ if ("providers" in auth) {
137
+ return Object.entries(auth.providers).map(([provider, entry]) => ({
138
+ provider,
139
+ mode: entry.mode,
140
+ tokenEnv: entry.tokenEnv,
141
+ upstream: entry.upstream,
142
+ }));
143
+ }
144
+ return [{ provider: auth.provider, mode: auth.mode, tokenEnv: auth.tokenEnv }];
145
+ }
146
+ /**
147
+ * Runtime auth lock: null when the entries' tokenEnv names equal the expected
148
+ * comma-separated set exactly (order-insensitive), else a human-readable
149
+ * mismatch. Set semantics because a multi-provider auth block names several
150
+ * credential envs — a PR must not be able to add, drop, or repoint any of them.
151
+ */
152
+ export function tokenEnvMismatch(auth, expected) {
153
+ const declared = [
154
+ ...new Set(auth.map((entry) => entry.tokenEnv).filter((v) => Boolean(v))),
155
+ ].sort();
156
+ const expectedSet = [
157
+ ...new Set(expected
158
+ .split(",")
159
+ .map((name) => name.trim())
160
+ .filter(Boolean)),
161
+ ].sort();
162
+ if (JSON.stringify(declared) === JSON.stringify(expectedSet)) {
163
+ return null;
164
+ }
165
+ return `configured tokenEnv set [${declared.join(", ") || "(none)"}] != ECR_EXPECTED_TOKEN_ENV [${expectedSet.join(", ")}]`;
166
+ }
124
167
  /**
125
168
  * auth is honored ONLY here: the manifest's `defaults.auth` wins when present,
126
169
  * otherwise the root config.jsonc auth. A scope config can never contribute auth
@@ -130,7 +173,7 @@ async function loadConfigDir(dir, schema) {
130
173
  export function loadAuthFromRoot(rootConfig, manifest) {
131
174
  const override = manifest?.defaults.auth;
132
175
  if (override) {
133
- return { mode: override.mode, provider: override.provider, tokenEnv: override.tokenEnv };
176
+ return normalizeAuth(override);
134
177
  }
135
178
  return rootConfig.auth;
136
179
  }
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  export const ReviewConfigSchema = z.object({
4
4
  /** Default model for every agent + the coordinator. Override per-agent via
5
5
  * frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
6
- model: z.string().default("anthropic/claude-sonnet-5"),
6
+ model: z.string().default("openai/gpt-5.5"),
7
7
  policy: z
8
8
  .object({
9
9
  includeSuggestions: z.boolean().default(false),
@@ -52,17 +52,45 @@ export const ReviewConfigSchema = z.object({
52
52
  .object({ marker: z.string().default("/skip-review") })
53
53
  .default({ marker: "/skip-review" }),
54
54
  commentTag: z.string().default("expo-ai-code-reviewer"),
55
+ // Two accepted shapes (see AuthConfigEntry for the canonical internal form):
56
+ // - legacy single credential: { mode, provider, tokenEnv }
57
+ // - per-provider map: { providers: { <id>: { mode, tokenEnv, upstream? } } }
58
+ // The map form allows a MIXED setup — e.g. the "openai" provider on a ChatGPT/Codex
59
+ // subscription (mode "oauth", tokenEnv = the refresh token) plus an "openai-api"
60
+ // alias (upstream "openai") holding a metered API key for pro-tier models the
61
+ // subscription doesn't offer.
62
+ //
63
+ // Union order matters: the map form must be tried FIRST — the legacy object's keys
64
+ // all have defaults, so a non-strict legacy parse would accept (and gut) a
65
+ // { providers } object by stripping the unknown key.
55
66
  auth: z
56
- .object({
57
- // "api-key": the token env is sent as the provider's API key (x-api-key).
58
- // "oauth": the token env is a Claude Pro/Max style OAuth token, injected
59
- // into an isolated OpenCode auth.json so it's sent as a Bearer token.
60
- mode: z.enum(["api-key", "oauth"]).default("api-key"),
61
- provider: z.string().default("anthropic"),
62
- /** Env var holding the key/token. */
63
- tokenEnv: z.string().optional(),
64
- })
65
- .default({ mode: "api-key", provider: "anthropic" }),
67
+ .union([
68
+ z.object({
69
+ providers: z.record(z.string(), z.object({
70
+ // "api-key": tokenEnv holds the provider's API key.
71
+ // "oauth": tokenEnv holds an OAuth token, injected into an isolated
72
+ // OpenCode auth.json. For "openai" this is the REFRESH token from a
73
+ // ChatGPT/Codex sign-in (OpenCode's codex plugin mints access tokens
74
+ // from it). NOTE: anthropic oauth cannot work — OpenCode has no
75
+ // anthropic OAuth plugin and Anthropic prohibits subscription tokens
76
+ // in third-party tools.
77
+ mode: z.enum(["api-key", "oauth"]).default("api-key"),
78
+ tokenEnv: z.string().optional(),
79
+ // Set ⇒ this provider id is an ALIAS synthesized into the OpenCode
80
+ // config, backed by the named upstream's SDK ("openai", "anthropic",
81
+ // anything else = openai-compatible). Lets one upstream be reached
82
+ // with two credentials at once (subscription + API key).
83
+ upstream: z.string().optional(),
84
+ })),
85
+ }),
86
+ z.object({
87
+ mode: z.enum(["api-key", "oauth"]).default("api-key"),
88
+ provider: z.string().default("openai"),
89
+ /** Env var holding the key/token. */
90
+ tokenEnv: z.string().optional(),
91
+ }),
92
+ ])
93
+ .default({ mode: "api-key", provider: "openai" }),
66
94
  review: z
67
95
  .object({
68
96
  // Which PRs `ecr ci` acts on — the source of truth for trigger policy (a
@@ -104,21 +132,23 @@ export const RoutingManifestSchema = z
104
132
  budget: z
105
133
  .object({
106
134
  /** Total passes budget (minutes) split across active scopes. Sized to fit
107
- * the scaffolded workflow's `timeout-minutes` (60) with margin for the
108
- * coordinator, verification, and git/gh overhead. */
109
- totalPassesMinutes: z.number().int().positive().default(32),
135
+ * the scaffolded workflow's `timeout-minutes` (90) with margin for the
136
+ * coordinator (10m), verification, and git/gh overhead. The cross-file pass
137
+ * expands to fill whatever of this window is left (see review.ts), so this
138
+ * is the knob that decides how long it may trace. */
139
+ totalPassesMinutes: z.number().int().positive().default(55),
110
140
  /** Per-scope floor (minutes): below this a scope review isn't worth
111
141
  * starting, so the even split clamps up to it — even when that makes the
112
142
  * scopes overshoot the total (ecr ci warns; doctor flags the worst case). */
113
143
  minScopeMinutes: z.number().int().positive().default(5),
114
144
  })
115
- .default({ totalPassesMinutes: 32, minScopeMinutes: 5 }),
145
+ .default({ totalPassesMinutes: 55, minScopeMinutes: 5 }),
116
146
  defaults: z
117
147
  .object({
118
148
  /** The ONLY manifest-level place auth is honored (locks the root value).
119
149
  * Unwrap the inner `.default()` first: in zod v4 a `.default().optional()`
120
150
  * chain still fires the default when the key is absent, which would make
121
- * `defaults.auth` a phantom `{mode:'api-key',provider:'anthropic'}` for every
151
+ * `defaults.auth` a phantom `{mode:'api-key',provider:'openai'}` for every
122
152
  * manifest that omits auth and silently override the root config's real auth. */
123
153
  auth: ReviewConfigSchema.shape.auth.unwrap().optional(),
124
154
  /** Agent ids injected into every scope with alwaysRun, from the ROOT roster. */