@counterfact-labs/cli 0.1.3 → 0.2.4

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.
Files changed (186) hide show
  1. package/README.md +176 -42
  2. package/bundle/client-release.json +7 -0
  3. package/bundle/sealed.json +908 -0
  4. package/dist/adapters/anthropic.js +151 -0
  5. package/dist/adapters/completion.js +112 -0
  6. package/dist/adapters/durable-write.js +34 -0
  7. package/dist/adapters/files.js +144 -0
  8. package/dist/adapters/folder-path.js +6 -0
  9. package/dist/adapters/git-ignore.js +49 -0
  10. package/dist/adapters/git.js +44 -0
  11. package/dist/adapters/ignored-files.js +36 -0
  12. package/dist/adapters/instructions.js +35 -0
  13. package/dist/adapters/models.js +51 -0
  14. package/dist/adapters/output-paths.js +92 -0
  15. package/dist/adapters/prompt-cache.js +28 -0
  16. package/dist/adapters/public-instructions.js +36 -0
  17. package/dist/adapters/quality.js +6 -0
  18. package/dist/adapters/relay-plan.js +15 -0
  19. package/dist/adapters/release.js +62 -0
  20. package/dist/adapters/report.js +29 -0
  21. package/dist/adapters/repository-files.js +199 -0
  22. package/dist/adapters/repository-root.js +120 -0
  23. package/dist/adapters/results.js +9 -0
  24. package/dist/adapters/settings.js +26 -0
  25. package/dist/adapters/snapshot.js +68 -0
  26. package/dist/analysis-core/analysis/analysis-flags.js +1 -0
  27. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -0
  28. package/dist/analysis-core/analysis/finding-quality/config.js +1 -0
  29. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
  30. package/dist/analysis-core/analysis/headlines/attachment.js +1 -0
  31. package/dist/analysis-core/analysis/headlines/input.js +1 -0
  32. package/dist/analysis-core/analysis/headlines/types.js +1 -0
  33. package/dist/analysis-core/analysis/schemas.js +1 -0
  34. package/dist/analysis-core/analysis/source-notes/types.js +1 -0
  35. package/dist/analysis-core/chunks/2PSNOSJ2.js +1 -0
  36. package/dist/analysis-core/chunks/3227KXHF.js +1 -0
  37. package/dist/analysis-core/chunks/326F4XNG.js +1 -0
  38. package/dist/analysis-core/chunks/3CKW5JIF.js +1 -0
  39. package/dist/analysis-core/chunks/3GUKUEEJ.js +1 -0
  40. package/dist/analysis-core/chunks/3MTXB4MK.js +6 -0
  41. package/dist/analysis-core/chunks/4F6ZIHXU.js +1 -0
  42. package/dist/analysis-core/chunks/57M4OQJM.js +1 -0
  43. package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
  44. package/dist/analysis-core/chunks/724WZMYW.js +2 -0
  45. package/dist/analysis-core/chunks/74FPPPIG.js +1 -0
  46. package/dist/analysis-core/chunks/7OYIBWX3.js +1 -0
  47. package/dist/analysis-core/chunks/D6AWHLRI.js +1 -0
  48. package/dist/analysis-core/chunks/DRGDIIFA.js +1 -0
  49. package/dist/analysis-core/chunks/E7TFIHC5.js +1 -0
  50. package/dist/analysis-core/chunks/EH3QQ3T4.js +1 -0
  51. package/dist/analysis-core/chunks/FSLWD2PE.js +1 -0
  52. package/dist/analysis-core/chunks/FTODBFQW.js +1 -0
  53. package/dist/analysis-core/chunks/HPIAEQHE.js +37 -0
  54. package/dist/analysis-core/chunks/HTLIRVP7.js +1 -0
  55. package/dist/analysis-core/chunks/IBCFGUHI.js +1 -0
  56. package/dist/analysis-core/chunks/IVDDV6CU.js +14 -0
  57. package/dist/analysis-core/chunks/J4AXGE5W.js +1 -0
  58. package/dist/analysis-core/chunks/JF4GYMOZ.js +1 -0
  59. package/dist/analysis-core/chunks/JKWTHRUR.js +1 -0
  60. package/dist/analysis-core/chunks/K5OTWBTT.js +22 -0
  61. package/dist/analysis-core/chunks/KU5DINLW.js +1 -0
  62. package/dist/analysis-core/chunks/MJGB25S5.js +1 -0
  63. package/dist/analysis-core/chunks/MXXNED6N.js +7 -0
  64. package/dist/analysis-core/chunks/NCWEOJVJ.js +6 -0
  65. package/dist/analysis-core/chunks/O652FQQH.js +1 -0
  66. package/dist/analysis-core/chunks/OFBJNHC6.js +1 -0
  67. package/dist/analysis-core/chunks/PYKAERZZ.js +1 -0
  68. package/dist/analysis-core/chunks/QDYMR6KS.js +1 -0
  69. package/dist/analysis-core/chunks/QMIXCT62.js +1 -0
  70. package/dist/analysis-core/chunks/QV2EOS7U.js +1 -0
  71. package/dist/analysis-core/chunks/SL27GMEN.js +1 -0
  72. package/dist/analysis-core/chunks/STZOC7LN.js +1 -0
  73. package/dist/analysis-core/chunks/T2SAP7OV.js +60 -0
  74. package/dist/analysis-core/chunks/T5TREZCQ.js +1 -0
  75. package/dist/analysis-core/chunks/T66VDFXF.js +1 -0
  76. package/dist/analysis-core/chunks/TDNTU4TF.js +1 -0
  77. package/dist/analysis-core/chunks/U5HOEVPE.js +1 -0
  78. package/dist/analysis-core/chunks/UEWL3GMQ.js +1 -0
  79. package/dist/analysis-core/chunks/V7BFUTM7.js +1 -0
  80. package/dist/analysis-core/chunks/VGYWI4X2.js +1 -0
  81. package/dist/analysis-core/chunks/Y7JGSUOA.js +1 -0
  82. package/dist/analysis-core/chunks/ZAA44GPI.js +1 -0
  83. package/dist/analysis-core/contracts/requests.js +1 -0
  84. package/dist/analysis-core/conventions/analysis-fit.js +1 -0
  85. package/dist/analysis-core/conventions/file-admission.js +1 -0
  86. package/dist/analysis-core/data/finding-types.js +1 -0
  87. package/dist/analysis-core/errors.js +1 -0
  88. package/dist/analysis-core/findings/presentation.js +1 -0
  89. package/dist/analysis-core/github/repo-key.js +1 -0
  90. package/dist/analysis-core/import/outside-loader-sources.js +1 -0
  91. package/dist/analysis-core/index.js +6 -0
  92. package/dist/analysis-core/llm/cache-policy.js +1 -0
  93. package/dist/analysis-core/llm/cost.js +1 -0
  94. package/dist/analysis-core/llm/execution.js +1 -0
  95. package/dist/analysis-core/llm/output-limit.js +1 -0
  96. package/dist/analysis-core/llm/slots.js +1 -0
  97. package/dist/analysis-core/llm/stable-json.js +1 -0
  98. package/dist/analysis-core/llm/structured-output-error.js +1 -0
  99. package/dist/analysis-core/map-limit.js +1 -0
  100. package/dist/analysis-core/requests/answer-formats.js +1 -0
  101. package/dist/analysis-core/requests/check-diagnostics.js +1 -0
  102. package/dist/analysis-core/requests/headline-request.js +1 -0
  103. package/dist/analysis-core/requests/instruction-text.js +1 -0
  104. package/dist/analysis-core/requests/local-plan.js +1 -0
  105. package/dist/analysis-core/requests/registry.js +1 -0
  106. package/dist/analysis-core/requests/schema-descriptions.js +1 -0
  107. package/dist/analysis-core/requests/shape-finding-input.js +1 -0
  108. package/dist/analysis-core/requests/template-version.js +1 -0
  109. package/dist/analysis-core/requests/tools.js +1 -0
  110. package/dist/analysis-core/settings.js +1 -0
  111. package/dist/bundle/seal/deserialise.js +75 -0
  112. package/dist/bundle/seal/encryption.js +25 -0
  113. package/dist/bundle/seal/metadata.js +69 -0
  114. package/dist/bundle/seal/open.js +38 -0
  115. package/dist/bundle/seal/planned-calls.js +20 -0
  116. package/dist/bundle/seal/signature.js +51 -0
  117. package/dist/cli/agent-folder.js +17 -0
  118. package/dist/cli/api-types.js +2 -0
  119. package/dist/cli/api.js +1 -0
  120. package/dist/{claude-code-permission.js → cli/claude-code-permission.js} +4 -3
  121. package/dist/cli/commands/analyze.js +75 -0
  122. package/dist/cli/commands/login.js +51 -0
  123. package/dist/cli/completion-recovery.js +18 -0
  124. package/dist/{context.js → cli/context.js} +8 -14
  125. package/dist/cli/development-relay.js +34 -0
  126. package/dist/cli/email-sign-in.js +56 -0
  127. package/dist/{git.js → cli/git.js} +12 -8
  128. package/dist/cli/intake-payload.js +18 -0
  129. package/dist/{main.js → cli/main.js} +23 -12
  130. package/dist/{mcp.js → cli/mcp.js} +109 -40
  131. package/dist/cli/output.js +10 -0
  132. package/dist/cli/relay-check.js +239 -0
  133. package/dist/cli/relay-preparation.js +33 -0
  134. package/dist/cli/sign-in-input.js +97 -0
  135. package/dist/{sign-in.js → cli/sign-in.js} +2 -1
  136. package/dist/cli/suggested-email.js +10 -0
  137. package/dist/cli/summary.js +30 -0
  138. package/dist/contract/cli-api-messages.js +5 -0
  139. package/dist/contract/cli-refusals.js +24 -0
  140. package/dist/contract/cli-version.js +1 -0
  141. package/dist/contract/console-url.js +8 -0
  142. package/dist/contract/console.js +131 -0
  143. package/dist/contract/email-sign-in-messages.js +18 -0
  144. package/dist/contract/messages.js +252 -0
  145. package/dist/contract/release-key.js +2 -0
  146. package/dist/contract/runtime-environment.js +37 -0
  147. package/dist/core/analysis/finding-quality/config.js +4 -0
  148. package/dist/core/analysis/run-to-end.js +55 -0
  149. package/dist/core/analysis/structural-run.js +16 -0
  150. package/dist/gatekeeper/attestation.js +67 -0
  151. package/dist/gatekeeper/certificate-algorithms.js +18 -0
  152. package/dist/gatekeeper/certificate-path.js +99 -0
  153. package/dist/gatekeeper/cli-api.js +174 -0
  154. package/dist/gatekeeper/client.js +76 -0
  155. package/dist/gatekeeper/connection-exchange.js +61 -0
  156. package/dist/gatekeeper/connection.js +140 -0
  157. package/dist/gatekeeper/console.js +175 -0
  158. package/dist/gatekeeper/loopback.js +5 -0
  159. package/dist/gatekeeper/model.js +70 -0
  160. package/dist/gatekeeper/production-attestation.js +149 -0
  161. package/dist/gatekeeper/trust/hardware.js +21 -0
  162. package/dist/gatekeeper/trust/policy.js +42 -0
  163. package/dist/gatekeeper/trust/production.json +25 -0
  164. package/dist/setup-pings/client.js +31 -0
  165. package/dist/setup-pings/contract.js +2 -0
  166. package/dist/setup-pings/policy.js +3 -0
  167. package/dist/setup-pings/state.js +261 -0
  168. package/dist/setup-pings/worker.js +95 -0
  169. package/dist/transport/base64url.js +9 -0
  170. package/dist/transport/http-response.js +89 -0
  171. package/dist/transport/tls-exporter.js +5 -0
  172. package/package.json +57 -11
  173. package/LICENSE +0 -1
  174. package/dist/api-types.js +0 -2
  175. package/dist/api.js +0 -97
  176. package/dist/commands/check.js +0 -104
  177. package/dist/commands/login.js +0 -9
  178. package/dist/files.js +0 -99
  179. package/dist/gitignore.js +0 -70
  180. package/dist/output.js +0 -23
  181. package/dist/secret-files.js +0 -5
  182. /package/dist/{browser.js → cli/browser.js} +0 -0
  183. /package/dist/{commands → cli/commands}/balance.js +0 -0
  184. /package/dist/{commands → cli/commands}/finding.js +0 -0
  185. /package/dist/{commands → cli/commands}/logout.js +0 -0
  186. /package/dist/{credentials.js → cli/credentials.js} +0 -0
