@expo/code-review-cli 0.4.0 → 0.5.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.
@@ -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. */
@@ -33,24 +33,93 @@ const FORBIDDEN_TOKEN_ENVS = new Set([
33
33
  ]);
34
34
  const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
35
35
  /**
36
- * Decide whether the configured model provider has a usable credential, WITHOUT
37
- * mutating the environment. Shared by `prepareAuth` (fail fast before spinning up
38
- * the server and every pass) and `doctor` (report), so the two never drift.
36
+ * Documented Anthropic OAuth token shape: `sk-ant-oat01-` + 95 chars = 108 total
37
+ * (what `claude setup-token` prints). Treated as a STRONG HINT, not a spec: it comes
38
+ * from observed tokens and third-party sources, not an Anthropic format guarantee, so
39
+ * an unrecognized shape only warns. The one exception is below — a value that becomes
40
+ * exactly this shape by restoring a dropped `sk-` is provably a mangled paste.
41
+ */
42
+ const ANTHROPIC_OAUTH_PREFIX = "sk-ant-oat";
43
+ /** Anthropic API keys — valid credentials, but for auth.mode "api-key", not "oauth". */
44
+ const ANTHROPIC_API_KEY_PREFIX = "sk-ant-api";
45
+ /** Shorter than any credential of any provider ⇒ truncated, whatever the format. */
46
+ const MIN_TOKEN_LENGTH = 40;
47
+ /**
48
+ * Catch an OAuth token that CANNOT work before it costs a whole run.
49
+ *
50
+ * Motivation: OpenCode refuses a malformed credential by dropping the provider from
51
+ * its provider list entirely, so every configured model then reports "model not
52
+ * found" and nothing anywhere mentions credentials. That misdirection cost a full
53
+ * debugging session for a value that had simply lost its leading `sk-`.
39
54
  *
40
- * We only report `ok: false` when we're confident there is no credential a
41
- * missing OAuth token, a forbidden tokenEnv, or an api-key run with neither the
42
- * configured tokenEnv nor the provider's own key env set. When nothing is
43
- * configured and no known key env is present, we assume OpenCode's own login may
44
- * cover it and don't hard-fail. `REVIEWER_MODEL` bypasses provider auth entirely.
55
+ * The bar for `ok: false` is "this cannot be a valid credential", not "this looks
56
+ * odd", because a false rejection blocks a working setup a worse failure than the
57
+ * one being prevented. Provable: whitespace, absurdly short, an API key in oauth
58
+ * mode, and an OAuth token missing its `sk-`. Everything else is at most a warning,
59
+ * and formats of providers we don't know are never judged at all.
45
60
  */
