@counterfact-labs/cli 0.2.5 → 0.2.6
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/bundle/client-release.json +1 -1
- package/dist/cli/agent-folder.js +3 -3
- package/dist/cli/commands/analyze.js +3 -2
- package/dist/cli/commands/balance.js +6 -3
- package/dist/cli/context.js +2 -0
- package/dist/cli/mcp-failure.js +8 -0
- package/dist/cli/mcp.js +60 -19
- package/dist/cli/relay-check.js +2 -2
- package/dist/cli/sign-in.js +61 -2
- package/dist/contract/cli-api-messages.js +2 -0
- package/dist/contract/cli-refusals.js +66 -1
- package/dist/contract/cli-version.js +1 -1
- package/dist/contract/email-sign-in-messages.js +4 -0
- package/dist/gatekeeper/cli-api.js +16 -7
- package/dist/gatekeeper/connection-exchange.js +1 -0
- package/dist/gatekeeper/console.js +31 -17
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"verificationKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbBab1PYdmBKXxCvoJ47toJhQvxPevo9nlZnb0zbDWoc=\n-----END PUBLIC KEY-----\n",
|
|
3
3
|
"relayVersion": "relay-1.0.1",
|
|
4
|
-
"localVersion": "0.2.
|
|
4
|
+
"localVersion": "0.2.6",
|
|
5
5
|
"relayUrl": "https://relay.counterfactlabs.com:8443",
|
|
6
6
|
"projectContext": "checks"
|
|
7
7
|
}
|
package/dist/cli/agent-folder.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveFolderPath } from "../adapters/folder-path.js";
|
|
2
|
-
import {
|
|
2
|
+
import { CliRefusalError } from "../contract/cli-refusals.js";
|
|
3
3
|
import { isAbsolute, parse, relative, resolve, sep } from "node:path";
|
|
4
4
|
export function resolveAgentFolder(path, cwd) {
|
|
5
5
|
return resolve(cwd ?? process.cwd(), path);
|
|
@@ -8,10 +8,10 @@ export function resolveAgentFolder(path, cwd) {
|
|
|
8
8
|
export async function resolveMcpAgentFolder(path, cwd, homeDir) {
|
|
9
9
|
const [folder, launch, home] = await Promise.all([
|
|
10
10
|
resolveFolderPath(resolveAgentFolder(path, cwd)), resolveFolderPath(cwd), resolveFolderPath(homeDir),
|
|
11
|
-
]).catch(() => { throw new
|
|
11
|
+
]).catch(() => { throw new CliRefusalError("folder_unavailable"); });
|
|
12
12
|
const rel = relative(launch, folder);
|
|
13
13
|
if (folder === parse(folder).root || folder === home || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
14
|
-
throw new
|
|
14
|
+
throw new CliRefusalError("outside_project");
|
|
15
15
|
}
|
|
16
16
|
return folder;
|
|
17
17
|
}
|
|
@@ -56,10 +56,10 @@ export async function analyze(path, options, ctx) {
|
|
|
56
56
|
const development = startup.transport.CF_RELAY_DEV === "1" ? await loadDevelopmentRelay(startup.transport) : undefined;
|
|
57
57
|
const release = development?.release ?? ctx.relayRuntime?.release ?? await loadInstalledRelease();
|
|
58
58
|
if (release.projectContext !== "checks")
|
|
59
|
-
throw new
|
|
59
|
+
throw new ApiError(503, "unavailable", unavailable);
|
|
60
60
|
const token = await ctx.token?.();
|
|
61
61
|
if (!token)
|
|
62
|
-
throw new
|
|
62
|
+
throw new ApiError(401, "unauthorized", cliApiMessages.unauthorized);
|
|
63
63
|
const folder = resolveAgentFolder(path, ctx.cwd);
|
|
64
64
|
const config = Object.freeze({ ...startup.transport, CF_RELAY_URL: startup.transport.CF_RELAY_URL ?? release.relayUrl,
|
|
65
65
|
CF_LOCAL_RELAY: "1", CF_CONSOLE_URL: ctx.api.baseUrl, CF_CONSOLE_TOKEN: token });
|
|
@@ -86,6 +86,7 @@ export async function analyze(path, options, ctx) {
|
|
|
86
86
|
}
|
|
87
87
|
},
|
|
88
88
|
onError: failure => {
|
|
89
|
+
ctx.onCheckFailure?.(failure);
|
|
89
90
|
if (failure.agentChoice)
|
|
90
91
|
ctx.onAgentChoice?.(failure.agentChoice);
|
|
91
92
|
clearProgress();
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { money } from "../output.js";
|
|
2
|
-
export async function balance(ctx) {
|
|
3
|
-
const
|
|
4
|
-
|
|
2
|
+
export async function balance(ctx, options = {}) {
|
|
3
|
+
const response = await ctx.api.request("balance");
|
|
4
|
+
const value = { ...response, freeAnalysisAvailable: response.freeAnalysisAvailable ?? false };
|
|
5
|
+
options.onLoaded?.(value);
|
|
6
|
+
const freeAnalysis = value.freeAnalysisAvailable ? "Your first analysis is free.\n" : "";
|
|
7
|
+
ctx.write(`Balance: ${money(value.balanceMicro)}\n${freeAnalysis}Top up: ${value.topUpUrl}\n`);
|
|
5
8
|
return 0;
|
|
6
9
|
}
|
package/dist/cli/context.js
CHANGED
|
@@ -16,8 +16,10 @@ export function commandContext(options = {}) {
|
|
|
16
16
|
const findingSettings = options.findingSettings ?? readFindingSettings(options.env);
|
|
17
17
|
return {
|
|
18
18
|
findingNumbers: findingSettings.numbersEnabled,
|
|
19
|
+
signInRetry: (options.env ?? process.env).CF_SIGN_IN_RETRY !== "0",
|
|
19
20
|
startup, signal: options.signal, relayRuntime: options.relayRuntime, cwd: options.cwd ?? process.cwd(),
|
|
20
21
|
token: () => readCredentials(homeDir, api.baseUrl), onCheckComplete: options.onCheckComplete, onCheckStart: options.onCheckStart, onAgentChoice: options.onAgentChoice,
|
|
22
|
+
onCheckFailure: options.onCheckFailure,
|
|
21
23
|
api, homeDir, write, openBrowser: options.openBrowser ?? openBrowser,
|
|
22
24
|
signInInput: options.signInInput ?? terminalSignInInput(), readEmail: options.readEmail ?? userEmail,
|
|
23
25
|
sleep: options.sleep ?? (async (ms) => { await delay(ms, undefined, { signal: options.signal }); }), now: options.now ?? Date.now,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CliRefusalError, failureReason, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
|
|
2
|
+
import { errorText } from "./api.js";
|
|
3
|
+
export function mcpFailure(error, enabled) {
|
|
4
|
+
// Only outside_project changed its sentence; the other local refusals already use the old sentences.
|
|
5
|
+
const message = !enabled && error instanceof CliRefusalError && error.code === "outside_project" ? START_FAILURE_MESSAGE
|
|
6
|
+
: errorText(error);
|
|
7
|
+
return { message, ...(enabled ? { reason: failureReason(error) } : {}) };
|
|
8
|
+
}
|
package/dist/cli/mcp.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { once } from "node:events";
|
|
3
3
|
import { resolveFolderPath } from "../adapters/folder-path.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { CliRefusalError, failureReason } from "../contract/cli-refusals.js";
|
|
5
|
+
import { mcpFailure } from "./mcp-failure.js";
|
|
6
|
+
import { errorCode } from "./api.js";
|
|
6
7
|
import { commandContext } from "./context.js";
|
|
7
8
|
import { analyze } from "./commands/analyze.js";
|
|
8
9
|
import { CLI_VERSION } from "../contract/cli-version.js";
|
|
9
10
|
import { finding } from "./commands/finding.js";
|
|
10
11
|
import { balance } from "./commands/balance.js";
|
|
11
12
|
import { ANALYZE_TOOL_NAME, analyzeToolAllowed } from "./claude-code-permission.js";
|
|
12
|
-
import { signInStatus, startSignIn } from "./sign-in.js";
|
|
13
|
+
import { signInStatus, startSignIn, waitForSignIn, SignInExpiredError, SignInDeniedError } from "./sign-in.js";
|
|
13
14
|
import { readEmailSignIn } from "../adapters/settings.js";
|
|
14
15
|
import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
|
|
15
16
|
import { EMAIL_SIGN_IN_TERMS, normalizeSignInEmail, validSignInCode, startEmailSignIn, signInWithCode } from "./email-sign-in.js";
|
|
@@ -23,11 +24,11 @@ import { readFindingSettings } from "./finding-settings.js";
|
|
|
23
24
|
const legacyTools = [
|
|
24
25
|
{
|
|
25
26
|
name: ANALYZE_TOOL_NAME,
|
|
26
|
-
description: "Analyze the agent in a folder on this computer. Each call runs one analysis and charges the user's balance, so call it only when the user asks for an analysis. After edits, suggest an analysis and wait for the user to ask. Prices are listed on the top-up page and website. If the balance doesn't cover the analysis, show the returned message and top-up link.",
|
|
27
|
+
description: "Analyze the agent in a folder on this computer. Each call runs one analysis and charges the user's balance unless a free analysis is available, so call it only when the user asks for an analysis. A free first analysis needs no top-up. After edits, suggest an analysis and wait for the user to ask. Prices are listed on the top-up page and website. If the balance doesn't cover the analysis, show the returned message and top-up link.",
|
|
27
28
|
inputSchema: { type: "object", properties: { path: { type: "string", description: "The agent folder on this computer." }, proposal: { ...agentProposalInputSchema, description: "Optional: the files you believe make up the agent, with each file's role. Paths are relative to the agent folder." } }, required: ["path"], additionalProperties: false },
|
|
28
29
|
},
|
|
29
30
|
{ name: "get_finding", description: "Get a finding's explanation, quoted lines, and suggested fixes.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } },
|
|
30
|
-
{ name: "get_balance", description: "Get the balance and top-up link.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
31
|
+
{ name: "get_balance", description: "Get the balance and top-up link, and whether a free first analysis is available. A free first analysis needs no top-up.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
31
32
|
{ name: "sign_in_status", description: "Check whether this computer is signed in to Counterfact, and whether the user has allowed the analysis tool in Claude Code. With wait: true, first wait for a sign-in started by sign_in to finish.", inputSchema: { type: "object", properties: { wait: { type: "boolean" } }, additionalProperties: false } },
|
|
32
33
|
{ name: "sign_in", description: "Open the Counterfact sign-in page in the user's browser. Then call sign_in_status with wait: true.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
33
34
|
];
|
|
@@ -43,6 +44,7 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
43
44
|
const audienceChoice = (options.env ?? process.env).CF_FINDING_AUDIENCE;
|
|
44
45
|
const cwd = options.cwd ?? process.cwd();
|
|
45
46
|
const guarded = (options.env ?? process.env).CF_MCP_PATH_GUARD !== "0";
|
|
47
|
+
const failureReasons = (options.env ?? process.env).CF_MCP_FAILURE_REASONS !== "0";
|
|
46
48
|
const launch = guarded ? resolveFolderPath(cwd).catch(() => null) : Promise.resolve(cwd);
|
|
47
49
|
const lastAnalysis = new Map();
|
|
48
50
|
const findingSettings = readFindingSettings(options.env);
|
|
@@ -78,6 +80,7 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
78
80
|
...tool, inputSchema: numberedFindingInputSchema,
|
|
79
81
|
} : tool).concat(fixesEnabled ? fixRequestTools : []);
|
|
80
82
|
let pendingSignIn;
|
|
83
|
+
let signInFailure;
|
|
81
84
|
const requests = new Map();
|
|
82
85
|
let queue = Promise.resolve();
|
|
83
86
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
@@ -111,6 +114,8 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
111
114
|
if (name === "sign_in_with_code" && !validSignInCode(args.code))
|
|
112
115
|
throw new ProtocolError(-32602, emailSignInMessages.invalid_code);
|
|
113
116
|
let text = "", exitCode = 0;
|
|
117
|
+
let reason;
|
|
118
|
+
let freeAnalysisAvailable = false;
|
|
114
119
|
let agentChoice;
|
|
115
120
|
let findingIds;
|
|
116
121
|
let findings, projectId;
|
|
@@ -122,6 +127,7 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
122
127
|
let signIn;
|
|
123
128
|
try {
|
|
124
129
|
const ctx = commandContext({ ...options, env: { ...(options.env ?? process.env), CF_FINDING_AUDIENCE: audienceChoice },
|
|
130
|
+
onCheckFailure: failure => { reason = failure.reason; options.onCheckFailure?.(failure); },
|
|
125
131
|
emailSignIn, findingSettings, signal, onCheckStart: () => {
|
|
126
132
|
started = true;
|
|
127
133
|
if (guarded && analysisFolder)
|
|
@@ -160,20 +166,32 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
160
166
|
} });
|
|
161
167
|
}
|
|
162
168
|
else if (name === "get_balance")
|
|
163
|
-
await balance(ctx);
|
|
169
|
+
await balance(ctx, { onLoaded: value => { freeAnalysisAvailable = value.freeAnalysisAvailable; } });
|
|
164
170
|
else if (name === "sign_in_status") {
|
|
165
171
|
const pending = pendingSignIn;
|
|
166
172
|
const allowed = await analyzeToolAllowed(ctx.homeDir, cwd);
|
|
167
173
|
if (args.wait === true && pending) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
if (pending.flow.retryEnabled) {
|
|
175
|
+
try {
|
|
176
|
+
await waitForSignIn(pending.flow, signal);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (signal.aborted)
|
|
180
|
+
throw error;
|
|
181
|
+
// The flow stores its failure; collect the usual status fields before reporting it.
|
|
182
|
+
}
|
|
174
183
|
}
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
else {
|
|
185
|
+
const cancel = () => pending.controller.abort();
|
|
186
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
187
|
+
try {
|
|
188
|
+
if (signal.aborted)
|
|
189
|
+
cancel();
|
|
190
|
+
await pending.flow.done;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
signal.removeEventListener("abort", cancel);
|
|
194
|
+
}
|
|
177
195
|
}
|
|
178
196
|
}
|
|
179
197
|
signIn = { ...await signInStatus(ctx), analyzeToolAllowed: allowed };
|
|
@@ -185,17 +203,31 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
185
203
|
if (suggestion)
|
|
186
204
|
signIn.suggestedEmail = suggestion;
|
|
187
205
|
}
|
|
206
|
+
if (signInFailure)
|
|
207
|
+
signal.throwIfAborted();
|
|
208
|
+
const failure = signInFailure;
|
|
209
|
+
signInFailure = undefined;
|
|
210
|
+
if (failure && !signIn.signedIn) {
|
|
211
|
+
const shown = mcpFailure(failure.error, failureReasons);
|
|
212
|
+
text = (failure.error instanceof SignInExpiredError
|
|
213
|
+
? emailSignInMessages.mcp_expired : failure.error instanceof SignInDeniedError
|
|
214
|
+
? emailSignInMessages.mcp_cancelled : shown.message) + "\n";
|
|
215
|
+
exitCode = errorCode(failure.error);
|
|
216
|
+
reason = shown.reason;
|
|
217
|
+
}
|
|
188
218
|
}
|
|
189
219
|
else if (emailFlow) {
|
|
190
220
|
if (pendingSignIn)
|
|
191
221
|
throw new Error(emailSignInMessages.browser_sign_in_pending);
|
|
192
222
|
if (name === "sign_in") {
|
|
193
223
|
const start = await startEmailSignIn(ctx, email);
|
|
224
|
+
signInFailure = undefined;
|
|
194
225
|
signIn = { email: email, expiresIn: start.expiresIn };
|
|
195
226
|
text = `We sent a sign-in code to ${email}. Ask the user for the code from their email, then call sign_in_with_code with the same email and their code. Do not repeat the code.`;
|
|
196
227
|
}
|
|
197
228
|
else {
|
|
198
229
|
await signInWithCode(ctx, email, args.code);
|
|
230
|
+
signInFailure = undefined;
|
|
199
231
|
signIn = { signedIn: true };
|
|
200
232
|
text = "Signed in.";
|
|
201
233
|
}
|
|
@@ -206,7 +238,7 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
206
238
|
else {
|
|
207
239
|
let pending = pendingSignIn;
|
|
208
240
|
if (!pending) {
|
|
209
|
-
//
|
|
241
|
+
// With retry on, the browser flow belongs to the session, so a cancelled status wait leaves it running.
|
|
210
242
|
const controller = new AbortController();
|
|
211
243
|
const cancel = () => controller.abort();
|
|
212
244
|
signal.addEventListener("abort", cancel, { once: true });
|
|
@@ -215,11 +247,16 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
215
247
|
const flow = await startSignIn(commandContext({ ...options, emailSignIn, findingSettings, homeDir: ctx.homeDir, baseUrl: ctx.api.baseUrl, signal: controller.signal }));
|
|
216
248
|
pending = { flow, controller, opened: false };
|
|
217
249
|
pendingSignIn = pending;
|
|
250
|
+
signInFailure = undefined;
|
|
218
251
|
const current = pending;
|
|
219
252
|
const clear = () => { if (pendingSignIn === current)
|
|
220
253
|
pendingSignIn = undefined; };
|
|
221
254
|
// Handle background failures even when the client never calls wait.
|
|
222
|
-
void flow.done.then(clear,
|
|
255
|
+
void flow.done.then(clear, error => {
|
|
256
|
+
if (flow.retryEnabled && pendingSignIn === current && !current.controller.signal.aborted)
|
|
257
|
+
signInFailure = { error };
|
|
258
|
+
clear();
|
|
259
|
+
});
|
|
223
260
|
pending.opened = await ctx.openBrowser(flow.pageUrl);
|
|
224
261
|
}
|
|
225
262
|
finally {
|
|
@@ -234,11 +271,11 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
234
271
|
else {
|
|
235
272
|
const launchFolder = await launch;
|
|
236
273
|
if (guarded && !launchFolder)
|
|
237
|
-
throw new
|
|
274
|
+
throw new CliRefusalError("launch_folder_unavailable");
|
|
238
275
|
const folder = guarded ? await resolveMcpAgentFolder(args.path, launchFolder, ctx.homeDir) : resolveAgentFolder(args.path, cwd);
|
|
239
276
|
const last = lastAnalysis.get(folder);
|
|
240
277
|
if (guarded && last !== undefined && ctx.now() - last < 10 * 60 * 1000) {
|
|
241
|
-
throw new
|
|
278
|
+
throw new CliRefusalError("repeat_too_soon");
|
|
242
279
|
}
|
|
243
280
|
analysisFolder = folder;
|
|
244
281
|
try {
|
|
@@ -251,13 +288,17 @@ export async function serveMcp(input, output, options = {}) {
|
|
|
251
288
|
}
|
|
252
289
|
}
|
|
253
290
|
catch (error) {
|
|
254
|
-
|
|
291
|
+
const failure = mcpFailure(error, failureReasons);
|
|
292
|
+
text += failure.message + "\n";
|
|
293
|
+
reason = failure.reason;
|
|
255
294
|
exitCode = errorCode(error);
|
|
256
295
|
}
|
|
257
296
|
// Some clients (Claude Code among them) give the model only the structured result when one is present, so it
|
|
258
297
|
// repeats the text: without it the model cannot read the result.
|
|
259
298
|
return { content: [{ type: "text", text }], isError: exitCode !== 0,
|
|
260
299
|
structuredContent: { text, exitCode, ...agentChoice, ...(findingIds ? { findingIds } : {}), ...(url ? { url } : {}), ...signIn,
|
|
300
|
+
...(exitCode !== 0 && failureReasons ? { reason: reason ?? failureReason(undefined) } : {}),
|
|
301
|
+
...(freeAnalysisAvailable ? { freeAnalysisAvailable } : {}),
|
|
261
302
|
...(findings ? { findings, projectId } : {}), ...(loadedFinding ? { finding: loadedFinding } : {}),
|
|
262
303
|
...(fixRequests ? { fixRequests } : {}), ...claimed, ...(waitingCount !== undefined ? { waitingFixes: waitingCount } : {}) } };
|
|
263
304
|
}
|
package/dist/cli/relay-check.js
CHANGED
|
@@ -10,7 +10,7 @@ import { resolveFolderPath } from "../adapters/folder-path.js";
|
|
|
10
10
|
import { AgentChoiceError } from "../contract/agent-choice.js";
|
|
11
11
|
import { savedAnalysisUrl } from "../contract/console-url.js";
|
|
12
12
|
import { prepareRelayCheck } from "./relay-preparation.js";
|
|
13
|
-
import { cliRefusals, COMMITTED_FILES_MESSAGE, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
|
|
13
|
+
import { cliRefusals, COMMITTED_FILES_MESSAGE, failureReason, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
|
|
14
14
|
import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
|
|
15
15
|
import { GatekeeperClient } from "../gatekeeper/client.js";
|
|
16
16
|
import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
|
|
@@ -252,7 +252,7 @@ async function runRelayLifecycle(folder, options, existing) {
|
|
|
252
252
|
|| error.message.startsWith("The saved completion") || error.message === COMPLETION_SETTINGS_MISMATCH_MESSAGE);
|
|
253
253
|
const message = known ? error.message : FINISH_FAILURE_MESSAGE;
|
|
254
254
|
try {
|
|
255
|
-
options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, message,
|
|
255
|
+
options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, reason: failureReason(error), message,
|
|
256
256
|
...(error instanceof AgentChoiceError ? { agentChoice: error.agentChoice } : {}) });
|
|
257
257
|
stderr(`${message}\n`);
|
|
258
258
|
if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
|
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,4 +1,6 @@
|
|
|
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,7 +9,13 @@ export const COMMITTED_FILES_MESSAGE = "Counterfact analyzes the committed versi
|
|
|
7
9
|
const unsupportedVersion = "This version of Counterfact can't run analyses. Update it: npm install -g @counterfact-labs/cli@latest.";
|
|
8
10
|
// The console separates over-limit costs from version or route mismatches, so each code gets its own recovery guidance.
|
|
9
11
|
const analysisTooLarge = "This analysis is too large to run at once. Choose a smaller folder, such as one agent's folder.";
|
|
10
|
-
|
|
12
|
+
export const localRefusals = {
|
|
13
|
+
outside_project: "Choose a folder inside this project, or start Claude Code or Codex in the folder you want to analyze.",
|
|
14
|
+
launch_folder_unavailable: START_FAILURE_MESSAGE,
|
|
15
|
+
folder_unavailable: START_FAILURE_MESSAGE,
|
|
16
|
+
repeat_too_soon: FINISH_FAILURE_MESSAGE,
|
|
17
|
+
};
|
|
18
|
+
/** Both console clients use the same public messages for server refusals. */
|
|
11
19
|
export const cliRefusals = {
|
|
12
20
|
folder_too_large: FOLDER_TOO_LARGE_MESSAGE,
|
|
13
21
|
low_balance: "Your balance doesn't cover this analysis. Add credit at https://app.counterfactlabs.com/account?status=low_balance#balance",
|
|
@@ -25,3 +33,60 @@ export const cliRefusals = {
|
|
|
25
33
|
stale_quote: START_FAILURE_MESSAGE,
|
|
26
34
|
};
|
|
27
35
|
export function refusalExitCode(code) { return code === "low_balance" ? 2 : 1; }
|
|
36
|
+
export class CliRefusalError extends Error {
|
|
37
|
+
code;
|
|
38
|
+
constructor(code) {
|
|
39
|
+
super(Object.hasOwn(localRefusals, code) ? localRefusals[code] : cliRefusals[code]);
|
|
40
|
+
this.code = code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const otherReasons = {
|
|
44
|
+
unavailable: "console_unavailable",
|
|
45
|
+
invalid_configuration: "invalid_configuration",
|
|
46
|
+
invalid_request: "invalid_request",
|
|
47
|
+
unauthorized: "unauthorized",
|
|
48
|
+
not_allowed: "not_allowed",
|
|
49
|
+
not_found: "not_found",
|
|
50
|
+
rate_limited: "rate_limited",
|
|
51
|
+
reservation_closed: "reservation_closed",
|
|
52
|
+
reservation_closing: "reservation_closing",
|
|
53
|
+
delivery_incomplete: "delivery_incomplete",
|
|
54
|
+
check_conflict: "check_conflict",
|
|
55
|
+
invalid_email: "invalid_email",
|
|
56
|
+
wrong_code: "wrong_code",
|
|
57
|
+
expired: "expired",
|
|
58
|
+
too_many_attempts: "too_many_attempts",
|
|
59
|
+
email_unavailable: "email_unavailable",
|
|
60
|
+
request_failed: "request_failed",
|
|
61
|
+
};
|
|
62
|
+
const messageReasons = {
|
|
63
|
+
[cliRefusals.release_unavailable]: "release_unavailable",
|
|
64
|
+
[cliApiMessages.invalid_response]: "invalid_response",
|
|
65
|
+
[emailSignInMessages.browser_sign_in_pending]: "browser_sign_in_pending",
|
|
66
|
+
[emailSignInMessages.cancelled]: "cancelled",
|
|
67
|
+
};
|
|
68
|
+
const reasonValues = [
|
|
69
|
+
...Object.keys(cliRefusals),
|
|
70
|
+
...Object.keys(localRefusals),
|
|
71
|
+
...Object.values(otherReasons), ...Object.values(messageReasons),
|
|
72
|
+
"console_unreachable", "invalid_console_url", "relay_unreachable", "results_not_saved",
|
|
73
|
+
];
|
|
74
|
+
const ownedReasons = new Set(reasonValues);
|
|
75
|
+
/** Only fixed reasons may cross the MCP boundary; diagnostics can contain customer data. */
|
|
76
|
+
export function failureReason(error) {
|
|
77
|
+
if (!(error instanceof Error))
|
|
78
|
+
return "request_failed";
|
|
79
|
+
if (error instanceof CliRefusalError)
|
|
80
|
+
return error.code;
|
|
81
|
+
if ("reason" in error && typeof error.reason === "string" && ownedReasons.has(error.reason))
|
|
82
|
+
return error.reason;
|
|
83
|
+
const code = "code" in error ? error.code : undefined;
|
|
84
|
+
if (typeof code === "string") {
|
|
85
|
+
if (Object.hasOwn(cliRefusals, code))
|
|
86
|
+
return code;
|
|
87
|
+
if (Object.hasOwn(otherReasons, code))
|
|
88
|
+
return otherReasons[code];
|
|
89
|
+
return "request_failed";
|
|
90
|
+
}
|
|
91
|
+
return Object.hasOwn(messageReasons, error.message) ? messageReasons[error.message] : "request_failed";
|
|
92
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.2.
|
|
1
|
+
export const CLI_VERSION = "0.2.6";
|
|
@@ -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
|
});
|
|
@@ -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. */
|
|
@@ -6,7 +6,7 @@ import { cliRefusals, FINISH_FAILURE_MESSAGE, RESERVATION_EXPIRED_MESSAGE, RESUL
|
|
|
6
6
|
import { cliRelayReservationRequestSchema, consoleCreatedSchema, consoleIntakeResultSchema, consoleReservationSchema, consoleStatusSchema, LOCAL_FINDINGS_BODY_LIMIT, localFindingsPayloadSchema, LOCAL_START_BODY_LIMIT, LOCAL_START_EXECUTION_HEADER, localStartRequestSchema, localStartResponseSchema, serializeLocalStartMetadata } from "../contract/console.js";
|
|
7
7
|
const RESPONSE_LIMIT = 64 * 1024;
|
|
8
8
|
const RESERVATION_BODY_LIMIT = 64 * 1024;
|
|
9
|
-
const
|
|
9
|
+
export const consoleErrorMessages = {
|
|
10
10
|
...cliRefusals,
|
|
11
11
|
invalid_configuration: START_FAILURE_MESSAGE,
|
|
12
12
|
invalid_request: FINISH_FAILURE_MESSAGE,
|
|
@@ -22,13 +22,24 @@ const messages = {
|
|
|
22
22
|
};
|
|
23
23
|
export class ConsoleError extends Error {
|
|
24
24
|
code;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
reason;
|
|
26
|
+
constructor(code = "unavailable", context, localReason) {
|
|
27
|
+
let message = consoleErrorMessages[code];
|
|
28
|
+
let reason = code === "unavailable" ? "results_not_saved" : code;
|
|
29
|
+
if (context === "start" && (code === "unavailable" || code === "invalid_request" || code === "not_found")) {
|
|
30
|
+
message = START_FAILURE_MESSAGE;
|
|
31
|
+
if (code === "unavailable")
|
|
32
|
+
reason = "console_unavailable";
|
|
33
|
+
}
|
|
34
|
+
else if (context === "intake" && code === "reservation_expired")
|
|
35
|
+
message = RESERVATION_EXPIRED_MESSAGE;
|
|
36
|
+
// Only saving results can be too large; a rejected status, heartbeat or fail request just could not finish.
|
|
37
|
+
else if (context === "intake" && code === "invalid_request")
|
|
38
|
+
message = RESULTS_TOO_LARGE_MESSAGE;
|
|
39
|
+
super(message);
|
|
30
40
|
this.name = "ConsoleError";
|
|
31
41
|
this.code = code;
|
|
42
|
+
this.reason = localReason ?? reason;
|
|
32
43
|
}
|
|
33
44
|
}
|
|
34
45
|
export const isConsoleToken = (token) => /^[\x21-\x7e]{1,4096}$/.test(token);
|
|
@@ -38,21 +49,24 @@ export class ConsoleClient {
|
|
|
38
49
|
#token;
|
|
39
50
|
#timeoutMs;
|
|
40
51
|
constructor(options) {
|
|
52
|
+
let url;
|
|
41
53
|
try {
|
|
42
|
-
|
|
43
|
-
const timeout = options.timeoutMs ?? 15_000;
|
|
54
|
+
url = new URL(options.url);
|
|
44
55
|
if (!consoleSchemeAllowed(url)
|
|
45
|
-
|| url.username || url.password || url.pathname !== "/" || url.search || url.hash
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
throw new ConsoleError("invalid_configuration");
|
|
49
|
-
this.#url = url;
|
|
50
|
-
this.#token = options.token;
|
|
51
|
-
this.#timeoutMs = timeout;
|
|
56
|
+
|| url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
57
|
+
throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
|
|
58
|
+
}
|
|
52
59
|
}
|
|
53
60
|
catch {
|
|
54
|
-
throw new ConsoleError("invalid_configuration");
|
|
61
|
+
throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
|
|
62
|
+
}
|
|
63
|
+
const timeout = options.timeoutMs ?? 15_000;
|
|
64
|
+
if (!isConsoleToken(options.token) || !Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000) {
|
|
65
|
+
throw new ConsoleError("invalid_configuration", undefined, "request_failed");
|
|
55
66
|
}
|
|
67
|
+
this.#url = url;
|
|
68
|
+
this.#token = options.token;
|
|
69
|
+
this.#timeoutMs = timeout;
|
|
56
70
|
}
|
|
57
71
|
async start(input, metadata) {
|
|
58
72
|
let headers;
|
|
@@ -152,7 +166,7 @@ export class ConsoleClient {
|
|
|
152
166
|
const decoded = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
153
167
|
if (!(options.statuses ?? [200]).includes(status)) {
|
|
154
168
|
const code = decoded && typeof decoded === "object" && "code" in decoded ? decoded.code : undefined;
|
|
155
|
-
throw new ConsoleError(typeof code === "string" && Object.hasOwn(
|
|
169
|
+
throw new ConsoleError(typeof code === "string" && Object.hasOwn(consoleErrorMessages, code) ? code : "unavailable");
|
|
156
170
|
}
|
|
157
171
|
const result = responseSchema.parse(decoded);
|
|
158
172
|
for (const [field, expected] of Object.entries(identities)) {
|