@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.
- package/README.md +133 -50
- package/build/commands/ci.js +10 -6
- package/build/commands/doctor.js +57 -11
- 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 +3 -3
- 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/config/load.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
176
|
+
return normalizeAuth(override);
|
|
134
177
|
}
|
|
135
178
|
return rootConfig.auth;
|
|
136
179
|
}
|
package/build/config/schema.js
CHANGED
|
@@ -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("
|
|
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
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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` (
|
|
108
|
-
* coordinator, verification, and git/gh overhead.
|
|
109
|
-
|
|
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:
|
|
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:'
|
|
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. */
|
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
|
}
|