sentinelayer-cli 0.3.0 → 0.4.5
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 +7 -5
- package/package.json +4 -3
- package/src/agents/jules/pulse.js +14 -6
- package/src/agents/jules/tools/auth-audit.js +410 -79
- package/src/agents/jules/tools/runtime-audit.js +36 -26
- package/src/agents/jules/tools/url-policy.js +100 -0
- package/src/auth/gate.js +45 -11
- package/src/auth/http.js +204 -47
- package/src/cli.js +1 -1
- package/src/legacy-cli.js +68 -24
- package/src/review/local-review.js +11 -0
- package/src/scaffold/templates.js +1 -1
- package/src/telemetry/sync.js +12 -3
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { assertPermittedAuditTarget } from "./url-policy.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Jules Tanaka — Runtime Audit Tool
|
|
@@ -54,14 +55,12 @@ async function lighthouseScan(input) {
|
|
|
54
55
|
if (!url) {
|
|
55
56
|
throw new RuntimeAuditError("lighthouse_scan requires a url parameter");
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
throw new RuntimeAuditError("Invalid URL: " + url);
|
|
59
|
-
}
|
|
58
|
+
const targetUrl = resolveRuntimeTargetUrl(url, input, "lighthouse_scan");
|
|
60
59
|
|
|
61
60
|
// Prefer SentinelLayer API scanner (authenticated, server-side Lighthouse).
|
|
62
61
|
// Falls back to local npx lighthouse if API unavailable.
|
|
63
62
|
try {
|
|
64
|
-
const apiResult = await callScannerApi(
|
|
63
|
+
const apiResult = await callScannerApi(targetUrl);
|
|
65
64
|
if (apiResult.available) return apiResult;
|
|
66
65
|
} catch { /* API unavailable — fall back to local */ }
|
|
67
66
|
|
|
@@ -76,7 +75,7 @@ async function lighthouseScan(input) {
|
|
|
76
75
|
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
77
76
|
|
|
78
77
|
execFileSync("npx", [
|
|
79
|
-
"--yes", "lighthouse@12",
|
|
78
|
+
"--yes", "lighthouse@12", targetUrl,
|
|
80
79
|
"--output", "json", "--output-path", outputPath,
|
|
81
80
|
"--chrome-flags=--headless --no-sandbox --disable-gpu", "--quiet",
|
|
82
81
|
], {
|
|
@@ -136,10 +135,10 @@ async function lighthouseScan(input) {
|
|
|
136
135
|
function checkResponseHeaders(input) {
|
|
137
136
|
const url = input.url;
|
|
138
137
|
if (!url) throw new RuntimeAuditError("check_response_headers requires a url");
|
|
139
|
-
|
|
138
|
+
const targetUrl = resolveRuntimeTargetUrl(url, input, "check_response_headers");
|
|
140
139
|
|
|
141
140
|
try {
|
|
142
|
-
const safeUrl = sanitizeUrlForShell(
|
|
141
|
+
const safeUrl = sanitizeUrlForShell(targetUrl);
|
|
143
142
|
if (!safeUrl) throw new Error("URL sanitization failed");
|
|
144
143
|
const output = execFileSync("curl", ["-sI", "-L", "--max-time", "10", safeUrl], {
|
|
145
144
|
encoding: "utf-8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -165,7 +164,7 @@ function checkResponseHeaders(input) {
|
|
|
165
164
|
|
|
166
165
|
return {
|
|
167
166
|
available: true,
|
|
168
|
-
url,
|
|
167
|
+
url: targetUrl,
|
|
169
168
|
statusCode: parseInt(output.match(/HTTP\/[\d.]+ (\d+)/)?.[1] || "0"),
|
|
170
169
|
headers,
|
|
171
170
|
securityFindings: findings,
|
|
@@ -224,7 +223,7 @@ function detectDeployedUrl(input) {
|
|
|
224
223
|
function checkConsoleErrors(input) {
|
|
225
224
|
const url = input.url;
|
|
226
225
|
if (!url) throw new RuntimeAuditError("check_console_errors requires a url");
|
|
227
|
-
|
|
226
|
+
const targetUrl = resolveRuntimeTargetUrl(url, input, "check_console_errors");
|
|
228
227
|
|
|
229
228
|
// Try playwright — URL passed via env var to prevent command injection
|
|
230
229
|
try {
|
|
@@ -250,7 +249,7 @@ function checkConsoleErrors(input) {
|
|
|
250
249
|
const output = execFileSync("node", [scriptPath], {
|
|
251
250
|
encoding: "utf-8", timeout: 45000,
|
|
252
251
|
stdio: ["pipe", "pipe", "pipe"],
|
|
253
|
-
env: { ...process.env, SL_AUDIT_TARGET_URL:
|
|
252
|
+
env: { ...process.env, SL_AUDIT_TARGET_URL: targetUrl },
|
|
254
253
|
});
|
|
255
254
|
try { fs.unlinkSync(scriptPath); } catch { /* best effort */ }
|
|
256
255
|
try { fs.rmdirSync(path.dirname(scriptPath)); } catch { /* best effort */ }
|
|
@@ -272,13 +271,13 @@ function checkConsoleErrors(input) {
|
|
|
272
271
|
function checkNetworkWaterfall(input) {
|
|
273
272
|
const url = input.url;
|
|
274
273
|
if (!url) throw new RuntimeAuditError("check_network_waterfall requires a url");
|
|
275
|
-
|
|
274
|
+
const targetUrl = resolveRuntimeTargetUrl(url, input, "check_network_waterfall");
|
|
276
275
|
|
|
277
276
|
try {
|
|
278
277
|
// Write curl format to temp file to avoid shell quoting issues across platforms
|
|
279
278
|
const formatFile = secureTempFile("sl-curl-fmt-" + randomUUID().slice(0, 8) + ".txt");
|
|
280
279
|
fs.writeFileSync(formatFile, '{"dns_ms":%{time_namelookup},"connect_ms":%{time_connect},"tls_ms":%{time_appconnect},"ttfb_ms":%{time_starttransfer},"total_ms":%{time_total},"size_bytes":%{size_download},"status":%{http_code}}');
|
|
281
|
-
const safeUrl = sanitizeUrlForShell(
|
|
280
|
+
const safeUrl = sanitizeUrlForShell(targetUrl);
|
|
282
281
|
if (!safeUrl) { try { fs.unlinkSync(formatFile); } catch {} throw new Error("URL sanitization failed"); }
|
|
283
282
|
const output = execFileSync("curl", [
|
|
284
283
|
"-sL", "-o", devNull(), "-w", "@" + formatFile, "--max-time", "15", safeUrl,
|
|
@@ -290,7 +289,7 @@ function checkNetworkWaterfall(input) {
|
|
|
290
289
|
for (const key of ["dns_ms", "connect_ms", "tls_ms", "ttfb_ms", "total_ms"]) {
|
|
291
290
|
timing[key] = Math.round(timing[key] * 1000);
|
|
292
291
|
}
|
|
293
|
-
return { available: true, url, timing };
|
|
292
|
+
return { available: true, url: targetUrl, timing };
|
|
294
293
|
} catch (err) {
|
|
295
294
|
return { available: false, reason: "curl timing failed: " + err.message };
|
|
296
295
|
}
|
|
@@ -302,7 +301,7 @@ function checkNetworkWaterfall(input) {
|
|
|
302
301
|
function checkDomStats(input) {
|
|
303
302
|
const url = input.url;
|
|
304
303
|
if (!url) throw new RuntimeAuditError("check_dom_stats requires a url");
|
|
305
|
-
|
|
304
|
+
const targetUrl = resolveRuntimeTargetUrl(url, input, "check_dom_stats");
|
|
306
305
|
|
|
307
306
|
// URL passed via env var to prevent command injection (CodeQL alert #51)
|
|
308
307
|
try {
|
|
@@ -336,7 +335,7 @@ function checkDomStats(input) {
|
|
|
336
335
|
const output = execFileSync("node", [scriptPath], {
|
|
337
336
|
encoding: "utf-8", timeout: 45000,
|
|
338
337
|
stdio: ["pipe", "pipe", "pipe"],
|
|
339
|
-
env: { ...process.env, SL_AUDIT_TARGET_URL:
|
|
338
|
+
env: { ...process.env, SL_AUDIT_TARGET_URL: targetUrl },
|
|
340
339
|
});
|
|
341
340
|
try { fs.unlinkSync(scriptPath); } catch { /* best effort */ }
|
|
342
341
|
try { fs.rmdirSync(path.dirname(scriptPath)); } catch { /* best effort */ }
|
|
@@ -348,12 +347,15 @@ function checkDomStats(input) {
|
|
|
348
347
|
|
|
349
348
|
// ── Helpers ──────────────────────────────────────────────��───────────
|
|
350
349
|
|
|
351
|
-
function
|
|
350
|
+
function resolveRuntimeTargetUrl(url, input, operation) {
|
|
352
351
|
try {
|
|
353
|
-
const parsed =
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
352
|
+
const parsed = assertPermittedAuditTarget(url, {
|
|
353
|
+
operation,
|
|
354
|
+
allowPrivateTargets: input.allowPrivateTargets === true,
|
|
355
|
+
});
|
|
356
|
+
return parsed.toString();
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw new RuntimeAuditError(error.message);
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
|
|
@@ -409,6 +411,16 @@ function sanitizeUrlForShell(url) {
|
|
|
409
411
|
}
|
|
410
412
|
}
|
|
411
413
|
|
|
414
|
+
async function fetchWithTimeout(url, options, timeoutMs) {
|
|
415
|
+
const controller = new AbortController();
|
|
416
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
417
|
+
try {
|
|
418
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
419
|
+
} finally {
|
|
420
|
+
clearTimeout(timeoutHandle);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
412
424
|
/**
|
|
413
425
|
* Call the SentinelLayer API scanner endpoint for server-side Lighthouse.
|
|
414
426
|
* Requires authenticated session (token from sl auth login).
|
|
@@ -429,15 +441,14 @@ async function callScannerApi(url) {
|
|
|
429
441
|
const scanEndpoint = apiUrl + "/api/v1/scan/url";
|
|
430
442
|
|
|
431
443
|
// Submit scan
|
|
432
|
-
const submitResponse = await
|
|
444
|
+
const submitResponse = await fetchWithTimeout(scanEndpoint, {
|
|
433
445
|
method: "POST",
|
|
434
446
|
headers: {
|
|
435
447
|
"Content-Type": "application/json",
|
|
436
448
|
"Authorization": "Bearer " + session.token,
|
|
437
449
|
},
|
|
438
450
|
body: JSON.stringify({ url, scan_type: "lighthouse" }),
|
|
439
|
-
|
|
440
|
-
});
|
|
451
|
+
}, 15000);
|
|
441
452
|
|
|
442
453
|
if (!submitResponse.ok) {
|
|
443
454
|
return { available: false, reason: "Scanner API returned " + submitResponse.status };
|
|
@@ -454,10 +465,9 @@ async function callScannerApi(url) {
|
|
|
454
465
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
455
466
|
await new Promise(r => setTimeout(r, 3000));
|
|
456
467
|
try {
|
|
457
|
-
const pollResponse = await
|
|
468
|
+
const pollResponse = await fetchWithTimeout(pollUrl, {
|
|
458
469
|
headers: { "Authorization": "Bearer " + session.token },
|
|
459
|
-
|
|
460
|
-
});
|
|
470
|
+
}, 10000);
|
|
461
471
|
if (!pollResponse.ok) continue;
|
|
462
472
|
const pollData = await pollResponse.json();
|
|
463
473
|
if (pollData.status === "completed" || pollData.status === "complete") {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const PRIVATE_HOST_SUFFIXES = [".internal", ".local", ".localhost"];
|
|
2
|
+
const BLOCKED_LITERAL_HOSTS = new Set([
|
|
3
|
+
"localhost",
|
|
4
|
+
"127.0.0.1",
|
|
5
|
+
"::1",
|
|
6
|
+
"0.0.0.0",
|
|
7
|
+
"169.254.169.254",
|
|
8
|
+
"metadata.google.internal",
|
|
9
|
+
"metadata.google.internal.",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function isNumericIpv4(hostname) {
|
|
13
|
+
const parts = String(hostname || "").split(".");
|
|
14
|
+
if (parts.length !== 4) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return parts.every((part) => /^[0-9]{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPrivateIpv4(hostname) {
|
|
21
|
+
if (!isNumericIpv4(hostname)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const parts = hostname.split(".").map((part) => Number(part));
|
|
25
|
+
const [a, b] = parts;
|
|
26
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
27
|
+
if (a === 169 && b === 254) return true;
|
|
28
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
29
|
+
if (a === 192 && b === 168) return true;
|
|
30
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
31
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPrivateIpv6(hostname) {
|
|
36
|
+
const normalized = String(hostname || "").toLowerCase().split("%")[0];
|
|
37
|
+
if (!normalized.includes(":")) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (normalized === "::1" || normalized === "::") {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb")) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPrivateHostname(hostname) {
|
|
53
|
+
const normalized = String(hostname || "").toLowerCase();
|
|
54
|
+
if (!normalized) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (BLOCKED_LITERAL_HOSTS.has(normalized)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (PRIVATE_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (isPrivateIpv4(normalized) || isPrivateIpv6(normalized)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isPrivateTargetBypassEnabled(allowPrivateTargets) {
|
|
70
|
+
if (allowPrivateTargets === true) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (process.env.SENTINELAYER_ALLOW_PRIVATE_AUDIT_TARGETS === "1") {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (process.env.NODE_ENV === "test") {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function assertPermittedAuditTarget(urlValue, options = {}) {
|
|
83
|
+
const { operation = "audit", allowPrivateTargets = false } = options;
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = new URL(urlValue);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error("Invalid URL: " + urlValue);
|
|
89
|
+
}
|
|
90
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
91
|
+
throw new Error("Invalid URL: " + parsed.toString());
|
|
92
|
+
}
|
|
93
|
+
if (!isPrivateTargetBypassEnabled(allowPrivateTargets) && isPrivateHostname(parsed.hostname)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Blocked private audit target for ${operation}: ${parsed.hostname}. ` +
|
|
96
|
+
"Set allowPrivateTargets=true or SENTINELAYER_ALLOW_PRIVATE_AUDIT_TARGETS=1 to override."
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
package/src/auth/gate.js
CHANGED
|
@@ -16,6 +16,7 @@ import { readStoredSession } from "./session-store.js";
|
|
|
16
16
|
|
|
17
17
|
const AUTH_BYPASS_COMMANDS = new Set([
|
|
18
18
|
"auth", // auth subcommands handle their own auth
|
|
19
|
+
"help", // help must work without login so agents can discover commands
|
|
19
20
|
"--help",
|
|
20
21
|
"-h",
|
|
21
22
|
"--version",
|
|
@@ -27,6 +28,46 @@ const NO_AUTH_REQUIRED = new Set([
|
|
|
27
28
|
"config", // local config inspection
|
|
28
29
|
]);
|
|
29
30
|
|
|
31
|
+
function hasTrustedBypassContext() {
|
|
32
|
+
const nonce = String(process.env.SENTINELAYER_CLI_TEST_BYPASS_NONCE || "").trim();
|
|
33
|
+
return (
|
|
34
|
+
process.env.NODE_ENV === "test" &&
|
|
35
|
+
process.env.SENTINELAYER_CLI_TEST_MODE === "1" &&
|
|
36
|
+
nonce.length >= 12
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isValidSessionToken(session) {
|
|
41
|
+
const token = String(session?.token || "");
|
|
42
|
+
if (!token || token !== token.trim()) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (/\s/.test(token)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
// Require printable ASCII only for bearer token material in local metadata.
|
|
49
|
+
if (/[^\x21-\x7E]/.test(token)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const tokenPrefix = String(session?.tokenPrefix || "").trim();
|
|
53
|
+
if (tokenPrefix && !token.startsWith(tokenPrefix)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isSessionUnexpired(tokenExpiresAt) {
|
|
60
|
+
const normalized = String(tokenExpiresAt || "").trim();
|
|
61
|
+
if (!normalized) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const expiresAt = new Date(normalized).getTime();
|
|
65
|
+
if (!Number.isFinite(expiresAt)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return expiresAt >= Date.now();
|
|
69
|
+
}
|
|
70
|
+
|
|
30
71
|
/**
|
|
31
72
|
* Check if the current command requires authentication.
|
|
32
73
|
* Returns true if auth is required but user is not logged in.
|
|
@@ -46,22 +87,15 @@ export async function checkAuthGate(args) {
|
|
|
46
87
|
return { authenticated: true, session: null, bypassReason: "no_auth_required" };
|
|
47
88
|
}
|
|
48
89
|
|
|
49
|
-
//
|
|
50
|
-
if (process.env.SENTINELAYER_CLI_SKIP_AUTH === "1"
|
|
51
|
-
return { authenticated: true, session: null, bypassReason: "
|
|
90
|
+
// Explicit bypass is gated to trusted test contexts only.
|
|
91
|
+
if (process.env.SENTINELAYER_CLI_SKIP_AUTH === "1" && hasTrustedBypassContext()) {
|
|
92
|
+
return { authenticated: true, session: null, bypassReason: "env_bypass_guarded" };
|
|
52
93
|
}
|
|
53
94
|
|
|
54
95
|
// Check for stored session
|
|
55
96
|
try {
|
|
56
97
|
const session = await readStoredSession();
|
|
57
|
-
if (session && session.
|
|
58
|
-
// Check if token is expired
|
|
59
|
-
if (session.tokenExpiresAt) {
|
|
60
|
-
const expiresAt = new Date(session.tokenExpiresAt).getTime();
|
|
61
|
-
if (expiresAt < Date.now()) {
|
|
62
|
-
return { authenticated: false, session: null, bypassReason: null };
|
|
63
|
-
}
|
|
64
|
-
}
|
|
98
|
+
if (session && isValidSessionToken(session) && isSessionUnexpired(session.tokenExpiresAt)) {
|
|
65
99
|
return { authenticated: true, session, bypassReason: null };
|
|
66
100
|
}
|
|
67
101
|
} catch {
|
package/src/auth/http.js
CHANGED
|
@@ -5,6 +5,18 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
5
5
|
* @type {number}
|
|
6
6
|
*/
|
|
7
7
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
|
|
8
|
+
export const DEFAULT_MAX_RETRIES = 2;
|
|
9
|
+
export const DEFAULT_RETRY_DELAY_MS = 250;
|
|
10
|
+
export const MAX_RETRY_DELAY_MS = 2_000;
|
|
11
|
+
export const CIRCUIT_BREAKER_THRESHOLD = 5;
|
|
12
|
+
export const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
15
|
+
const CIRCUIT_TRACK_STATUS_CODES = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
|
|
16
|
+
const circuitState = {
|
|
17
|
+
consecutiveFailures: 0,
|
|
18
|
+
openedAtMs: 0,
|
|
19
|
+
};
|
|
8
20
|
|
|
9
21
|
function normalizeApiError(errorPayload = {}) {
|
|
10
22
|
if (!errorPayload || typeof errorPayload !== "object" || Array.isArray(errorPayload)) {
|
|
@@ -35,6 +47,88 @@ export class SentinelayerApiError extends Error {
|
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
function normalizePositiveNumber(value, fallback) {
|
|
51
|
+
const parsed = Number(value);
|
|
52
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeNonNegativeInteger(value, fallback) {
|
|
59
|
+
const parsed = Number(value);
|
|
60
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
return Math.floor(parsed);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseRetryAfterMs(value) {
|
|
67
|
+
const raw = String(value || "").trim();
|
|
68
|
+
if (!raw) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const seconds = Number(raw);
|
|
72
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
73
|
+
return Math.round(seconds * 1000);
|
|
74
|
+
}
|
|
75
|
+
const parsedDate = Date.parse(raw);
|
|
76
|
+
if (Number.isFinite(parsedDate)) {
|
|
77
|
+
const delta = parsedDate - Date.now();
|
|
78
|
+
if (delta > 0) {
|
|
79
|
+
return delta;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function computeBackoffMs({ attempt, retryDelayMs, retryAfterHeader }) {
|
|
86
|
+
const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
|
|
87
|
+
if (retryAfterMs !== null) {
|
|
88
|
+
return Math.min(retryAfterMs, MAX_RETRY_DELAY_MS);
|
|
89
|
+
}
|
|
90
|
+
const exponent = Math.max(0, Number(attempt) - 1);
|
|
91
|
+
const computed = Math.round(retryDelayMs * Math.pow(2, exponent));
|
|
92
|
+
return Math.min(Math.max(1, computed), MAX_RETRY_DELAY_MS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isCircuitOpen() {
|
|
96
|
+
if (circuitState.openedAtMs <= 0) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (Date.now() - circuitState.openedAtMs >= CIRCUIT_BREAKER_COOLDOWN_MS) {
|
|
100
|
+
circuitState.openedAtMs = 0;
|
|
101
|
+
circuitState.consecutiveFailures = 0;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function recordFailureForCircuit() {
|
|
108
|
+
circuitState.consecutiveFailures += 1;
|
|
109
|
+
if (circuitState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
110
|
+
circuitState.openedAtMs = Date.now();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function recordSuccessForCircuit() {
|
|
115
|
+
circuitState.consecutiveFailures = 0;
|
|
116
|
+
circuitState.openedAtMs = 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function shouldRetryStatus(statusCode) {
|
|
120
|
+
return RETRYABLE_STATUS_CODES.has(Number(statusCode || 0));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function shouldRecordFailureForStatus(statusCode) {
|
|
124
|
+
return CIRCUIT_TRACK_STATUS_CODES.has(Number(statusCode || 0));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function __resetRequestCircuitForTests() {
|
|
128
|
+
circuitState.consecutiveFailures = 0;
|
|
129
|
+
circuitState.openedAtMs = 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
38
132
|
/**
|
|
39
133
|
* Execute an HTTP request against the Sentinelayer API and parse a JSON response.
|
|
40
134
|
* Throws `SentinelayerApiError` for transport errors, timeouts, API failures, and invalid JSON.
|
|
@@ -45,69 +139,132 @@ export class SentinelayerApiError extends Error {
|
|
|
45
139
|
* headers?: Record<string, string>,
|
|
46
140
|
* body?: unknown,
|
|
47
141
|
* timeoutMs?: number
|
|
142
|
+
* maxRetries?: number,
|
|
143
|
+
* retryDelayMs?: number
|
|
48
144
|
* }} [options]
|
|
49
145
|
* @returns {Promise<any>}
|
|
50
146
|
*/
|
|
51
147
|
export async function requestJson(
|
|
52
148
|
url,
|
|
53
|
-
{
|
|
149
|
+
{
|
|
150
|
+
method = "GET",
|
|
151
|
+
headers = {},
|
|
152
|
+
body,
|
|
153
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
154
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
155
|
+
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
|
156
|
+
} = {}
|
|
54
157
|
) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const response = await fetch(String(url), {
|
|
60
|
-
method,
|
|
61
|
-
headers: {
|
|
62
|
-
"Content-Type": "application/json",
|
|
63
|
-
...headers,
|
|
64
|
-
},
|
|
65
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
66
|
-
signal: controller.signal,
|
|
158
|
+
if (isCircuitOpen()) {
|
|
159
|
+
throw new SentinelayerApiError("Request circuit breaker is open after consecutive API failures.", {
|
|
160
|
+
status: 503,
|
|
161
|
+
code: "CIRCUIT_OPEN",
|
|
67
162
|
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const normalizedTimeoutMs = normalizePositiveNumber(timeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
|
|
166
|
+
const normalizedMaxRetries = normalizeNonNegativeInteger(maxRetries, DEFAULT_MAX_RETRIES);
|
|
167
|
+
const normalizedRetryDelayMs = normalizePositiveNumber(retryDelayMs, DEFAULT_RETRY_DELAY_MS);
|
|
68
168
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
169
|
+
let lastRetryableError = null;
|
|
170
|
+
for (let attempt = 0; attempt <= normalizedMaxRetries; attempt += 1) {
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
const timeout = setTimeout(() => controller.abort(), normalizedTimeoutMs);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const response = await fetch(String(url), {
|
|
176
|
+
method,
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
...headers,
|
|
180
|
+
},
|
|
181
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const rawBody = await response.text();
|
|
186
|
+
let json = {};
|
|
187
|
+
if (rawBody.trim()) {
|
|
188
|
+
try {
|
|
189
|
+
json = JSON.parse(rawBody);
|
|
190
|
+
} catch {
|
|
191
|
+
if (response.ok) {
|
|
192
|
+
throw new SentinelayerApiError("Invalid JSON returned by API.", {
|
|
193
|
+
status: response.status,
|
|
194
|
+
code: "INVALID_JSON",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (response.ok) {
|
|
201
|
+
recordSuccessForCircuit();
|
|
202
|
+
return json;
|
|
79
203
|
}
|
|
80
|
-
}
|
|
81
204
|
|
|
82
|
-
if (!response.ok) {
|
|
83
205
|
const apiError = normalizeApiError(json && typeof json === "object" ? json.error : {});
|
|
84
|
-
|
|
85
|
-
|
|
206
|
+
const statusCode = Number(response.status || 500);
|
|
207
|
+
const retryable = shouldRetryStatus(statusCode);
|
|
208
|
+
const shouldRecordCircuitFailure = shouldRecordFailureForStatus(statusCode);
|
|
209
|
+
const error = new SentinelayerApiError(apiError.message, {
|
|
210
|
+
status: statusCode,
|
|
86
211
|
code: apiError.code,
|
|
87
212
|
requestId: apiError.requestId,
|
|
88
213
|
});
|
|
89
|
-
}
|
|
90
214
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
215
|
+
if (!retryable || attempt >= normalizedMaxRetries) {
|
|
216
|
+
if (shouldRecordCircuitFailure) {
|
|
217
|
+
recordFailureForCircuit();
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
lastRetryableError = error;
|
|
223
|
+
const delayMs = computeBackoffMs({
|
|
224
|
+
attempt: attempt + 1,
|
|
225
|
+
retryDelayMs: normalizedRetryDelayMs,
|
|
226
|
+
retryAfterHeader: response.headers.get("retry-after"),
|
|
100
227
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
|
|
106
|
-
code: "NETWORK_ERROR",
|
|
228
|
+
await sleep(delayMs);
|
|
229
|
+
continue;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof SentinelayerApiError) {
|
|
232
|
+
throw error;
|
|
107
233
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
234
|
+
|
|
235
|
+
const isAbortError = Boolean(error && typeof error === "object" && error.name === "AbortError");
|
|
236
|
+
const normalizedError = new SentinelayerApiError(
|
|
237
|
+
isAbortError ? "Request timed out." : (error instanceof Error ? error.message : String(error || "Request failed")),
|
|
238
|
+
{
|
|
239
|
+
status: isAbortError ? 408 : 503,
|
|
240
|
+
code: isAbortError ? "TIMEOUT" : "NETWORK_ERROR",
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (attempt >= normalizedMaxRetries) {
|
|
245
|
+
recordFailureForCircuit();
|
|
246
|
+
throw normalizedError;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
lastRetryableError = normalizedError;
|
|
250
|
+
const delayMs = computeBackoffMs({
|
|
251
|
+
attempt: attempt + 1,
|
|
252
|
+
retryDelayMs: normalizedRetryDelayMs,
|
|
253
|
+
retryAfterHeader: null,
|
|
254
|
+
});
|
|
255
|
+
await sleep(delayMs);
|
|
256
|
+
continue;
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timeout);
|
|
259
|
+
await sleep(0);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (lastRetryableError instanceof SentinelayerApiError) {
|
|
264
|
+
throw lastRetryableError;
|
|
112
265
|
}
|
|
266
|
+
throw new SentinelayerApiError("Request failed without a terminal response.", {
|
|
267
|
+
status: 503,
|
|
268
|
+
code: "NETWORK_ERROR",
|
|
269
|
+
});
|
|
113
270
|
}
|
package/src/cli.js
CHANGED
|
@@ -168,7 +168,7 @@ function shouldBypassCommander(rawArgs) {
|
|
|
168
168
|
return true;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
if (first === "--help" || first === "-h" || first === "--version" || first === "-v") {
|
|
171
|
+
if (first === "--help" || first === "-h" || first === "help" || first === "--version" || first === "-v") {
|
|
172
172
|
return true;
|
|
173
173
|
}
|
|
174
174
|
|