@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.
- package/README.md +139 -58
- package/build/cli.js +5 -0
- package/build/commands/ci.js +10 -6
- package/build/commands/doctor.js +82 -11
- package/build/commands/setup-auth.js +200 -0
- package/build/commands/verify-config.js +65 -27
- package/build/config/load.js +50 -7
- package/build/config/schema.js +46 -16
- package/build/core/auth.js +209 -50
- package/build/core/coordinator.js +2 -2
- package/build/core/opencode.js +453 -53
- package/build/core/prompts.js +68 -7
- package/build/core/review.js +145 -32
- package/build/core/verify.js +4 -2
- package/package.json +5 -4
- package/templates/agents/security.md +4 -4
- package/templates/command.yml +11 -8
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +3 -3
- package/templates/routing.jsonc +1 -1
- package/templates/workflow.yml +13 -8
package/build/core/auth.js
CHANGED
|
@@ -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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
47
|
-
const {
|
|
48
|
-
|
|
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:
|
|
51
|
-
detail:
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
101
|
-
*
|
|
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
|
|
104
|
-
* (so the workflow can pass a namespaced secret and
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
115
|
-
// runs against their own
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
}
|