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.
@@ -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
- if (!isValidUrl(url)) {
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(url);
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", url,
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
- if (!isValidUrl(url)) throw new RuntimeAuditError("Invalid URL: " + url);
138
+ const targetUrl = resolveRuntimeTargetUrl(url, input, "check_response_headers");
140
139
 
141
140
  try {
142
- const safeUrl = sanitizeUrlForShell(url);
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
- if (!isValidUrl(url)) throw new RuntimeAuditError("Invalid URL: " + url);
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: 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
- if (!isValidUrl(url)) throw new RuntimeAuditError("Invalid URL: " + url);
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(url);
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
- if (!isValidUrl(url)) throw new RuntimeAuditError("Invalid URL: " + url);
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: 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 isValidUrl(url) {
350
+ function resolveRuntimeTargetUrl(url, input, operation) {
352
351
  try {
353
- const parsed = new URL(url);
354
- return parsed.protocol === "http:" || parsed.protocol === "https:";
355
- } catch {
356
- return false;
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 fetch(scanEndpoint, {
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
- signal: AbortSignal.timeout(15000),
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 fetch(pollUrl, {
468
+ const pollResponse = await fetchWithTimeout(pollUrl, {
458
469
  headers: { "Authorization": "Bearer " + session.token },
459
- signal: AbortSignal.timeout(10000),
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
- // CI/test bypass allows automated testing without auth
50
- if (process.env.SENTINELAYER_CLI_SKIP_AUTH === "1" || process.env.CI === "true") {
51
- return { authenticated: true, session: null, bypassReason: "env_bypass" };
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.token) {
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
- { method = "GET", headers = {}, body, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}
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
- const controller = new AbortController();
56
- const timeout = setTimeout(() => controller.abort(), Number(timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS));
57
-
58
- try {
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
- const rawBody = await response.text();
70
- let json = {};
71
- if (rawBody.trim()) {
72
- try {
73
- json = JSON.parse(rawBody);
74
- } catch {
75
- throw new SentinelayerApiError("Invalid JSON returned by API.", {
76
- status: response.status,
77
- code: "INVALID_JSON",
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
- throw new SentinelayerApiError(apiError.message, {
85
- status: response.status,
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
- return json;
92
- } catch (error) {
93
- if (error instanceof SentinelayerApiError) {
94
- throw error;
95
- }
96
- if (error && typeof error === "object" && error.name === "AbortError") {
97
- throw new SentinelayerApiError("Request timed out.", {
98
- status: 408,
99
- code: "TIMEOUT",
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
- throw new SentinelayerApiError(
103
- error instanceof Error ? error.message : String(error || "Request failed"),
104
- {
105
- status: 503,
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
- } finally {
110
- clearTimeout(timeout);
111
- await sleep(0);
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