@expo/code-review-cli 0.6.0 → 0.7.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 +33 -12
- package/build/commands/ci.js +8 -8
- package/build/commands/doctor.js +167 -33
- package/build/commands/setup-auth.js +83 -11
- package/build/config/schema.js +7 -3
- package/build/core/auth.js +122 -9
- package/build/core/claude-code.js +680 -0
- package/build/core/exec.js +278 -9
- package/build/core/opencode.js +95 -15
- package/build/core/prompts.js +19 -2
- package/build/core/render.js +21 -2
- package/build/core/review.js +158 -25
- package/build/core/schema.js +6 -1
- package/build/core/scrub.js +59 -1
- package/build/core/throttle.js +10 -0
- package/build/core/util.js +17 -0
- package/build/core/verify.js +13 -1
- package/build/reporters/github.js +79 -13
- package/build/sources/github-pr.js +14 -7
- package/build/sources/local-git.js +3 -2
- package/package.json +3 -3
- package/templates/config.jsonc +21 -3
- package/templates/shared.md +28 -0
package/build/core/exec.js
CHANGED
|
@@ -1,36 +1,271 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { promisify } from "node:util";
|
|
3
5
|
const execFileAsync = promisify(execFile);
|
|
4
6
|
/**
|
|
5
7
|
* Run a command capturing stdout/stderr. Never interpolates a shell, so
|
|
6
8
|
* arguments are passed verbatim and are not subject to shell injection.
|
|
9
|
+
*
|
|
10
|
+
* With `input`, the call routes through spawn so the text can be streamed to
|
|
11
|
+
* stdin; every other caller keeps the execFile path unchanged.
|
|
7
12
|
*/
|
|
8
13
|
export async function run(command, args, options = {}) {
|
|
14
|
+
if (options.input !== undefined) {
|
|
15
|
+
return runWithInput(command, args, options, options.input);
|
|
16
|
+
}
|
|
9
17
|
const check = options.check ?? true;
|
|
10
18
|
try {
|
|
11
19
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
12
20
|
cwd: options.cwd,
|
|
13
21
|
maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
|
|
14
22
|
encoding: "utf8",
|
|
23
|
+
env: options.env,
|
|
24
|
+
timeout: options.timeout,
|
|
25
|
+
killSignal: options.killSignal,
|
|
15
26
|
});
|
|
16
27
|
return { stdout, stderr, code: 0 };
|
|
17
28
|
}
|
|
18
29
|
catch (error) {
|
|
19
30
|
const err = error;
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
// Same timeout contract as runWithInput: a child our own `timeout` killed
|
|
32
|
+
// resolves with `timedOut: true` (even under check) instead of a generic
|
|
33
|
+
// throw, so callers see one shape regardless of which path ran.
|
|
34
|
+
const timedOut = options.timeout !== undefined && err.killed === true;
|
|
35
|
+
if (!check || timedOut) {
|
|
36
|
+
return {
|
|
37
|
+
stdout: err.stdout ?? "",
|
|
38
|
+
stderr: err.stderr ?? "",
|
|
39
|
+
code: err.code ?? 1,
|
|
40
|
+
signal: err.signal,
|
|
41
|
+
timedOut: timedOut || undefined,
|
|
42
|
+
};
|
|
22
43
|
}
|
|
23
44
|
throw new Error(`Command failed: ${command} ${args.join(" ")}\n${err.stderr ?? err.message ?? ""}`.trim());
|
|
24
45
|
}
|
|
25
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Kill callbacks for children still running, so an interrupt or exit never
|
|
49
|
+
* orphans them. Detached children live in their own process group (see
|
|
50
|
+
* runWithInput), so SIGINT from Ctrl-C reaches only this process — without this,
|
|
51
|
+
* an aborted review leaves a credential-bearing `claude` running unbounded.
|
|
52
|
+
*/
|
|
53
|
+
const liveChildKillers = new Set();
|
|
54
|
+
let childCleanupInstalled = false;
|
|
55
|
+
function installChildCleanup() {
|
|
56
|
+
if (childCleanupInstalled) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
childCleanupInstalled = true;
|
|
60
|
+
const killAll = () => {
|
|
61
|
+
for (const kill of liveChildKillers) {
|
|
62
|
+
kill();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
process.on("exit", killAll);
|
|
66
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
67
|
+
process.on(signal, () => {
|
|
68
|
+
killAll();
|
|
69
|
+
// Re-raise the conventional exit code; registering a handler suppressed
|
|
70
|
+
// Node's default termination.
|
|
71
|
+
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
72
|
+
process.exit();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* spawn-based variant that feeds `input` to the child's stdin. Collects
|
|
78
|
+
* stdout/stderr up to `maxBuffer`, enforces `timeout`/`killSignal` manually, and
|
|
79
|
+
* resolves the same RunResult shape (with `timedOut`/`overflowed` set).
|
|
80
|
+
*
|
|
81
|
+
* The deadline is enforced with our own timers rather than spawn's native
|
|
82
|
+
* `timeout`: spawn sends `killSignal` once, to the direct child only, with no
|
|
83
|
+
* SIGKILL escalation — a child that traps SIGTERM, or a shim wrapper
|
|
84
|
+
* (volta/mise/asdf) whose grandchild holds the work, would run unbounded. Here a
|
|
85
|
+
* child launched detached forms its own process group, we signal the whole group,
|
|
86
|
+
* and a grace timer escalates to SIGKILL.
|
|
87
|
+
*/
|
|
88
|
+
function runWithInput(command, args, options, input) {
|
|
89
|
+
const check = options.check ?? true;
|
|
90
|
+
const maxBuffer = options.maxBuffer ?? 64 * 1024 * 1024;
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const detached = process.platform !== "win32";
|
|
93
|
+
const child = spawn(command, args, {
|
|
94
|
+
cwd: options.cwd,
|
|
95
|
+
env: options.env,
|
|
96
|
+
detached,
|
|
97
|
+
});
|
|
98
|
+
let stdout = "";
|
|
99
|
+
let stderr = "";
|
|
100
|
+
let overflowed = false;
|
|
101
|
+
let timedOut = false;
|
|
102
|
+
let killTimer;
|
|
103
|
+
let graceTimer;
|
|
104
|
+
const clearTimers = () => {
|
|
105
|
+
if (killTimer)
|
|
106
|
+
clearTimeout(killTimer);
|
|
107
|
+
if (graceTimer)
|
|
108
|
+
clearTimeout(graceTimer);
|
|
109
|
+
};
|
|
110
|
+
// Signal the whole process group when detached so a shim wrapper's grandchild
|
|
111
|
+
// is killed too. On Windows there are no process groups — kill the tree via
|
|
112
|
+
// taskkill instead, for the same reason.
|
|
113
|
+
const killChild = (sig) => {
|
|
114
|
+
try {
|
|
115
|
+
if (process.platform === "win32" && child.pid !== undefined) {
|
|
116
|
+
// A no-op error handler is required: an async spawn failure (ENOENT/EPERM)
|
|
117
|
+
// has no other listener here and would otherwise throw unhandled and
|
|
118
|
+
// crash the parent, defeating the point of this cleanup path.
|
|
119
|
+
spawn(taskkillPath(), ["/pid", String(child.pid), "/T", "/F"]).on("error", () => { });
|
|
120
|
+
}
|
|
121
|
+
else if (detached && child.pid !== undefined) {
|
|
122
|
+
process.kill(-child.pid, sig);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
child.kill(sig);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Already exited, or the group is gone — nothing to kill.
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const emergencyKill = () => killChild("SIGKILL");
|
|
133
|
+
installChildCleanup();
|
|
134
|
+
liveChildKillers.add(emergencyKill);
|
|
135
|
+
if (options.timeout && options.timeout > 0) {
|
|
136
|
+
killTimer = setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
killChild(options.killSignal ?? "SIGTERM");
|
|
139
|
+
graceTimer = setTimeout(() => killChild("SIGKILL"), 5000);
|
|
140
|
+
graceTimer.unref?.();
|
|
141
|
+
}, options.timeout);
|
|
142
|
+
killTimer.unref?.();
|
|
143
|
+
}
|
|
144
|
+
const cap = (current, chunk) => {
|
|
145
|
+
if (current.length >= maxBuffer) {
|
|
146
|
+
overflowed = true;
|
|
147
|
+
return current;
|
|
148
|
+
}
|
|
149
|
+
const next = current + chunk;
|
|
150
|
+
// Check AFTER appending too — a single oversized chunk must both flag the
|
|
151
|
+
// overflow and stay capped, not sail through because the pre-append length
|
|
152
|
+
// was still under the limit.
|
|
153
|
+
if (next.length > maxBuffer) {
|
|
154
|
+
overflowed = true;
|
|
155
|
+
return next.slice(0, maxBuffer);
|
|
156
|
+
}
|
|
157
|
+
return next;
|
|
158
|
+
};
|
|
159
|
+
child.stdout.setEncoding("utf8");
|
|
160
|
+
child.stderr.setEncoding("utf8");
|
|
161
|
+
child.stdout.on("data", (chunk) => {
|
|
162
|
+
stdout = cap(stdout, chunk);
|
|
163
|
+
});
|
|
164
|
+
child.stderr.on("data", (chunk) => {
|
|
165
|
+
stderr = cap(stderr, chunk);
|
|
166
|
+
});
|
|
167
|
+
// A late I/O error on either stream (e.g. the process group getting
|
|
168
|
+
// SIGKILLed mid-read) would otherwise throw unhandled and crash the
|
|
169
|
+
// parent; the close handler reports the real outcome regardless.
|
|
170
|
+
child.stdout.on("error", () => { });
|
|
171
|
+
child.stderr.on("error", () => { });
|
|
172
|
+
child.on("error", (error) => {
|
|
173
|
+
clearTimers();
|
|
174
|
+
liveChildKillers.delete(emergencyKill);
|
|
175
|
+
if (!check) {
|
|
176
|
+
// A spawn error (ENOENT/EACCES) fires before any stderr can be
|
|
177
|
+
// captured, so fall back to error.message rather than resolving
|
|
178
|
+
// with an unexplained empty stderr.
|
|
179
|
+
resolve({ stdout, stderr: stderr || error.message, code: 1, timedOut, overflowed });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${error.message}`.trim()));
|
|
183
|
+
});
|
|
184
|
+
child.on("close", (code, signal) => {
|
|
185
|
+
clearTimers();
|
|
186
|
+
liveChildKillers.delete(emergencyKill);
|
|
187
|
+
const exitCode = code ?? 1;
|
|
188
|
+
if (overflowed && check) {
|
|
189
|
+
reject(new Error(`Command output exceeded ${maxBuffer} bytes: ${command}`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (exitCode !== 0 && check && !timedOut) {
|
|
193
|
+
reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${stderr}`.trim()));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
resolve({
|
|
197
|
+
stdout,
|
|
198
|
+
stderr,
|
|
199
|
+
code: exitCode,
|
|
200
|
+
signal: signal ?? undefined,
|
|
201
|
+
timedOut,
|
|
202
|
+
overflowed,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
child.stdin.on("error", () => {
|
|
206
|
+
// A child that exits before reading stdin (e.g. bad args) closes the pipe;
|
|
207
|
+
// ignore EPIPE — the close handler reports the real outcome.
|
|
208
|
+
});
|
|
209
|
+
child.stdin.end(input);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Memoized trusted resolutions for the host `git`/`gh` binaries, keyed by name so
|
|
214
|
+
* git()'s many callers share ONE which/where lookup instead of each spawning their
|
|
215
|
+
* own. See resolveTrustedTool.
|
|
216
|
+
*/
|
|
217
|
+
const trustedToolResolutions = new Map();
|
|
218
|
+
/**
|
|
219
|
+
* Resolve `git`/`gh` to a trusted ABSOLUTE path, refusing any binary that resolves
|
|
220
|
+
* INSIDE the reviewed tree. Every git/gh spawn goes through this, never a bare name.
|
|
221
|
+
*
|
|
222
|
+
* A review has chdir'd into the untrusted PR-head tree (and `ecr review` of a local
|
|
223
|
+
* branch, plus `ecr ci`/doctor, operate on an untrusted checkout). libuv on Windows
|
|
224
|
+
* searches the child's cwd BEFORE PATH when the command is a BARE NAME, so a
|
|
225
|
+
* PR-committed `git.bat`/`gh.exe` at the repo root would win the lookup and run with
|
|
226
|
+
* ambient secrets (GH_TOKEN, model creds) in its environment. Spawning a resolved
|
|
227
|
+
* absolute path does no cwd search at all — the same property that fixed `claude`
|
|
228
|
+
* and `opencode`. resolveOnPath itself does the which/where lookup from tmpdir(), so
|
|
229
|
+
* the in-tree shim is never even FOUND on POSIX or Windows; pathInside is the backstop.
|
|
230
|
+
*
|
|
231
|
+
* Memoized per name: resolveOnPath is cwd-INDEPENDENT (it looks up from tmpdir), so a
|
|
232
|
+
* first call can never cache a cwd-tainted value, and the host binary is stable for
|
|
233
|
+
* the process. The caller's cwd is NOT changed — only the binary is resolved, so
|
|
234
|
+
* git/gh keep operating on their target tree. Throws (not null) so callers that
|
|
235
|
+
* assume a working git/gh fail loudly rather than silently spawning nothing.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveTrustedTool(name) {
|
|
238
|
+
let resolution = trustedToolResolutions.get(name);
|
|
239
|
+
if (!resolution) {
|
|
240
|
+
resolution = (async () => {
|
|
241
|
+
const resolved = await resolveOnPath(name);
|
|
242
|
+
if (!resolved) {
|
|
243
|
+
throw new Error(`The \`${name}\` CLI is not installed or not on PATH.`);
|
|
244
|
+
}
|
|
245
|
+
if (pathInside(resolved, process.cwd())) {
|
|
246
|
+
throw new Error(`refusing to run a \`${name}\` binary found inside the reviewed tree (${resolved}) — ` +
|
|
247
|
+
`install ${name} on the host and remove it from the repository.`);
|
|
248
|
+
}
|
|
249
|
+
return resolved;
|
|
250
|
+
})();
|
|
251
|
+
trustedToolResolutions.set(name, resolution);
|
|
252
|
+
}
|
|
253
|
+
return resolution;
|
|
254
|
+
}
|
|
255
|
+
/** Test-only: drop memoized git/gh resolutions so a test can re-resolve under a changed cwd/PATH. */
|
|
256
|
+
export function resetTrustedToolCache() {
|
|
257
|
+
trustedToolResolutions.clear();
|
|
258
|
+
}
|
|
26
259
|
export async function git(args, cwd) {
|
|
27
|
-
const
|
|
260
|
+
const gitPath = await resolveTrustedTool("git");
|
|
261
|
+
const { stdout } = await run(gitPath, args, { cwd });
|
|
28
262
|
return stdout;
|
|
29
263
|
}
|
|
30
264
|
/** Resolve owner/repo from the current checkout via gh (for PR-targeting commands). */
|
|
31
265
|
export async function resolveRepo(cwd) {
|
|
32
266
|
try {
|
|
33
|
-
const
|
|
267
|
+
const gh = await resolveTrustedTool("gh");
|
|
268
|
+
const { stdout } = await run(gh, ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
|
|
34
269
|
cwd,
|
|
35
270
|
});
|
|
36
271
|
const repo = stdout.trim();
|
|
@@ -52,10 +287,44 @@ export async function repoRoot(cwd) {
|
|
|
52
287
|
return null;
|
|
53
288
|
}
|
|
54
289
|
}
|
|
55
|
-
/**
|
|
56
|
-
export async function
|
|
57
|
-
|
|
290
|
+
/** Absolute path of an executable on PATH (first match), or null if unresolved. */
|
|
291
|
+
export async function resolveOnPath(command) {
|
|
292
|
+
// SECURITY: run the lookup from a trusted directory, never the inherited cwd.
|
|
293
|
+
// During a review the process has chdir'd into the untrusted PR-head tree, and
|
|
294
|
+
// Windows `where` searches the CURRENT DIRECTORY before PATH — a committed
|
|
295
|
+
// `claude.exe` at the repo root would win the lookup and be executed with the
|
|
296
|
+
// engine's credentials in its environment. tmpdir() is host-controlled.
|
|
297
|
+
const { stdout, code } = await run(process.platform === "win32" ? "where" : "which", [command], {
|
|
58
298
|
check: false,
|
|
299
|
+
cwd: tmpdir(),
|
|
59
300
|
});
|
|
60
|
-
|
|
301
|
+
if (code !== 0) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return stdout.trim().split("\n")[0]?.trim() || null;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Whether `filePath` lies inside `dir` (after resolution). Used as the backstop
|
|
308
|
+
* that refuses to execute a binary resolved from inside the reviewed tree.
|
|
309
|
+
*/
|
|
310
|
+
export function pathInside(filePath, dir) {
|
|
311
|
+
const rel = path.relative(path.resolve(dir), path.resolve(filePath));
|
|
312
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Absolute path to Windows' `taskkill`, so a process-tree kill never spawns a BARE
|
|
316
|
+
* `taskkill` — during a review the cwd is the untrusted PR-head tree, and Windows
|
|
317
|
+
* resolves a bare name against the current directory before PATH, so a PR-committed
|
|
318
|
+
* `taskkill.exe`/`.bat` at the tree root could otherwise run in place of the real one
|
|
319
|
+
* (with ambient secrets in its env) on the timeout-kill path. An absolute path does no
|
|
320
|
+
* search at all. `taskkill` always lives in System32, and `SystemRoot` is set by
|
|
321
|
+
* Windows, never by the PR. Callers stay win32-guarded.
|
|
322
|
+
*/
|
|
323
|
+
export function taskkillPath() {
|
|
324
|
+
const root = process.env.SystemRoot || process.env.windir || "C:\\Windows";
|
|
325
|
+
return path.join(root, "System32", "taskkill.exe");
|
|
326
|
+
}
|
|
327
|
+
/** Whether an executable is resolvable on PATH. */
|
|
328
|
+
export async function onPath(command) {
|
|
329
|
+
return (await resolveOnPath(command)) !== null;
|
|
61
330
|
}
|
package/build/core/opencode.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createOpencode } from "@opencode-ai/sdk";
|
|
4
|
+
import { pathInside, resolveOnPath } from "./exec.js";
|
|
4
5
|
import { RateLimitWatch } from "./throttle.js";
|
|
5
6
|
import { toolMap } from "./tools.js";
|
|
6
7
|
import { errorMessage, sleep } from "./util.js";
|
|
8
|
+
/** Discriminant for the Claude Code CLI engine (see core/claude-code.ts). */
|
|
9
|
+
export const CLAUDE_CODE_ENGINE = "claude-code";
|
|
10
|
+
/**
|
|
11
|
+
* Resolve which engine an agent's pass dispatches to, and (when claude) which
|
|
12
|
+
* claude handle to run it against. The per-agent router (engineOf) wins; absent it
|
|
13
|
+
* the carrier's own single `engine` decides. When the pass is claude-routed the
|
|
14
|
+
* claude handle is the carrier itself (a claude-only run, where the carrier IS the
|
|
15
|
+
* claude handle) or its `.claude` field (any run that also drives OpenCode). Pure
|
|
16
|
+
* and side-effect-free so the seam's dispatch is unit-testable without spawning.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveEngineDispatch(handle, agent) {
|
|
19
|
+
const engine = handle.engineOf?.(agent) ?? handle.engine ?? "opencode";
|
|
20
|
+
if (engine !== CLAUDE_CODE_ENGINE) {
|
|
21
|
+
return { engine };
|
|
22
|
+
}
|
|
23
|
+
// A claude-only run: the carrier itself IS the claude handle. Cast is safe (and
|
|
24
|
+
// still needed) because `handle` is typed OpencodeHandle here regardless.
|
|
25
|
+
if (handle.engine === CLAUDE_CODE_ENGINE) {
|
|
26
|
+
return { engine, claudeHandle: handle };
|
|
27
|
+
}
|
|
28
|
+
// Mixed run: engineOf routed this agent to claude-code, so the carrier's `.claude`
|
|
29
|
+
// field must be set. If it isn't, that's an invariant violation in how the handle
|
|
30
|
+
// was assembled, not a runtime fluke — fail loudly here instead of letting a bad
|
|
31
|
+
// cast smuggle `undefined` past the type checker and crash deep inside
|
|
32
|
+
// runClaudePrompt with no clue what went wrong.
|
|
33
|
+
if (!handle.claude) {
|
|
34
|
+
throw new Error(`Agent "${agent}" is routed to the claude-code engine but this handle has no ` +
|
|
35
|
+
`.claude carrier — the handle was assembled inconsistently.`);
|
|
36
|
+
}
|
|
37
|
+
return { engine, claudeHandle: handle.claude };
|
|
38
|
+
}
|
|
7
39
|
/** Sum token usage across attempts (for per-task/run totals). */
|
|
8
40
|
export function addTokenUsage(into, from) {
|
|
9
41
|
if (!from) {
|
|
@@ -158,11 +190,50 @@ export function opencodeBinSource() {
|
|
|
158
190
|
const dir = bundledOpencodeBinDir();
|
|
159
191
|
return { dir, pinned: dir !== null };
|
|
160
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Resolve the `opencode` binary the way we trust it: OUR bundled shim when the
|
|
195
|
+
* dependency resolves, else a PATH lookup from a trusted cwd (resolveOnPath, never
|
|
196
|
+
* the inherited one) with a refusal of any binary that resolves INSIDE the current
|
|
197
|
+
* tree. Null when unresolved or in-tree.
|
|
198
|
+
*
|
|
199
|
+
* `ecr doctor`/`ecr setup-auth` may run inside a cloned untrusted repo, so a bare
|
|
200
|
+
* `opencode` handed to execFile/spawn resolves against the inherited cwd — and Windows
|
|
201
|
+
* checks the current directory before PATH, letting a PR-committed `opencode` shim run
|
|
202
|
+
* with ambient secrets in its env. Every opencode spawn in those commands goes through
|
|
203
|
+
* this, mirroring resolveClaudeCli for the `claude` binary.
|
|
204
|
+
*/
|
|
205
|
+
export async function resolveOpencodeCli() {
|
|
206
|
+
const bundled = bundledOpencodeBinDir();
|
|
207
|
+
if (bundled) {
|
|
208
|
+
// Our own dependency tree (require.resolve is relative to THIS module, not cwd), so
|
|
209
|
+
// it's trusted by construction — and NO in-tree refusal here: ecr's node_modules
|
|
210
|
+
// commonly sits under cwd when run from its own repo, which pathInside would then
|
|
211
|
+
// wrongly reject.
|
|
212
|
+
return path.join(bundled, "opencode");
|
|
213
|
+
}
|
|
214
|
+
// PATH fallback: resolved from a trusted cwd, and refused if it lands in-tree.
|
|
215
|
+
const cliPath = await resolveOnPath("opencode");
|
|
216
|
+
if (!cliPath || pathInside(cliPath, process.cwd())) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return cliPath;
|
|
220
|
+
}
|
|
161
221
|
/** Start an in-process OpenCode server with the given inline config. */
|
|
162
222
|
export async function startOpencode(config) {
|
|
163
223
|
// Make our pinned CLI win over any global install (see bundledOpencodeBinDir).
|
|
164
224
|
// The SDK takes no `env`, so PATH is the only lever; it spreads `process.env` at
|
|
165
225
|
// spawn time, so setting it here reaches the child.
|
|
226
|
+
//
|
|
227
|
+
// SECURITY residual (accepted, POSIX-only deployment): the SDK spawns a BARE
|
|
228
|
+
// `opencode` (cross-spawn `launch("opencode")`), which on Windows resolves the name
|
|
229
|
+
// against the current directory before PATH — during a review the cwd is the
|
|
230
|
+
// untrusted PR-head tree, so a PR-committed `opencode.exe` at its root could run in
|
|
231
|
+
// its place. We deliberately do NOT reimplement the SDK's server bootstrap to inject
|
|
232
|
+
// an absolute path here: on POSIX (the supported platform) `execvp` never searches
|
|
233
|
+
// the cwd, so the hijack cannot fire, and forking the launch would mean silently
|
|
234
|
+
// maintaining our own copy of it against SDK drift. The direct-spawn `opencode`
|
|
235
|
+
// callers we own (`ecr doctor`/`ecr setup-auth`) are still hardened via
|
|
236
|
+
// resolveOpencodeCli. Revisit if Windows becomes a supported target.
|
|
166
237
|
const binDir = bundledOpencodeBinDir();
|
|
167
238
|
if (binDir) {
|
|
168
239
|
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
@@ -257,27 +328,19 @@ export function formatUnknownModels(unknown, auths) {
|
|
|
257
328
|
// Do NOT blame the token alone: the most common causes have nothing to do with
|
|
258
329
|
// the credential's validity (see below). An earlier version of this message sent
|
|
259
330
|
// us to re-issue two perfectly good tokens.
|
|
260
|
-
|
|
261
|
-
|
|
331
|
+
// No anthropic special case here anymore: anthropic models never reach the
|
|
332
|
+
// OpenCode preflight (engineForModel routes every anthropic/… id to the Claude
|
|
333
|
+
// Code engine), so this message only ever names non-anthropic providers.
|
|
262
334
|
return (`The OpenCode server does not offer the "${provider}" provider, even though this run ` +
|
|
263
335
|
`supplied a ${auth?.mode ?? "configured"} credential for it. OpenCode drops a provider whose ` +
|
|
264
336
|
`credential it could not use, which makes every ${provider} model look nonexistent: ` +
|
|
265
337
|
`${refused.map((entry) => entry.model).join(", ")}.\n` +
|
|
266
338
|
`The credential itself is often FINE. Check these in order:\n` +
|
|
267
|
-
(deadOauth
|
|
268
|
-
? ` 1. anthropic OAuth cannot work through OpenCode at all. Anthropic does not permit ` +
|
|
269
|
-
`Pro/Max subscription tokens in third-party tools, and OpenCode (since 1.3.0) ships no ` +
|
|
270
|
-
`anthropic OAuth support — an oauth credential never registers the provider, no matter ` +
|
|
271
|
-
`how valid the token is. Switch auth in .expo-code-review/config.jsonc to ` +
|
|
272
|
-
`{ "mode": "api-key", "provider": "anthropic", "tokenEnv": "ANTHROPIC_API_KEY" } with a ` +
|
|
273
|
-
`Console API key, or run with REVIEWER_MODEL set to a model you are logged into ` +
|
|
274
|
-
`(e.g. REVIEWER_MODEL=openai/gpt-5.5).\n`
|
|
275
|
-
: "") +
|
|
276
339
|
(tokenEnv
|
|
277
|
-
? `
|
|
340
|
+
? ` 1. The credential is wrong for the mode. ` +
|
|
278
341
|
`auth.mode "api-key" expects a plain API key for ${provider}; an OAuth/subscription ` +
|
|
279
342
|
`token is not an API key. A truncated or half-pasted ${tokenEnv} fails the same way.\n`
|
|
280
|
-
: `
|
|
343
|
+
: ` 1. The credential is wrong for the configured auth.mode.\n`) +
|
|
281
344
|
`Providers the server does offer: ${refused[0].suggestions.join(", ") || "(none)"}.`);
|
|
282
345
|
}
|
|
283
346
|
const lines = unknown.map((entry) => entry.reason === "provider"
|
|
@@ -295,6 +358,10 @@ export function formatUnknownModels(unknown, auths) {
|
|
|
295
358
|
* per-pass as before.
|
|
296
359
|
*/
|
|
297
360
|
export async function assertModelsResolvable(handle, models, auths) {
|
|
361
|
+
if (handle.engine === CLAUDE_CODE_ENGINE) {
|
|
362
|
+
const { assertClaudeModels } = await import("./claude-code.js");
|
|
363
|
+
return assertClaudeModels(handle, models);
|
|
364
|
+
}
|
|
298
365
|
let available;
|
|
299
366
|
try {
|
|
300
367
|
available = await fetchProviderModels(handle);
|
|
@@ -502,6 +569,14 @@ export class AgentTimeoutError extends Error {
|
|
|
502
569
|
* message completes.
|
|
503
570
|
*/
|
|
504
571
|
export async function promptAgent(handle, args) {
|
|
572
|
+
// A Claude handle never runs OpenCode's session/polling machinery: consumers
|
|
573
|
+
// use promptAndParse, but guard here too so a direct call can't run this code
|
|
574
|
+
// against a Claude handle. Dispatch is per-agent (see resolveEngineDispatch).
|
|
575
|
+
const dispatch = resolveEngineDispatch(handle, args.agent);
|
|
576
|
+
if (dispatch.engine === CLAUDE_CODE_ENGINE) {
|
|
577
|
+
const { runClaudePrompt } = await import("./claude-code.js");
|
|
578
|
+
return runClaudePrompt(dispatch.claudeHandle, args);
|
|
579
|
+
}
|
|
505
580
|
const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
506
581
|
// ONE deadline for the whole pass, shared by the first attempt and any stall
|
|
507
582
|
// retry, so retrying a wedged request can never push the pass past its declared
|
|
@@ -628,7 +703,7 @@ export async function promptAgent(handle, args) {
|
|
|
628
703
|
}
|
|
629
704
|
}
|
|
630
705
|
}
|
|
631
|
-
const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single " +
|
|
706
|
+
export const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single " +
|
|
632
707
|
"JSON object described above — no prose, no code fences, no partial output.";
|
|
633
708
|
// Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
|
|
634
709
|
// it should return almost immediately.
|
|
@@ -684,7 +759,7 @@ export function isTransientApiError(error) {
|
|
|
684
759
|
* drop the whole pass with no retry, reported as a coverage gap. Non-transient
|
|
685
760
|
* errors (incl. AgentTimeoutError) propagate immediately.
|
|
686
761
|
*/
|
|
687
|
-
async function withTransientRetry(label, onActivity, fn) {
|
|
762
|
+
export async function withTransientRetry(label, onActivity, fn) {
|
|
688
763
|
for (let attempt = 0;; attempt++) {
|
|
689
764
|
try {
|
|
690
765
|
return await fn();
|
|
@@ -712,6 +787,11 @@ async function withTransientRetry(label, onActivity, fn) {
|
|
|
712
787
|
* the task instead of retrying a non-convergent run.
|
|
713
788
|
*/
|
|
714
789
|
export async function promptAndParse(handle, args, parse) {
|
|
790
|
+
const dispatch = resolveEngineDispatch(handle, args.agent);
|
|
791
|
+
if (dispatch.engine === CLAUDE_CODE_ENGINE) {
|
|
792
|
+
const { claudeCodePromptAndParse } = await import("./claude-code.js");
|
|
793
|
+
return claudeCodePromptAndParse(dispatch.claudeHandle, args, parse);
|
|
794
|
+
}
|
|
715
795
|
let cost = 0;
|
|
716
796
|
let truncated = false;
|
|
717
797
|
let model;
|
package/build/core/prompts.js
CHANGED
|
@@ -57,6 +57,15 @@ export function sanitizeUntrusted(input, maxLength = 4000) {
|
|
|
57
57
|
}
|
|
58
58
|
return out.trim();
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* sanitizeUntrusted for a value that must stay on ONE line — a single-line bullet in a
|
|
62
|
+
* prompt. Collapsing newlines stops injected text from forging a standalone boundary
|
|
63
|
+
* line (e.g. a bare `EVIDENCE` fence delimiter) that the token-oriented
|
|
64
|
+
* sanitizeUntrusted does not itself remove.
|
|
65
|
+
*/
|
|
66
|
+
export function flattenUntrusted(input, maxLength = 4000) {
|
|
67
|
+
return sanitizeUntrusted(input, maxLength).replace(/\s*\n\s*/g, " ");
|
|
68
|
+
}
|
|
60
69
|
function withShared(config, rolePrompt) {
|
|
61
70
|
return config.sharedPromptText
|
|
62
71
|
? `${config.sharedPromptText}\n\n---\n\n${rolePrompt}`
|
|
@@ -285,8 +294,16 @@ export function buildVerifierTask(finding, opts = {}) {
|
|
|
285
294
|
`- line: ${finding.line ?? "(unspecified)"}`,
|
|
286
295
|
`- severity: ${finding.severity}`,
|
|
287
296
|
`- category: ${finding.category}`,
|
|
288
|
-
|
|
289
|
-
|
|
297
|
+
// title/rationale are LLM-authored over the untrusted diff (a reviewer may quote an
|
|
298
|
+
// adjacent malicious comment straight into them), and buildVerifierSystem is
|
|
299
|
+
// deliberately NOT wrapped in the shared injection-defense rules — so, like
|
|
300
|
+
// finding.file above, neutralize their prompt-boundary constructs rather than
|
|
301
|
+
// interpolating them raw. Flatten to one line too: these are single-line bullet
|
|
302
|
+
// values, so collapsing newlines stops injected text from forging a standalone
|
|
303
|
+
// boundary line (e.g. a bare `EVIDENCE` fence delimiter) that sanitizeUntrusted,
|
|
304
|
+
// which targets role/PR tokens, would not catch.
|
|
305
|
+
`- title: ${flattenUntrusted(finding.title)}`,
|
|
306
|
+
`- rationale: ${flattenUntrusted(finding.rationale)}`,
|
|
290
307
|
];
|
|
291
308
|
if (finding.evidence) {
|
|
292
309
|
lines.push("- code the finding claims is present (UNTRUSTED — verify it against the file):", "<<<EVIDENCE", finding.evidence, "EVIDENCE");
|
package/build/core/render.js
CHANGED
|
@@ -157,14 +157,33 @@ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
|
|
|
157
157
|
}
|
|
158
158
|
return out;
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Indent every line of a multi-line value to a list item's content column.
|
|
162
|
+
*
|
|
163
|
+
* Rationales embed a `<details>` block, and only indenting the first line let
|
|
164
|
+
* that HTML escape the list item: GitHub then treated the closing `</details>`
|
|
165
|
+
* as ending a top-level HTML block, and because the next finding's bullet
|
|
166
|
+
* followed after a single newline it was emitted as raw text instead of
|
|
167
|
+
* Markdown. Every finding after the first in a group rendered with visible
|
|
168
|
+
* `**` and backticks.
|
|
169
|
+
*
|
|
170
|
+
* Blank lines stay truly empty — trailing whitespace would make them
|
|
171
|
+
* non-blank and reopen the same class of parsing bug.
|
|
172
|
+
*/
|
|
173
|
+
function indentContinuation(value, indent = " ") {
|
|
174
|
+
return value.split("\n").map((line) => (line.trim() === "" ? "" : `${indent}${line}`));
|
|
175
|
+
}
|
|
160
176
|
function renderFindingLines(finding, link, id = fingerprintFinding(finding)) {
|
|
161
177
|
const out = [
|
|
162
178
|
`- **${finding.title}** — ${location(finding, link)} _(${finding.category})_ · \`id:${id}\``,
|
|
163
|
-
|
|
179
|
+
...indentContinuation(finding.rationale),
|
|
164
180
|
];
|
|
165
181
|
if (finding.suggestion) {
|
|
166
|
-
out.push(`
|
|
182
|
+
out.push(...indentContinuation(`_Suggestion:_ ${finding.suggestion}`));
|
|
167
183
|
}
|
|
184
|
+
// Separator so a rationale ending in `</details>` cannot swallow the next
|
|
185
|
+
// bullet. Findings are already loose list items, so this changes no spacing.
|
|
186
|
+
out.push("");
|
|
168
187
|
return out;
|
|
169
188
|
}
|
|
170
189
|
/** Parse the fingerprints embedded in a previously-posted comment body. */
|