@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
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
tokenEnvOccurrences.push({ file: rel(file), value
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
148
|
-
problem: `tokenEnv is declared in ${
|
|
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
|
-
//
|
|
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
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 };
|
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. */
|