@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.
@@ -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
  }