@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.
- package/README.md +176 -42
- package/bundle/client-release.json +7 -0
- package/bundle/sealed.json +908 -0
- package/dist/adapters/anthropic.js +151 -0
- package/dist/adapters/completion.js +112 -0
- package/dist/adapters/durable-write.js +34 -0
- package/dist/adapters/files.js +144 -0
- package/dist/adapters/folder-path.js +6 -0
- package/dist/adapters/git-ignore.js +49 -0
- package/dist/adapters/git.js +44 -0
- package/dist/adapters/ignored-files.js +36 -0
- package/dist/adapters/instructions.js +35 -0
- package/dist/adapters/models.js +51 -0
- package/dist/adapters/output-paths.js +92 -0
- package/dist/adapters/prompt-cache.js +28 -0
- package/dist/adapters/public-instructions.js +36 -0
- package/dist/adapters/quality.js +6 -0
- package/dist/adapters/relay-plan.js +15 -0
- package/dist/adapters/release.js +62 -0
- package/dist/adapters/report.js +29 -0
- package/dist/adapters/repository-files.js +199 -0
- package/dist/adapters/repository-root.js +120 -0
- package/dist/adapters/results.js +9 -0
- package/dist/adapters/settings.js +26 -0
- package/dist/adapters/snapshot.js +68 -0
- package/dist/analysis-core/analysis/analysis-flags.js +1 -0
- package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -0
- package/dist/analysis-core/analysis/finding-quality/config.js +1 -0
- package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
- package/dist/analysis-core/analysis/headlines/attachment.js +1 -0
- package/dist/analysis-core/analysis/headlines/input.js +1 -0
- package/dist/analysis-core/analysis/headlines/types.js +1 -0
- package/dist/analysis-core/analysis/schemas.js +1 -0
- package/dist/analysis-core/analysis/source-notes/types.js +1 -0
- package/dist/analysis-core/chunks/2PSNOSJ2.js +1 -0
- package/dist/analysis-core/chunks/3227KXHF.js +1 -0
- package/dist/analysis-core/chunks/326F4XNG.js +1 -0
- package/dist/analysis-core/chunks/3CKW5JIF.js +1 -0
- package/dist/analysis-core/chunks/3GUKUEEJ.js +1 -0
- package/dist/analysis-core/chunks/3MTXB4MK.js +6 -0
- package/dist/analysis-core/chunks/4F6ZIHXU.js +1 -0
- package/dist/analysis-core/chunks/57M4OQJM.js +1 -0
- package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
- package/dist/analysis-core/chunks/724WZMYW.js +2 -0
- package/dist/analysis-core/chunks/74FPPPIG.js +1 -0
- package/dist/analysis-core/chunks/7OYIBWX3.js +1 -0
- package/dist/analysis-core/chunks/D6AWHLRI.js +1 -0
- package/dist/analysis-core/chunks/DRGDIIFA.js +1 -0
- package/dist/analysis-core/chunks/E7TFIHC5.js +1 -0
- package/dist/analysis-core/chunks/EH3QQ3T4.js +1 -0
- package/dist/analysis-core/chunks/FSLWD2PE.js +1 -0
- package/dist/analysis-core/chunks/FTODBFQW.js +1 -0
- package/dist/analysis-core/chunks/HPIAEQHE.js +37 -0
- package/dist/analysis-core/chunks/HTLIRVP7.js +1 -0
- package/dist/analysis-core/chunks/IBCFGUHI.js +1 -0
- package/dist/analysis-core/chunks/IVDDV6CU.js +14 -0
- package/dist/analysis-core/chunks/J4AXGE5W.js +1 -0
- package/dist/analysis-core/chunks/JF4GYMOZ.js +1 -0
- package/dist/analysis-core/chunks/JKWTHRUR.js +1 -0
- package/dist/analysis-core/chunks/K5OTWBTT.js +22 -0
- package/dist/analysis-core/chunks/KU5DINLW.js +1 -0
- package/dist/analysis-core/chunks/MJGB25S5.js +1 -0
- package/dist/analysis-core/chunks/MXXNED6N.js +7 -0
- package/dist/analysis-core/chunks/NCWEOJVJ.js +6 -0
- package/dist/analysis-core/chunks/O652FQQH.js +1 -0
- package/dist/analysis-core/chunks/OFBJNHC6.js +1 -0
- package/dist/analysis-core/chunks/PYKAERZZ.js +1 -0
- package/dist/analysis-core/chunks/QDYMR6KS.js +1 -0
- package/dist/analysis-core/chunks/QMIXCT62.js +1 -0
- package/dist/analysis-core/chunks/QV2EOS7U.js +1 -0
- package/dist/analysis-core/chunks/SL27GMEN.js +1 -0
- package/dist/analysis-core/chunks/STZOC7LN.js +1 -0
- package/dist/analysis-core/chunks/T2SAP7OV.js +60 -0
- package/dist/analysis-core/chunks/T5TREZCQ.js +1 -0
- package/dist/analysis-core/chunks/T66VDFXF.js +1 -0
- package/dist/analysis-core/chunks/TDNTU4TF.js +1 -0
- package/dist/analysis-core/chunks/U5HOEVPE.js +1 -0
- package/dist/analysis-core/chunks/UEWL3GMQ.js +1 -0
- package/dist/analysis-core/chunks/V7BFUTM7.js +1 -0
- package/dist/analysis-core/chunks/VGYWI4X2.js +1 -0
- package/dist/analysis-core/chunks/Y7JGSUOA.js +1 -0
- package/dist/analysis-core/chunks/ZAA44GPI.js +1 -0
- package/dist/analysis-core/contracts/requests.js +1 -0
- package/dist/analysis-core/conventions/analysis-fit.js +1 -0
- package/dist/analysis-core/conventions/file-admission.js +1 -0
- package/dist/analysis-core/data/finding-types.js +1 -0
- package/dist/analysis-core/errors.js +1 -0
- package/dist/analysis-core/findings/presentation.js +1 -0
- package/dist/analysis-core/github/repo-key.js +1 -0
- package/dist/analysis-core/import/outside-loader-sources.js +1 -0
- package/dist/analysis-core/index.js +6 -0
- package/dist/analysis-core/llm/cache-policy.js +1 -0
- package/dist/analysis-core/llm/cost.js +1 -0
- package/dist/analysis-core/llm/execution.js +1 -0
- package/dist/analysis-core/llm/output-limit.js +1 -0
- package/dist/analysis-core/llm/slots.js +1 -0
- package/dist/analysis-core/llm/stable-json.js +1 -0
- package/dist/analysis-core/llm/structured-output-error.js +1 -0
- package/dist/analysis-core/map-limit.js +1 -0
- package/dist/analysis-core/requests/answer-formats.js +1 -0
- package/dist/analysis-core/requests/check-diagnostics.js +1 -0
- package/dist/analysis-core/requests/headline-request.js +1 -0
- package/dist/analysis-core/requests/instruction-text.js +1 -0
- package/dist/analysis-core/requests/local-plan.js +1 -0
- package/dist/analysis-core/requests/registry.js +1 -0
- package/dist/analysis-core/requests/schema-descriptions.js +1 -0
- package/dist/analysis-core/requests/shape-finding-input.js +1 -0
- package/dist/analysis-core/requests/template-version.js +1 -0
- package/dist/analysis-core/requests/tools.js +1 -0
- package/dist/analysis-core/settings.js +1 -0
- package/dist/bundle/seal/deserialise.js +75 -0
- package/dist/bundle/seal/encryption.js +25 -0
- package/dist/bundle/seal/metadata.js +69 -0
- package/dist/bundle/seal/open.js +38 -0
- package/dist/bundle/seal/planned-calls.js +20 -0
- package/dist/bundle/seal/signature.js +51 -0
- package/dist/cli/agent-folder.js +17 -0
- package/dist/cli/api-types.js +2 -0
- package/dist/cli/api.js +1 -0
- package/dist/{claude-code-permission.js → cli/claude-code-permission.js} +4 -3
- package/dist/cli/commands/analyze.js +75 -0
- package/dist/cli/commands/login.js +51 -0
- package/dist/cli/completion-recovery.js +18 -0
- package/dist/{context.js → cli/context.js} +8 -14
- package/dist/cli/development-relay.js +34 -0
- package/dist/cli/email-sign-in.js +56 -0
- package/dist/{git.js → cli/git.js} +12 -8
- package/dist/cli/intake-payload.js +18 -0
- package/dist/{main.js → cli/main.js} +23 -12
- package/dist/{mcp.js → cli/mcp.js} +109 -40
- package/dist/cli/output.js +10 -0
- package/dist/cli/relay-check.js +239 -0
- package/dist/cli/relay-preparation.js +33 -0
- package/dist/cli/sign-in-input.js +97 -0
- package/dist/{sign-in.js → cli/sign-in.js} +2 -1
- package/dist/cli/suggested-email.js +10 -0
- package/dist/cli/summary.js +30 -0
- package/dist/contract/cli-api-messages.js +5 -0
- package/dist/contract/cli-refusals.js +24 -0
- package/dist/contract/cli-version.js +1 -0
- package/dist/contract/console-url.js +8 -0
- package/dist/contract/console.js +131 -0
- package/dist/contract/email-sign-in-messages.js +18 -0
- package/dist/contract/messages.js +252 -0
- package/dist/contract/release-key.js +2 -0
- package/dist/contract/runtime-environment.js +37 -0
- package/dist/core/analysis/finding-quality/config.js +4 -0
- package/dist/core/analysis/run-to-end.js +55 -0
- package/dist/core/analysis/structural-run.js +16 -0
- package/dist/gatekeeper/attestation.js +67 -0
- package/dist/gatekeeper/certificate-algorithms.js +18 -0
- package/dist/gatekeeper/certificate-path.js +99 -0
- package/dist/gatekeeper/cli-api.js +174 -0
- package/dist/gatekeeper/client.js +76 -0
- package/dist/gatekeeper/connection-exchange.js +61 -0
- package/dist/gatekeeper/connection.js +140 -0
- package/dist/gatekeeper/console.js +175 -0
- package/dist/gatekeeper/loopback.js +5 -0
- package/dist/gatekeeper/model.js +70 -0
- package/dist/gatekeeper/production-attestation.js +149 -0
- package/dist/gatekeeper/trust/hardware.js +21 -0
- package/dist/gatekeeper/trust/policy.js +42 -0
- package/dist/gatekeeper/trust/production.json +25 -0
- package/dist/setup-pings/client.js +31 -0
- package/dist/setup-pings/contract.js +2 -0
- package/dist/setup-pings/policy.js +3 -0
- package/dist/setup-pings/state.js +261 -0
- package/dist/setup-pings/worker.js +95 -0
- package/dist/transport/base64url.js +9 -0
- package/dist/transport/http-response.js +89 -0
- package/dist/transport/tls-exporter.js +5 -0
- package/package.json +57 -11
- package/LICENSE +0 -1
- package/dist/api-types.js +0 -2
- package/dist/api.js +0 -97
- package/dist/commands/check.js +0 -104
- package/dist/commands/login.js +0 -9
- package/dist/files.js +0 -99
- package/dist/gitignore.js +0 -70
- package/dist/output.js +0 -23
- package/dist/secret-files.js +0 -5
- /package/dist/{browser.js → cli/browser.js} +0 -0
- /package/dist/{commands → cli/commands}/balance.js +0 -0
- /package/dist/{commands → cli/commands}/finding.js +0 -0
- /package/dist/{commands → cli/commands}/logout.js +0 -0
- /package/dist/{credentials.js → cli/credentials.js} +0 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { cliRefusals, refusalExitCode } from "../contract/cli-refusals.js";
|
|
2
|
+
import { CLI_VERSION } from "../contract/cli-version.js";
|
|
3
|
+
import { consoleSchemeAllowed } from "../contract/console-url.js";
|
|
4
|
+
import { cliApiMessages } from "../contract/cli-api-messages.js";
|
|
5
|
+
import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
|
|
6
|
+
import { CLI_BODY_LIMIT } from "../cli/api-types.js";
|
|
7
|
+
export { DEFAULT_CONSOLE_URL as DEFAULT_URL } from "../setup-pings/client.js";
|
|
8
|
+
export class ApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
retryAfterMs;
|
|
12
|
+
constructor(status, code, message, retryAfterMs = 0) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.retryAfterMs = retryAfterMs;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const emailRoutes = {
|
|
20
|
+
"email/start": { fields: ["email"], successStatus: 202, failures: { invalid_email: 400 }, jsonCooldown: true },
|
|
21
|
+
"email/verify": { fields: ["email", "code", "clientName"], successStatus: 200,
|
|
22
|
+
failures: { wrong_code: 400, expired: 400, too_many_attempts: 429 }, jsonCooldown: false },
|
|
23
|
+
};
|
|
24
|
+
// Longer cooldowns keep the server's delay but use the sentence without a duration.
|
|
25
|
+
const MAX_DISPLAYED_EMAIL_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
function retryAfterMs(value, rejectNegativeSeconds = false) {
|
|
27
|
+
if (!value)
|
|
28
|
+
return undefined;
|
|
29
|
+
const seconds = Number(value);
|
|
30
|
+
// Date parsing also accepts negative number strings, which are not valid email cooldowns.
|
|
31
|
+
if (rejectNegativeSeconds && seconds < 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
34
|
+
return seconds * 1000;
|
|
35
|
+
const date = Date.parse(value);
|
|
36
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
37
|
+
}
|
|
38
|
+
function requestBody(path, body, emailRoute) {
|
|
39
|
+
if ((path === "balance" || path === "checks/mode" || /^findings\/[a-zA-Z0-9_-]+$/.test(path)) && body === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
42
|
+
const fields = Object.keys(body);
|
|
43
|
+
if (path === "device" && fields.length === 0)
|
|
44
|
+
return {};
|
|
45
|
+
if (path === "device/token" && fields.length === 1 && fields[0] === "deviceCode") {
|
|
46
|
+
const deviceCode = body.deviceCode;
|
|
47
|
+
if (typeof deviceCode === "string")
|
|
48
|
+
return { deviceCode };
|
|
49
|
+
}
|
|
50
|
+
if (emailRoute && fields.length === emailRoute.fields.length && fields.every(field => emailRoute.fields.includes(field))) {
|
|
51
|
+
const selected = {};
|
|
52
|
+
for (const field of emailRoute.fields) {
|
|
53
|
+
const value = body[field];
|
|
54
|
+
if (typeof value !== "string")
|
|
55
|
+
throw new Error(cliApiMessages.request_failed);
|
|
56
|
+
selected[field] = value;
|
|
57
|
+
}
|
|
58
|
+
return selected;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new Error(cliApiMessages.request_failed);
|
|
62
|
+
}
|
|
63
|
+
function emailFailure(route, response, value) {
|
|
64
|
+
if (response.status === 404)
|
|
65
|
+
return new ApiError(404, "email_unavailable", emailSignInMessages.email_unavailable);
|
|
66
|
+
const failure = value && typeof value === "object" && !Array.isArray(value)
|
|
67
|
+
? value : undefined;
|
|
68
|
+
const code = failure?.error;
|
|
69
|
+
if (typeof code === "string" && Object.hasOwn(route.failures, code) && route.failures[code] === response.status) {
|
|
70
|
+
return new ApiError(response.status, code, emailSignInMessages[code]);
|
|
71
|
+
}
|
|
72
|
+
if (response.status === 429 && code === "rate_limited") {
|
|
73
|
+
let cooldown = retryAfterMs(response.headers.get("retry-after"), true);
|
|
74
|
+
if (route.jsonCooldown && typeof failure?.retryAfter === "number" && failure.retryAfter >= 0 && Number.isFinite(failure.retryAfter * 1000)) {
|
|
75
|
+
cooldown = failure.retryAfter * 1000;
|
|
76
|
+
}
|
|
77
|
+
if (cooldown !== undefined && !Number.isFinite(cooldown))
|
|
78
|
+
cooldown = undefined;
|
|
79
|
+
return new ApiError(429, code, cooldown === undefined || cooldown > MAX_DISPLAYED_EMAIL_COOLDOWN_MS ? emailSignInMessages.rate_limited
|
|
80
|
+
: emailSignInMessages.rate_limited_with_cooldown(Math.ceil(cooldown / 1000)), cooldown ?? 0);
|
|
81
|
+
}
|
|
82
|
+
return new ApiError(response.status, "request_failed", emailSignInMessages.request_failed);
|
|
83
|
+
}
|
|
84
|
+
export class ApiClient {
|
|
85
|
+
token;
|
|
86
|
+
fetcher;
|
|
87
|
+
signal;
|
|
88
|
+
baseUrl;
|
|
89
|
+
constructor(baseUrl, token, fetcher = fetch, signal) {
|
|
90
|
+
this.token = token;
|
|
91
|
+
this.fetcher = fetcher;
|
|
92
|
+
this.signal = signal;
|
|
93
|
+
const url = new URL(baseUrl);
|
|
94
|
+
if (!consoleSchemeAllowed(url) || url.username || url.password || url.search || url.hash)
|
|
95
|
+
throw new Error("COUNTERFACT_URL must be an HTTP or HTTPS address without credentials or query fields.");
|
|
96
|
+
this.baseUrl = url.href.replace(/\/+$/, "");
|
|
97
|
+
}
|
|
98
|
+
async request(path, body, authenticated = true) {
|
|
99
|
+
const emailRoute = Object.hasOwn(emailRoutes, path) ? emailRoutes[path] : undefined;
|
|
100
|
+
const throwIfAborted = () => {
|
|
101
|
+
if (emailRoute && this.signal?.aborted)
|
|
102
|
+
throw new DOMException(emailSignInMessages.cancelled, "AbortError");
|
|
103
|
+
this.signal?.throwIfAborted();
|
|
104
|
+
};
|
|
105
|
+
throwIfAborted();
|
|
106
|
+
const safeBody = requestBody(path, body, emailRoute);
|
|
107
|
+
const headers = { accept: "application/json", "x-counterfact-cli-version": CLI_VERSION };
|
|
108
|
+
if (authenticated) {
|
|
109
|
+
const token = await this.token();
|
|
110
|
+
if (!token)
|
|
111
|
+
throw new Error("Not signed in. Run counterfact-labs login.");
|
|
112
|
+
headers.authorization = `Bearer ${token}`;
|
|
113
|
+
}
|
|
114
|
+
if (body !== undefined)
|
|
115
|
+
headers["content-type"] = "application/json";
|
|
116
|
+
const serialized = body === undefined ? undefined : JSON.stringify(safeBody);
|
|
117
|
+
if (serialized !== undefined && Buffer.byteLength(serialized, "utf8") > CLI_BODY_LIMIT) {
|
|
118
|
+
throw new ApiError(413, "body_too_large", cliRefusals.body_too_large);
|
|
119
|
+
}
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const cancel = () => controller.abort(this.signal?.reason);
|
|
122
|
+
const timeout = setTimeout(() => controller.abort(), 120_000);
|
|
123
|
+
timeout.unref();
|
|
124
|
+
this.signal?.addEventListener("abort", cancel, { once: true });
|
|
125
|
+
try {
|
|
126
|
+
throwIfAborted();
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await this.fetcher(`${this.baseUrl}/api/cli/${path}`, {
|
|
130
|
+
method: body === undefined ? "GET" : "POST", headers,
|
|
131
|
+
body: serialized,
|
|
132
|
+
redirect: "error", signal: controller.signal,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throwIfAborted();
|
|
137
|
+
throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
|
|
138
|
+
}
|
|
139
|
+
throwIfAborted();
|
|
140
|
+
if (emailRoute && response.ok && response.status !== emailRoute.successStatus) {
|
|
141
|
+
// Cleanup failures must not replace the safe invalid-response sentence.
|
|
142
|
+
await response.body?.cancel().catch(() => undefined);
|
|
143
|
+
throw new Error(emailSignInMessages.invalid_response);
|
|
144
|
+
}
|
|
145
|
+
let value;
|
|
146
|
+
try {
|
|
147
|
+
value = await response.json();
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throwIfAborted();
|
|
151
|
+
if (response.ok) {
|
|
152
|
+
if (error instanceof SyntaxError)
|
|
153
|
+
throw new Error(cliApiMessages.invalid_response);
|
|
154
|
+
throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throwIfAborted();
|
|
158
|
+
if (emailRoute && !response.ok)
|
|
159
|
+
throw emailFailure(emailRoute, response, value);
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const error = value;
|
|
162
|
+
throw new ApiError(response.status, error?.code ?? "request_failed", error?.code && Object.hasOwn(cliRefusals, error.code) ? cliRefusals[error.code]
|
|
163
|
+
: typeof error?.error === "string" ? error.error : cliApiMessages.request_failed, retryAfterMs(response.headers.get("retry-after")) ?? 0);
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
this.signal?.removeEventListener("abort", cancel);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function errorCode(error) { return refusalExitCode(error instanceof ApiError ? error.code : undefined); }
|
|
174
|
+
export function errorText(error) { return error instanceof Error ? error.message : cliApiMessages.request_failed; }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { relayAnswer, relayRequest } from "../contract/messages.js";
|
|
3
|
+
import { verifyAttestation } from "./attestation.js";
|
|
4
|
+
const BUSY_INITIAL_BACKOFF_MS = 250;
|
|
5
|
+
const BUSY_MAX_BACKOFF_MS = 5_000;
|
|
6
|
+
export class GatekeeperClientError extends Error {
|
|
7
|
+
name = "GatekeeperClientError";
|
|
8
|
+
}
|
|
9
|
+
export class GatekeeperClient {
|
|
10
|
+
options;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
async call(request, sendOptions = {}) {
|
|
15
|
+
const beforeSend = () => {
|
|
16
|
+
if (sendOptions.signal?.aborted || this.options.deadlineMs && this.options.deadlineMs() <= Date.now())
|
|
17
|
+
throw new GatekeeperClientError("Relay request cancelled");
|
|
18
|
+
sendOptions.beforeSend?.();
|
|
19
|
+
};
|
|
20
|
+
beforeSend();
|
|
21
|
+
const outgoing = relayRequest.safeParse({ ...request, nonce: randomBytes(32).toString("hex") });
|
|
22
|
+
if (!outgoing.success)
|
|
23
|
+
throw new GatekeeperClientError("Invalid relay request");
|
|
24
|
+
const options = this.options;
|
|
25
|
+
if (options.mode === "development") {
|
|
26
|
+
let token;
|
|
27
|
+
try {
|
|
28
|
+
token = await options.connection.attestation();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new GatekeeperClientError("Unable to read relay attestation");
|
|
32
|
+
}
|
|
33
|
+
verifyAttestation(token, options.connection.channelPublicKey, options.policy);
|
|
34
|
+
}
|
|
35
|
+
let backoffMs = BUSY_INITIAL_BACKOFF_MS;
|
|
36
|
+
while (true) {
|
|
37
|
+
let incoming;
|
|
38
|
+
try {
|
|
39
|
+
beforeSend();
|
|
40
|
+
incoming = await options.connection.send(outgoing.data, { ...sendOptions, beforeSend });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Transport errors can include request bodies, so do not retain their text or cause.
|
|
44
|
+
throw new GatekeeperClientError("Unable to send relay request");
|
|
45
|
+
}
|
|
46
|
+
const answer = relayAnswer.safeParse(incoming);
|
|
47
|
+
if (!answer.success)
|
|
48
|
+
throw new GatekeeperClientError("Invalid relay answer");
|
|
49
|
+
if (answer.data.nonce !== outgoing.data.nonce)
|
|
50
|
+
throw new GatekeeperClientError("Relay answer nonce mismatch");
|
|
51
|
+
if (answer.data.ok || answer.data.code !== "busy" || !options.deadlineMs)
|
|
52
|
+
return answer.data;
|
|
53
|
+
const remainingMs = options.deadlineMs() - Date.now();
|
|
54
|
+
if (remainingMs <= 0)
|
|
55
|
+
return answer.data;
|
|
56
|
+
// Busy has not started provider work. Keep its call identity and retry only within this run's lease.
|
|
57
|
+
await this.waitForCapacity(Math.min(backoffMs, remainingMs), sendOptions.signal);
|
|
58
|
+
if (options.deadlineMs() <= Date.now())
|
|
59
|
+
return answer.data;
|
|
60
|
+
backoffMs = Math.min(backoffMs * 2, BUSY_MAX_BACKOFF_MS);
|
|
61
|
+
outgoing.data = { ...outgoing.data, nonce: randomBytes(32).toString("hex") };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async waitForCapacity(waitMs, signal) {
|
|
65
|
+
if (signal?.aborted)
|
|
66
|
+
throw new GatekeeperClientError("Relay request cancelled");
|
|
67
|
+
await new Promise((resolve, reject) => {
|
|
68
|
+
const abort = () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
reject(new GatekeeperClientError("Relay request cancelled"));
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, waitMs);
|
|
73
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { RELAY_MAX_FRAME_BYTES, deliveryReceipt, relayAnswer, relayAnswerFrame, relayRequest } from "../contract/messages.js";
|
|
2
|
+
export class RelayConnectionError extends Error {
|
|
3
|
+
name = "RelayConnectionError";
|
|
4
|
+
constructor() { super("Relay connection failed"); }
|
|
5
|
+
}
|
|
6
|
+
/** Calls and delivery retries share the authenticated transport and run in input order. */
|
|
7
|
+
export function createRelayExchange(options) {
|
|
8
|
+
let queue = Promise.resolve();
|
|
9
|
+
async function sendOne(input) {
|
|
10
|
+
try {
|
|
11
|
+
options.valid();
|
|
12
|
+
const request = relayRequest.parse(input);
|
|
13
|
+
if (!request.callId)
|
|
14
|
+
throw new Error();
|
|
15
|
+
// Provider work can exceed a network-body timeout; evidence expiry still closes this exact socket.
|
|
16
|
+
const response = await options.exchange("/relay", request, RELAY_MAX_FRAME_BYTES, options.remainingEvidenceMs());
|
|
17
|
+
const legacy = options.development ? relayAnswer.safeParse(response.body) : undefined;
|
|
18
|
+
const frame = legacy?.success ? { answer: legacy.data, delivery: undefined } : relayAnswerFrame.parse(response.body);
|
|
19
|
+
if (frame.answer.nonce !== request.nonce)
|
|
20
|
+
throw new Error();
|
|
21
|
+
if (frame.delivery) {
|
|
22
|
+
const { confirmation } = frame.delivery;
|
|
23
|
+
if (confirmation.callId !== request.callId)
|
|
24
|
+
throw new Error();
|
|
25
|
+
// The relay enforces its deadline on its own clock; local clock drift must not discard a paid answer.
|
|
26
|
+
let acknowledged = false;
|
|
27
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
28
|
+
options.valid();
|
|
29
|
+
const receipt = await options.exchange("/delivery", confirmation, 1024);
|
|
30
|
+
if (receipt.status === 200) {
|
|
31
|
+
deliveryReceipt.parse(receipt.body);
|
|
32
|
+
acknowledged = true;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!acknowledged)
|
|
37
|
+
throw new Error();
|
|
38
|
+
}
|
|
39
|
+
return frame.answer;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
options.close();
|
|
43
|
+
throw new RelayConnectionError();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return (request, sendOptions = {}) => {
|
|
47
|
+
// Snapshot before queuing so overlapping callers cannot alter later writes.
|
|
48
|
+
const parsed = relayRequest.safeParse(request);
|
|
49
|
+
if (!parsed.success)
|
|
50
|
+
return Promise.reject(new RelayConnectionError());
|
|
51
|
+
const pending = queue.then(() => {
|
|
52
|
+
// Cancellation stops queued work, but a dispatched answer must finish delivery before closing.
|
|
53
|
+
if (sendOptions.signal?.aborted)
|
|
54
|
+
throw new RelayConnectionError();
|
|
55
|
+
sendOptions.beforeSend?.();
|
|
56
|
+
return sendOne(parsed.data);
|
|
57
|
+
});
|
|
58
|
+
queue = pending.catch(() => { });
|
|
59
|
+
return pending;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { isLoopbackHost } from "./loopback.js";
|
|
2
|
+
import { randomBytes, X509Certificate } from "node:crypto";
|
|
3
|
+
import { Agent, request as httpsRequest } from "node:https";
|
|
4
|
+
import { connect } from "node:tls";
|
|
5
|
+
import { PRODUCTION_EVIDENCE_VERSION, RELAY_MAX_FRAME_BYTES, RELAY_URL } from "../contract/messages.js";
|
|
6
|
+
import { requestBoundedResponse } from "../transport/http-response.js";
|
|
7
|
+
import { relayExportedKeyMaterial } from "../transport/tls-exporter.js";
|
|
8
|
+
import { createRelayExchange, RelayConnectionError } from "./connection-exchange.js";
|
|
9
|
+
export { RelayConnectionError } from "./connection-exchange.js";
|
|
10
|
+
import { verifyProductionAttestation } from "./production-attestation.js";
|
|
11
|
+
import { verifyAttestation } from "./attestation.js";
|
|
12
|
+
import { validateProductionTrust } from "./trust/policy.js";
|
|
13
|
+
export function relayOrigin(value) {
|
|
14
|
+
const origin = new URL(value);
|
|
15
|
+
if (origin.protocol !== "https:" || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash)
|
|
16
|
+
throw new Error();
|
|
17
|
+
return origin;
|
|
18
|
+
}
|
|
19
|
+
/** A self-signed leaf establishes encryption provisionally; the selected attestation policy authenticates the workload. */
|
|
20
|
+
export async function connectRelay(url = RELAY_URL, policy) {
|
|
21
|
+
let socket, agent;
|
|
22
|
+
let closed = false, expiryTimer;
|
|
23
|
+
const close = () => {
|
|
24
|
+
closed = true;
|
|
25
|
+
clearTimeout(expiryTimer);
|
|
26
|
+
agent?.destroy();
|
|
27
|
+
socket?.destroy();
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
const trust = policy.development ? undefined : validateProductionTrust(policy.trust);
|
|
31
|
+
const origin = relayOrigin(url);
|
|
32
|
+
const timeoutMs = policy.timeoutMs ?? 30_000;
|
|
33
|
+
const nowMs = policy.now ? () => policy.now() * 1000 : Date.now;
|
|
34
|
+
const now = () => Math.floor(nowMs() / 1000);
|
|
35
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)
|
|
36
|
+
throw new Error();
|
|
37
|
+
let hostname = origin.hostname.replace(/^\[|\]$/g, "");
|
|
38
|
+
if (policy.development) {
|
|
39
|
+
if (!policy.development.allowDevelopment || policy.trust ||
|
|
40
|
+
!isLoopbackHost(hostname))
|
|
41
|
+
throw new Error();
|
|
42
|
+
// Resolve the conventional name locally so DNS cannot move development trust off the machine.
|
|
43
|
+
if (hostname === "localhost")
|
|
44
|
+
hostname = "127.0.0.1";
|
|
45
|
+
}
|
|
46
|
+
socket = await new Promise((resolve, reject) => {
|
|
47
|
+
// Attestation authenticates this exact TLS socket before any customer request is sent.
|
|
48
|
+
// nosemgrep: problem-based-packs.insecure-transport.js-node.bypass-tls-verification.bypass-tls-verification
|
|
49
|
+
const provisional = connect({ host: hostname, port: Number(origin.port || 443),
|
|
50
|
+
rejectUnauthorized: false, minVersion: "TLSv1.3", ALPNProtocols: ["http/1.1"] });
|
|
51
|
+
// Install ownership before connection establishment so even a handshake timeout destroys the socket.
|
|
52
|
+
socket = provisional;
|
|
53
|
+
const timer = setTimeout(() => { provisional.destroy(); reject(new RelayConnectionError()); }, timeoutMs);
|
|
54
|
+
provisional.once("error", () => { clearTimeout(timer); reject(new RelayConnectionError()); });
|
|
55
|
+
provisional.once("close", () => { clearTimeout(timer); reject(new RelayConnectionError()); });
|
|
56
|
+
provisional.once("secureConnect", () => { clearTimeout(timer); resolve(provisional); });
|
|
57
|
+
});
|
|
58
|
+
socket.on("error", close);
|
|
59
|
+
socket.once("close", close);
|
|
60
|
+
socket.disableRenegotiation();
|
|
61
|
+
// A new handshake must never replace exporter material on an authenticated socket.
|
|
62
|
+
socket.on("secure", close);
|
|
63
|
+
if (closed || socket.destroyed || (socket.alpnProtocol && socket.alpnProtocol !== "http/1.1"))
|
|
64
|
+
throw new Error();
|
|
65
|
+
const actualSocket = socket;
|
|
66
|
+
const exportedKeyMaterial = relayExportedKeyMaterial(actualSocket);
|
|
67
|
+
// The agent may hand out this socket once. It has no path that dials a replacement connection.
|
|
68
|
+
agent = new Agent({ keepAlive: true, maxSockets: 1, maxCachedSessions: 0 });
|
|
69
|
+
let assigned = false;
|
|
70
|
+
agent.createConnection = () => {
|
|
71
|
+
if (assigned || closed || actualSocket.destroyed)
|
|
72
|
+
throw new RelayConnectionError();
|
|
73
|
+
assigned = true;
|
|
74
|
+
return actualSocket;
|
|
75
|
+
};
|
|
76
|
+
let expires = 0;
|
|
77
|
+
const valid = () => {
|
|
78
|
+
if (closed || actualSocket.destroyed || expires <= now())
|
|
79
|
+
throw new RelayConnectionError();
|
|
80
|
+
};
|
|
81
|
+
async function exchange(path, input, maxBytes, authenticated, waitMs = timeoutMs) {
|
|
82
|
+
if (authenticated)
|
|
83
|
+
valid();
|
|
84
|
+
if (closed || actualSocket.destroyed)
|
|
85
|
+
throw new RelayConnectionError();
|
|
86
|
+
const body = JSON.stringify(input);
|
|
87
|
+
if (Buffer.byteLength(body) > RELAY_MAX_FRAME_BYTES)
|
|
88
|
+
throw new RelayConnectionError();
|
|
89
|
+
const developmentAttestation = !!policy.development && path === "/attestation";
|
|
90
|
+
try {
|
|
91
|
+
const result = await requestBoundedResponse(receive => {
|
|
92
|
+
const req = httpsRequest({ hostname, port: origin.port || 443, method: developmentAttestation ? "GET" : "POST", path, agent,
|
|
93
|
+
maxHeaderSize: 16 * 1024,
|
|
94
|
+
headers: { "content-type": "application/json", "content-length": developmentAttestation ? 0 : Buffer.byteLength(body), "cache-control": "no-store" } }, receive);
|
|
95
|
+
req.on("socket", supplied => { if (supplied !== actualSocket)
|
|
96
|
+
req.destroy(new RelayConnectionError()); });
|
|
97
|
+
return req;
|
|
98
|
+
}, { maxBytes, timeoutMs: waitMs, bodyTimeoutMs: timeoutMs, requestBody: developmentAttestation ? "" : body, validate: response => {
|
|
99
|
+
const status = response.statusCode ?? 0;
|
|
100
|
+
if (response.socket !== actualSocket || (status !== 200 && !(path === "/delivery" && status === 503)) ||
|
|
101
|
+
!(developmentAttestation ? /^application\/jwt(?:;|$)/i : /^application\/json(?:;|$)/i).test(response.headers["content-type"] ?? ""))
|
|
102
|
+
throw new Error();
|
|
103
|
+
} });
|
|
104
|
+
if (authenticated)
|
|
105
|
+
valid();
|
|
106
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(result.body);
|
|
107
|
+
const answer = developmentAttestation ? text : JSON.parse(text);
|
|
108
|
+
return { status: result.response.statusCode ?? 0, body: answer };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
close();
|
|
112
|
+
throw new RelayConnectionError();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const challenge = randomBytes(32).toString("hex");
|
|
116
|
+
const challengeStarted = nowMs();
|
|
117
|
+
const challengeStartedMonotonic = performance.now();
|
|
118
|
+
const result = await exchange("/attestation", { version: PRODUCTION_EVIDENCE_VERSION, challenge }, 40 * 1024, false);
|
|
119
|
+
const verified = policy.development
|
|
120
|
+
? verifyAttestation(result.body, new X509Certificate(actualSocket.getPeerCertificate().raw).publicKey, policy.development, now())
|
|
121
|
+
: await verifyProductionAttestation(result.body, { exportedKeyMaterial, challenge }, trust, now());
|
|
122
|
+
if (nowMs() < challengeStarted || nowMs() - challengeStarted >= timeoutMs ||
|
|
123
|
+
performance.now() - challengeStartedMonotonic >= timeoutMs)
|
|
124
|
+
throw new Error();
|
|
125
|
+
expires = verified.exp;
|
|
126
|
+
valid();
|
|
127
|
+
expiryTimer = setTimeout(close, Math.max(1, (expires - now()) * 1000));
|
|
128
|
+
expiryTimer.unref();
|
|
129
|
+
return {
|
|
130
|
+
get attestedImageDigest() { return verified.imageDigest; },
|
|
131
|
+
send: createRelayExchange({ development: !!policy.development, valid, close, remainingEvidenceMs: () => Math.max(1, (expires - now()) * 1000),
|
|
132
|
+
exchange: (path, input, maxBytes, waitMs) => exchange(path, input, maxBytes, true, waitMs) }),
|
|
133
|
+
close,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
close();
|
|
138
|
+
throw new RelayConnectionError();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { consoleSchemeAllowed } from "../contract/console-url.js";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { CLI_VERSION } from "../contract/cli-version.js";
|
|
5
|
+
import { cliRefusals, FINISH_FAILURE_MESSAGE, RESERVATION_EXPIRED_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
|
|
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
|
+
const RESPONSE_LIMIT = 64 * 1024;
|
|
8
|
+
const RESERVATION_BODY_LIMIT = 64 * 1024;
|
|
9
|
+
const messages = {
|
|
10
|
+
...cliRefusals,
|
|
11
|
+
invalid_configuration: START_FAILURE_MESSAGE,
|
|
12
|
+
invalid_request: FINISH_FAILURE_MESSAGE,
|
|
13
|
+
unavailable: "The results couldn't be saved yet. Run the analysis on the same folder again to save them; you won't be charged again.",
|
|
14
|
+
unauthorized: "Your sign-in has expired. Run counterfact-labs login, then try again.",
|
|
15
|
+
not_allowed: "Your account can't run analyses. Ask Counterfact for access.",
|
|
16
|
+
not_found: "Counterfact couldn't find this analysis. Start a new analysis.",
|
|
17
|
+
rate_limited: "Counterfact is busy. Try again in a minute.",
|
|
18
|
+
reservation_closed: "The analysis ended before its results were saved. Start a new analysis.",
|
|
19
|
+
reservation_closing: "The results are still being saved. Run the analysis on the same folder again in a minute.",
|
|
20
|
+
delivery_incomplete: "The analysis ended before its results were saved. Start a new analysis.",
|
|
21
|
+
check_conflict: "Saved results from an earlier run don't match this analysis. Start a new analysis.",
|
|
22
|
+
};
|
|
23
|
+
export class ConsoleError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code = "unavailable", context) {
|
|
26
|
+
super(context === "start" && (code === "unavailable" || code === "invalid_request" || code === "not_found") ? START_FAILURE_MESSAGE
|
|
27
|
+
: context === "intake" && code === "reservation_expired" ? RESERVATION_EXPIRED_MESSAGE
|
|
28
|
+
// Only saving results can be too large; a rejected status, heartbeat or fail request just could not finish.
|
|
29
|
+
: context === "intake" && code === "invalid_request" ? RESULTS_TOO_LARGE_MESSAGE : messages[code]);
|
|
30
|
+
this.name = "ConsoleError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export const isConsoleToken = (token) => /^[\x21-\x7e]{1,4096}$/.test(token);
|
|
35
|
+
/** Only this client may send repository metadata, content-free lifecycle or findings-only intake payloads to the console. */
|
|
36
|
+
export class ConsoleClient {
|
|
37
|
+
#url;
|
|
38
|
+
#token;
|
|
39
|
+
#timeoutMs;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(options.url);
|
|
43
|
+
const timeout = options.timeoutMs ?? 15_000;
|
|
44
|
+
if (!consoleSchemeAllowed(url)
|
|
45
|
+
|| url.username || url.password || url.pathname !== "/" || url.search || url.hash
|
|
46
|
+
|| !isConsoleToken(options.token)
|
|
47
|
+
|| !Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000)
|
|
48
|
+
throw new ConsoleError("invalid_configuration");
|
|
49
|
+
this.#url = url;
|
|
50
|
+
this.#token = options.token;
|
|
51
|
+
this.#timeoutMs = timeout;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new ConsoleError("invalid_configuration");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async start(input, metadata) {
|
|
58
|
+
let headers;
|
|
59
|
+
try {
|
|
60
|
+
headers = metadata === undefined ? {} : serializeLocalStartMetadata(metadata);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new ConsoleError("invalid_request", "start");
|
|
64
|
+
}
|
|
65
|
+
return this.#send("/api/cli/checks", input, localStartRequestSchema, localStartResponseSchema, LOCAL_START_BODY_LIMIT, { statuses: [201], headers: { ...headers, [LOCAL_START_EXECUTION_HEADER.name]: LOCAL_START_EXECUTION_HEADER.value }, context: "start" });
|
|
66
|
+
}
|
|
67
|
+
create(input) {
|
|
68
|
+
return this.#lifecycle({ ...input, action: "create" }, consoleCreatedSchema);
|
|
69
|
+
}
|
|
70
|
+
reserve(input) {
|
|
71
|
+
return this.#lifecycle({ ...input, action: "reserve" }, consoleReservationSchema);
|
|
72
|
+
}
|
|
73
|
+
status(reservationId) {
|
|
74
|
+
return this.#lifecycle({ action: "status", reservationId }, consoleStatusSchema);
|
|
75
|
+
}
|
|
76
|
+
heartbeat(reservationId) {
|
|
77
|
+
return this.#lifecycle({ action: "heartbeat", reservationId }, consoleStatusSchema);
|
|
78
|
+
}
|
|
79
|
+
fail(reservationId) {
|
|
80
|
+
return this.#lifecycle({ action: "fail", reservationId }, consoleStatusSchema);
|
|
81
|
+
}
|
|
82
|
+
intake(input) {
|
|
83
|
+
return this.#send("/api/cli/checks/local", input, localFindingsPayloadSchema, consoleIntakeResultSchema, LOCAL_FINDINGS_BODY_LIMIT, { statuses: [200, 201], context: "intake" });
|
|
84
|
+
}
|
|
85
|
+
#lifecycle(input, response) {
|
|
86
|
+
// Starting work has no saved results to retry; recovery keeps its saving guidance.
|
|
87
|
+
const context = input.action === "create" || input.action === "reserve" ? "start" : undefined;
|
|
88
|
+
return this.#send("/api/cli/relay-reservations", input, cliRelayReservationRequestSchema, response, RESERVATION_BODY_LIMIT, { context });
|
|
89
|
+
}
|
|
90
|
+
async #send(path, input, schema, responseSchema, limit, options = {}) {
|
|
91
|
+
let body;
|
|
92
|
+
const identities = {};
|
|
93
|
+
try {
|
|
94
|
+
const parsed = schema.parse(input);
|
|
95
|
+
for (const field of ["jobId", "checkId", "projectId", "reservationId"]) {
|
|
96
|
+
const value = parsed[field];
|
|
97
|
+
if (typeof value === "string" && !(options.context === "intake" && field === "reservationId")) {
|
|
98
|
+
identities[field] = value.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
body = Buffer.from(JSON.stringify(parsed), "utf8");
|
|
102
|
+
if (body.length > limit)
|
|
103
|
+
throw new ConsoleError("invalid_request", options.context);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new ConsoleError("invalid_request", options.context);
|
|
107
|
+
}
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
let settled = false;
|
|
110
|
+
const finish = (error, result) => {
|
|
111
|
+
if (settled)
|
|
112
|
+
return;
|
|
113
|
+
settled = true;
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
if (error)
|
|
116
|
+
reject(error);
|
|
117
|
+
else
|
|
118
|
+
resolve(result);
|
|
119
|
+
};
|
|
120
|
+
const destination = new URL(path, this.#url);
|
|
121
|
+
const request = (destination.protocol === "https:" ? httpsRequest : httpRequest)(destination, {
|
|
122
|
+
method: "POST", agent: false, rejectUnauthorized: true, maxHeaderSize: 16 * 1024,
|
|
123
|
+
headers: { "x-counterfact-cli-version": CLI_VERSION, authorization: `Bearer ${this.#token}`, "content-type": "application/json", accept: "application/json",
|
|
124
|
+
"content-length": body.length, "accept-encoding": "identity",
|
|
125
|
+
...options.headers },
|
|
126
|
+
}, response => {
|
|
127
|
+
const status = response.statusCode ?? 0;
|
|
128
|
+
const declared = response.headers["content-length"];
|
|
129
|
+
const contentType = response.headers["content-type"] ?? "";
|
|
130
|
+
if (status >= 300 && status < 400 || !/^application\/json(?:\s*;|$)/i.test(contentType)
|
|
131
|
+
|| response.headers["content-encoding"] && response.headers["content-encoding"] !== "identity"
|
|
132
|
+
|| declared && (!/^\d+$/.test(declared) || Number(declared) > RESPONSE_LIMIT)) {
|
|
133
|
+
finish(new ConsoleError());
|
|
134
|
+
response.destroy();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let size = 0;
|
|
138
|
+
const chunks = [];
|
|
139
|
+
response.on("data", (chunk) => {
|
|
140
|
+
size += chunk.length;
|
|
141
|
+
if (size > RESPONSE_LIMIT) {
|
|
142
|
+
finish(new ConsoleError());
|
|
143
|
+
response.destroy();
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
chunks.push(chunk);
|
|
147
|
+
});
|
|
148
|
+
response.on("error", () => finish(new ConsoleError()));
|
|
149
|
+
response.on("aborted", () => finish(new ConsoleError()));
|
|
150
|
+
response.on("end", () => {
|
|
151
|
+
try {
|
|
152
|
+
const decoded = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
153
|
+
if (!(options.statuses ?? [200]).includes(status)) {
|
|
154
|
+
const code = decoded && typeof decoded === "object" && "code" in decoded ? decoded.code : undefined;
|
|
155
|
+
throw new ConsoleError(typeof code === "string" && Object.hasOwn(messages, code) ? code : "unavailable");
|
|
156
|
+
}
|
|
157
|
+
const result = responseSchema.parse(decoded);
|
|
158
|
+
for (const [field, expected] of Object.entries(identities)) {
|
|
159
|
+
if (result[field] !== expected)
|
|
160
|
+
throw new ConsoleError();
|
|
161
|
+
}
|
|
162
|
+
finish(undefined, result);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
finish(error instanceof ConsoleError ? error : new ConsoleError());
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
// Use one wall-clock deadline: an idle timeout alone lets a trickling response run forever.
|
|
170
|
+
const timer = setTimeout(() => { finish(new ConsoleError()); request.destroy(); }, this.#timeoutMs);
|
|
171
|
+
request.on("error", () => finish(new ConsoleError()));
|
|
172
|
+
request.end(body);
|
|
173
|
+
}).catch(error => { throw new ConsoleError(error instanceof ConsoleError ? error.code : "unavailable", options.context); });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Whether a URL hostname names this machine, so development trust and plain HTTP never leave it. */
|
|
2
|
+
export function isLoopbackHost(hostname) {
|
|
3
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
4
|
+
return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
5
|
+
}
|