@counterfact-labs/cli 0.2.5 → 0.2.7
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 +20 -0
- package/bundle/client-release.json +1 -1
- package/dist/adapters/completion-lock.js +95 -0
- package/dist/adapters/completion.js +132 -17
- package/dist/adapters/entry-files.js +126 -0
- package/dist/adapters/entry-selection.js +45 -0
- package/dist/adapters/files.js +2 -1
- package/dist/adapters/results.js +10 -2
- package/dist/adapters/selection.js +104 -0
- package/dist/adapters/settings.js +29 -2
- package/dist/analysis-core/analysis/schemas.js +1 -1
- package/dist/analysis-core/chunks/2PZF54KH.js +10 -0
- package/dist/analysis-core/chunks/{OPMA32A6.js → 6RWEWBHK.js} +1 -1
- package/dist/analysis-core/chunks/{PECX3OXB.js → 7NEWZ74F.js} +1 -1
- package/dist/analysis-core/chunks/{6QY5BV5B.js → CGCJN73D.js} +1 -1
- package/dist/analysis-core/chunks/CWYVUGG4.js +1 -0
- package/dist/analysis-core/chunks/{3JWEFCTJ.js → FBTOOUPL.js} +1 -1
- package/dist/analysis-core/chunks/GOQVKWP3.js +32 -0
- package/dist/analysis-core/chunks/IHAVDX4L.js +66 -0
- package/dist/analysis-core/chunks/IVULXWQN.js +21 -0
- package/dist/analysis-core/chunks/K5FUABWW.js +1 -0
- package/dist/analysis-core/chunks/KGQYR62R.js +7 -0
- package/dist/analysis-core/chunks/PY5M22CL.js +1 -0
- package/dist/analysis-core/chunks/{YDBO7P4X.js → RNTMH4MU.js} +1 -1
- package/dist/analysis-core/chunks/RUVMFAST.js +1 -0
- package/dist/analysis-core/chunks/S27VIQMZ.js +1 -0
- package/dist/analysis-core/chunks/{5DGPLXYB.js → SLJ6CW7W.js} +1 -1
- package/dist/analysis-core/chunks/{STI4JE7L.js → UEORNVQB.js} +1 -1
- package/dist/analysis-core/chunks/{X7WZAA7S.js → WYLIV2P2.js} +1 -1
- package/dist/analysis-core/contracts/analysis-selection.js +1 -0
- package/dist/analysis-core/contracts/module-resolution.js +1 -0
- package/dist/analysis-core/contracts/relay-analysis.js +1 -1
- package/dist/analysis-core/contracts/requests.js +1 -1
- package/dist/analysis-core/contracts/selection-paths.js +1 -0
- package/dist/analysis-core/conventions/analysis-fit.js +1 -1
- package/dist/analysis-core/conventions/file-admission.js +1 -1
- package/dist/analysis-core/import/calls/modules.js +1 -0
- package/dist/analysis-core/import/outside-loader-sources.js +1 -1
- package/dist/analysis-core/index.js +3 -3
- package/dist/analysis-core/requests/local-plan.js +1 -1
- package/dist/analysis-core/requests/registry.js +1 -1
- package/dist/analysis-core/requests/template-version.js +1 -1
- package/dist/analysis-core/requests/tools.js +1 -1
- package/dist/analysis-core/settings.js +1 -1
- package/dist/analysis-core/stages/engine-entry.js +1 -1
- package/dist/cli/agent-folder.js +3 -3
- package/dist/cli/analysis-selection.js +91 -0
- package/dist/cli/commands/analyze.js +34 -8
- package/dist/cli/commands/balance.js +6 -3
- package/dist/cli/completion-delivery.js +63 -0
- package/dist/cli/completion-recovery.js +68 -14
- package/dist/cli/context.js +4 -1
- package/dist/cli/main.js +21 -9
- package/dist/cli/mcp-failure.js +8 -0
- package/dist/cli/mcp.js +130 -30
- package/dist/cli/prior-completion.js +35 -0
- package/dist/cli/relay-check.js +163 -97
- package/dist/cli/relay-failure.js +32 -0
- package/dist/cli/relay-preparation.js +24 -11
- package/dist/cli/results-upload.js +131 -0
- package/dist/cli/scope-choices.js +57 -0
- package/dist/cli/sign-in.js +61 -2
- package/dist/contract/cli-api-messages.js +2 -0
- package/dist/contract/cli-refusals.js +74 -1
- package/dist/contract/cli-version.js +1 -1
- package/dist/contract/console.js +7 -2
- package/dist/contract/email-sign-in-messages.js +4 -0
- package/dist/contract/scope-choice.js +78 -0
- package/dist/contract/selection-metadata.js +9 -0
- package/dist/contract/selection-offer.js +43 -0
- package/dist/gatekeeper/cli-api.js +16 -7
- package/dist/gatekeeper/connection-exchange.js +1 -0
- package/dist/gatekeeper/console.js +61 -36
- package/package.json +2 -2
- package/dist/analysis-core/chunks/377MLK4M.js +0 -21
- package/dist/analysis-core/chunks/DLPVYWHZ.js +0 -1
- package/dist/analysis-core/chunks/FOKDE64Y.js +0 -1
- package/dist/analysis-core/chunks/KTNA2KTN.js +0 -7
- package/dist/analysis-core/chunks/NIOS7U6C.js +0 -41
- package/dist/analysis-core/chunks/QFY6A3MS.js +0 -66
- package/dist/analysis-core/chunks/SVMEWGD3.js +0 -1
- package/dist/analysis-core/chunks/ZTOVBAG7.js +0 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { outputPaths, writeOutputFiles } from "../adapters/output-paths.js";
|
|
4
|
+
import { analysisScopeSchema, chooseScopeResultSchema, CHOOSE_SCOPE_MESSAGE, INCLUDED_FILES_CAPTION, OMITTED_IMPORTS_CAPTION, SCOPE_CHOICE_INSTRUCTION, INCOMPLETE_IMPORTS_MESSAGE, IMPORT_DISCOVERY_LIMIT_MESSAGE, INVALID_SCOPE_MESSAGE, SCOPE_RETRY_TEMPLATE } from "../contract/scope-choice.js";
|
|
5
|
+
import { scopeChoiceRefusal } from "../contract/selection-offer.js";
|
|
6
|
+
import { scopeChoicesEnabled } from "../adapters/settings.js";
|
|
7
|
+
export async function loadAnalysisScope(options, cwd, settings) {
|
|
8
|
+
if (options.scope === undefined && options.scopeFile === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (!scopeChoicesEnabled(settings))
|
|
11
|
+
throw new Error(INVALID_SCOPE_MESSAGE);
|
|
12
|
+
try {
|
|
13
|
+
const value = options.scopeFile === undefined ? options.scope : JSON.parse(await readFile(resolve(cwd, options.scopeFile), "utf8"));
|
|
14
|
+
return analysisScopeSchema.parse(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(INVALID_SCOPE_MESSAGE);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'`; }
|
|
21
|
+
/** MCP and terminal output share the same file coverage; only the terminal adds retry commands. */
|
|
22
|
+
export function scopeChoiceText(value, retryPaths = {}) {
|
|
23
|
+
const result = chooseScopeResultSchema.parse(value);
|
|
24
|
+
const refusal = scopeChoiceRefusal(result);
|
|
25
|
+
if (refusal)
|
|
26
|
+
return refusal + "\n";
|
|
27
|
+
const lines = [CHOOSE_SCOPE_MESSAGE];
|
|
28
|
+
if (!result.coverageComplete && result.refusedEdges.length > 0)
|
|
29
|
+
lines.push(INCOMPLETE_IMPORTS_MESSAGE);
|
|
30
|
+
if (!result.coverageComplete && result.frontierCount > 0)
|
|
31
|
+
lines.push(IMPORT_DISCOVERY_LIMIT_MESSAGE);
|
|
32
|
+
result.choices.forEach((choice, index) => {
|
|
33
|
+
lines.push(`${index + 1}. ${choice.label}`, choice.description, INCLUDED_FILES_CAPTION, ...choice.includes.map(path => ` ${path}`));
|
|
34
|
+
if (choice.omitted.length > 0)
|
|
35
|
+
lines.push(OMITTED_IMPORTS_CAPTION, ...choice.omitted.map(path => ` ${path}`));
|
|
36
|
+
if (choice.fits !== false && retryPaths[index]) {
|
|
37
|
+
const entry = choice.scope.entry.startsWith("-") ? `./${choice.scope.entry}` : choice.scope.entry;
|
|
38
|
+
lines.push(SCOPE_RETRY_TEMPLATE.replace("{quoted-entry}", () => shellQuote(entry))
|
|
39
|
+
.replace("{quoted-scope-file}", () => shellQuote(retryPaths[index])));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
lines.push(SCOPE_CHOICE_INSTRUCTION);
|
|
43
|
+
return lines.join("\n") + "\n";
|
|
44
|
+
}
|
|
45
|
+
export async function renderScopeChoices(value, anchor) {
|
|
46
|
+
const result = chooseScopeResultSchema.parse(value);
|
|
47
|
+
// Validate the output directory and old entries before removing only generated scope files.
|
|
48
|
+
const [probe] = await outputPaths(anchor.outputAnchor, ["scope-1.json"], anchor.outputDir, true);
|
|
49
|
+
const old = (await readdir(dirname(probe))).filter(name => /^scope-.*\.json$/.test(name));
|
|
50
|
+
await outputPaths(anchor.outputAnchor, old, anchor.outputDir);
|
|
51
|
+
for (const name of old)
|
|
52
|
+
await unlink(join(dirname(probe), name));
|
|
53
|
+
const runnable = result.choices.flatMap((choice, index) => choice.fits === false ? [] : [{ choice, index }]);
|
|
54
|
+
const files = Object.fromEntries(runnable.map(({ choice, index }) => [`scope-${index + 1}.json`, JSON.stringify(choice.scope, null, 2) + "\n"]));
|
|
55
|
+
const paths = await writeOutputFiles(anchor.outputAnchor, files, anchor.outputDir);
|
|
56
|
+
return scopeChoiceText(result, Object.fromEntries(runnable.map(({ index }, position) => [index, paths[position]])));
|
|
57
|
+
}
|
package/dist/cli/sign-in.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ApiError } from "./api.js";
|
|
2
2
|
import { readCredentials, saveCredentials } from "./credentials.js";
|
|
3
3
|
import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
|
|
4
|
+
import { cliApiMessages } from "../contract/cli-api-messages.js";
|
|
5
|
+
export class SignInExpiredError extends ApiError {
|
|
6
|
+
constructor() { super(400, "expired", emailSignInMessages.browser_expired); }
|
|
7
|
+
}
|
|
8
|
+
export class SignInDeniedError extends Error {
|
|
9
|
+
constructor() { super(emailSignInMessages.cancelled); }
|
|
10
|
+
}
|
|
4
11
|
async function pollSignIn(ctx, device) {
|
|
5
12
|
const until = ctx.now() + device.expiresIn * 1000;
|
|
6
13
|
while (ctx.now() < until) {
|
|
@@ -15,12 +22,64 @@ async function pollSignIn(ctx, device) {
|
|
|
15
22
|
if (poll.status === "denied")
|
|
16
23
|
throw new Error(emailSignInMessages.cancelled);
|
|
17
24
|
}
|
|
18
|
-
throw new
|
|
25
|
+
throw new SignInExpiredError();
|
|
26
|
+
}
|
|
27
|
+
async function pollSignInWithRetry(ctx, device) {
|
|
28
|
+
const until = ctx.now() + device.expiresIn * 1000;
|
|
29
|
+
const interval = device.interval * 1000;
|
|
30
|
+
let delay = interval;
|
|
31
|
+
while (ctx.now() < until) {
|
|
32
|
+
await ctx.sleep(Math.min(delay, until - ctx.now()));
|
|
33
|
+
if (ctx.now() >= until)
|
|
34
|
+
break;
|
|
35
|
+
let poll;
|
|
36
|
+
try {
|
|
37
|
+
poll = await ctx.api.request("device/token", { deviceCode: device.deviceCode }, false, { timeoutMs: Math.min(120_000, until - ctx.now()) });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
ctx.signal?.throwIfAborted();
|
|
41
|
+
if (!(error instanceof ApiError) || !(error.status === 0 || error.status === 408 || error.status === 429
|
|
42
|
+
|| (error.status >= 500 && error.status < 600)))
|
|
43
|
+
throw error;
|
|
44
|
+
// A temporary failure cannot tell us whether the user approved the code.
|
|
45
|
+
delay = Math.max(interval, error.retryAfterMs);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
delay = interval;
|
|
49
|
+
if (!poll)
|
|
50
|
+
throw new Error(cliApiMessages.invalid_response);
|
|
51
|
+
if (poll.status === "ok") {
|
|
52
|
+
// The server has consumed the code, so keep its token even after local expiry.
|
|
53
|
+
await saveCredentials(ctx.homeDir, ctx.api.baseUrl, poll.token);
|
|
54
|
+
return "signed_in";
|
|
55
|
+
}
|
|
56
|
+
if (poll.status === "expired")
|
|
57
|
+
break;
|
|
58
|
+
if (poll.status === "denied")
|
|
59
|
+
throw new SignInDeniedError();
|
|
60
|
+
}
|
|
61
|
+
throw new SignInExpiredError();
|
|
19
62
|
}
|
|
20
63
|
export async function startSignIn(ctx) {
|
|
21
64
|
const device = await ctx.api.request("device", {}, false);
|
|
22
65
|
return { userCode: device.userCode, verificationUri: device.verificationUri,
|
|
23
|
-
pageUrl: `${device.verificationUri}?code=${encodeURIComponent(device.userCode)}`,
|
|
66
|
+
pageUrl: `${device.verificationUri}?code=${encodeURIComponent(device.userCode)}`, retryEnabled: ctx.signInRetry,
|
|
67
|
+
done: ctx.signInRetry ? pollSignInWithRetry(ctx, device) : pollSignIn(ctx, device) };
|
|
68
|
+
}
|
|
69
|
+
export async function waitForSignIn(flow, signal) {
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
let cancel;
|
|
72
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
73
|
+
cancel = () => reject(signal.reason);
|
|
74
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
75
|
+
});
|
|
76
|
+
// A cancelled tool wait must leave the shared browser sign-in running.
|
|
77
|
+
try {
|
|
78
|
+
return await Promise.race([flow.done, cancelled]);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
signal.removeEventListener("abort", cancel);
|
|
82
|
+
}
|
|
24
83
|
}
|
|
25
84
|
export async function signInStatus(ctx) {
|
|
26
85
|
if (!await readCredentials(ctx.homeDir, ctx.api.baseUrl))
|
|
@@ -2,4 +2,6 @@ export const cliApiMessages = Object.freeze({
|
|
|
2
2
|
unreachable: "Can't reach Counterfact. Try again shortly.",
|
|
3
3
|
invalid_response: "Counterfact returned an invalid response. Try again shortly.",
|
|
4
4
|
request_failed: "The request could not be completed. Try again shortly.",
|
|
5
|
+
unauthorized: "Not signed in. Run counterfact-labs login.",
|
|
6
|
+
invalid_console_url: "COUNTERFACT_URL must be an HTTP or HTTPS address without credentials or query fields.",
|
|
5
7
|
});
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { FOLDER_TOO_LARGE_MESSAGE } from "../analysis-core/errors.js";
|
|
2
|
+
import { cliApiMessages } from "./cli-api-messages.js";
|
|
3
|
+
import { emailSignInMessages } from "./email-sign-in-messages.js";
|
|
2
4
|
export const START_FAILURE_MESSAGE = "This couldn't start. Try again.";
|
|
3
5
|
export const RESERVATION_EXPIRED_MESSAGE = "This reservation expired. Start a new analysis.";
|
|
4
6
|
export const FINISH_FAILURE_MESSAGE = "The analysis couldn't finish. Try again.";
|
|
7
|
+
export const NOT_ALLOWED_MESSAGE = "Your account can't run analyses. Ask Counterfact for access.";
|
|
8
|
+
export const SIGN_IN_EXPIRED_MESSAGE = "Your sign-in has expired. Run counterfact-labs login, then try again.";
|
|
9
|
+
export const FOLDER_BUSY_MESSAGE = "Another analysis is running on this folder. Try again when it finishes.";
|
|
10
|
+
export const CHECK_CONFLICT_MESSAGE = "Saved results from an earlier run don't match this analysis. Start a new analysis.";
|
|
11
|
+
export const UNSAVED_RESULTS_MESSAGE = "The analysis ended before its results were saved. Start a new analysis.";
|
|
12
|
+
export const PENDING_RESULTS_MESSAGE = "Your results are kept on this computer. Run the same command on the same folder again to save them at no extra charge.";
|
|
5
13
|
export const RESULTS_TOO_LARGE_MESSAGE = "The results were too large to save. Try again.";
|
|
6
14
|
export const COMMITTED_FILES_MESSAGE = "Counterfact analyzes the committed version of the agent's files. Commit your changes, then try again.";
|
|
7
15
|
const unsupportedVersion = "This version of Counterfact can't run analyses. Update it: npm install -g @counterfact-labs/cli@latest.";
|
|
8
16
|
// The console separates over-limit costs from version or route mismatches, so each code gets its own recovery guidance.
|
|
9
17
|
const analysisTooLarge = "This analysis is too large to run at once. Choose a smaller folder, such as one agent's folder.";
|
|
10
|
-
|
|
18
|
+
export const localRefusals = {
|
|
19
|
+
outside_project: "Choose a folder inside this project, or start Claude Code or Codex in the folder you want to analyze.",
|
|
20
|
+
launch_folder_unavailable: START_FAILURE_MESSAGE,
|
|
21
|
+
folder_unavailable: START_FAILURE_MESSAGE,
|
|
22
|
+
repeat_too_soon: FINISH_FAILURE_MESSAGE,
|
|
23
|
+
};
|
|
24
|
+
/** Both console clients use the same public messages for server refusals. */
|
|
11
25
|
export const cliRefusals = {
|
|
12
26
|
folder_too_large: FOLDER_TOO_LARGE_MESSAGE,
|
|
13
27
|
low_balance: "Your balance doesn't cover this analysis. Add credit at https://app.counterfactlabs.com/account?status=low_balance#balance",
|
|
@@ -25,3 +39,62 @@ export const cliRefusals = {
|
|
|
25
39
|
stale_quote: START_FAILURE_MESSAGE,
|
|
26
40
|
};
|
|
27
41
|
export function refusalExitCode(code) { return code === "low_balance" ? 2 : 1; }
|
|
42
|
+
export class CliRefusalError extends Error {
|
|
43
|
+
code;
|
|
44
|
+
constructor(code) {
|
|
45
|
+
super(Object.hasOwn(localRefusals, code) ? localRefusals[code] : cliRefusals[code]);
|
|
46
|
+
this.code = code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const otherReasons = {
|
|
50
|
+
unavailable: "console_unavailable",
|
|
51
|
+
invalid_configuration: "invalid_configuration",
|
|
52
|
+
invalid_request: "invalid_request",
|
|
53
|
+
unauthorized: "unauthorized",
|
|
54
|
+
not_allowed: "not_allowed",
|
|
55
|
+
not_found: "not_found",
|
|
56
|
+
rate_limited: "rate_limited",
|
|
57
|
+
reservation_closed: "reservation_closed",
|
|
58
|
+
reservation_closing: "reservation_closing",
|
|
59
|
+
delivery_incomplete: "delivery_incomplete",
|
|
60
|
+
check_conflict: "check_conflict",
|
|
61
|
+
invalid_email: "invalid_email",
|
|
62
|
+
wrong_code: "wrong_code",
|
|
63
|
+
expired: "expired",
|
|
64
|
+
too_many_attempts: "too_many_attempts",
|
|
65
|
+
email_unavailable: "email_unavailable",
|
|
66
|
+
request_failed: "request_failed",
|
|
67
|
+
};
|
|
68
|
+
const messageReasons = {
|
|
69
|
+
[cliRefusals.release_unavailable]: "release_unavailable",
|
|
70
|
+
[cliApiMessages.invalid_response]: "invalid_response",
|
|
71
|
+
[emailSignInMessages.browser_sign_in_pending]: "browser_sign_in_pending",
|
|
72
|
+
[emailSignInMessages.cancelled]: "cancelled",
|
|
73
|
+
};
|
|
74
|
+
const reasonValues = [
|
|
75
|
+
...Object.keys(cliRefusals),
|
|
76
|
+
...Object.keys(localRefusals),
|
|
77
|
+
...Object.values(otherReasons), ...Object.values(messageReasons),
|
|
78
|
+
"console_unreachable", "invalid_console_url", "relay_unreachable", "results_not_saved",
|
|
79
|
+
"results_saved_report_failed", "results_pending", "results_discarded",
|
|
80
|
+
"folder_busy", "completion_settings_mismatch",
|
|
81
|
+
];
|
|
82
|
+
const ownedReasons = new Set(reasonValues);
|
|
83
|
+
/** Only fixed reasons may cross the MCP boundary; diagnostics can contain customer data. */
|
|
84
|
+
export function failureReason(error) {
|
|
85
|
+
if (!(error instanceof Error))
|
|
86
|
+
return "request_failed";
|
|
87
|
+
if (error instanceof CliRefusalError)
|
|
88
|
+
return error.code;
|
|
89
|
+
if ("reason" in error && typeof error.reason === "string" && ownedReasons.has(error.reason))
|
|
90
|
+
return error.reason;
|
|
91
|
+
const code = "code" in error ? error.code : undefined;
|
|
92
|
+
if (typeof code === "string") {
|
|
93
|
+
if (Object.hasOwn(cliRefusals, code))
|
|
94
|
+
return code;
|
|
95
|
+
if (Object.hasOwn(otherReasons, code))
|
|
96
|
+
return otherReasons[code];
|
|
97
|
+
return "request_failed";
|
|
98
|
+
}
|
|
99
|
+
return Object.hasOwn(messageReasons, error.message) ? messageReasons[error.message] : "request_failed";
|
|
100
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.2.
|
|
1
|
+
export const CLI_VERSION = "0.2.7";
|
package/dist/contract/console.js
CHANGED
|
@@ -5,6 +5,7 @@ import { sourceNotesSchema } from "../analysis-core/analysis/source-notes/types.
|
|
|
5
5
|
import { parseAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
|
|
6
6
|
import { canonicalUuidSchema as uuid, matchingFindingNumbers, numberedFindingsSchema } from "./findings.js";
|
|
7
7
|
import { RESULT_CHECK_LIMITS, validIntakeText } from "../analysis-core/contracts/relay-analysis.js";
|
|
8
|
+
import { selectionStartIdentitySchema } from "./selection-metadata.js";
|
|
8
9
|
export const id = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/);
|
|
9
10
|
const money = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
10
11
|
const count = (max) => z.number().int().positive().max(max);
|
|
@@ -13,13 +14,15 @@ export const LOCAL_START_EXECUTION_HEADER = { name: "x-counterfact-execution", v
|
|
|
13
14
|
export const LOCAL_START_BODY_LIMIT = 2 * 1024 * 1024;
|
|
14
15
|
export const ANALYSIS_SIZE_HEADER = "x-counterfact-analysis-size";
|
|
15
16
|
export const START_ATTEMPT_HEADER = "x-counterfact-start-attempt";
|
|
17
|
+
export const SELECTION_IDENTITY_HEADER = "x-counterfact-selection";
|
|
16
18
|
export const ANALYSIS_SIZE_HEADER_LIMIT = 256;
|
|
17
19
|
/** Additive headers let older consoles keep accepting the unchanged, strict start body. */
|
|
18
20
|
export function serializeLocalStartMetadata(metadata) {
|
|
19
21
|
const size = JSON.stringify(parseAnalysisSize(metadata.analysisSize));
|
|
20
22
|
if (Buffer.byteLength(size, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
|
|
21
23
|
throw new Error("Invalid analysis size header");
|
|
22
|
-
return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt)
|
|
24
|
+
return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt),
|
|
25
|
+
...(metadata.selection === undefined ? {} : { [SELECTION_IDENTITY_HEADER]: JSON.stringify(selectionStartIdentitySchema.parse(metadata.selection)) }) };
|
|
23
26
|
}
|
|
24
27
|
/** Missing measurements preserve old clients; present measurements must be complete. */
|
|
25
28
|
export function parseLocalStartMetadata(headers) {
|
|
@@ -28,7 +31,9 @@ export function parseLocalStartMetadata(headers) {
|
|
|
28
31
|
return undefined;
|
|
29
32
|
if (Buffer.byteLength(raw, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
|
|
30
33
|
throw new Error("Invalid analysis size header");
|
|
31
|
-
|
|
34
|
+
const selection = headers.get(SELECTION_IDENTITY_HEADER);
|
|
35
|
+
return { analysisSize: parseAnalysisSize(JSON.parse(raw)), startAttempt: uuid.parse(headers.get(START_ATTEMPT_HEADER)),
|
|
36
|
+
...(selection === null ? {} : { selection: selectionStartIdentitySchema.parse(JSON.parse(z.string().max(256).parse(selection))) }) };
|
|
32
37
|
}
|
|
33
38
|
const localStartPath = z.string().min(1).max(4096).refine(value => !value.includes("\\") && !value.includes("\0") && !value.startsWith("/")
|
|
34
39
|
&& value.split("/").every(part => part !== ".." && part !== "." && part !== ""));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cliApiMessages } from "./cli-api-messages.js";
|
|
2
2
|
const rateLimited = "Too many sign-in requests. Try again later.";
|
|
3
|
+
// Holds the sentences both sign-in flows share, so browser expiry lives here too.
|
|
3
4
|
export const emailSignInMessages = Object.freeze({
|
|
4
5
|
invalid_email: "Enter a valid email address.",
|
|
5
6
|
invalid_code: "Enter the code from your email.",
|
|
@@ -11,8 +12,11 @@ export const emailSignInMessages = Object.freeze({
|
|
|
11
12
|
: `Too many sign-in requests. Try again in ${seconds} ${seconds === 1 ? "second" : "seconds"}.`,
|
|
12
13
|
email_unavailable: "Email sign-in isn't available. Use browser sign-in instead.",
|
|
13
14
|
browser_sign_in_pending: "A browser sign-in is still in progress. Finish it before starting email sign-in.",
|
|
15
|
+
browser_expired: "The sign-in code expired. Run counterfact-labs login to try again.",
|
|
14
16
|
unreachable: cliApiMessages.unreachable,
|
|
15
17
|
invalid_response: cliApiMessages.invalid_response,
|
|
16
18
|
request_failed: cliApiMessages.request_failed,
|
|
17
19
|
cancelled: "Sign-in was cancelled. Run counterfact-labs login to try again.",
|
|
20
|
+
mcp_expired: "The sign-in code expired. Call sign_in to try again.",
|
|
21
|
+
mcp_cancelled: "Sign-in was cancelled. Call sign_in to try again.",
|
|
18
22
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ANALYSIS_SELECTION_LIMITS, provenScopeChoiceSchema } from "../analysis-core/contracts/analysis-selection.js";
|
|
3
|
+
import { refusedImportSchema } from "../analysis-core/contracts/module-resolution.js";
|
|
4
|
+
import { selectionPathSchema, snapshotIdSchema } from "../analysis-core/contracts/selection-paths.js";
|
|
5
|
+
import { FileReadRefusal } from "../analysis-core/conventions/file-admission.js";
|
|
6
|
+
export { analysisScopeSchema } from "../analysis-core/contracts/analysis-selection.js";
|
|
7
|
+
// Proposed copy stays here so wiring the CLI and MCP cannot create different promises about coverage.
|
|
8
|
+
export const CHOOSE_SCOPE_MESSAGE = "This analysis is too large to run at once. Choose what Counterfact should read.";
|
|
9
|
+
export const ENTRY_ONLY_LABEL = "Only the selected file";
|
|
10
|
+
export const ENTRY_ONLY_DESCRIPTION = "Read {entry}. Leave its imports out of this analysis.";
|
|
11
|
+
export const SELECTED_IMPORTS_LABEL = "The selected file and some imports";
|
|
12
|
+
export const SELECTED_IMPORTS_DESCRIPTION = "Read {entry} and the imports listed below. Leave the other imports out of this analysis.";
|
|
13
|
+
export const ENTRY_IMPORTS_LABEL = "One agent file and its imports";
|
|
14
|
+
export const ENTRY_IMPORTS_DESCRIPTION = "Read {entry} and its project imports inside the launch folder.";
|
|
15
|
+
export const SINGLE_FILE_TOO_LARGE_MESSAGE = "This file is too large to analyze on its own. Choose another agent file or split this file into smaller files.";
|
|
16
|
+
export const INVALID_SELECTION_MESSAGE = "Choose an agent file or folder inside the launch folder.";
|
|
17
|
+
export const INVALID_SCOPE_MESSAGE = "This scope could not be used. Choose one of the returned scopes and try again.";
|
|
18
|
+
export const STALE_SCOPE_MESSAGE = "The selected files changed. Run the analysis again to get updated scope choices.";
|
|
19
|
+
export const SCOPE_PROPOSAL_CONFLICT_MESSAGE = "The selected scope and proposed files do not match. Keep the selected file and only its listed imports.";
|
|
20
|
+
export const SELECTION_REFUSAL_MESSAGES = Object.freeze([
|
|
21
|
+
INVALID_SELECTION_MESSAGE, INVALID_SCOPE_MESSAGE, STALE_SCOPE_MESSAGE, SCOPE_PROPOSAL_CONFLICT_MESSAGE,
|
|
22
|
+
SINGLE_FILE_TOO_LARGE_MESSAGE,
|
|
23
|
+
]);
|
|
24
|
+
export function isSelectionRefusal(error, fileMode) {
|
|
25
|
+
return error instanceof Error && (SELECTION_REFUSAL_MESSAGES.includes(error.message)
|
|
26
|
+
|| fileMode && error instanceof FileReadRefusal);
|
|
27
|
+
}
|
|
28
|
+
export const INCLUDED_FILES_CAPTION = "Included files:";
|
|
29
|
+
export const OMITTED_IMPORTS_CAPTION = "Imports left out:";
|
|
30
|
+
export const SCOPE_CHOICE_INSTRUCTION = "Choose a scope, then run the analysis again with that scope.";
|
|
31
|
+
export const INCOMPLETE_IMPORTS_MESSAGE = "Some imports could not be resolved. This analysis covers only the files listed below.";
|
|
32
|
+
export const IMPORT_DISCOVERY_LIMIT_MESSAGE = "Counterfact reached the import discovery limit. The list of imports is incomplete.";
|
|
33
|
+
export const SELECTED_FILES_PROGRESS = "Reading the selected files and running analysis.\n";
|
|
34
|
+
export const SELECTED_PATH_FAILURE = "Analysis could not finish. Check the selected path, provider key and connection.";
|
|
35
|
+
export function progressLine(selection, surface = "source") {
|
|
36
|
+
return selection?.kind === "file" ? SELECTED_FILES_PROGRESS
|
|
37
|
+
: surface === "terminal" ? "Analyzing the agent…" : "Reading the folder and running analysis.\n";
|
|
38
|
+
}
|
|
39
|
+
export function failureLine(selection) {
|
|
40
|
+
return selection?.kind === "file" ? SELECTED_PATH_FAILURE : "Analysis could not finish. Check the folder, provider key and connection.";
|
|
41
|
+
}
|
|
42
|
+
export const SELECTION_COVERAGE_DESCRIPTION = "This analysis covers {entry} and {count} selected imports. Other imports were left out.";
|
|
43
|
+
export const SCOPE_RETRY_TEMPLATE = "Run again: counterfact-labs analyze {quoted-entry} --scope {quoted-scope-file}";
|
|
44
|
+
export const FILE_SCOPE_PUBLIC_USAGE = "Usage: counterfact-labs analyze [path] [--proposal <json-file>] [--scope <json-file>]\n counterfact-labs finding <id>\n counterfact-labs balance | login | logout | mcp\n";
|
|
45
|
+
export const FILE_SCOPE_SOURCE_USAGE = "Usage: npx tsx cli/check.ts <path> [--output <directory>] [--scope <json-file>]\n";
|
|
46
|
+
export const scopeChoiceSchema = provenScopeChoiceSchema.safeExtend({
|
|
47
|
+
label: z.enum([ENTRY_ONLY_LABEL, SELECTED_IMPORTS_LABEL, ENTRY_IMPORTS_LABEL]),
|
|
48
|
+
description: z.string().min(1).max(8192),
|
|
49
|
+
// Fewer files do not prove admission; only the ordinary run checks can set true.
|
|
50
|
+
fits: z.union([z.boolean(), z.literal("unknown")]),
|
|
51
|
+
}).refine(choice => {
|
|
52
|
+
const imports = choice.scope.imports;
|
|
53
|
+
const entryOnly = imports === "none" || Array.isArray(imports) && imports.length === 0;
|
|
54
|
+
const label = entryOnly ? ENTRY_ONLY_LABEL : imports === "all" ? ENTRY_IMPORTS_LABEL : SELECTED_IMPORTS_LABEL;
|
|
55
|
+
const description = entryOnly && choice.fits === false ? SINGLE_FILE_TOO_LARGE_MESSAGE
|
|
56
|
+
: (entryOnly ? ENTRY_ONLY_DESCRIPTION : imports === "all" ? ENTRY_IMPORTS_DESCRIPTION : SELECTED_IMPORTS_DESCRIPTION)
|
|
57
|
+
.replace("{entry}", () => choice.scope.entry);
|
|
58
|
+
return choice.label === label && choice.description === description;
|
|
59
|
+
});
|
|
60
|
+
export const chooseScopeResultSchema = z.strictObject({
|
|
61
|
+
kind: z.literal("choose_scope"), text: z.literal(CHOOSE_SCOPE_MESSAGE), snapshot: snapshotIdSchema,
|
|
62
|
+
entry: selectionPathSchema.nullable(), coverageComplete: z.boolean(),
|
|
63
|
+
frontierCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
64
|
+
choices: z.array(scopeChoiceSchema).min(1).max(ANALYSIS_SELECTION_LIMITS.choices),
|
|
65
|
+
imports: z.array(z.strictObject({ path: selectionPathSchema, role: z.literal("imported-helper"),
|
|
66
|
+
importedBy: z.array(selectionPathSchema).min(1).max(ANALYSIS_SELECTION_LIMITS.files)
|
|
67
|
+
.refine(paths => new Set(paths).size === paths.length) })).max(ANALYSIS_SELECTION_LIMITS.files),
|
|
68
|
+
refusedEdges: z.array(refusedImportSchema).max(ANALYSIS_SELECTION_LIMITS.edges),
|
|
69
|
+
}).refine(value => (!value.coverageComplete || value.frontierCount === 0 && value.refusedEdges.every(edge => edge.reason === "external"))
|
|
70
|
+
&& new Set(value.choices.map(choice => choice.id)).size === value.choices.length
|
|
71
|
+
&& new Set(value.imports.map(file => file.path)).size === value.imports.length
|
|
72
|
+
&& value.choices.every(choice => choice.scope.snapshot === value.snapshot && (value.entry === null || choice.scope.entry === value.entry
|
|
73
|
+
&& (choice.scope.imports !== "all" || choice.omitted.length === 0))));
|
|
74
|
+
export const chooseScopeMcpResultSchema = z.strictObject({
|
|
75
|
+
content: z.tuple([z.strictObject({ type: z.literal("text"), text: z.string().startsWith(CHOOSE_SCOPE_MESSAGE) })]),
|
|
76
|
+
isError: z.literal(false), structuredContent: chooseScopeResultSchema.safeExtend({ exitCode: z.literal(0) }),
|
|
77
|
+
});
|
|
78
|
+
export const SCOPE_CHOICE_EXIT_CODE = 3;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
4
|
+
export const selectionStartIdentitySchema = z.strictObject({ version: z.literal(1), key: digest, snapshot: digest });
|
|
5
|
+
/** The console can distinguish selected runs without receiving local coordinates or snapshot contents. */
|
|
6
|
+
export function selectionStartIdentity(key, snapshot) {
|
|
7
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
8
|
+
return { version: 1, key: hash(key), snapshot: hash(snapshot) };
|
|
9
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CHOOSE_SCOPE_MESSAGE, ENTRY_IMPORTS_DESCRIPTION, ENTRY_IMPORTS_LABEL, ENTRY_ONLY_DESCRIPTION, ENTRY_ONLY_LABEL, SELECTED_IMPORTS_DESCRIPTION, SELECTED_IMPORTS_LABEL, SINGLE_FILE_TOO_LARGE_MESSAGE } from "./scope-choice.js";
|
|
2
|
+
export class ScopeChoiceError extends Error {
|
|
3
|
+
choice;
|
|
4
|
+
constructor(choice) {
|
|
5
|
+
super(choice.text);
|
|
6
|
+
this.choice = choice;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** A known nonfitting choice must never invite another paid attempt. */
|
|
10
|
+
export function scopeChoiceRefusal(choice) {
|
|
11
|
+
return choice.choices.every(option => option.fits === false) ? SINGLE_FILE_TOO_LARGE_MESSAGE : undefined;
|
|
12
|
+
}
|
|
13
|
+
/** Membership choices are proven by core; fewer files alone never prove that a run fits. */
|
|
14
|
+
export function selectionScopeChoices(membership, options = {}) {
|
|
15
|
+
const current = new Set(options.currentPaths ?? membership.files.map(file => file.path));
|
|
16
|
+
const oversized = new Set(options.oversizedPaths ?? []);
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
const choices = membership.choices.flatMap(choice => {
|
|
19
|
+
const entryOnly = choice.scope.imports === "none" || Array.isArray(choice.scope.imports) && choice.scope.imports.length === 0;
|
|
20
|
+
const knownOversized = entryOnly && oversized.has(choice.scope.entry);
|
|
21
|
+
const identical = choice.includes.length === current.size && choice.includes.every(path => current.has(path));
|
|
22
|
+
const key = JSON.stringify([...choice.includes].sort());
|
|
23
|
+
if (seen.has(key) || choice.includes.some(path => !current.has(path)) || identical && !knownOversized)
|
|
24
|
+
return [];
|
|
25
|
+
seen.add(key);
|
|
26
|
+
const label = entryOnly ? ENTRY_ONLY_LABEL : choice.scope.imports === "all" ? ENTRY_IMPORTS_LABEL : SELECTED_IMPORTS_LABEL;
|
|
27
|
+
const description = knownOversized ? SINGLE_FILE_TOO_LARGE_MESSAGE
|
|
28
|
+
: (entryOnly ? ENTRY_ONLY_DESCRIPTION : choice.scope.imports === "all" ? ENTRY_IMPORTS_DESCRIPTION : SELECTED_IMPORTS_DESCRIPTION)
|
|
29
|
+
.replace("{entry}", () => choice.scope.entry);
|
|
30
|
+
return [{ ...choice, label, description, fits: knownOversized ? false : "unknown" }];
|
|
31
|
+
});
|
|
32
|
+
if (!choices.length)
|
|
33
|
+
return undefined;
|
|
34
|
+
const imports = membership.files.flatMap(file => {
|
|
35
|
+
if (file.role !== "imported-helper")
|
|
36
|
+
return [];
|
|
37
|
+
const importedBy = [...new Set(membership.edges.filter(edge => edge.path === file.path).map(edge => edge.from))];
|
|
38
|
+
return importedBy.length ? [{ path: file.path, role: "imported-helper", importedBy }] : [];
|
|
39
|
+
});
|
|
40
|
+
return { kind: "choose_scope", text: CHOOSE_SCOPE_MESSAGE, snapshot: membership.snapshot,
|
|
41
|
+
entry: membership.kind === "file" ? membership.entry : null, coverageComplete: membership.completeness.complete,
|
|
42
|
+
frontierCount: membership.completeness.frontierCount, choices, imports, refusedEdges: membership.refusedEdges };
|
|
43
|
+
}
|
|
@@ -11,11 +11,13 @@ export class ApiError extends Error {
|
|
|
11
11
|
status;
|
|
12
12
|
code;
|
|
13
13
|
retryAfterMs;
|
|
14
|
-
|
|
14
|
+
reason;
|
|
15
|
+
constructor(status, code, message, retryAfterMs = 0, reason) {
|
|
15
16
|
super(message);
|
|
16
17
|
this.status = status;
|
|
17
18
|
this.code = code;
|
|
18
19
|
this.retryAfterMs = retryAfterMs;
|
|
20
|
+
this.reason = reason;
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
const emailRoutes = {
|
|
@@ -100,9 +102,16 @@ export class ApiClient {
|
|
|
100
102
|
this.token = token;
|
|
101
103
|
this.fetcher = fetcher;
|
|
102
104
|
this.signal = signal;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
let url;
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(baseUrl);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new ApiError(0, "invalid_console_url", errorText(error), 0, "invalid_console_url");
|
|
111
|
+
}
|
|
112
|
+
if (!consoleSchemeAllowed(url) || url.username || url.password || url.search || url.hash) {
|
|
113
|
+
throw new ApiError(0, "invalid_console_url", cliApiMessages.invalid_console_url, 0, "invalid_console_url");
|
|
114
|
+
}
|
|
106
115
|
this.baseUrl = url.href.replace(/\/+$/, "");
|
|
107
116
|
}
|
|
108
117
|
async request(path, body, authenticated = true, options = {}) {
|
|
@@ -118,7 +127,7 @@ export class ApiClient {
|
|
|
118
127
|
if (authenticated) {
|
|
119
128
|
const token = await this.token();
|
|
120
129
|
if (!token)
|
|
121
|
-
throw new
|
|
130
|
+
throw new ApiError(401, "unauthorized", cliApiMessages.unauthorized);
|
|
122
131
|
headers.authorization = `Bearer ${token}`;
|
|
123
132
|
}
|
|
124
133
|
if (body !== undefined)
|
|
@@ -144,7 +153,7 @@ export class ApiClient {
|
|
|
144
153
|
}
|
|
145
154
|
catch {
|
|
146
155
|
throwIfAborted();
|
|
147
|
-
throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
|
|
156
|
+
throw new ApiError(0, "unreachable", cliApiMessages.unreachable, 0, "console_unreachable");
|
|
148
157
|
}
|
|
149
158
|
throwIfAborted();
|
|
150
159
|
if (emailRoute && response.ok && response.status !== emailRoute.successStatus) {
|
|
@@ -161,7 +170,7 @@ export class ApiClient {
|
|
|
161
170
|
if (response.ok) {
|
|
162
171
|
if (error instanceof SyntaxError)
|
|
163
172
|
throw new Error(cliApiMessages.invalid_response);
|
|
164
|
-
throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
|
|
173
|
+
throw new ApiError(0, "unreachable", cliApiMessages.unreachable, 0, "console_unreachable");
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
throwIfAborted();
|
|
@@ -2,6 +2,7 @@ import { RELAY_MAX_FRAME_BYTES, deliveryReceipt, relayAnswer, relayAnswerFrame,
|
|
|
2
2
|
import { unredactedAnswer } from "../contract/public-report.js";
|
|
3
3
|
export class RelayConnectionError extends Error {
|
|
4
4
|
name = "RelayConnectionError";
|
|
5
|
+
reason = "relay_unreachable";
|
|
5
6
|
constructor() { super("Relay connection failed"); }
|
|
6
7
|
}
|
|
7
8
|
/** Both carriers retry the same confirmation, never another paid request. */
|