@open-cr-agent/cli 0.3.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 +3 -3
- package/dist/account-salt.d.ts +7 -0
- package/dist/account-salt.js +43 -0
- package/dist/cloud.d.ts +34 -0
- package/dist/cloud.js +272 -0
- package/dist/plugin-store.d.ts +20 -0
- package/dist/plugin-store.js +84 -0
- package/dist/plugins-command.d.ts +15 -0
- package/dist/plugins-command.js +183 -0
- package/dist/review/account-plugins.d.ts +23 -0
- package/dist/review/account-plugins.js +54 -0
- package/dist/review/account-settings.d.ts +100 -0
- package/dist/review/account-settings.js +106 -0
- package/dist/review/args.d.ts +2 -1
- package/dist/review/args.js +5 -0
- package/dist/review/cloud-findings.d.ts +31 -0
- package/dist/review/cloud-findings.js +80 -0
- package/dist/review/cloud-memory.d.ts +6 -0
- package/dist/review/cloud-memory.js +58 -0
- package/dist/review/cloud-providers.d.ts +12 -0
- package/dist/review/cloud-providers.js +60 -0
- package/dist/review/cloud-review.d.ts +19 -0
- package/dist/review/cloud-review.js +51 -0
- package/dist/review/cloud-settings.d.ts +16 -0
- package/dist/review/cloud-settings.js +118 -0
- package/dist/review/cloud-upload.d.ts +60 -0
- package/dist/review/cloud-upload.js +180 -0
- package/dist/review/command.d.ts +4 -0
- package/dist/review/command.js +91 -14
- package/dist/review/config.d.ts +65 -6
- package/dist/review/config.js +71 -7
- package/dist/review/plan-render.js +1 -1
- package/dist/review/plugins.d.ts +10 -0
- package/dist/review/plugins.js +46 -1
- package/dist/review/provenance.js +15 -2
- package/dist/review/remote-config.d.ts +6 -0
- package/dist/review/remote-config.js +3 -1
- package/dist/review/render.js +7 -2
- package/dist/review/sarif.js +1 -1
- package/dist/review/settings-sources.d.ts +11 -0
- package/dist/review/settings-sources.js +58 -0
- package/dist/review/target.d.ts +1 -0
- package/dist/review/target.js +4 -1
- package/dist/run.js +36 -0
- package/package.json +8 -8
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { ensureDir, installedEntry, isExactVersion, isPackageName, readAllowed, writeAllowed, } from "./plugin-store.js";
|
|
5
|
+
import { UsageError } from "./review/args.js";
|
|
6
|
+
import { ConfigError } from "./review/config.js";
|
|
7
|
+
import { forTerminal } from "./review/terminal.js";
|
|
8
|
+
// `ocra plugins` (ADR-0027): the user's own decision, on each machine, to
|
|
9
|
+
// let their ocra Cloud account load a third-party plugin, like adding a
|
|
10
|
+
// dependency. The account names packages; only what is allowed here loads.
|
|
11
|
+
export const PLUGINS_USAGE = `Usage: ocra plugins allow <name>@<version>
|
|
12
|
+
ocra plugins deny <name>
|
|
13
|
+
ocra plugins list
|
|
14
|
+
|
|
15
|
+
Your ocra Cloud settings may name plugins; a review loads one only if this
|
|
16
|
+
machine allows it. allow shows the package and who published it, installs that
|
|
17
|
+
exact version with install scripts off into a directory of your own, and
|
|
18
|
+
records its integrity; deny removes it.
|
|
19
|
+
`;
|
|
20
|
+
export async function pluginsCommand(argv, out, deps) {
|
|
21
|
+
const [action, target, ...extra] = argv;
|
|
22
|
+
if (action === "--help" || action === "-h" || action === "help") {
|
|
23
|
+
out.write(PLUGINS_USAGE);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
if (extra.length > 0)
|
|
27
|
+
throw new UsageError(`Unexpected argument: ${extra[0]}`);
|
|
28
|
+
if (action === "list" && target === undefined)
|
|
29
|
+
return list(out, deps.dir);
|
|
30
|
+
if (action === "allow" && target !== undefined)
|
|
31
|
+
return allow(target, out, deps);
|
|
32
|
+
if (action === "deny" && target !== undefined)
|
|
33
|
+
return deny(target, out, deps);
|
|
34
|
+
throw new UsageError(action ? `Unknown or incomplete command: plugins ${action}` : "");
|
|
35
|
+
}
|
|
36
|
+
async function list(out, dir) {
|
|
37
|
+
const allowed = await readAllowed(dir);
|
|
38
|
+
if (allowed.length === 0) {
|
|
39
|
+
out.write("No plugins allowed. Allow one with: ocra plugins allow <name>@<version>\n");
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
for (const p of allowed)
|
|
43
|
+
out.write(`${p.name}@${p.version} ${p.integrity}\n`);
|
|
44
|
+
out.write(`(installed in ${dir})\n`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
export function parseSpec(spec) {
|
|
48
|
+
const at = spec.lastIndexOf("@");
|
|
49
|
+
const name = at > 0 ? spec.slice(0, at) : spec;
|
|
50
|
+
const version = at > 0 ? spec.slice(at + 1) : "";
|
|
51
|
+
if (!isPackageName(name))
|
|
52
|
+
throw new UsageError(`Not an npm package name: ${forTerminal(name)}`);
|
|
53
|
+
if (!isExactVersion(version)) {
|
|
54
|
+
throw new UsageError(`Name an exact version, such as ${name}@1.2.3: a range or tag could install something else later`);
|
|
55
|
+
}
|
|
56
|
+
return { name, version };
|
|
57
|
+
}
|
|
58
|
+
async function allow(spec, out, deps) {
|
|
59
|
+
const { name, version } = parseSpec(spec);
|
|
60
|
+
const view = describe(await deps.npm(["view", `${name}@${version}`, "--json"]));
|
|
61
|
+
if (view.name !== name || view.version !== version) {
|
|
62
|
+
throw new ConfigError(`npm did not resolve ${name}@${version} to that exact version`);
|
|
63
|
+
}
|
|
64
|
+
out.write(forTerminal([
|
|
65
|
+
`Package: ${view.name}@${view.version}`,
|
|
66
|
+
`Published by: ${view.publisher ?? "unknown"}`,
|
|
67
|
+
`Maintainers: ${view.maintainers.join(", ") || "unknown"}`,
|
|
68
|
+
`Integrity: ${view.integrity ?? "unknown"}`,
|
|
69
|
+
`Installing into ${deps.dir} with install scripts off`,
|
|
70
|
+
"",
|
|
71
|
+
].join("\n")));
|
|
72
|
+
await ensureDir(deps.dir);
|
|
73
|
+
// npm installs into the nearest package.json; one of the directory's own
|
|
74
|
+
// keeps it from walking up to a project.
|
|
75
|
+
const manifest = join(deps.dir, "package.json");
|
|
76
|
+
if (!(await exists(manifest))) {
|
|
77
|
+
await writeFile(manifest, `${JSON.stringify({ private: true }, null, 2)}\n`, { mode: 0o600 });
|
|
78
|
+
}
|
|
79
|
+
await deps.npm([
|
|
80
|
+
"install",
|
|
81
|
+
"--ignore-scripts",
|
|
82
|
+
"--no-audit",
|
|
83
|
+
"--no-fund",
|
|
84
|
+
"--save-exact",
|
|
85
|
+
"--prefix",
|
|
86
|
+
deps.dir,
|
|
87
|
+
`${name}@${version}`,
|
|
88
|
+
]);
|
|
89
|
+
const installed = await installedEntry(deps.dir, name);
|
|
90
|
+
if (installed?.version !== version || !installed.integrity) {
|
|
91
|
+
throw new ConfigError(`npm did not install ${name}@${version} into ${deps.dir}`);
|
|
92
|
+
}
|
|
93
|
+
if (view.integrity && view.integrity !== installed.integrity) {
|
|
94
|
+
throw new ConfigError(`${name}@${version} installed with another integrity than the registry lists; not allowed`);
|
|
95
|
+
}
|
|
96
|
+
const entry = { name, version, integrity: installed.integrity };
|
|
97
|
+
const allowed = (await readAllowed(deps.dir)).filter((p) => p.name !== name);
|
|
98
|
+
await writeAllowed(deps.dir, [...allowed, entry]);
|
|
99
|
+
out.write(`Allowed ${name}@${version}: your ocra Cloud settings can now load it here.\n`);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
async function deny(name, out, deps) {
|
|
103
|
+
if (!isPackageName(name))
|
|
104
|
+
throw new UsageError(`Not an npm package name: ${forTerminal(name)}`);
|
|
105
|
+
const allowed = await readAllowed(deps.dir);
|
|
106
|
+
const kept = allowed.filter((p) => p.name !== name);
|
|
107
|
+
if (kept.length === allowed.length) {
|
|
108
|
+
out.write(`${name} was not allowed.\n`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
// The record is what loading checks; removing the files is housekeeping.
|
|
112
|
+
await writeAllowed(deps.dir, kept);
|
|
113
|
+
try {
|
|
114
|
+
await deps.npm([
|
|
115
|
+
"uninstall",
|
|
116
|
+
"--ignore-scripts",
|
|
117
|
+
"--no-audit",
|
|
118
|
+
"--no-fund",
|
|
119
|
+
"--prefix",
|
|
120
|
+
deps.dir,
|
|
121
|
+
name,
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
await rm(join(deps.dir, "node_modules", name), { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
out.write(`Denied ${name}: it no longer loads.\n`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
function describe(stdout) {
|
|
131
|
+
let data;
|
|
132
|
+
try {
|
|
133
|
+
data = JSON.parse(stdout);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new ConfigError("npm view did not answer with JSON");
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(data))
|
|
139
|
+
data = data.length === 1 ? data[0] : undefined;
|
|
140
|
+
if (typeof data !== "object" || data === null) {
|
|
141
|
+
throw new ConfigError("npm view did not describe one version");
|
|
142
|
+
}
|
|
143
|
+
const v = data;
|
|
144
|
+
const person = (p) => typeof p === "string"
|
|
145
|
+
? p
|
|
146
|
+
: typeof p === "object" && p !== null && typeof p.name === "string"
|
|
147
|
+
? p.name
|
|
148
|
+
: undefined;
|
|
149
|
+
const integrity = v.dist?.integrity ?? v["dist.integrity"];
|
|
150
|
+
const publisher = person(v._npmUser);
|
|
151
|
+
return {
|
|
152
|
+
...(typeof v.name === "string" ? { name: v.name } : {}),
|
|
153
|
+
...(typeof v.version === "string" ? { version: v.version } : {}),
|
|
154
|
+
...(publisher ? { publisher } : {}),
|
|
155
|
+
maintainers: (Array.isArray(v.maintainers) ? v.maintainers : [])
|
|
156
|
+
.map(person)
|
|
157
|
+
.filter((m) => m !== undefined),
|
|
158
|
+
...(typeof integrity === "string" ? { integrity } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async function exists(path) {
|
|
162
|
+
return access(path).then(() => true, () => false);
|
|
163
|
+
}
|
|
164
|
+
// npm on Windows is npm.cmd, which execFile cannot run without a shell; run
|
|
165
|
+
// the npm that ships with this Node directly instead.
|
|
166
|
+
export function defaultNpm(env = process.env) {
|
|
167
|
+
const [file, prefix] = process.platform === "win32"
|
|
168
|
+
? [
|
|
169
|
+
process.execPath,
|
|
170
|
+
[join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js")],
|
|
171
|
+
]
|
|
172
|
+
: ["npm", []];
|
|
173
|
+
return (args) => new Promise((resolve, reject) => {
|
|
174
|
+
execFile(file, [...prefix, ...args], { env, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024, shell: false }, (error, stdout, stderr) => {
|
|
175
|
+
if (error) {
|
|
176
|
+
reject(new ConfigError(`npm ${args[0]} failed: ${forTerminal(String(stderr).trim().split("\n").slice(-3).join(" ") || error.message)}`));
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
resolve(String(stdout));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=plugins-command.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OcraPlugin } from "@open-cr-agent/core";
|
|
2
|
+
export interface AccountPlugins {
|
|
3
|
+
plugins: string[];
|
|
4
|
+
pluginSettings: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export declare function parseAccountPlugins(body: unknown, warn: (message: string) => void): AccountPlugins;
|
|
7
|
+
/**
|
|
8
|
+
* The account's plugins this review loads, and their settings. Only where a
|
|
9
|
+
* repository's plugins could load (allowed: never for pull or merge requests,
|
|
10
|
+
* never with --no-repo-config); a package the configuration already lists
|
|
11
|
+
* loads as the configuration's, and a plugin whose name is taken is skipped.
|
|
12
|
+
*/
|
|
13
|
+
export declare function accountPlugins(account: AccountPlugins, options: {
|
|
14
|
+
allowed: boolean;
|
|
15
|
+
configured: readonly string[];
|
|
16
|
+
taken: readonly OcraPlugin[];
|
|
17
|
+
dir: string;
|
|
18
|
+
warn: (message: string) => void;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
plugins: OcraPlugin[];
|
|
21
|
+
settings: Record<string, unknown>;
|
|
22
|
+
}>;
|
|
23
|
+
//# sourceMappingURL=account-plugins.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isPackageName } from "../plugin-store.js";
|
|
2
|
+
import { loadAccountPlugins } from "./plugins.js";
|
|
3
|
+
const MAX_PLUGINS = 50;
|
|
4
|
+
export function parseAccountPlugins(body, warn) {
|
|
5
|
+
const settings = record(record(body).settings);
|
|
6
|
+
const listed = Array.isArray(settings.plugins) ? settings.plugins : [];
|
|
7
|
+
const plugins = [
|
|
8
|
+
...new Set(listed.filter((p) => typeof p === "string" && isPackageName(p))),
|
|
9
|
+
].slice(0, MAX_PLUGINS);
|
|
10
|
+
const refused = listed.filter((p) => typeof p !== "string" || !isPackageName(p));
|
|
11
|
+
if (refused.length > 0 || (settings.plugins !== undefined && !Array.isArray(settings.plugins))) {
|
|
12
|
+
warn(`ignoring plugins in your ocra Cloud settings that are not npm package names: ${refused.map((p) => JSON.stringify(p)).join(", ") || JSON.stringify(settings.plugins)}`);
|
|
13
|
+
}
|
|
14
|
+
if (listed.length > MAX_PLUGINS) {
|
|
15
|
+
warn(`your ocra Cloud settings name more than ${MAX_PLUGINS} plugins; the rest are ignored`);
|
|
16
|
+
}
|
|
17
|
+
return { plugins, pluginSettings: record(settings.pluginSettings) };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The account's plugins this review loads, and their settings. Only where a
|
|
21
|
+
* repository's plugins could load (allowed: never for pull or merge requests,
|
|
22
|
+
* never with --no-repo-config); a package the configuration already lists
|
|
23
|
+
* loads as the configuration's, and a plugin whose name is taken is skipped.
|
|
24
|
+
*/
|
|
25
|
+
export async function accountPlugins(account, options) {
|
|
26
|
+
const names = account.plugins.filter((name) => !options.configured.includes(name));
|
|
27
|
+
if (names.length === 0)
|
|
28
|
+
return { plugins: [], settings: {} };
|
|
29
|
+
if (!options.allowed) {
|
|
30
|
+
options.warn(`plugins from your ocra Cloud settings do not load for pull or merge requests or with --no-repo-config: ${names.join(", ")}`);
|
|
31
|
+
return { plugins: [], settings: {} };
|
|
32
|
+
}
|
|
33
|
+
const taken = new Set(options.taken.map((p) => p.name));
|
|
34
|
+
const plugins = [];
|
|
35
|
+
const settings = {};
|
|
36
|
+
for (const { name, plugin } of await loadAccountPlugins(names, options.dir, options.warn)) {
|
|
37
|
+
if (taken.has(plugin.name)) {
|
|
38
|
+
options.warn(`skipping plugin ${name} from your ocra Cloud settings: a plugin named "${plugin.name}" is already loaded`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
taken.add(plugin.name);
|
|
42
|
+
plugins.push(plugin);
|
|
43
|
+
if (Object.hasOwn(account.pluginSettings, plugin.name)) {
|
|
44
|
+
settings[plugin.name] = account.pluginSettings[plugin.name];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { plugins, settings };
|
|
48
|
+
}
|
|
49
|
+
function record(value) {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
51
|
+
? value
|
|
52
|
+
: {};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=account-plugins.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const SETTINGS: {
|
|
3
|
+
readonly concurrency: z.ZodOptional<z.ZodNumber>;
|
|
4
|
+
readonly taskTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
readonly runTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
readonly maxCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
readonly maxTasks: z.ZodOptional<z.ZodNumber>;
|
|
8
|
+
readonly verify: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
+
readonly judge: z.ZodOptional<z.ZodBoolean>;
|
|
10
|
+
readonly sampling: z.ZodOptional<z.ZodObject<{
|
|
11
|
+
temperature: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
seed: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
}, z.core.$strict>>;
|
|
14
|
+
readonly include: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
15
|
+
readonly exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
16
|
+
readonly rules: z.ZodArray<z.ZodObject<{
|
|
17
|
+
path: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
18
|
+
rule: z.ZodString;
|
|
19
|
+
}, z.core.$strict>>;
|
|
20
|
+
readonly ultra: z.ZodBoolean;
|
|
21
|
+
};
|
|
22
|
+
declare const AGENTS: {
|
|
23
|
+
readonly effort: z.ZodDefault<z.ZodObject<{
|
|
24
|
+
top: z.ZodOptional<z.ZodEnum<{
|
|
25
|
+
high: "high";
|
|
26
|
+
low: "low";
|
|
27
|
+
medium: "medium";
|
|
28
|
+
minimal: "minimal";
|
|
29
|
+
none: "none";
|
|
30
|
+
}>>;
|
|
31
|
+
standard: z.ZodOptional<z.ZodEnum<{
|
|
32
|
+
high: "high";
|
|
33
|
+
low: "low";
|
|
34
|
+
medium: "medium";
|
|
35
|
+
minimal: "minimal";
|
|
36
|
+
none: "none";
|
|
37
|
+
}>>;
|
|
38
|
+
light: z.ZodOptional<z.ZodEnum<{
|
|
39
|
+
high: "high";
|
|
40
|
+
low: "low";
|
|
41
|
+
medium: "medium";
|
|
42
|
+
minimal: "minimal";
|
|
43
|
+
none: "none";
|
|
44
|
+
}>>;
|
|
45
|
+
}, z.core.$strict>>;
|
|
46
|
+
readonly reviewers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
47
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
48
|
+
minTier: z.ZodOptional<z.ZodEnum<{
|
|
49
|
+
full: "full";
|
|
50
|
+
lite: "lite";
|
|
51
|
+
trivial: "trivial";
|
|
52
|
+
}>>;
|
|
53
|
+
models: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
54
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
55
|
+
high: "high";
|
|
56
|
+
low: "low";
|
|
57
|
+
medium: "medium";
|
|
58
|
+
minimal: "minimal";
|
|
59
|
+
none: "none";
|
|
60
|
+
}>>;
|
|
61
|
+
}, z.core.$strict>>>;
|
|
62
|
+
readonly roles: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
63
|
+
helper: "helper";
|
|
64
|
+
judge: "judge";
|
|
65
|
+
verifier: "verifier";
|
|
66
|
+
}> & z.core.$partial, z.ZodObject<{
|
|
67
|
+
models: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
68
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
69
|
+
high: "high";
|
|
70
|
+
low: "low";
|
|
71
|
+
medium: "medium";
|
|
72
|
+
minimal: "minimal";
|
|
73
|
+
none: "none";
|
|
74
|
+
}>>;
|
|
75
|
+
}, z.core.$strict>>>;
|
|
76
|
+
};
|
|
77
|
+
declare const TOP: {
|
|
78
|
+
readonly models: z.ZodDefault<z.ZodObject<{
|
|
79
|
+
top: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
80
|
+
standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
81
|
+
light: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
82
|
+
}, z.core.$strict>>;
|
|
83
|
+
readonly runtime: z.ZodEnum<{
|
|
84
|
+
direct: "direct";
|
|
85
|
+
opencode: "opencode";
|
|
86
|
+
}>;
|
|
87
|
+
};
|
|
88
|
+
type Parsed<T extends Record<string, z.ZodType>> = {
|
|
89
|
+
[K in keyof T]?: z.output<T[K]>;
|
|
90
|
+
};
|
|
91
|
+
export type AccountSettings = Parsed<typeof TOP> & Parsed<typeof AGENTS> & Parsed<typeof SETTINGS> & {
|
|
92
|
+
version: string | null;
|
|
93
|
+
};
|
|
94
|
+
/** The account's settings, and warnings naming what was refused or ignored. */
|
|
95
|
+
export declare function parseAccountSettings(data: unknown): {
|
|
96
|
+
settings: AccountSettings;
|
|
97
|
+
warnings: string[];
|
|
98
|
+
};
|
|
99
|
+
export {};
|
|
100
|
+
//# sourceMappingURL=account-settings.d.ts.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { repoRuleSchema } from "@open-cr-agent/core/internal";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CLOUD_PREFIX } from "./cloud-providers.js";
|
|
4
|
+
import { configSchema } from "./config.js";
|
|
5
|
+
// What ocra Cloud's GET /api/preferences may set (ADR-0025, ADR-0027),
|
|
6
|
+
// checked key by key with the configuration file's own schema: a key that
|
|
7
|
+
// does not match is refused alone, and a key this CLI does not know is
|
|
8
|
+
// ignored, so a newer server never breaks an older CLI.
|
|
9
|
+
// Keys that can move code or keys, or decide whose comments are trusted:
|
|
10
|
+
// only a configuration file may set them, whatever the server sends.
|
|
11
|
+
const FORBIDDEN = new Set(["providers", "extends", "github", "botLogin", "$schema"]);
|
|
12
|
+
// The account's rules are bounded (ADR-0027); the repository's are its own.
|
|
13
|
+
const accountRules = z
|
|
14
|
+
.array(repoRuleSchema.extend({ rule: z.string().min(1).max(2_000) }).strict())
|
|
15
|
+
.max(50);
|
|
16
|
+
// Account models go through the gateway with the account's stored keys
|
|
17
|
+
// (ADR-0027): a chain naming any other provider could send the code where
|
|
18
|
+
// this machine's own keys reach.
|
|
19
|
+
const cloudModel = (chain) => chain.every((model) => model.startsWith(CLOUD_PREFIX) && model.indexOf("/") > 0);
|
|
20
|
+
const shape = configSchema.shape;
|
|
21
|
+
const SETTINGS = {
|
|
22
|
+
concurrency: shape.concurrency,
|
|
23
|
+
taskTimeoutMinutes: shape.taskTimeoutMinutes,
|
|
24
|
+
runTimeoutMinutes: shape.runTimeoutMinutes,
|
|
25
|
+
maxCostUsd: shape.maxCostUsd,
|
|
26
|
+
maxTasks: shape.maxTasks,
|
|
27
|
+
verify: shape.verify,
|
|
28
|
+
judge: shape.judge,
|
|
29
|
+
sampling: shape.sampling,
|
|
30
|
+
include: shape.include,
|
|
31
|
+
exclude: shape.exclude,
|
|
32
|
+
rules: accountRules,
|
|
33
|
+
// A flag, not a configuration key: the account's default for --ultra,
|
|
34
|
+
// which the review command applies when the command line leaves it out.
|
|
35
|
+
ultra: z.boolean(),
|
|
36
|
+
};
|
|
37
|
+
const AGENTS = {
|
|
38
|
+
effort: shape.effort,
|
|
39
|
+
reviewers: shape.reviewers.refine((reviewers) => Object.values(reviewers).every((r) => !r.models || cloudModel(r.models)), `models must be ${CLOUD_PREFIX}<provider>/<model>`),
|
|
40
|
+
roles: shape.roles.refine((roles) => Object.values(roles).every((r) => !r?.models || cloudModel(r.models)), `models must be ${CLOUD_PREFIX}<provider>/<model>`),
|
|
41
|
+
};
|
|
42
|
+
const TOP = {
|
|
43
|
+
models: shape.models.refine((models) => Object.values(models).every((chain) => !chain || cloudModel(chain)), `models must be ${CLOUD_PREFIX}<provider>/<model>`),
|
|
44
|
+
// A plugin runtime would load code; the account names a built-in one.
|
|
45
|
+
runtime: z.enum(["opencode", "direct"]),
|
|
46
|
+
};
|
|
47
|
+
const IGNORED_TOP = new Set(["agents", "settings", "updatedAt", "version"]);
|
|
48
|
+
/** The account's settings, and warnings naming what was refused or ignored. */
|
|
49
|
+
export function parseAccountSettings(data) {
|
|
50
|
+
const body = record(data);
|
|
51
|
+
const settings = {
|
|
52
|
+
version: typeof body.version === "string" ? body.version : null,
|
|
53
|
+
};
|
|
54
|
+
const refused = [];
|
|
55
|
+
const unknown = [];
|
|
56
|
+
const forbidden = [];
|
|
57
|
+
const pick = (source, schemas, prefix, known = new Set()) => {
|
|
58
|
+
for (const [key, value] of Object.entries(source)) {
|
|
59
|
+
if (isEmpty(value) || known.has(key))
|
|
60
|
+
continue;
|
|
61
|
+
const name = `${prefix}${key}`;
|
|
62
|
+
if (FORBIDDEN.has(key)) {
|
|
63
|
+
forbidden.push(name);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!Object.hasOwn(schemas, key)) {
|
|
67
|
+
unknown.push(name);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const parsed = schemas[key].safeParse(value);
|
|
71
|
+
if (parsed.success)
|
|
72
|
+
Object.assign(settings, { [key]: parsed.data });
|
|
73
|
+
else
|
|
74
|
+
refused.push(`${name} (${z.prettifyError(parsed.error).replaceAll("\n", " ")})`);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
pick(body, TOP, "", IGNORED_TOP);
|
|
78
|
+
pick(record(body.agents), AGENTS, "agents.");
|
|
79
|
+
// Plugins and their settings are read by account-plugins.ts (ADR-0027, 3).
|
|
80
|
+
pick(record(body.settings), SETTINGS, "settings.", new Set(["plugins", "pluginSettings"]));
|
|
81
|
+
const warnings = [];
|
|
82
|
+
if (refused.length > 0) {
|
|
83
|
+
warnings.push(`ignoring ocra Cloud settings that do not match this version of ocra: ${refused.join("; ")}`);
|
|
84
|
+
}
|
|
85
|
+
if (forbidden.length > 0) {
|
|
86
|
+
warnings.push(`ignoring ${forbidden.join(", ")} from ocra Cloud: only a configuration file may set ${forbidden.length > 1 ? "them" : "it"}`);
|
|
87
|
+
}
|
|
88
|
+
if (unknown.length > 0) {
|
|
89
|
+
warnings.push(`ignoring ocra Cloud settings this version of ocra does not know: ${unknown.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
return { settings, warnings };
|
|
92
|
+
}
|
|
93
|
+
function record(value) {
|
|
94
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
95
|
+
? value
|
|
96
|
+
: {};
|
|
97
|
+
}
|
|
98
|
+
// The server may send a key it has no value for; that sets nothing.
|
|
99
|
+
function isEmpty(value) {
|
|
100
|
+
if (value === null || value === undefined)
|
|
101
|
+
return true;
|
|
102
|
+
if (Array.isArray(value))
|
|
103
|
+
return value.length === 0;
|
|
104
|
+
return typeof value === "object" && Object.keys(value).length === 0;
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=account-settings.js.map
|
package/dist/review/args.d.ts
CHANGED
|
@@ -25,12 +25,13 @@ export interface ReviewArgs {
|
|
|
25
25
|
plan?: true;
|
|
26
26
|
importSarif?: string[];
|
|
27
27
|
configFile?: string;
|
|
28
|
+
noUpload?: true;
|
|
28
29
|
temperature?: number;
|
|
29
30
|
seed?: number;
|
|
30
31
|
}
|
|
31
32
|
export declare class UsageError extends OcraError {
|
|
32
33
|
constructor(message: string);
|
|
33
34
|
}
|
|
34
|
-
export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, \u2026)\n that fall on the change; repeatable; not with --plan\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)\n --seed <n> Sampling seed, where the runtime and provider support one\n --config <file> Read this configuration file instead of the repository's\n .ocra/config.json; it applies with --no-repo-config too\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables or --config\n -h, --help Show help\n";
|
|
35
|
+
export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, \u2026)\n that fall on the change; repeatable; not with --plan\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)\n --seed <n> Sampling seed, where the runtime and provider support one\n --config <file> Read this configuration file instead of the repository's\n .ocra/config.json; it applies with --no-repo-config too\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables or --config\n --no-upload When signed in to ocra Cloud, do not send this review's\n counts (verdict, numbers of findings, files, tokens)\n -h, --help Show help\n";
|
|
35
36
|
export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
|
|
36
37
|
//# sourceMappingURL=args.d.ts.map
|
package/dist/review/args.js
CHANGED
|
@@ -35,6 +35,8 @@ Options:
|
|
|
35
35
|
.ocra/config.json; it applies with --no-repo-config too
|
|
36
36
|
--no-repo-config Ignore .ocra/config.json and its plugins (for untrusted
|
|
37
37
|
code); models come from OCRA_MODEL_* variables or --config
|
|
38
|
+
--no-upload When signed in to ocra Cloud, do not send this review's
|
|
39
|
+
counts (verdict, numbers of findings, files, tokens)
|
|
38
40
|
-h, --help Show help
|
|
39
41
|
`;
|
|
40
42
|
export function parseReviewArgs(argv) {
|
|
@@ -70,6 +72,8 @@ export function parseReviewArgs(argv) {
|
|
|
70
72
|
args.output = values.output;
|
|
71
73
|
if (values["no-repo-config"])
|
|
72
74
|
args.ignoreRepoConfig = true;
|
|
75
|
+
if (values["no-upload"])
|
|
76
|
+
args.noUpload = true;
|
|
73
77
|
if (values.ultra)
|
|
74
78
|
args.ultra = true;
|
|
75
79
|
if (values.full) {
|
|
@@ -137,6 +141,7 @@ function parse(argv) {
|
|
|
137
141
|
format: { type: "string" },
|
|
138
142
|
output: { type: "string" },
|
|
139
143
|
"no-repo-config": { type: "boolean" },
|
|
144
|
+
"no-upload": { type: "boolean" },
|
|
140
145
|
config: { type: "string" },
|
|
141
146
|
reviewers: { type: "string" },
|
|
142
147
|
"max-cost-usd": { type: "string" },
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReviewReport, Severity, Verification } from "@open-cr-agent/core";
|
|
2
|
+
export type SharedFinding = {
|
|
3
|
+
fingerprint: string;
|
|
4
|
+
reviewer: string;
|
|
5
|
+
severity: Severity;
|
|
6
|
+
category: string;
|
|
7
|
+
verification: Verification;
|
|
8
|
+
file: string;
|
|
9
|
+
lineStart: number | null;
|
|
10
|
+
lineEnd: number | null;
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
suggestion: string | null;
|
|
14
|
+
code: string;
|
|
15
|
+
redacted?: true;
|
|
16
|
+
truncated?: true;
|
|
17
|
+
};
|
|
18
|
+
export declare const MAX_FINDINGS = 200;
|
|
19
|
+
export declare const MAX_FIELD = 4096;
|
|
20
|
+
export declare const MAX_TOTAL_BYTES = 262144;
|
|
21
|
+
export declare const REDACTED = "[redacted]";
|
|
22
|
+
export declare function redact(text: string): {
|
|
23
|
+
text: string;
|
|
24
|
+
redacted: boolean;
|
|
25
|
+
};
|
|
26
|
+
/** The report's findings as uploaded, redacted and bounded; `left` counts those not sent. */
|
|
27
|
+
export declare function sharedFindings(report: Pick<ReviewReport, "findings">): {
|
|
28
|
+
findings: SharedFinding[];
|
|
29
|
+
left: number;
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=cloud-findings.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const MAX_FINDINGS = 200;
|
|
2
|
+
export const MAX_FIELD = 4_096;
|
|
3
|
+
export const MAX_TOTAL_BYTES = 262_144;
|
|
4
|
+
// The same patterns as the server's (ocra-cloud src/redact.ts); the two
|
|
5
|
+
// evolve together so the web never shows what this pass would have hidden.
|
|
6
|
+
const PATTERNS = [
|
|
7
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
|
|
8
|
+
/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
9
|
+
/\b(ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{20,}\b/g,
|
|
10
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
11
|
+
/\bglpat-[A-Za-z0-9_-]{20,}\b/g,
|
|
12
|
+
/\bsk-(ant-|or-|proj-)?[A-Za-z0-9_-]{20,}\b/g,
|
|
13
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
14
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
15
|
+
/\bnpm_[A-Za-z0-9]{36}\b/g,
|
|
16
|
+
];
|
|
17
|
+
// A long run mixing lower and upper case and digits: a likely key. A hex
|
|
18
|
+
// digest (one case) and a path (has a slash) are left alone.
|
|
19
|
+
const LONG_RUN = /\b[A-Za-z0-9+/_-]{32,}={0,2}/g;
|
|
20
|
+
const looksRandom = (s) => /[a-z]/.test(s) && /[A-Z]/.test(s) && /\d/.test(s) && !s.includes("/");
|
|
21
|
+
export const REDACTED = "[redacted]";
|
|
22
|
+
export function redact(text) {
|
|
23
|
+
let out = text;
|
|
24
|
+
for (const pattern of PATTERNS)
|
|
25
|
+
out = out.replace(pattern, REDACTED);
|
|
26
|
+
out = out.replace(LONG_RUN, (m) => (looksRandom(m) ? REDACTED : m));
|
|
27
|
+
return { text: out, redacted: out !== text };
|
|
28
|
+
}
|
|
29
|
+
/** The report's findings as uploaded, redacted and bounded; `left` counts those not sent. */
|
|
30
|
+
export function sharedFindings(report) {
|
|
31
|
+
const findings = [];
|
|
32
|
+
let total = 0;
|
|
33
|
+
for (const finding of report.findings.slice(0, MAX_FINDINGS)) {
|
|
34
|
+
const shared = share(finding);
|
|
35
|
+
const size = textBytes(shared);
|
|
36
|
+
if (total + size > MAX_TOTAL_BYTES)
|
|
37
|
+
break;
|
|
38
|
+
total += size;
|
|
39
|
+
findings.push(shared);
|
|
40
|
+
}
|
|
41
|
+
return { findings, left: report.findings.length - findings.length };
|
|
42
|
+
}
|
|
43
|
+
function share(f) {
|
|
44
|
+
let redacted = false;
|
|
45
|
+
let truncated = false;
|
|
46
|
+
const text = (value) => {
|
|
47
|
+
const r = redact(value);
|
|
48
|
+
redacted ||= r.redacted;
|
|
49
|
+
if (r.text.length <= MAX_FIELD)
|
|
50
|
+
return r.text;
|
|
51
|
+
truncated = true;
|
|
52
|
+
const cut = r.text.slice(0, MAX_FIELD);
|
|
53
|
+
// Never leave half of a surrogate pair at the cut.
|
|
54
|
+
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
55
|
+
};
|
|
56
|
+
const shared = {
|
|
57
|
+
fingerprint: f.fingerprint,
|
|
58
|
+
reviewer: f.reviewer,
|
|
59
|
+
severity: f.severity,
|
|
60
|
+
category: f.category,
|
|
61
|
+
verification: f.verification ?? "unchecked",
|
|
62
|
+
file: f.file,
|
|
63
|
+
lineStart: f.lineRange?.start ?? null,
|
|
64
|
+
lineEnd: f.lineRange?.end ?? null,
|
|
65
|
+
title: text(f.title),
|
|
66
|
+
body: text(f.body),
|
|
67
|
+
suggestion: f.suggestion === undefined ? null : text(f.suggestion),
|
|
68
|
+
code: text(f.existingCode),
|
|
69
|
+
};
|
|
70
|
+
if (redacted)
|
|
71
|
+
shared.redacted = true;
|
|
72
|
+
if (truncated)
|
|
73
|
+
shared.truncated = true;
|
|
74
|
+
return shared;
|
|
75
|
+
}
|
|
76
|
+
// Bytes as sent, escapes included, so the body stays under the server's limit.
|
|
77
|
+
function textBytes(f) {
|
|
78
|
+
return [f.title, f.body, f.suggestion ?? "", f.code].reduce((sum, s) => sum + Buffer.byteLength(JSON.stringify(s)), 0);
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=cloud-findings.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MemoryEntry } from "@open-cr-agent/core";
|
|
2
|
+
import type { CloudDeps, Credentials } from "../cloud.js";
|
|
3
|
+
/** The account's entries for the repository; [] and a warning when they cannot be read. */
|
|
4
|
+
export declare function fetchAccountMemory(deps: Pick<CloudDeps, "fetch">, session: Pick<Credentials, "server" | "access_token">, repoHash: string, warn: (message: string) => void): Promise<MemoryEntry[]>;
|
|
5
|
+
export declare function parseAccountMemory(body: unknown, warn: (message: string) => void): MemoryEntry[];
|
|
6
|
+
//# sourceMappingURL=cloud-memory.d.ts.map
|