@open-cr-agent/cli 0.4.0 → 0.6.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/dist/cloud/account-salt.d.ts +6 -0
- package/dist/cloud/account-salt.js +33 -0
- package/dist/cloud/account-settings.d.ts +100 -0
- package/dist/cloud/account-settings.js +107 -0
- package/dist/cloud/browser.d.ts +17 -0
- package/dist/cloud/browser.js +37 -0
- package/dist/cloud/client.d.ts +93 -0
- package/dist/cloud/client.js +221 -0
- package/dist/cloud/credentials.d.ts +18 -0
- package/dist/cloud/credentials.js +52 -0
- package/dist/cloud/deps.d.ts +14 -0
- package/dist/cloud/deps.js +34 -0
- package/dist/cloud/file-lock.d.ts +14 -0
- package/dist/cloud/file-lock.js +77 -0
- package/dist/cloud/findings.d.ts +8 -0
- package/dist/cloud/findings.js +58 -0
- package/dist/cloud/gateway-token.d.ts +18 -0
- package/dist/cloud/gateway-token.js +63 -0
- package/dist/cloud/memory.d.ts +8 -0
- package/dist/cloud/memory.js +68 -0
- package/dist/cloud/private-file.d.ts +8 -0
- package/dist/cloud/private-file.js +46 -0
- package/dist/cloud/providers.d.ts +18 -0
- package/dist/cloud/providers.js +132 -0
- package/dist/cloud/review.d.ts +28 -0
- package/dist/cloud/review.js +76 -0
- package/dist/cloud/settings.d.ts +22 -0
- package/dist/cloud/settings.js +64 -0
- package/dist/cloud/upload.d.ts +20 -0
- package/dist/cloud/upload.js +183 -0
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.js +164 -0
- package/dist/commands/memory.d.ts +3 -0
- package/dist/{memory.js → commands/memory.js} +21 -20
- package/dist/commands/metrics.d.ts +45 -0
- package/dist/{metrics.js → commands/metrics.js} +32 -45
- package/dist/commands/plugins.d.ts +9 -0
- package/dist/commands/plugins.js +159 -0
- package/dist/{review → commands/review}/args.d.ts +7 -10
- package/dist/{review → commands/review}/args.js +11 -16
- package/dist/commands/review/deliver.d.ts +7 -0
- package/dist/commands/review/deliver.js +56 -0
- package/dist/commands/review/deps.d.ts +18 -0
- package/dist/commands/review/deps.js +2 -0
- package/dist/commands/review/execute-run.d.ts +13 -0
- package/dist/commands/review/execute-run.js +97 -0
- package/dist/commands/review/exit-code.d.ts +4 -0
- package/dist/commands/review/exit-code.js +29 -0
- package/dist/commands/review/plan-prices.d.ts +3 -0
- package/dist/commands/review/plan-prices.js +18 -0
- package/dist/{review → commands/review}/plan-render.js +22 -2
- package/dist/commands/review/plan-run.d.ts +5 -0
- package/dist/commands/review/plan-run.js +38 -0
- package/dist/commands/review/plan-schema.d.ts +97 -0
- package/dist/commands/review/plan-schema.js +34 -0
- package/dist/{review → commands/review}/progress.d.ts +3 -5
- package/dist/{review → commands/review}/progress.js +3 -3
- package/dist/{review → commands/review}/provenance.d.ts +4 -2
- package/dist/{review → commands/review}/provenance.js +11 -2
- package/dist/{review → commands/review}/render.js +8 -3
- package/dist/commands/review/resolve-run.d.ts +29 -0
- package/dist/commands/review/resolve-run.js +125 -0
- package/dist/commands/review/review-options.d.ts +6 -0
- package/dist/commands/review/review-options.js +35 -0
- package/dist/{review → commands/review}/runtimes.js +1 -1
- package/dist/{review → commands/review}/sarif-input.d.ts +0 -1
- package/dist/{review → commands/review}/sarif-input.js +5 -5
- package/dist/commands/review/sarif.d.ts +96 -0
- package/dist/{review → commands/review}/sarif.js +29 -7
- package/dist/commands/review/settings-sources.d.ts +17 -0
- package/dist/commands/review/settings-sources.js +30 -0
- package/dist/commands/review/target.d.ts +38 -0
- package/dist/commands/review/target.js +126 -0
- package/dist/commands/review.d.ts +7 -0
- package/dist/commands/review.js +34 -0
- package/dist/config/cli-config.d.ts +41 -0
- package/dist/config/cli-config.js +199 -0
- package/dist/{review/remote-config.d.ts → config/remote.d.ts} +3 -3
- package/dist/{review/remote-config.js → config/remote.js} +5 -23
- package/dist/{review/config.d.ts → config/schema.d.ts} +13 -25
- package/dist/config/schema.js +138 -0
- package/dist/config/settings.d.ts +37 -0
- package/dist/config/settings.js +133 -0
- package/dist/config/user-dir.d.ts +3 -0
- package/dist/config/user-dir.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +10 -1
- package/dist/internal.d.ts +6 -0
- package/dist/internal.js +7 -0
- package/dist/io/exit.d.ts +9 -0
- package/dist/io/exit.js +12 -0
- package/dist/io/output.d.ts +4 -0
- package/dist/io/output.js +2 -0
- package/dist/io/usage-error.d.ts +5 -0
- package/dist/io/usage-error.js +8 -0
- package/dist/main.js +2 -2
- package/dist/plugins/account.d.ts +23 -0
- package/dist/plugins/account.js +54 -0
- package/dist/plugins/load.d.ts +13 -0
- package/dist/plugins/load.js +78 -0
- package/dist/plugins/npm.d.ts +8 -0
- package/dist/plugins/npm.js +24 -0
- package/dist/plugins/store.d.ts +20 -0
- package/dist/plugins/store.js +85 -0
- package/dist/repository-id.d.ts +12 -0
- package/dist/repository-id.js +72 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +32 -10
- package/dist/session/store.d.ts +9 -0
- package/dist/session/store.js +31 -0
- package/package.json +17 -7
- package/dist/cloud.d.ts +0 -32
- package/dist/cloud.js +0 -262
- package/dist/memory.d.ts +0 -4
- package/dist/metrics.d.ts +0 -47
- package/dist/review/cloud-providers.d.ts +0 -12
- package/dist/review/cloud-providers.js +0 -59
- package/dist/review/cloud-upload.d.ts +0 -40
- package/dist/review/cloud-upload.js +0 -135
- package/dist/review/command.d.ts +0 -31
- package/dist/review/command.js +0 -240
- package/dist/review/config.js +0 -280
- package/dist/review/plugins.d.ts +0 -3
- package/dist/review/plugins.js +0 -32
- package/dist/review/sarif.d.ts +0 -4
- package/dist/review/target.d.ts +0 -17
- package/dist/review/target.js +0 -207
- /package/dist/{review → commands/review}/plan-render.d.ts +0 -0
- /package/dist/{review → commands/review}/render.d.ts +0 -0
- /package/dist/{review → commands/review}/runtimes.d.ts +0 -0
- /package/dist/{review → io}/terminal.d.ts +0 -0
- /package/dist/{review → io}/terminal.js +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ocraConfigDir } from "../config/user-dir.js";
|
|
4
|
+
import { writePrivateFile } from "./private-file.js";
|
|
5
|
+
export function credentialsHint() {
|
|
6
|
+
return process.platform === "win32"
|
|
7
|
+
? "%APPDATA%\\ocra\\credentials.json"
|
|
8
|
+
: "~/.config/ocra/credentials.json";
|
|
9
|
+
}
|
|
10
|
+
export function credentialsPath(env) {
|
|
11
|
+
return join(ocraConfigDir(env), "credentials.json");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The saved session; undefined when there is none or the file does not hold
|
|
15
|
+
* one, which `warn` hears about (a file cut short by a crash, or edited).
|
|
16
|
+
*/
|
|
17
|
+
export async function readCredentials(path, warn) {
|
|
18
|
+
const saved = await loadCredentials(path);
|
|
19
|
+
if (saved !== "unreadable")
|
|
20
|
+
return saved;
|
|
21
|
+
warn?.(`ignoring ${path}: it holds no ocra Cloud session; run ocra login to sign in again`);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
export async function loadCredentials(path) {
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = await readFile(path, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
let c;
|
|
33
|
+
try {
|
|
34
|
+
c = JSON.parse(text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "unreadable";
|
|
38
|
+
}
|
|
39
|
+
if (typeof c !== "object" ||
|
|
40
|
+
c === null ||
|
|
41
|
+
!c.server ||
|
|
42
|
+
!c.access_token ||
|
|
43
|
+
!c.refresh_token ||
|
|
44
|
+
typeof c.expires_at !== "number") {
|
|
45
|
+
return "unreadable";
|
|
46
|
+
}
|
|
47
|
+
return c;
|
|
48
|
+
}
|
|
49
|
+
export async function writeCredentials(path, c) {
|
|
50
|
+
await writePrivateFile(path, `${JSON.stringify(c, null, 2)}\n`);
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
|
|
2
|
+
export declare const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
|
|
3
|
+
export type CloudDeps = {
|
|
4
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
5
|
+
fetch: typeof fetch;
|
|
6
|
+
now: () => number;
|
|
7
|
+
sleep: (ms: number) => Promise<void>;
|
|
8
|
+
openBrowser: (url: string) => void;
|
|
9
|
+
credentialsPath: string;
|
|
10
|
+
clientName: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function defaultCloudDeps(env?: CloudDeps["env"]): CloudDeps;
|
|
13
|
+
export declare function cloudUrl(env: CloudDeps["env"]): string;
|
|
14
|
+
//# sourceMappingURL=deps.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { UsageError } from "../io/usage-error.js";
|
|
4
|
+
import { VERSION } from "../version.js";
|
|
5
|
+
import { openBrowser } from "./browser.js";
|
|
6
|
+
import { credentialsPath } from "./credentials.js";
|
|
7
|
+
/** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
|
|
8
|
+
export const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
|
|
9
|
+
export function defaultCloudDeps(env = process.env) {
|
|
10
|
+
return {
|
|
11
|
+
env,
|
|
12
|
+
fetch: globalThis.fetch,
|
|
13
|
+
now: Date.now,
|
|
14
|
+
sleep: (ms) => sleep(ms),
|
|
15
|
+
openBrowser: (url) => openBrowser(url),
|
|
16
|
+
credentialsPath: credentialsPath(env),
|
|
17
|
+
clientName: `${hostname()} (ocra ${VERSION})`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function cloudUrl(env) {
|
|
21
|
+
const named = env.OCRA_CLOUD_URL || DEFAULT_CLOUD_URL;
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(named);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new UsageError(`OCRA_CLOUD_URL is not a URL: ${named}`);
|
|
28
|
+
}
|
|
29
|
+
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
30
|
+
throw new UsageError(`OCRA_CLOUD_URL must use https: ${url}`);
|
|
31
|
+
}
|
|
32
|
+
return url.origin;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=deps.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type LockTiming = {
|
|
2
|
+
/** How long to wait for another holder before going on without the lock. */
|
|
3
|
+
waitMs: number;
|
|
4
|
+
/** A lock older than this was left by a process that died holding it. */
|
|
5
|
+
staleMs: number;
|
|
6
|
+
pollMs: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Runs `fn` holding the lock at `path`. When it cannot be had within
|
|
10
|
+
* `waitMs`, `fn` runs anyway: the lock only narrows races its callers must
|
|
11
|
+
* still survive, and a stuck holder must not stop a review.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withFileLock<T>(path: string, fn: () => Promise<T>, timing?: LockTiming): Promise<T>;
|
|
14
|
+
//# sourceMappingURL=file-lock.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { errnoCode, isNotFound } from "@open-cr-agent/core/internal";
|
|
5
|
+
const TIMING = { waitMs: 10_000, staleMs: 30_000, pollMs: 50 };
|
|
6
|
+
/**
|
|
7
|
+
* Runs `fn` holding the lock at `path`. When it cannot be had within
|
|
8
|
+
* `waitMs`, `fn` runs anyway: the lock only narrows races its callers must
|
|
9
|
+
* still survive, and a stuck holder must not stop a review.
|
|
10
|
+
*/
|
|
11
|
+
export async function withFileLock(path, fn, timing = TIMING) {
|
|
12
|
+
const token = await acquire(path, timing);
|
|
13
|
+
try {
|
|
14
|
+
return await fn();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
if (token)
|
|
18
|
+
await release(path, token);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function acquire(path, timing) {
|
|
22
|
+
const token = randomBytes(8).toString("hex");
|
|
23
|
+
const deadline = Date.now() + timing.waitMs;
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(path, JSON.stringify({ token, at: Date.now(), pid: process.pid }), {
|
|
27
|
+
flag: "wx",
|
|
28
|
+
mode: 0o600,
|
|
29
|
+
});
|
|
30
|
+
return token;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// A lock that cannot be made at all (a read-only directory) is no lock.
|
|
34
|
+
if (errnoCode(error) !== "EEXIST")
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const age = await ageOf(path);
|
|
38
|
+
if (age === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
if (age > timing.staleMs) {
|
|
41
|
+
await rm(path, { force: true });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (Date.now() >= deadline)
|
|
45
|
+
return undefined;
|
|
46
|
+
await sleep(timing.pollMs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Milliseconds since the lock was taken; undefined when it is gone. */
|
|
50
|
+
async function ageOf(path) {
|
|
51
|
+
try {
|
|
52
|
+
const at = JSON.parse(await readFile(path, "utf8")).at;
|
|
53
|
+
if (typeof at === "number")
|
|
54
|
+
return Date.now() - at;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (isNotFound(error))
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
// Being written, or not a lock of ours: its file's own time stands in.
|
|
61
|
+
try {
|
|
62
|
+
return Date.now() - (await stat(path)).mtimeMs;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function release(path, token) {
|
|
69
|
+
try {
|
|
70
|
+
const held = JSON.parse(await readFile(path, "utf8"));
|
|
71
|
+
// Broken as stale and taken by another: theirs now.
|
|
72
|
+
if (held.token === token)
|
|
73
|
+
await rm(path, { force: true });
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=file-lock.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type SharedFinding } from "@open-cr-agent/cloud-contract";
|
|
2
|
+
import type { ReviewReport } from "@open-cr-agent/core";
|
|
3
|
+
/** The report's findings as uploaded, redacted and bounded; `left` counts those not sent. */
|
|
4
|
+
export declare function sharedFindings(report: Pick<ReviewReport, "findings">): {
|
|
5
|
+
findings: SharedFinding[];
|
|
6
|
+
left: number;
|
|
7
|
+
};
|
|
8
|
+
//# sourceMappingURL=findings.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { MAX_FIELD, MAX_FINDINGS, MAX_TOTAL_BYTES, redact, } from "@open-cr-agent/cloud-contract";
|
|
2
|
+
// The findings a review sends ocra Cloud when the account shares them
|
|
3
|
+
// (ADR-0028, 2): what the web needs to show the review, after the contract's
|
|
4
|
+
// redaction pass and within its bounds. The server redacts and bounds again;
|
|
5
|
+
// this pass keeps a secret from leaving the machine at all. It catches
|
|
6
|
+
// patterns, not every secret.
|
|
7
|
+
/** The report's findings as uploaded, redacted and bounded; `left` counts those not sent. */
|
|
8
|
+
export function sharedFindings(report) {
|
|
9
|
+
const findings = [];
|
|
10
|
+
let total = 0;
|
|
11
|
+
for (const finding of report.findings.slice(0, MAX_FINDINGS)) {
|
|
12
|
+
const shared = share(finding);
|
|
13
|
+
const size = textBytes(shared);
|
|
14
|
+
if (total + size > MAX_TOTAL_BYTES)
|
|
15
|
+
break;
|
|
16
|
+
total += size;
|
|
17
|
+
findings.push(shared);
|
|
18
|
+
}
|
|
19
|
+
return { findings, left: report.findings.length - findings.length };
|
|
20
|
+
}
|
|
21
|
+
function share(f) {
|
|
22
|
+
let redacted = false;
|
|
23
|
+
let truncated = false;
|
|
24
|
+
const text = (value) => {
|
|
25
|
+
const r = redact(value);
|
|
26
|
+
redacted ||= r.redacted;
|
|
27
|
+
if (r.text.length <= MAX_FIELD)
|
|
28
|
+
return r.text;
|
|
29
|
+
truncated = true;
|
|
30
|
+
const cut = r.text.slice(0, MAX_FIELD);
|
|
31
|
+
// Never leave half of a surrogate pair at the cut.
|
|
32
|
+
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
33
|
+
};
|
|
34
|
+
const shared = {
|
|
35
|
+
fingerprint: f.fingerprint,
|
|
36
|
+
reviewer: f.reviewer,
|
|
37
|
+
severity: f.severity,
|
|
38
|
+
category: text(f.category),
|
|
39
|
+
verification: f.verification ?? "unchecked",
|
|
40
|
+
file: text(f.file),
|
|
41
|
+
lineStart: f.lineRange?.start ?? null,
|
|
42
|
+
lineEnd: f.lineRange?.end ?? null,
|
|
43
|
+
title: text(f.title),
|
|
44
|
+
body: text(f.body),
|
|
45
|
+
suggestion: f.suggestion === undefined ? null : text(f.suggestion),
|
|
46
|
+
code: text(f.existingCode),
|
|
47
|
+
};
|
|
48
|
+
if (redacted)
|
|
49
|
+
shared.redacted = true;
|
|
50
|
+
if (truncated)
|
|
51
|
+
shared.truncated = true;
|
|
52
|
+
return shared;
|
|
53
|
+
}
|
|
54
|
+
// Bytes as sent, escapes included, so the body stays under the server's limit.
|
|
55
|
+
function textBytes(f) {
|
|
56
|
+
return [f.title, f.body, f.suggestion ?? "", f.code ?? ""].reduce((sum, s) => sum + Buffer.byteLength(JSON.stringify(s)), 0);
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=findings.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Credentials } from "./credentials.js";
|
|
2
|
+
import type { CloudDeps } from "./deps.js";
|
|
3
|
+
export declare const RENEW_BEFORE_MS: number;
|
|
4
|
+
export declare class GatewayToken {
|
|
5
|
+
private timer;
|
|
6
|
+
private stopped;
|
|
7
|
+
private failing;
|
|
8
|
+
private credentials;
|
|
9
|
+
private readonly deps;
|
|
10
|
+
private readonly warn;
|
|
11
|
+
constructor(credentials: Credentials, deps: CloudDeps, warn: (message: string) => void);
|
|
12
|
+
get value(): string;
|
|
13
|
+
start(): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
private schedule;
|
|
16
|
+
private renew;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=gateway-token.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { MAX_TIMER_MS } from "@open-cr-agent/core/internal";
|
|
2
|
+
import { CloudClient, sessionLostReason } from "./client.js";
|
|
3
|
+
// The access token a run's models use at the ocra Cloud gateway lives an
|
|
4
|
+
// hour; a run may last longer. While the run lasts, the token is renewed a
|
|
5
|
+
// few minutes before it expires, for a runtime that reads its key at each
|
|
6
|
+
// call (the direct runtime does).
|
|
7
|
+
export const RENEW_BEFORE_MS = 5 * 60_000;
|
|
8
|
+
const RETRY_MS = 60_000;
|
|
9
|
+
export class GatewayToken {
|
|
10
|
+
timer;
|
|
11
|
+
stopped = false;
|
|
12
|
+
failing = false;
|
|
13
|
+
credentials;
|
|
14
|
+
deps;
|
|
15
|
+
warn;
|
|
16
|
+
constructor(credentials, deps, warn) {
|
|
17
|
+
this.credentials = credentials;
|
|
18
|
+
this.deps = deps;
|
|
19
|
+
this.warn = warn;
|
|
20
|
+
}
|
|
21
|
+
get value() {
|
|
22
|
+
return this.credentials.access_token;
|
|
23
|
+
}
|
|
24
|
+
start() {
|
|
25
|
+
this.schedule(this.credentials.expires_at - RENEW_BEFORE_MS - this.deps.now());
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
this.stopped = true;
|
|
29
|
+
clearTimeout(this.timer);
|
|
30
|
+
}
|
|
31
|
+
schedule(delayMs) {
|
|
32
|
+
if (this.stopped)
|
|
33
|
+
return;
|
|
34
|
+
this.timer = setTimeout(() => void this.renew(), Math.min(Math.max(delayMs, 0), MAX_TIMER_MS));
|
|
35
|
+
this.timer.unref?.();
|
|
36
|
+
}
|
|
37
|
+
async renew() {
|
|
38
|
+
let reason;
|
|
39
|
+
try {
|
|
40
|
+
// Asking for more than the token has left renews it.
|
|
41
|
+
const session = await new CloudClient(this.deps).session(2 * RENEW_BEFORE_MS);
|
|
42
|
+
if (this.stopped)
|
|
43
|
+
return;
|
|
44
|
+
if (session.kind === "ok") {
|
|
45
|
+
this.credentials = session.credentials;
|
|
46
|
+
this.failing = false;
|
|
47
|
+
this.start();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
reason = session.kind === "signed-out" ? "you signed out" : sessionLostReason(session);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
reason = error instanceof Error ? error.message : "error";
|
|
54
|
+
}
|
|
55
|
+
if (!this.failing) {
|
|
56
|
+
this.warn(`could not renew the ocra Cloud token (${reason}); model calls through ocra Cloud fail once it expires`);
|
|
57
|
+
this.failing = true;
|
|
58
|
+
}
|
|
59
|
+
if (this.deps.now() < this.credentials.expires_at)
|
|
60
|
+
this.schedule(RETRY_MS);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=gateway-token.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MemoryEntry } from "@open-cr-agent/core";
|
|
2
|
+
import type { CloudDeps } from "./deps.js";
|
|
3
|
+
/** The account's entries for the repository; [] and a warning when they cannot be read. */
|
|
4
|
+
export declare function fetchAccountMemory(deps: CloudDeps, repoHash: string, warn: (message: string) => void): Promise<MemoryEntry[]>;
|
|
5
|
+
/** Whether the account remembers any finding, for any repository; false when that cannot be read. */
|
|
6
|
+
export declare function accountHasMemory(deps: CloudDeps): Promise<boolean>;
|
|
7
|
+
export declare function parseAccountMemory(list: readonly unknown[], warn: (message: string) => void): MemoryEntry[];
|
|
8
|
+
//# sourceMappingURL=memory.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { MAX_MEMORY_PER_REPO } from "@open-cr-agent/cloud-contract";
|
|
2
|
+
import { errorMessage } from "@open-cr-agent/core";
|
|
3
|
+
import { memoryEntrySchema } from "@open-cr-agent/core/internal";
|
|
4
|
+
import { CloudClient, sessionLostReason } from "./client.js";
|
|
5
|
+
// The findings the account remembers for one repository (ADR-0028, 4), set
|
|
6
|
+
// from the web. A review applies them with .ocra/memory.json's.
|
|
7
|
+
/** The account's entries for the repository; [] and a warning when they cannot be read. */
|
|
8
|
+
export async function fetchAccountMemory(deps, repoHash, warn) {
|
|
9
|
+
const cannotRead = (reason) => {
|
|
10
|
+
warn(`could not read your ocra Cloud memory (${reason}); the review applies the repository's alone`);
|
|
11
|
+
return [];
|
|
12
|
+
};
|
|
13
|
+
let answer;
|
|
14
|
+
try {
|
|
15
|
+
answer = await new CloudClient(deps).memory(repoHash);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
return cannotRead(errorMessage(error));
|
|
19
|
+
}
|
|
20
|
+
switch (answer.kind) {
|
|
21
|
+
case "ok":
|
|
22
|
+
return parseAccountMemory(answer.value, warn);
|
|
23
|
+
case "signed-out":
|
|
24
|
+
return [];
|
|
25
|
+
case "status":
|
|
26
|
+
// A server without account memory has none to apply.
|
|
27
|
+
return answer.status === 404 ? [] : cannotRead(`HTTP ${answer.status}`);
|
|
28
|
+
case "malformed":
|
|
29
|
+
warn("ignoring your ocra Cloud memory: the server's answer has no entries");
|
|
30
|
+
return [];
|
|
31
|
+
default:
|
|
32
|
+
return cannotRead(sessionLostReason(answer));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Whether the account remembers any finding, for any repository; false when that cannot be read. */
|
|
36
|
+
export async function accountHasMemory(deps) {
|
|
37
|
+
try {
|
|
38
|
+
const answer = await new CloudClient(deps).memory();
|
|
39
|
+
return answer.kind === "ok" && answer.value.length > 0;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function parseAccountMemory(list, warn) {
|
|
46
|
+
const entries = [];
|
|
47
|
+
let refused = 0;
|
|
48
|
+
// The server keeps no more per repository; more is not an answer to trust.
|
|
49
|
+
for (const raw of list.slice(0, MAX_MEMORY_PER_REPO)) {
|
|
50
|
+
const r = (typeof raw === "object" && raw !== null ? raw : {});
|
|
51
|
+
const parsed = memoryEntrySchema.safeParse({
|
|
52
|
+
fingerprint: r.fingerprint,
|
|
53
|
+
file: r.file,
|
|
54
|
+
title: r.title,
|
|
55
|
+
reason: r.reason,
|
|
56
|
+
...(typeof r.createdAt === "string" ? { added: r.createdAt.slice(0, 10) } : {}),
|
|
57
|
+
});
|
|
58
|
+
if (parsed.success)
|
|
59
|
+
entries.push(parsed.data);
|
|
60
|
+
else
|
|
61
|
+
refused += 1;
|
|
62
|
+
}
|
|
63
|
+
if (refused > 0) {
|
|
64
|
+
warn(`ignoring ${refused} ocra Cloud memory entr${refused > 1 ? "ies" : "y"} this version of ocra cannot read`);
|
|
65
|
+
}
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Writes the file atomically, readable only by this user. */
|
|
2
|
+
export declare function writePrivateFile(path: string, text: string): Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* Writes the file atomically unless it exists: false when another writer
|
|
5
|
+
* made it first, whose content then stands.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createPrivateFile(path: string, text: string): Promise<boolean>;
|
|
8
|
+
//# sourceMappingURL=private-file.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { link, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { errnoCode } from "@open-cr-agent/core/internal";
|
|
5
|
+
// Files only this user may read (the ocra Cloud session, the salts), written
|
|
6
|
+
// so that no reader, another ocra process included, ever sees one half
|
|
7
|
+
// written: the text goes to a temporary file in the same directory, created
|
|
8
|
+
// 0600, which then replaces the file in one rename.
|
|
9
|
+
/** Writes the file atomically, readable only by this user. */
|
|
10
|
+
export async function writePrivateFile(path, text) {
|
|
11
|
+
const temp = await writeTemp(path, text);
|
|
12
|
+
try {
|
|
13
|
+
await rename(temp, path);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
await rm(temp, { force: true });
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Writes the file atomically unless it exists: false when another writer
|
|
22
|
+
* made it first, whose content then stands.
|
|
23
|
+
*/
|
|
24
|
+
export async function createPrivateFile(path, text) {
|
|
25
|
+
const temp = await writeTemp(path, text);
|
|
26
|
+
try {
|
|
27
|
+
await link(temp, path);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (errnoCode(error) === "EEXIST")
|
|
32
|
+
return false;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await rm(temp, { force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function writeTemp(path, text) {
|
|
40
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
41
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
42
|
+
// "wx": a fresh file, so the mode applies; never one that already exists.
|
|
43
|
+
await writeFile(temp, text, { mode: 0o600, flag: "wx" });
|
|
44
|
+
return temp;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=private-file.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CustomProvider } from "@open-cr-agent/core";
|
|
2
|
+
import type { CloudDeps } from "./deps.js";
|
|
3
|
+
/** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
|
|
4
|
+
export declare const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
|
|
5
|
+
/** The run the models serve: how long it may last, and whether its runtime reads a key at each call. */
|
|
6
|
+
export type CloudRun = {
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
keyReadPerCall: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type CloudProviders = {
|
|
11
|
+
providers: Record<string, CustomProvider>;
|
|
12
|
+
env: Env;
|
|
13
|
+
stop(): void;
|
|
14
|
+
};
|
|
15
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
16
|
+
export declare function withCloudProviders(chains: readonly (readonly string[] | undefined)[], providers: Readonly<Record<string, CustomProvider>>, env: Env, deps: CloudDeps | undefined, warn: (message: string) => void, run?: CloudRun): Promise<CloudProviders>;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=providers.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { CLOUD_PREFIX, EFFORT_STYLES, GATEWAY_PATH, isCloudModel, } from "@open-cr-agent/cloud-contract";
|
|
2
|
+
import { errorMessage, OcraError } from "@open-cr-agent/core";
|
|
3
|
+
import { REVIEW_DEFAULTS } from "@open-cr-agent/core/internal";
|
|
4
|
+
import { CloudClient, sessionLostReason } from "./client.js";
|
|
5
|
+
import { GatewayToken } from "./gateway-token.js";
|
|
6
|
+
// Models named ocra-<provider>/<model> go through the ocra Cloud gateway with
|
|
7
|
+
// the key stored there for <provider> (ADR-0024). Each such provider becomes
|
|
8
|
+
// an OpenAI-compatible endpoint declared for this run, authorized by the
|
|
9
|
+
// signed-in session's access token. Without such a model nothing here reads
|
|
10
|
+
// the session or touches the network.
|
|
11
|
+
/** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
|
|
12
|
+
export const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
|
|
13
|
+
// A review runs 25 minutes at most by default; start it with a token that
|
|
14
|
+
// outlives that, and ask for one that outlives a longer run.
|
|
15
|
+
const MIN_TOKEN_MS = 35 * 60_000;
|
|
16
|
+
const TOKEN_MARGIN_MS = 10 * 60_000;
|
|
17
|
+
// chains: every chain of the run, the tiers' and the agents' own.
|
|
18
|
+
export async function withCloudProviders(chains, providers, env, deps, warn, run = { timeoutMs: REVIEW_DEFAULTS.runTimeoutMs, keyReadPerCall: false }) {
|
|
19
|
+
const wanted = new Map();
|
|
20
|
+
const dropped = [];
|
|
21
|
+
for (const model of chains.flatMap((chain) => chain ?? [])) {
|
|
22
|
+
const slash = model.indexOf("/");
|
|
23
|
+
const id = model.slice(0, slash);
|
|
24
|
+
if (slash < 0 || !id.startsWith(CLOUD_PREFIX) || providers[id])
|
|
25
|
+
continue;
|
|
26
|
+
if (!isCloudModel(model)) {
|
|
27
|
+
dropped.push(JSON.stringify(model));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const set = wanted.get(id) ?? new Set();
|
|
31
|
+
set.add(model.slice(slash + 1));
|
|
32
|
+
wanted.set(id, set);
|
|
33
|
+
}
|
|
34
|
+
if (dropped.length > 0) {
|
|
35
|
+
warn(`ignoring ${dropped.join(", ")}: an ocra Cloud model is ${CLOUD_PREFIX}<provider>/<model>, with a provider of lowercase letters, digits and - and a model of letters, digits and ./:@_-`);
|
|
36
|
+
}
|
|
37
|
+
if (wanted.size === 0)
|
|
38
|
+
return { providers: { ...providers }, env, stop: () => { } };
|
|
39
|
+
const names = [...wanted.keys()].join(", ");
|
|
40
|
+
if (env.OCRA_CLOUD === "off") {
|
|
41
|
+
throw new OcraError("CONFIG_INVALID", `Models name ocra Cloud (${names}) but OCRA_CLOUD=off`);
|
|
42
|
+
}
|
|
43
|
+
const client = deps ? new CloudClient(deps) : undefined;
|
|
44
|
+
// A runtime that reads the key once needs a token that outlives the run;
|
|
45
|
+
// one that reads it at each call gets it renewed during the run.
|
|
46
|
+
const needed = run.timeoutMs + TOKEN_MARGIN_MS;
|
|
47
|
+
const minValidity = run.keyReadPerCall ? MIN_TOKEN_MS : Math.max(MIN_TOKEN_MS, needed);
|
|
48
|
+
const state = client ? await client.session(minValidity) : { kind: "signed-out" };
|
|
49
|
+
if (!client || !deps || state.kind === "signed-out") {
|
|
50
|
+
throw new OcraError("CONFIG_CREDENTIALS_MISSING", `Models name ocra Cloud (${names}): sign in with ocra login`);
|
|
51
|
+
}
|
|
52
|
+
if (state.kind === "revoked") {
|
|
53
|
+
throw new OcraError("CONFIG_CREDENTIALS_MISSING", `Models name ocra Cloud (${names}): ${sessionLostReason(state)}`);
|
|
54
|
+
}
|
|
55
|
+
const unreachable = (reason) => new OcraError("RUNTIME_START_FAILED", `Models name ocra Cloud (${names}) but ocra Cloud could not be reached (${reason})`);
|
|
56
|
+
if (state.kind === "unreachable")
|
|
57
|
+
throw unreachable(state.reason);
|
|
58
|
+
const session = state.credentials;
|
|
59
|
+
let answer;
|
|
60
|
+
try {
|
|
61
|
+
answer = await client.providers(session.server);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw unreachable(errorMessage(error));
|
|
65
|
+
}
|
|
66
|
+
if (answer.kind === "status" && (answer.status >= 500 || answer.status === 429)) {
|
|
67
|
+
throw unreachable(`HTTP ${answer.status}`);
|
|
68
|
+
}
|
|
69
|
+
if (answer.kind === "malformed") {
|
|
70
|
+
throw new OcraError("RUNTIME_START_FAILED", "ocra Cloud's answer for its providers is not a list of providers");
|
|
71
|
+
}
|
|
72
|
+
if (answer.kind !== "ok") {
|
|
73
|
+
throw new OcraError("RUNTIME_START_FAILED", `ocra Cloud answered ${answer.status} for its providers`);
|
|
74
|
+
}
|
|
75
|
+
const listed = answer.value;
|
|
76
|
+
const declared = { ...providers };
|
|
77
|
+
for (const [id, modelIds] of wanted) {
|
|
78
|
+
const name = id.slice(CLOUD_PREFIX.length);
|
|
79
|
+
const entry = listed.find((p) => p.name === name);
|
|
80
|
+
const paths = entry?.paths ?? [];
|
|
81
|
+
const valid = paths.filter((path) => typeof path === "string" && GATEWAY_PATH.test(path));
|
|
82
|
+
if (valid.length < paths.length) {
|
|
83
|
+
warn(`ignoring ${paths.length - valid.length} gateway path(s) ocra Cloud listed for "${name}" that are not plain paths`);
|
|
84
|
+
}
|
|
85
|
+
const chat = valid.find((path) => path.endsWith("/chat/completions"));
|
|
86
|
+
if (!chat) {
|
|
87
|
+
throw new OcraError("CONFIG_INVALID", `ocra Cloud has no OpenAI-compatible chat endpoint for "${name}" (named by ${id})`);
|
|
88
|
+
}
|
|
89
|
+
declared[id] = {
|
|
90
|
+
baseUrl: `${session.server}/api/gateway/${name}${chat.slice(0, -"/chat/completions".length)}`,
|
|
91
|
+
apiKeyEnv: CLOUD_TOKEN_ENV,
|
|
92
|
+
models: Object.fromEntries([...modelIds].map((m) => [m, { input: 0, output: 0 }])),
|
|
93
|
+
...effortStyleOf(entry?.effort, name, warn),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
warn(`${names}: models through ocra Cloud are not priced, so reported cost and --max-cost-usd do not count them`);
|
|
97
|
+
if (!run.keyReadPerCall) {
|
|
98
|
+
const left = session.expires_at - deps.now();
|
|
99
|
+
if (left < needed) {
|
|
100
|
+
warn(`this run may last ${minutes(run.timeoutMs)} minutes, but its runtime reads the ocra Cloud token once and the token lasts ${minutes(left)}: model calls through ocra Cloud fail after that (use "runtime": "direct", which renews it, or a shorter runTimeoutMinutes)`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
providers: declared,
|
|
104
|
+
env: { ...env, [CLOUD_TOKEN_ENV]: session.access_token },
|
|
105
|
+
stop: () => { },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const token = new GatewayToken(session, deps, warn);
|
|
109
|
+
token.start();
|
|
110
|
+
// The token is read at each call, so a renewed one is used from then on.
|
|
111
|
+
const live = Object.defineProperty({ ...env }, CLOUD_TOKEN_ENV, {
|
|
112
|
+
get: () => token.value,
|
|
113
|
+
enumerable: true,
|
|
114
|
+
});
|
|
115
|
+
return { providers: declared, env: live, stop: () => token.stop() };
|
|
116
|
+
}
|
|
117
|
+
function minutes(ms) {
|
|
118
|
+
return Math.floor(ms / 60_000);
|
|
119
|
+
}
|
|
120
|
+
// How the gateway's provider takes a reasoning effort, as ocra Cloud lists
|
|
121
|
+
// it; unlisted, the runtime's default (OpenAI's reasoning_effort). A style
|
|
122
|
+
// this version of ocra does not know is refused rather than guessed.
|
|
123
|
+
function effortStyleOf(style, name, warn) {
|
|
124
|
+
if (style === undefined)
|
|
125
|
+
return {};
|
|
126
|
+
const known = EFFORT_STYLES.find((s) => s === style);
|
|
127
|
+
if (known)
|
|
128
|
+
return { effort: known };
|
|
129
|
+
warn(`ignoring the effort style ocra Cloud listed for "${name}", which this version of ocra does not know; efforts go as OpenAI's reasoning_effort`);
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=providers.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReviewSource } from "@open-cr-agent/cloud-contract";
|
|
2
|
+
import type { MemoryEntry, ReviewReport } from "@open-cr-agent/core";
|
|
3
|
+
import type { Output } from "../io/output.js";
|
|
4
|
+
import { type CloudSessionLost } from "./client.js";
|
|
5
|
+
import type { CloudDeps } from "./deps.js";
|
|
6
|
+
export type CloudReview = {
|
|
7
|
+
repoHash: string;
|
|
8
|
+
shareFindings: boolean;
|
|
9
|
+
memory: MemoryEntry[];
|
|
10
|
+
};
|
|
11
|
+
/** What a review loses when ocra Cloud cannot be used for it, said once. */
|
|
12
|
+
export declare function sessionLostWarning(lost: Exclude<CloudSessionLost, {
|
|
13
|
+
kind: "signed-out";
|
|
14
|
+
}>): string;
|
|
15
|
+
/**
|
|
16
|
+
* Undefined when the saved session is gone; a failure to reach ocra Cloud is
|
|
17
|
+
* one warning. The repository is the pull or merge request's when given,
|
|
18
|
+
* else origin's.
|
|
19
|
+
*/
|
|
20
|
+
export declare function prepareCloudReview(root: string, deps: CloudDeps, warn: (message: string) => void, repository?: string): Promise<CloudReview | undefined>;
|
|
21
|
+
/** Sends the counts, and the findings when the account shares them. */
|
|
22
|
+
export declare function sendToCloud(report: ReviewReport, cloud: CloudReview, deps: CloudDeps, run: {
|
|
23
|
+
source: ReviewSource;
|
|
24
|
+
durationMs: number;
|
|
25
|
+
err: Output;
|
|
26
|
+
warn: (message: string) => void;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
//# sourceMappingURL=review.d.ts.map
|