46
- export function checkProviderAuth(config, env = process.env) {
47
- const { mode, provider, tokenEnv } = config.auth;
48
- if (env.REVIEWER_MODEL) {
61
+ export function checkOauthTokenShape(provider, token, tokenEnv) {
62
+ const ok = { ok: true, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
63
+ const fix = `Re-generate it with \`claude setup-token\` and set ${tokenEnv} to the full value.`;
64
+ if (token !== token.trim()) {
49
65
  return {
50
- ok: true,
51
- detail: `REVIEWER_MODEL override (${env.REVIEWER_MODEL}); using OpenCode's own login for that model`,
66
+ ok: false,
67
+ detail: `${tokenEnv} has leading/trailing whitespace (a newline from a copy-paste or a ` +
68
+ `\`cat\`-ed file is the usual cause); the provider will refuse it. ${fix}`,
69
+ };
70
+ }
71
+ if (token.length < MIN_TOKEN_LENGTH) {
72
+ return {
73
+ ok: false,
74
+ detail: `${tokenEnv} holds only ${token.length} characters, too short to be a real ${provider} token — it looks truncated. ${fix}`,
75
+ };
76
+ }
77
+ // Only anthropic's formats are known well enough to say anything about.
78
+ if (provider !== "anthropic") {
79
+ return ok;
80
+ }
81
+ if (token.startsWith(ANTHROPIC_API_KEY_PREFIX)) {
82
+ return {
83
+ ok: false,
84
+ detail: `${tokenEnv} holds an Anthropic API key ("${ANTHROPIC_API_KEY_PREFIX}…"), but auth.mode is ` +
85
+ `"oauth", which expects the OAuth token from \`claude setup-token\` ` +
86
+ `("${ANTHROPIC_OAUTH_PREFIX}…"). Either set auth.mode to "api-key" in ` +
87
+ `.expo-code-review/config.jsonc, or put an OAuth token in ${tokenEnv}.`,
52
88
  };
53
89
  }
90
+ // Provable mangled paste: prepending the dropped "sk-" yields exactly the documented
91
+ // OAuth shape. Only this reconstruction earns a hard failure — the value is not a
92
+ // credential of any known kind as it stands, but is one character-for-character with
93
+ // its prefix restored.
94
+ if (!token.startsWith("sk-") && `sk-${token}`.startsWith(ANTHROPIC_OAUTH_PREFIX)) {
95
+ return {
96
+ ok: false,
97
+ detail: `${tokenEnv} starts with "${token.slice(0, 10)}…", which is an Anthropic OAuth token ` +
98
+ `missing its leading "sk-" — the prefix was dropped when the value was copied ` +
99
+ `(adding it back gives "${ANTHROPIC_OAUTH_PREFIX}…", the shape \`claude setup-token\` ` +
100
+ `prints). OpenCode refuses a malformed credential by dropping the provider entirely, ` +
101
+ `which then surfaces as "model not found" for every model. ${fix}`,
102
+ };
103
+ }
104
+ if (!token.startsWith(ANTHROPIC_OAUTH_PREFIX)) {
105
+ // Unrecognized, but we can't prove it's wrong: Anthropic can mint shapes we don't
106
+ // know, so advise and continue rather than refusing to run.
107
+ return {
108
+ ...ok,
109
+ warning: `${tokenEnv} does not start with "${ANTHROPIC_OAUTH_PREFIX}…" (the shape ` +
110
+ `\`claude setup-token\` prints for auth.mode "oauth"). It may still be valid — but if ` +
111
+ `the run fails with "model not found" for every model, the credential was refused, ` +
112
+ `and this is the first thing to check.`,
113
+ };
114
+ }
115
+ return ok;
116
+ }
117
+ /**
118
+ * Decide whether ONE configured credential is usable, WITHOUT mutating the
119
+ * environment. See checkProviderAuth for the all-entries wrapper.
120
+ */
121
+ export function checkAuthEntry(entry, env = process.env) {
122
+ const { mode, provider, tokenEnv, upstream } = entry;
54
123
  if (tokenEnv && FORBIDDEN_TOKEN_ENVS.has(tokenEnv)) {
55
124
  return {
56
125
  ok: false,
@@ -59,11 +128,28 @@ export function checkProviderAuth(config, env = process.env) {
59
128
  `token minted for the provider instead.`,
60
129
  };
61
130
  }
131
+ // A well-known provider key env may only feed THAT provider. The tokenEnv guard
132
+ // locks which env names are forwarded, but not where they go — without this, a
133
+ // PR-supplied auth entry could keep the locked name (e.g. OPENAI_API_KEY) and
134
+ // point its provider/upstream somewhere else, sending one provider's key to a
135
+ // different provider.
136
+ if (tokenEnv) {
137
+ const keyOwner = Object.entries(PROVIDER_KEY_ENV).find(([, env]) => env === tokenEnv)?.[0];
138
+ if (keyOwner && keyOwner !== provider && keyOwner !== upstream) {
139
+ return {
140
+ ok: false,
141
+ detail: `auth for ${provider} names tokenEnv "${tokenEnv}", which is ${keyOwner}'s ` +
142
+ `well-known key env — refusing to send one provider's credential to another. ` +
143
+ `Use a credential minted for ${provider}${upstream ? ` (upstream ${upstream})` : ""}, ` +
144
+ `or fix the provider/upstream mapping.`,
145
+ };
146
+ }
147
+ }
62
148
  if (mode === "oauth") {
63
149
  if (!tokenEnv) {
64
150
  return {
65
151
  ok: false,
66
- detail: 'auth.mode "oauth" requires auth.tokenEnv to name the env var holding the OAuth token.',
152
+ detail: `auth mode "oauth" for ${provider} requires tokenEnv to name the env var holding the OAuth token.`,
67
153
  };
68
154
  }
69
155
  if (!env[tokenEnv]) {
@@ -72,7 +158,32 @@ export function checkProviderAuth(config, env = process.env) {
72
158
  detail: `auth is oauth for ${provider} but token env "${tokenEnv}" is not set.`,
73
159
  };
74
160
  }
75
- return { ok: true, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
161
+ const shape = checkOauthTokenShape(provider, env[tokenEnv], tokenEnv);
162
+ if (!shape.ok) {
163
+ return shape;
164
+ }
165
+ return { ...shape, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
166
+ }
167
+ // api-key with an upstream alias: the synthesized provider reads the key
168
+ // straight from {env:tokenEnv}, so both must exist — there is no ambient
169
+ // fallback for a provider id we invented.
170
+ if (upstream) {
171
+ if (!tokenEnv) {
172
+ return {
173
+ ok: false,
174
+ detail: `auth for ${provider} (upstream ${upstream}) requires tokenEnv — the synthesized provider reads the key from that env var.`,
175
+ };
176
+ }
177
+ if (!env[tokenEnv]) {
178
+ return {
179
+ ok: false,
180
+ detail: `auth for ${provider} (upstream ${upstream}) names token env "${tokenEnv}" but it is not set.`,
181
+ };
182
+ }
183
+ return {
184
+ ok: true,
185
+ detail: `api-key for ${provider} (upstream ${upstream}); token env ${tokenEnv} is set`,
186
+ };
76
187
  }
77
188
  // api-key: usable if the configured tokenEnv is set, or the provider's own key
78
189
  // env is already present in the environment.
@@ -97,27 +208,79 @@ export function checkProviderAuth(config, env = process.env) {
97
208
  };
98
209
  }
99
210
  /**
100
- * Prepare model credentials for the OpenCode server based on the repo's auth mode.
101
- * Must run before the server starts (it mutates env). Returns a cleanup handle.
211
+ * Decide whether EVERY configured provider credential is usable, WITHOUT
212
+ * mutating the environment. Shared by `prepareAuth` (fail fast before spinning up
213
+ * the server and every pass) and `doctor` (report), so the two never drift.
214
+ *
215
+ * All entries must pass — a mixed setup with one broken credential would fail
216
+ * exactly the passes routed to it, which is the silent-degradation this check
217
+ * exists to prevent. `REVIEWER_MODEL` bypasses provider auth entirely.
218
+ */
219
+ export function checkProviderAuth(config, env = process.env) {
220
+ if (env.REVIEWER_MODEL) {
221
+ return {
222
+ ok: true,
223
+ detail: `REVIEWER_MODEL override (${env.REVIEWER_MODEL}); using OpenCode's own login for that model`,
224
+ };
225
+ }
226
+ const details = [];
227
+ const warnings = [];
228
+ for (const entry of config.auth) {
229
+ const readiness = checkAuthEntry(entry, env);
230
+ if (!readiness.ok) {
231
+ return readiness;
232
+ }
233
+ details.push(readiness.detail);
234
+ if (readiness.warning) {
235
+ warnings.push(readiness.warning);
236
+ }
237
+ }
238
+ return {
239
+ ok: true,
240
+ detail: details.join("; "),
241
+ ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
242
+ };
243
+ }
244
+ /**
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.
249
+ * - everything else: the token IS the access credential (e.g. long-lived
250
+ * setup-token style bearers), far-future expiry so OpenCode never tries to
251
+ * refresh a credential that has no refresh half.
252
+ */
253
+ export function oauthAuthJsonEntry(provider, token) {
254
+ if (provider === "openai") {
255
+ return { type: "oauth", access: "", refresh: token, expires: 0 };
256
+ }
257
+ return { type: "oauth", access: token, refresh: "", expires: Date.now() + YEAR_MS };
258
+ }
259
+ /**
260
+ * Prepare model credentials for the OpenCode server from the repo's auth entries
261
+ * (any mix of modes/providers). Must run before the server starts (it mutates
262
+ * env). Returns a cleanup handle.
102
263
  *
103
- * - `api-key`: copy the configured token env into the provider's API-key env var
104
- * (so the workflow can pass a namespaced secret and OpenCode still finds it).
105
- * - `oauth`: write an isolated OpenCode `auth.json` with the token as a Bearer
106
- * OAuth credential and point OpenCode at it via XDG_DATA_HOME, so it uses its
107
- * native Claude Pro/Max path (correct bearer + oauth headers) rather than
108
- * x-api-key. Isolated so it never touches the developer's real auth.json.
264
+ * - `api-key` (standard provider): copy the configured token env into the
265
+ * provider's API-key env var (so the workflow can pass a namespaced secret and
266
+ * OpenCode still finds it).
267
+ * - `api-key` (upstream alias): nothing to do here buildOpencodeConfig
268
+ * synthesizes a provider block whose options read `{env:tokenEnv}` directly.
269
+ * - `oauth`: write ALL oauth credentials into one isolated OpenCode `auth.json`
270
+ * and point OpenCode at it via XDG_DATA_HOME (see oauthAuthJsonEntry for the
271
+ * per-provider shapes). Isolated so it never touches the developer's real
272
+ * auth.json.
109
273
  */
110
274
  export async function prepareAuth(config) {
111
275
  const noop = { cleanup: async () => { } };
112
- const { mode, provider, tokenEnv } = config.auth;
113
276
  // REVIEWER_MODEL is an explicit "use this model with my own creds" override — a
114
- // common local case (e.g. the repo config targets Claude OAuth in CI, but a dev
115
- // runs against their own OpenAI login). Don't inject the configured provider's
277
+ // common local case (e.g. the repo config targets a CI credential, but a dev
278
+ // runs against their own OpenCode login). Don't inject the configured providers'
116
279
  // auth; let OpenCode use whatever it's logged into for the override model.
117
280
  if (process.env.REVIEWER_MODEL) {
118
281
  return noop;
119
282
  }
120
- // Fail fast, before starting the server and every pass, if the configured
283
+ // Fail fast, before starting the server and every pass, if ANY configured
121
284
  // provider has no usable credential — otherwise it surfaces as N failed passes
122
285
  // mid-run. This is the same readiness check `doctor` reports, and it also covers
123
286
  // the forbidden-secret guard (refusing to forward a well-known unrelated secret).
@@ -125,36 +288,32 @@ export async function prepareAuth(config) {
125
288
  if (!readiness.ok) {
126
289
  throw new Error(readiness.detail);
127
290
  }
128
- if (mode === "api-key") {
129
- if (tokenEnv) {
130
- const value = process.env[tokenEnv];
131
- const target = PROVIDER_KEY_ENV[provider] ?? "ANTHROPIC_API_KEY";
132
- // The explicitly-configured tokenEnv is authoritative — set it even if the
133
- // provider env is already present, so config wins over ambient env.
134
- if (value) {
291
+ const authJson = {};
292
+ for (const entry of config.auth) {
293
+ const { mode, provider, tokenEnv, upstream } = entry;
294
+ const value = tokenEnv ? process.env[tokenEnv] : undefined;
295
+ if (mode === "api-key") {
296
+ // Upstream aliases read {env:tokenEnv} from the synthesized provider block.
297
+ if (!upstream && tokenEnv && value) {
298
+ const target = PROVIDER_KEY_ENV[provider] ?? "ANTHROPIC_API_KEY";
299
+ // The explicitly-configured tokenEnv is authoritative — set it even if the
300
+ // provider env is already present, so config wins over ambient env.
135
301
  process.env[target] = value;
136
302
  }
303
+ continue;
137
304
  }
138
- return noop;
305
+ // oauth — checkProviderAuth guarantees tokenEnv is set and present; read
306
+ // defensively so TypeScript narrows and this stays correct if called directly.
307
+ if (!value) {
308
+ throw new Error(`auth mode "oauth" for ${provider} requires tokenEnv to name a set OAuth token env.`);
309
+ }
310
+ authJson[provider] = oauthAuthJsonEntry(provider, value);
139
311
  }
140
- // oauth checkProviderAuth guarantees tokenEnv is set and present; read
141
- // defensively so TypeScript narrows and this stays correct if called directly.
142
- const token = tokenEnv ? process.env[tokenEnv] : undefined;
143
- if (!token) {
144
- throw new Error('auth.mode "oauth" requires auth.tokenEnv to name a set OAuth token env.');
312
+ if (Object.keys(authJson).length === 0) {
313
+ return noop;
145
314
  }
146
315
  const dir = await mkdtemp(path.join(tmpdir(), "ecr-auth-"));
147
316
  await mkdir(path.join(dir, "opencode"), { recursive: true });
148
- const authJson = {
149
- [provider]: {
150
- type: "oauth",
151
- access: token,
152
- refresh: "",
153
- // Far-future expiry so OpenCode uses the token as-is and does not try to
154
- // refresh it (setup-token tokens are long-lived and carry no refresh).
155
- expires: Date.now() + YEAR_MS,
156
- },
157
- };
158
317
  await writeFile(path.join(dir, "opencode", "auth.json"), JSON.stringify(authJson), "utf8");
159
318
  process.env.XDG_DATA_HOME = dir;
160
319
  return {
@@ -12,7 +12,7 @@ const COORDINATOR_TIMEOUT_MS = 10 * 60 * 1000;
12
12
  export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = []) {
13
13
  const system = buildCoordinatorSystem(config);
14
14
  const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes);
15
- const { value, cost, tokens, truncated } = await promptAndParse(handle, {
15
+ const { value, cost, tokens, truncated, model } = await promptAndParse(handle, {
16
16
  agent: "coordinator",
17
17
  system,
18
18
  text,
@@ -20,5 +20,5 @@ export async function coordinate(handle, config, metadata, agentFindings, covera
20
20
  maxWaitMs: COORDINATOR_TIMEOUT_MS,
21
21
  finalizeOnTimeout: true,
22
22
  }, parseCoordinatorOutput);
23
- return { output: value, cost, tokens, truncated };
23
+ return { output: value, cost, tokens, truncated, model };
24
24
  }