@@ -0,0 +1,239 @@
1
+ import { resolveFolderPath } from "../adapters/folder-path.js";
2
+ import { savedAnalysisUrl } from "../contract/console-url.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { disabledSetupPings } from "../setup-pings/client.js";
5
+ import { saveCompletion, loadCompletion } from "../adapters/completion.js";
6
+ import { saveLocalResults } from "../adapters/results.js";
7
+ import { OutputPathError, outputPaths } from "../adapters/output-paths.js";
8
+ import { validateClientRelease } from "../adapters/release.js";
9
+ import { selectAnalysisExecution } from "../adapters/models.js";
10
+ import { prepareRelayCheck } from "./relay-preparation.js";
11
+ import { cliRefusals, COMMITTED_FILES_MESSAGE, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
12
+ import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
13
+ import { GatekeeperClient } from "../gatekeeper/client.js";
14
+ import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
15
+ import productionTrust from "../gatekeeper/trust/production.json" with { type: "json" };
16
+ import { runAnalysisToEnd } from "../core/analysis/run-to-end.js";
17
+ import { formatSummary } from "./summary.js";
18
+ import { buildIntakePayload } from "./intake-payload.js";
19
+ import { completionRecoveryDecision, COMPLETION_SETTINGS_MISMATCH_MESSAGE, matchesCheck } from "./completion-recovery.js";
20
+ const stateLine = (status) => `Reservation ${status.state}; settlement ${status.settlement}; lease ${new Date(status.expiresAt).toLocaleString("en-US", { timeZone: "America/Los_Angeles", timeZoneName: "short" })}.\n`;
21
+ function requireIntakeReservation(status) {
22
+ if (status.state === "reserved")
23
+ return;
24
+ if (status.state === "closing")
25
+ throw new ConsoleError("reservation_closing");
26
+ if (status.state === "closed")
27
+ throw new ConsoleError("reservation_closed");
28
+ throw new ConsoleError("reservation_expired", "intake");
29
+ }
30
+ /** Retry an exact completed payload; a lost intake response must never purchase another analysis. */
31
+ async function submit(client, payload, signal) {
32
+ signal.throwIfAborted();
33
+ const status = await client.status(payload.reservationId);
34
+ const committed = (value) => ({ checkId: value.checkId, projectId: value.projectId, analysisId: value.analysisId, findingIds: value.findingIds, settlement: value.settlement });
35
+ if (!matchesCheck(status, payload))
36
+ throw new ConsoleError("check_conflict");
37
+ if (status.uploadState === "saved")
38
+ return committed(status);
39
+ requireIntakeReservation(status);
40
+ signal.throwIfAborted();
41
+ try {
42
+ return await client.intake(payload);
43
+ }
44
+ catch (error) {
45
+ // Keep the refusal for display; giving up the saved payload requires confirmed recovery status.
46
+ const after = await client.status(payload.reservationId).catch(() => { throw error; });
47
+ if (!matchesCheck(after, payload))
48
+ throw new ConsoleError("check_conflict");
49
+ if (after.uploadState === "saved")
50
+ return committed(after);
51
+ // Exact idempotent retry only; a second uncertainty leaves the completion record for restart.
52
+ if (error instanceof ConsoleError && error.code === "unavailable") {
53
+ requireIntakeReservation(after);
54
+ signal.throwIfAborted();
55
+ return client.intake(payload);
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ export function runRelayCheck(folder, options) {
61
+ return runRelayLifecycle(folder, options);
62
+ }
63
+ /** The caller supplies the paid reservation; this runner owns its connection through cleanup. */
64
+ export function runReservedRelayCheck(folder, options) {
65
+ return runRelayLifecycle(folder, options, options);
66
+ }
67
+ async function runRelayLifecycle(folder, options, existing) {
68
+ const { config, settings, stdout, stderr } = options;
69
+ let client, reservationId, projectId;
70
+ let connection = existing?.connection;
71
+ let identity;
72
+ let completedPayload;
73
+ let completionOrigin;
74
+ let completed = false, uploaded = false, heartbeat;
75
+ let renewal;
76
+ const controller = new AbortController();
77
+ const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
78
+ const showSaved = (result) => {
79
+ const url = savedAnalysisUrl(result.projectId, config.CF_CONSOLE_URL);
80
+ options.onSaved?.({ ...result, url });
81
+ stdout(`Saved analysis ${result.analysisId}: ${url}\n`);
82
+ };
83
+ try {
84
+ if (!config.CF_CONSOLE_URL || !existing && (!config.CF_RELAY_URL || !config.CF_CONSOLE_TOKEN)) {
85
+ throw new Error(cliRefusals.release_unavailable);
86
+ }
87
+ if (existing) {
88
+ requireIntakeReservation(existing.reservation);
89
+ if (existing.reservation.projectId !== existing.expectedProjectId)
90
+ throw new ConsoleError("check_conflict");
91
+ reservationId = existing.reservation.reservationId;
92
+ projectId = existing.reservation.projectId;
93
+ identity = existing.reservation;
94
+ client = existing.client;
95
+ }
96
+ else {
97
+ relayOrigin(config.CF_RELAY_URL);
98
+ client = new ConsoleClient({ url: config.CF_CONSOLE_URL, token: config.CF_CONSOLE_TOKEN });
99
+ }
100
+ // Signature verification precedes all folder reads, status calls and spending.
101
+ const validated = validateClientRelease(options.release, settings);
102
+ const { bundle } = validated;
103
+ folder = await resolveFolderPath(folder);
104
+ signal.throwIfAborted();
105
+ const prior = await loadCompletion(folder);
106
+ if (prior && (!prior.uploaded && !prior.finished || options.retryUpload)) {
107
+ // Recovering another check would leave the caller's paid reservation unexecuted.
108
+ if (existing && (prior.payload.reservationId !== reservationId || !matchesCheck(prior.payload, existing.reservation))) {
109
+ throw new ConsoleError("check_conflict");
110
+ }
111
+ completedPayload = prior.payload;
112
+ completionOrigin = prior.consoleOrigin;
113
+ uploaded = prior.uploaded ?? false;
114
+ const recovery = completionRecoveryDecision({ statusSucceeded: false,
115
+ localMismatch: prior.consoleOrigin !== new URL(config.CF_CONSOLE_URL).origin });
116
+ if (recovery.message)
117
+ throw new Error(recovery.message);
118
+ reservationId = prior.payload.reservationId;
119
+ identity = prior.payload;
120
+ completed = true;
121
+ const saved = await submit(client, prior.payload, signal);
122
+ uploaded = true;
123
+ await saveCompletion(folder, prior.payload, prior.consoleOrigin, true);
124
+ showSaved(saved);
125
+ return 0;
126
+ }
127
+ signal.throwIfAborted();
128
+ if (options.retryUpload)
129
+ throw new Error("The saved completion record is missing. Keep the local report and check the output directory.");
130
+ try {
131
+ await outputPaths(folder, ["findings.json", "summary.md"], options.outputDir);
132
+ }
133
+ catch (error) {
134
+ if (error.code !== "ENOENT")
135
+ throw error;
136
+ }
137
+ const { source, provenance, plan, start, startMetadata } = await prepareRelayCheck(folder, { ...options, signal, expectedCommit: existing?.expectedCommit }, validated);
138
+ let reservation = existing?.reservation;
139
+ if (!reservation) {
140
+ const started = await client.start(start, startMetadata);
141
+ projectId = started.projectId;
142
+ signal.throwIfAborted();
143
+ const created = await client.create({ ...started, checkId: randomUUID(), plan });
144
+ reservationId = created.reservationId;
145
+ identity = created;
146
+ signal.throwIfAborted();
147
+ // Attestation contains no source and precedes reserve, so a bad relay never starts paid work.
148
+ connection = await (options.connect ?? (url => connectRelay(url, { trust: productionTrust })))(config.CF_RELAY_URL);
149
+ signal.throwIfAborted();
150
+ reservation = await client.reserve({ reservationId });
151
+ }
152
+ const activeClient = client, activeId = reservationId;
153
+ let lease = reservation;
154
+ heartbeat = setInterval(() => {
155
+ if (renewal)
156
+ return;
157
+ renewal = activeClient.heartbeat(activeId).then(status => {
158
+ if (status.state !== "reserved")
159
+ throw new Error();
160
+ lease = status;
161
+ }).catch(() => { controller.abort(); }).finally(() => { renewal = undefined; });
162
+ }, 60000);
163
+ heartbeat.unref();
164
+ const execution = await selectAnalysisExecution(config, () => { throw new Error(); }, {
165
+ client: new GatekeeperClient({ mode: "production", connection: connection,
166
+ deadlineMs: () => Math.min(Date.parse(lease.expiresAt), lease.absoluteExpiresAt ? Date.parse(lease.absoluteExpiresAt) : Infinity), }), bundleVersion: bundle.version,
167
+ reservationId: activeId, reservationToken: reservation.reservationToken, instructions: { bundle, verificationKey: options.release.verificationKey }, signal,
168
+ });
169
+ options.onStart?.();
170
+ stderr("Reading the folder and running analysis.\n");
171
+ (options.setupPings ?? disabledSetupPings).ping("first_analysis_started");
172
+ const report = await runAnalysisToEnd(source, execution.model, settings, { execution });
173
+ signal.throwIfAborted();
174
+ const paths = await saveLocalResults(folder, report, source.skippedFiles, options.outputDir);
175
+ const payload = buildIntakePayload({ checkId: reservation.checkId, projectId: projectId, reservationId,
176
+ bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report);
177
+ completionOrigin = new URL(config.CF_CONSOLE_URL).origin;
178
+ await saveCompletion(folder, payload, completionOrigin);
179
+ completedPayload = payload;
180
+ completed = true;
181
+ const saved = await submit(client, payload, signal);
182
+ uploaded = true;
183
+ await saveCompletion(folder, payload, completionOrigin, true);
184
+ stdout(formatSummary(report));
185
+ stdout(`\nSaved ${paths.findingsPath}\nSaved ${paths.summaryPath}\n`);
186
+ showSaved(saved);
187
+ return 0;
188
+ }
189
+ catch (error) {
190
+ let status;
191
+ if (client && reservationId) {
192
+ try {
193
+ status = await (completed || uploaded ? client.status(reservationId) : client.fail(reservationId));
194
+ }
195
+ catch {
196
+ // An uncertain server outcome cannot prove a refund.
197
+ }
198
+ }
199
+ if (status?.uploadState === "saved" && identity && matchesCheck(status, identity)) {
200
+ if (completedPayload) {
201
+ try {
202
+ await saveCompletion(folder, completedPayload, completionOrigin, true);
203
+ }
204
+ catch { /* Committed server state still survives a local write failure. */ }
205
+ }
206
+ try {
207
+ showSaved({ projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds });
208
+ }
209
+ catch { /* Output failure must not change a server outcome. */ }
210
+ return 0;
211
+ }
212
+ const recovery = completionRecoveryDecision({ statusSucceeded: status !== undefined, status, completedPayload });
213
+ if (completedPayload && recovery.finished) {
214
+ try {
215
+ await saveCompletion(folder, completedPayload, completionOrigin, false, true);
216
+ }
217
+ catch { /* Keep the pending record if the durable write cannot be confirmed. */ }
218
+ }
219
+ const known = error instanceof ConsoleError || error instanceof OutputPathError || error instanceof Error && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE].includes(error.message)
220
+ || error.message.startsWith("The saved completion") || error.message === COMPLETION_SETTINGS_MISMATCH_MESSAGE);
221
+ const message = known ? error.message : FINISH_FAILURE_MESSAGE;
222
+ try {
223
+ options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, message });
224
+ stderr(`${message}\n`);
225
+ if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
226
+ stderr(stateLine(status));
227
+ }
228
+ catch { /* Output failure must not change a server outcome. */ }
229
+ return refusalExitCode(error instanceof ConsoleError ? error.code : undefined);
230
+ }
231
+ finally {
232
+ clearInterval(heartbeat);
233
+ await renewal;
234
+ try {
235
+ connection?.close();
236
+ }
237
+ catch { /* A committed intake is already authoritative. */ }
238
+ }
239
+ }
@@ -0,0 +1,33 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { outsideLoadersEnabled } from "../analysis-core/settings.js";
3
+ import { readLocalFolder } from "../adapters/files.js";
4
+ import { snapshotProvenance } from "../adapters/snapshot.js";
5
+ import { validateClientRelease } from "../adapters/release.js";
6
+ import { prepareRelayPlan } from "../adapters/relay-plan.js";
7
+ import { COMMITTED_FILES_MESSAGE } from "../contract/cli-refusals.js";
8
+ import { repositoryFacts } from "./git.js";
9
+ /** Absolute checkout paths stay local; repository facts own the start's commit. */
10
+ export function buildRelayStartPayload(facts, files) {
11
+ const { remoteUrl, relPath, folderName, branch, commit, dirty } = facts;
12
+ return { execution: "local", remoteUrl, relPath, folderName, branch, commit, dirty, files };
13
+ }
14
+ /** Recovery can reuse signature validation without reading the folder again. */
15
+ export async function prepareRelayCheck(folder, options, validated = validateClientRelease(options.release, options.settings)) {
16
+ const { bundle, tools } = validated;
17
+ options.signal?.throwIfAborted();
18
+ // Discovery must agree with Git before publishing repository-relative captures.
19
+ const earlyFacts = outsideLoadersEnabled(options.settings) ? await (options.facts ?? repositoryFacts)(folder) : undefined;
20
+ options.signal?.throwIfAborted();
21
+ const read = await readLocalFolder(options.settings, folder, options.trialAdmission, { captureSnapshot: true,
22
+ ...(earlyFacts ? { expectedRepositoryRoot: earlyFacts.repoRoot ?? null } : {}) });
23
+ const source = { ...read, files: Object.freeze({ ...read.files }) };
24
+ const provenance = await snapshotProvenance(source.sourceBase ?? folder, source.snapshot, source.sourceBase ? { selectionFolder: folder } : undefined);
25
+ if (options.expectedCommit !== undefined && provenance.commit !== options.expectedCommit)
26
+ throw new Error(COMMITTED_FILES_MESSAGE);
27
+ const { plan, files } = prepareRelayPlan({ bundle, tools, settings: options.settings, relayVersion: options.release.relayVersion, source });
28
+ options.signal?.throwIfAborted();
29
+ const facts = earlyFacts ?? await (options.facts ?? repositoryFacts)(folder);
30
+ options.signal?.throwIfAborted();
31
+ const startMetadata = { analysisSize: source.analysisSize, startAttempt: randomUUID() };
32
+ return { source, provenance, plan, start: buildRelayStartPayload(source.sourceBase ? { ...facts, relPath: "" } : facts, files), startMetadata };
33
+ }
@@ -0,0 +1,97 @@
1
+ import { createInterface } from "node:readline";
2
+ import { PassThrough } from "node:stream";
3
+ export function terminalSignInInput(input = process.stdin, output = process.stdout) {
4
+ const adapter = {
5
+ get isTerminal() { return input.isTTY === true && output.isTTY === true; },
6
+ async readCode(prompt, signal) {
7
+ if (signal?.aborted || !adapter.isTerminal || input.destroyed || input.readableEnded || output.destroyed || output.writableEnded)
8
+ return undefined;
9
+ if (!input.setRawMode)
10
+ return undefined;
11
+ const setRawMode = input.setRawMode.bind(input);
12
+ return new Promise(resolve => {
13
+ const wasRaw = input.isRaw === true;
14
+ const wasFlowing = input.readableFlowing === true;
15
+ // Keep readline's editing and keypress listeners off stdin, with no output to echo a code.
16
+ const hidden = new PassThrough();
17
+ const lines = createInterface({ input: hidden, terminal: true, historySize: 0 });
18
+ let finished = false;
19
+ let result;
20
+ const cancel = () => { result = undefined; finish(); };
21
+ const outputFailed = () => { cancel(); complete(); };
22
+ const forward = (chunk) => { hidden.write(chunk); };
23
+ function complete() {
24
+ process.removeListener("SIGINT", cancel);
25
+ signal?.removeEventListener("abort", cancel);
26
+ output.removeListener("error", outputFailed);
27
+ output.removeListener("close", outputFailed);
28
+ output.removeListener("finish", outputFailed);
29
+ resolve(result);
30
+ }
31
+ function finish(code) {
32
+ if (finished)
33
+ return;
34
+ finished = true;
35
+ result = code;
36
+ lines.close();
37
+ lines.removeAllListeners();
38
+ hidden.destroy();
39
+ hidden.removeAllListeners();
40
+ input.removeListener("data", forward);
41
+ input.removeListener("end", cancel);
42
+ input.removeListener("close", cancel);
43
+ input.removeListener("error", cancel);
44
+ try {
45
+ if (!wasRaw)
46
+ setRawMode(false);
47
+ }
48
+ catch {
49
+ result = undefined;
50
+ }
51
+ if (!wasFlowing)
52
+ input.pause();
53
+ if (output.destroyed || output.writableEnded) {
54
+ result = undefined;
55
+ return;
56
+ }
57
+ // Keep the error handler until the write finishes: stream errors usually arrive later.
58
+ try {
59
+ output.write("\n", error => { if (!error)
60
+ complete(); });
61
+ }
62
+ catch {
63
+ result = undefined;
64
+ complete();
65
+ }
66
+ }
67
+ lines.once("line", finish);
68
+ lines.once("close", finish);
69
+ lines.once("SIGINT", cancel);
70
+ lines.once("SIGTSTP", cancel);
71
+ input.once("end", cancel);
72
+ input.once("close", cancel);
73
+ input.once("error", cancel);
74
+ process.once("SIGINT", cancel);
75
+ signal?.addEventListener("abort", cancel, { once: true });
76
+ output.once("error", outputFailed);
77
+ output.once("close", outputFailed);
78
+ output.once("finish", outputFailed);
79
+ try {
80
+ if (!wasRaw)
81
+ setRawMode(true);
82
+ if (finished)
83
+ return;
84
+ output.write(prompt);
85
+ if (finished)
86
+ return;
87
+ input.on("data", forward);
88
+ input.resume();
89
+ }
90
+ catch {
91
+ finish();
92
+ }
93
+ });
94
+ },
95
+ };
96
+ return adapter;
97
+ }
@@ -1,5 +1,6 @@
1
1
  import { ApiError } from "./api.js";
2
2
  import { readCredentials, saveCredentials } from "./credentials.js";
3
+ import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
3
4
  async function pollSignIn(ctx, device) {
4
5
  const until = ctx.now() + device.expiresIn * 1000;
5
6
  while (ctx.now() < until) {
@@ -12,7 +13,7 @@ async function pollSignIn(ctx, device) {
12
13
  if (poll.status === "expired")
13
14
  break;
14
15
  if (poll.status === "denied")
15
- throw new Error("Sign-in was cancelled. Run counterfact-labs login to try again.");
16
+ throw new Error(emailSignInMessages.cancelled);
16
17
  }
17
18
  throw new Error("The sign-in code expired. Run counterfact-labs login to try again.");
18
19
  }
@@ -0,0 +1,10 @@
1
+ import { normalizeSignInEmail } from "./email-sign-in.js";
2
+ import { userEmail } from "./git.js";
3
+ export async function suggestedEmail(folder, readEmail = userEmail) {
4
+ try {
5
+ return normalizeSignInEmail(await readEmail(folder));
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
@@ -0,0 +1,30 @@
1
+ import { findingPresentation } from "../analysis-core/findings/presentation.js";
2
+ export function formatSummary(report) {
3
+ const lines = ["# Counterfact analysis", "",
4
+ `${report.findings.length} findings; ${report.structures.length} structural proposals.`, ""];
5
+ if (!report.findings.length)
6
+ lines.push("No findings.", "");
7
+ for (const [index, finding] of report.findings.entries()) {
8
+ const presentation = findingPresentation(finding);
9
+ lines.push(`## ${index + 1}. ${presentation.displayTitle}`, "");
10
+ if (presentation.ifLeft)
11
+ lines.push(`If left: ${presentation.ifLeft}`, "");
12
+ lines.push(`Severity: ${finding.severity}; category: ${finding.category ?? "unclassified"}.`, "", finding.cause, "");
13
+ const sites = [...(finding.site ? [finding.site] : []), ...finding.otherSites];
14
+ if (sites.length)
15
+ lines.push(`Sources: ${sites.map(site => `${site.path}:${site.line ?? "?"}`).join(", ")}`, "");
16
+ if (finding.question)
17
+ lines.push(finding.question, "");
18
+ if (finding.fixes.length)
19
+ lines.push(...finding.fixes.map(fix => `- ${fix.title}`), "");
20
+ }
21
+ if (report.structures.length)
22
+ lines.push("## Structural proposals", "", ...report.structures.flatMap(proposal => {
23
+ const presentation = findingPresentation({ ...proposal, title: proposal.body.title });
24
+ return [presentation.ifLeft ? `- ${presentation.displayTitle}` : `- ${presentation.displayTitle}: ${proposal.body.cause}`, "",
25
+ ...(presentation.ifLeft ? [`If left: ${presentation.ifLeft}`, ""] : [])];
26
+ }));
27
+ if (report.warnings.length)
28
+ lines.push("## Reading and analysis notices", "", ...report.warnings.map(warning => `- ${warning}`), "");
29
+ return lines.join("\n");
30
+ }
@@ -0,0 +1,5 @@
1
+ export const cliApiMessages = Object.freeze({
2
+ unreachable: "Can't reach Counterfact. Try again shortly.",
3
+ invalid_response: "Counterfact returned an invalid response. Try again shortly.",
4
+ request_failed: "The request could not be completed. Try again shortly.",
5
+ });
@@ -0,0 +1,24 @@
1
+ import { FOLDER_TOO_LARGE_MESSAGE } from "../analysis-core/errors.js";
2
+ export const START_FAILURE_MESSAGE = "This couldn't start. Try again.";
3
+ export const RESERVATION_EXPIRED_MESSAGE = "This reservation expired. Start a new analysis.";
4
+ export const FINISH_FAILURE_MESSAGE = "The analysis couldn't finish. Try again.";
5
+ export const RESULTS_TOO_LARGE_MESSAGE = "The results were too large to save. Try again.";
6
+ export const COMMITTED_FILES_MESSAGE = "Counterfact analyzes the committed version of the agent's files. Commit your changes, then try again.";
7
+ const unsupportedVersion = "This version of Counterfact can't run analyses. Update it: npm install -g @counterfact-labs/cli@latest.";
8
+ /** Shared public refusals keep mode admission and reservation errors consistent. */
9
+ export const cliRefusals = {
10
+ folder_too_large: FOLDER_TOO_LARGE_MESSAGE,
11
+ low_balance: "Your balance doesn't cover this analysis. Add credit at https://app.counterfactlabs.com/account?status=low_balance#balance",
12
+ local_checks_paused: "Analyses are paused right now. Try again later.",
13
+ cli_update_required: "Update Counterfact: run npm install -g @counterfact-labs/cli@latest.",
14
+ release_unavailable: unsupportedVersion,
15
+ invalid_plan: unsupportedVersion,
16
+ invalid_body: "Counterfact couldn't read this request. Update Counterfact, then try again.",
17
+ body_too_large: "This request is too large. The limit is 2 MB.",
18
+ free_cap_reached: "Free analyses are paused for today. Try again tomorrow, or top up to run a paid analysis.",
19
+ org_spend_limit: "The workspace spending limit was reached. Ask Counterfact to raise it.",
20
+ not_reserved: "Start a new analysis.",
21
+ reservation_expired: "Start a new analysis.",
22
+ stale_quote: START_FAILURE_MESSAGE,
23
+ };
24
+ export function refusalExitCode(code) { return code === "low_balance" ? 2 : 1; }
@@ -0,0 +1 @@
1
+ export const CLI_VERSION = "0.2.4";
@@ -0,0 +1,8 @@
1
+ export function savedAnalysisUrl(projectId, consoleOrigin) {
2
+ return new URL(`/projects/${projectId}/findings`, consoleOrigin).href;
3
+ }
4
+ import { isLoopbackHost } from "../gatekeeper/loopback.js";
5
+ /** Bearer credentials may use plain HTTP only on this machine. */
6
+ export function consoleSchemeAllowed(url) {
7
+ return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHost(url.hostname);
8
+ }
@@ -0,0 +1,131 @@
1
+ import { z } from "zod";
2
+ import { fixDeltaSchema, fixFactsSchema } from "../analysis-core/data/finding-types.js";
3
+ import { inspectFixSchema, otherSiteSchema, rawFindingSchema } from "../analysis-core/analysis/schemas.js";
4
+ import { sourceNotesSchema } from "../analysis-core/analysis/source-notes/types.js";
5
+ import { parseAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
6
+ export const id = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/);
7
+ const uuid = z.uuid().transform(value => value.toLowerCase());
8
+ const money = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
9
+ const count = (max) => z.number().int().positive().max(max);
10
+ // These data-only boundaries follow the SaaS local start, reservation and findings owners; see docs/provenance.md.
11
+ export const LOCAL_START_EXECUTION_HEADER = { name: "x-counterfact-execution", value: "local" };
12
+ export const LOCAL_START_BODY_LIMIT = 2 * 1024 * 1024;
13
+ export const ANALYSIS_SIZE_HEADER = "x-counterfact-analysis-size";
14
+ export const START_ATTEMPT_HEADER = "x-counterfact-start-attempt";
15
+ export const ANALYSIS_SIZE_HEADER_LIMIT = 256;
16
+ /** Additive headers let older consoles keep accepting the unchanged, strict start body. */
17
+ export function serializeLocalStartMetadata(metadata) {
18
+ const size = JSON.stringify(parseAnalysisSize(metadata.analysisSize));
19
+ if (Buffer.byteLength(size, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
20
+ throw new Error("Invalid analysis size header");
21
+ return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt) };
22
+ }
23
+ /** Missing measurements preserve old clients; present measurements must be complete. */
24
+ export function parseLocalStartMetadata(headers) {
25
+ const raw = headers.get(ANALYSIS_SIZE_HEADER);
26
+ if (raw === null)
27
+ return undefined;
28
+ if (Buffer.byteLength(raw, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
29
+ throw new Error("Invalid analysis size header");
30
+ return { analysisSize: parseAnalysisSize(JSON.parse(raw)), startAttempt: uuid.parse(headers.get(START_ATTEMPT_HEADER)) };
31
+ }
32
+ const localStartPath = z.string().min(1).max(4096).refine(value => !value.includes("\\") && !value.includes("\0") && !value.startsWith("/")
33
+ && value.split("/").every(part => part !== ".." && part !== "." && part !== ""));
34
+ export const localStartRequestSchema = z.strictObject({
35
+ execution: z.literal("local"), remoteUrl: z.string().max(4096).nullable(), relPath: z.string().max(4096),
36
+ folderName: z.string().trim().min(1).max(255), branch: z.string().max(4096).nullable().optional(),
37
+ commit: z.string().max(4096).nullable().optional(), dirty: z.boolean().nullable().optional(),
38
+ files: z.array(z.strictObject({ path: localStartPath, size: money })).min(1)
39
+ .refine(files => new Set(files.map(file => file.path)).size === files.length),
40
+ });
41
+ export const localStartResponseSchema = z.strictObject({ jobId: uuid, projectId: uuid });
42
+ export const relayPlanSchema = z.strictObject({
43
+ bundleVersion: id, bundleHash: z.string().regex(/^[a-f0-9]{64}$/), relayVersion: id,
44
+ calls: z.array(z.strictObject({ routeId: id, model: id, calls: count(512), maxInputBytes: count(800_000),
45
+ maxInputTokens: count(200_000), maxOutputTokens: count(64_000) })).min(1).max(64)
46
+ .refine(calls => new Set(calls.map(call => call.routeId)).size === calls.length && calls.reduce((sum, call) => sum + call.calls, 0) <= 512),
47
+ });
48
+ export const cliRelayReservationRequestSchema = z.discriminatedUnion("action", [
49
+ z.strictObject({ action: z.literal("create"), jobId: uuid, checkId: uuid, projectId: uuid, plan: relayPlanSchema }),
50
+ z.strictObject({ action: z.literal("reserve"), reservationId: uuid }),
51
+ z.strictObject({ action: z.literal("status"), reservationId: uuid }),
52
+ z.strictObject({ action: z.literal("heartbeat"), reservationId: uuid }),
53
+ z.strictObject({ action: z.literal("fail"), reservationId: uuid }),
54
+ ]);
55
+ export const consoleCreatedSchema = z.strictObject({ reservationId: uuid, jobId: uuid, projectId: uuid, checkId: uuid, state: z.literal("quoted") });
56
+ export const consoleStatusSchema = z.strictObject({
57
+ reservationId: uuid, checkId: uuid, projectId: uuid,
58
+ state: z.enum(["quoted", "reserved", "closing", "closed", "expired"]),
59
+ settlement: z.enum(["unheld", "held", "charged", "released"]),
60
+ reason: z.enum(["service_failure", "required_failure", "authorization_refused", "our_deadline_failure",
61
+ "findings_saved", "required_delivered", "no_required_delivery"]).nullable(),
62
+ uploadState: z.enum(["saved", "missing"]), analysisId: uuid.optional(), findingIds: z.array(uuid).max(20_000).optional(),
63
+ quoteId: z.string().min(1).max(256).optional(), priceMicro: money.nullable(), providerBudgetMicro: money,
64
+ expiresAt: z.iso.datetime(), absoluteExpiresAt: z.iso.datetime().nullable(),
65
+ deadlineClassification: z.enum(["caller_timeout", "our_deadline_failure"]).nullable(),
66
+ }).refine(value => value.uploadState === "saved"
67
+ ? value.analysisId !== undefined && value.findingIds !== undefined
68
+ : value.analysisId === undefined && value.findingIds === undefined);
69
+ export const consoleReservationSchema = consoleStatusSchema.safeExtend({ reservationToken: z.string().min(1).max(256),
70
+ quoteId: z.string().min(1).max(256) });
71
+ export const consoleIntakeResultSchema = z.strictObject({
72
+ checkId: uuid, projectId: uuid, analysisId: uuid, findingIds: z.array(uuid).max(20_000),
73
+ settlement: z.enum(["unheld", "held", "charged", "released"]).optional(),
74
+ });
75
+ export const LOCAL_FINDINGS_BODY_LIMIT = 2 * 1024 * 1024;
76
+ export const LOCAL_FINDINGS_QUOTE_LIMIT = 2000;
77
+ export const LOCAL_FINDINGS_TEXT_LIMIT = 4096;
78
+ function shortFindingFields(value) {
79
+ if (typeof value === "string")
80
+ return Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_TEXT_LIMIT;
81
+ return value === null || typeof value !== "object" || Object.values(value).every(shortFindingFields);
82
+ }
83
+ function validIntakeText(value) {
84
+ if (typeof value === "string")
85
+ return !value.includes(String.fromCharCode(0)) && !/[\uD800-\uDFFF]/u.test(value);
86
+ return value === null || typeof value !== "object" || Object.values(value).every(validIntakeText);
87
+ }
88
+ const quote = z.string().refine(value => Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_QUOTE_LIMIT);
89
+ const text = z.string().min(1).refine(value => value.trim().length > 0);
90
+ const delta = fixDeltaSchema.extend({
91
+ nodes: z.array(fixDeltaSchema.shape.nodes.element.strict()),
92
+ edges: z.array(fixDeltaSchema.shape.edges.element.strict()),
93
+ }).strict();
94
+ const fix = inspectFixSchema.extend({
95
+ ...fixFactsSchema.shape,
96
+ targetFiles: z.array(inspectFixSchema.shape.targetFiles.element.strict()),
97
+ delta: delta.nullable(),
98
+ }).strict();
99
+ const notes = sourceNotesSchema.extend({
100
+ sites: z.array(sourceNotesSchema.shape.sites.element.extend({ keyPhrase: quote }).strict()),
101
+ counterEvidence: z.array(sourceNotesSchema.shape.counterEvidence.element.extend({ excerpt: quote }).strict()),
102
+ }).strict();
103
+ const finding = rawFindingSchema.extend({
104
+ summary: z.string().nullable(),
105
+ category: rawFindingSchema.shape.category.removeCatch(),
106
+ question: rawFindingSchema.shape.question.removeCatch().nullable(),
107
+ siteRepo: text.nullable(),
108
+ site: rawFindingSchema.shape.site.unwrap().strict().nullable(),
109
+ excerpt: quote.nullable(),
110
+ otherSites: z.array(otherSiteSchema.extend({ repo: text, excerpt: otherSiteSchema.shape.excerpt.pipe(quote) }).strict()),
111
+ fixes: z.array(fix).min(1),
112
+ sourceNotes: notes.nullable().optional(),
113
+ }).strict().refine(shortFindingFields);
114
+ const payload = z.strictObject({
115
+ checkId: z.uuid(), projectId: z.uuid(), reservationId: uuid.optional(),
116
+ agent: z.strictObject({
117
+ repository: text.max(4096), commit: z.string().regex(/^(?:[a-f\d]{40}|[a-f\d]{64})$/i),
118
+ files: z.array(z.strictObject({ path: text.max(4096), hash: z.string().regex(/^[a-f\d]{64}$/i) })).min(1),
119
+ }),
120
+ findings: z.array(finding), bundleVersion: text.max(128), relayVersion: text.max(128), counterfactLocalVersion: text.max(128),
121
+ }).refine(validIntakeText);
122
+ // Original JSON size is checked before a shared schema can trim text or apply defaults.
123
+ export const localFindingsPayloadSchema = z.unknown().refine(value => {
124
+ try {
125
+ const json = JSON.stringify(value);
126
+ return typeof json === "string" && Buffer.byteLength(json, "utf8") <= LOCAL_FINDINGS_BODY_LIMIT;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }).pipe(payload);
@@ -0,0 +1,18 @@
1
+ import { cliApiMessages } from "./cli-api-messages.js";
2
+ const rateLimited = "Too many sign-in requests. Try again later.";
3
+ export const emailSignInMessages = Object.freeze({
4
+ invalid_email: "Enter a valid email address.",
5
+ invalid_code: "Enter the code from your email.",
6
+ wrong_code: "That code didn't work. Try the code from your email again.",
7
+ expired: "That code expired. Request a new sign-in code.",
8
+ too_many_attempts: "Too many incorrect codes. Request a new sign-in code.",
9
+ rate_limited: rateLimited,
10
+ rate_limited_with_cooldown: (seconds) => seconds === 0 ? rateLimited
11
+ : `Too many sign-in requests. Try again in ${seconds} ${seconds === 1 ? "second" : "seconds"}.`,
12
+ email_unavailable: "Email sign-in isn't available. Use browser sign-in instead.",
13
+ browser_sign_in_pending: "A browser sign-in is still in progress. Finish it before starting email sign-in.",
14
+ unreachable: cliApiMessages.unreachable,
15
+ invalid_response: cliApiMessages.invalid_response,
16
+ request_failed: cliApiMessages.request_failed,
17
+ cancelled: "Sign-in was cancelled. Run counterfact-labs login to try again.",
18
+ });