@gajae-code/coding-agent 0.11.9 → 0.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/types/config/model-registry.d.ts +2 -0
  3. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  4. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  5. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  6. package/dist/types/sdk/bus/index.d.ts +8 -1
  7. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  8. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -7
  9. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  10. package/dist/types/sdk/session.d.ts +7 -0
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/session/internal/managed-session-scope.d.ts +1 -1
  13. package/dist/types/task/provider-retry-status.d.ts +13 -0
  14. package/dist/types/task/types.d.ts +3 -0
  15. package/dist/types/tools/index.d.ts +3 -1
  16. package/package.json +8 -8
  17. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  18. package/src/cli/notify-cli.ts +1 -0
  19. package/src/config/model-profiles.ts +7 -7
  20. package/src/config/model-registry.ts +4 -2
  21. package/src/eval/js/tool-bridge.ts +1 -1
  22. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  23. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  24. package/src/gjc-runtime/state-writer.ts +1 -1
  25. package/src/hooks/skill-state.ts +23 -1
  26. package/src/internal-urls/docs-index.generated.ts +3 -3
  27. package/src/modes/controllers/selector-controller.ts +38 -8
  28. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  29. package/src/prompts/agents/architect.md +8 -1
  30. package/src/prompts/agents/critic.md +8 -1
  31. package/src/prompts/agents/planner.md +8 -1
  32. package/src/sdk/broker/identity.ts +22 -12
  33. package/src/sdk/bus/daemon-paths.ts +2 -0
  34. package/src/sdk/bus/index.ts +60 -5
  35. package/src/sdk/bus/notification-service.ts +19 -5
  36. package/src/sdk/bus/operator-runtime.ts +31 -5
  37. package/src/sdk/bus/telegram-cli.ts +6 -13
  38. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  39. package/src/sdk/bus/telegram-daemon-contract.ts +13 -7
  40. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  41. package/src/sdk/bus/telegram-daemon.ts +667 -118
  42. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  43. package/src/sdk/session.ts +12 -0
  44. package/src/session/agent-session.ts +54 -10
  45. package/src/session/internal/managed-session-scope.ts +133 -117
  46. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  47. package/src/task/executor.ts +141 -39
  48. package/src/task/provider-retry-status.ts +88 -0
  49. package/src/task/render.ts +45 -26
  50. package/src/task/types.ts +3 -0
  51. package/src/tools/bash-allowed-prefixes.ts +45 -1
  52. package/src/tools/index.ts +3 -1
  53. package/src/tools/subagent-render.ts +74 -16
  54. package/src/tools/subagent.ts +2 -0
  55. package/vendor/insane-search/MANIFEST.json +4 -1
  56. package/vendor/insane-search/engine/bias_check.py +5 -0
  57. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  58. package/vendor/insane-search/engine/transport.py +13 -2
  59. package/vendor/insane-search/engine/validators.py +4 -1
@@ -11,6 +11,12 @@ import type { Component } from "@gajae-code/tui";
11
11
  import { Text } from "@gajae-code/tui";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import type { Theme } from "../modes/theme/theme";
14
+ import {
15
+ collectProviderDegradationGroups,
16
+ hasActiveProviderRetryInProgress,
17
+ providerProgressAgeLabel,
18
+ providerRetryPhaseLabel,
19
+ } from "../task/provider-retry-status";
14
20
  import { renderSubagentLiveProgress } from "../task/render";
15
21
  import { Ellipsis, Hasher, renderStatusLine } from "../tui";
16
22
  import {
@@ -42,7 +48,8 @@ const PREVIEW_LINE_WIDTH = 80;
42
48
  * wall-clock displays are deliberately kept OUT of the cached body — the animated
43
49
  * spinner and the fresh duration live in the cheap per-subagent status line, and
44
50
  * `renderSubagentLiveProgress` is invoked with `staticTime` so current-tool elapsed
45
- * and retry countdowns are never baked into cached lines.
51
+ * and retry countdowns are never baked into cached lines. Snapshots with an active
52
+ * retry anywhere in their nested task tree bypass this cache and render dynamically.
46
53
  */
47
54
  const SUBAGENT_BODY_CACHE_MAX = 128;
48
55
  const subagentBodyCache = new Map<bigint, string[]>();
@@ -77,6 +84,15 @@ export const subagentBodyCacheTestHooks = {
77
84
  },
78
85
  };
79
86
 
87
+ function snapshotHasActiveRetry(snapshot: SubagentSnapshot): boolean {
88
+ if (snapshot.liveProgressAvailable === false || !snapshot.progress) return false;
89
+ return hasActiveProviderRetryInProgress(snapshot.progress);
90
+ }
91
+
92
+ function boundSubagentBodyLines(lines: string[], width: number): string[] {
93
+ return lines.map(line => (line.length > 0 ? truncateToWidth(replaceTabs(line), width, Ellipsis.Omit) : ""));
94
+ }
95
+
80
96
  function renderCachedSubagentBody(
81
97
  snapshot: SubagentSnapshot,
82
98
  signature: string,
@@ -92,9 +108,7 @@ function renderCachedSubagentBody(
92
108
  subagentBodyCache.set(key, hit);
93
109
  return hit;
94
110
  }
95
- const lines = renderSubagentSnapshotBody(snapshot, expanded, theme).map(line =>
96
- line.length > 0 ? truncateToWidth(line, width, Ellipsis.Omit) : "",
97
- );
111
+ const lines = boundSubagentBodyLines(renderSubagentSnapshotBody(snapshot, expanded, theme), width);
98
112
  subagentBodyRenderCount += 1;
99
113
  subagentBodyCache.set(key, lines);
100
114
  if (subagentBodyCache.size > SUBAGENT_BODY_CACHE_MAX) {
@@ -104,6 +118,16 @@ function renderCachedSubagentBody(
104
118
  return lines;
105
119
  }
106
120
 
121
+ function renderDynamicSubagentBody(
122
+ snapshot: SubagentSnapshot,
123
+ expanded: boolean,
124
+ width: number,
125
+ theme: Theme,
126
+ ): string[] {
127
+ subagentBodyRenderCount += 1;
128
+ return boundSubagentBodyLines(renderSubagentSnapshotBody(snapshot, expanded, theme, false), width);
129
+ }
130
+
107
131
  function statusIconKind(status: SubagentSnapshot["status"]): ToolUIStatus {
108
132
  switch (status) {
109
133
  case "completed":
@@ -131,15 +155,25 @@ function renderSubagentStatusLine(snapshot: SubagentSnapshot, theme: Theme, spin
131
155
  snapshot.status === "running" ? spinnerFrame : undefined,
132
156
  );
133
157
  const id = theme.fg("muted", truncateToWidth(replaceTabs(snapshot.id), PREVIEW_LINE_WIDTH, Ellipsis.Unicode));
134
- const status = theme.fg("dim", snapshot.status);
158
+ const retryState = snapshot.liveProgressAvailable !== false ? snapshot.progress?.retryState : undefined;
159
+ const status = retryState
160
+ ? theme.fg(
161
+ "warning",
162
+ `provider degraded · ${providerRetryPhaseLabel(retryState.kind)} · ${providerProgressAgeLabel(retryState, Date.now())}`,
163
+ )
164
+ : theme.fg("dim", snapshot.status);
135
165
  const duration = theme.fg("dim", formatDuration(snapshot.durationMs));
136
166
  return `${icon} ${id} ${status} ${duration}`;
137
167
  }
138
168
 
139
- // Heavy, cacheable per-subagent body: a pure function of (snapshot content, expanded,
140
- // theme). No spinner frame and no wall-clock displays leak in (live progress uses
141
- // `staticTime`), so the module body cache can never serve stale or frozen-ticking lines.
142
- function renderSubagentSnapshotBody(snapshot: SubagentSnapshot, expanded: boolean, theme: Theme): string[] {
169
+ // Heavy per-subagent body. The cache path uses staticTime=true; the bounded dynamic
170
+ // path opts into wall-clock displays when an active retry exists in the nested tree.
171
+ function renderSubagentSnapshotBody(
172
+ snapshot: SubagentSnapshot,
173
+ expanded: boolean,
174
+ theme: Theme,
175
+ staticTime = true,
176
+ ): string[] {
143
177
  const lines: string[] = [];
144
178
 
145
179
  // Static receipt fields (parity with the markdown content for non-await actions).
@@ -174,9 +208,9 @@ function renderSubagentSnapshotBody(snapshot: SubagentSnapshot, expanded: boolea
174
208
  // exists (subagent.ts #liveProgressFields), but the renderer also honors an
175
209
  // explicit `liveProgressAvailable: false` so stale retained progress can never
176
210
  // resurrect a live panel (AC5). `staticTime` keeps wall-clock displays out of
177
- // these cached lines.
211
+ // cached lines when the body is served by the cache.
178
212
  if (snapshot.progress && snapshot.liveProgressAvailable !== false) {
179
- for (const pl of renderSubagentLiveProgress(snapshot.progress, expanded, theme, undefined, true)) {
213
+ for (const pl of renderSubagentLiveProgress(snapshot.progress, expanded, theme, undefined, staticTime)) {
180
214
  lines.push(` ${pl}`);
181
215
  }
182
216
  } else if (snapshot.liveProgressAvailable && (snapshot.status === "running" || snapshot.status === "queued")) {
@@ -251,26 +285,50 @@ export const subagentToolRenderer = {
251
285
  },
252
286
  theme,
253
287
  );
254
- const out: string[] = [truncateToWidth(header, width, Ellipsis.Omit)];
288
+ const out: string[] = [truncateToWidth(replaceTabs(header), width, Ellipsis.Omit)];
255
289
  // Discoverability: the inline panel is a bounded preview; the session
256
290
  // observer (ctrl+s) streams the full per-subagent message history.
257
291
  if (runningCount > 0) {
258
- out.push(truncateToWidth(` ${theme.fg("dim", "(ctrl+s to observe sessions)")}`, width, Ellipsis.Omit));
292
+ out.push(
293
+ truncateToWidth(
294
+ replaceTabs(` ${theme.fg("dim", "(ctrl+s to observe sessions)")}`),
295
+ width,
296
+ Ellipsis.Omit,
297
+ ),
298
+ );
299
+ }
300
+ const liveProgress = subagents.flatMap(snapshot =>
301
+ snapshot.progress && snapshot.liveProgressAvailable !== false ? [snapshot.progress] : [],
302
+ );
303
+ for (const group of collectProviderDegradationGroups(liveProgress)) {
304
+ out.push(
305
+ truncateToWidth(
306
+ replaceTabs(
307
+ ` ${theme.fg("warning", `provider degraded: ${group.count} subagents retrying on ${group.provider}`)}`,
308
+ ),
309
+ width,
310
+ Ellipsis.Omit,
311
+ ),
312
+ );
259
313
  }
260
314
 
261
315
  snapshotSignatures ??= subagents.map(snapshot =>
262
316
  subagentAwaitRenderedStateSignature([snapshot], result.details),
263
317
  );
264
318
  subagents.forEach((snapshot, index) => {
265
- // Fresh per-subagent status line (cheap), then the cached heavy body.
319
+ // Fresh per-subagent status line (cheap), then a cached or dynamic body.
266
320
  out.push(
267
321
  truncateToWidth(
268
- renderSubagentStatusLine(snapshot, theme, options.spinnerFrame),
322
+ replaceTabs(renderSubagentStatusLine(snapshot, theme, options.spinnerFrame)),
269
323
  width,
270
324
  Ellipsis.Omit,
271
325
  ),
272
326
  );
273
- out.push(...renderCachedSubagentBody(snapshot, snapshotSignatures![index]!, expanded, width, theme));
327
+ out.push(
328
+ ...(snapshotHasActiveRetry(snapshot)
329
+ ? renderDynamicSubagentBody(snapshot, expanded, width, theme)
330
+ : renderCachedSubagentBody(snapshot, snapshotSignatures![index]!, expanded, width, theme)),
331
+ );
274
332
  });
275
333
  return out;
276
334
  },
@@ -936,6 +936,8 @@ function canonicalizeProgressForSignature(progress: AgentProgress): unknown {
936
936
  attempt: progress.retryState.attempt,
937
937
  maxAttempts: progress.retryState.maxAttempts,
938
938
  unbounded: progress.retryState.unbounded ?? false,
939
+ kind: progress.retryState.kind,
940
+ provider: progress.retryState.provider ?? null,
939
941
  delayMs: progress.retryState.delayMs,
940
942
  errorMessage: progress.retryState.errorMessage,
941
943
  // startedAtMs intentionally excluded (drives countdown only).
@@ -21,7 +21,10 @@
21
21
  "exclusionRationale": "Excludes upstream install hooks (SessionStart settings.json mutation), GitHub star-baiting (gh api user/starred), the update-notifier, and the past-session transcript-language scanner. Only the runtime Phase 0-3 engine and its Playwright/stealth templates are vendored.",
22
22
  "localPatches": [
23
23
  "engine/content_safety.py + FetchResult trust/risk metadata (content_trust, prompt_injection_risk, prompt_injection_signals, untrusted_content_boundary) and to_untrusted_text(): cherry-picked from insane-search a16f7c1 (upstream PR #5, v0.9.0). Adds prompt-injection labelling to the JSON surface (to_dict); the default CLI text output is intentionally left unchanged (plain content preserved, no envelope).",
24
- "engine/phase0.py _ytdlp_argv() console-script→module fallback + engine/bias_check.py rel.as_posix() Windows exemption + engine/tests/test_u9.py: cherry-picked from insane-search 33f898d (upstream PR #6, released v0.9.2). Cross-platform yt-dlp resolution; no capability or site logic added."
24
+ "engine/phase0.py _ytdlp_argv() console-script→module fallback + engine/bias_check.py rel.as_posix() Windows exemption + engine/tests/test_u9.py: cherry-picked from insane-search 33f898d (upstream PR #6, released v0.9.2). Cross-platform yt-dlp resolution; no capability or site logic added.",
25
+ "engine/validators.py TERMINAL_NONSUCCESS drops Verdict.RATE_LIMITED + engine/fetch_chain.py _rate_limit_backoff(resp) and grid-loop 429 skip-and-continue + probe-phase 429 backoff: local P0 fix. Upstream treated 429 as terminal, so a single rate-limited probe or candidate aborted the whole fetch chain (no grid diversity, no Playwright fallback, no backoff), contradicting the transient-429 routing hints the same module already emitted. Backoff is linear (INSANE_RATE_LIMIT_BACKOFF_S, default 2s, x1..x5) and honours the server-provided Retry-After header (numeric, capped at 30s) on every 429 path (probe and grid). The configured base is validated and clamped (fetch_chain._clamp_rate_limit_base) and the final per-attempt delay is hard-capped to 30s, so a hostile or mis-set INSANE_RATE_LIMIT_BACKOFF_S (non-numeric, NaN, infinite, negative, or huge) can neither crash the chain, hang time.sleep, nor bypass the advertised bound; backoff sleeps stay finite and <=30s so a cancellation (KeyboardInterrupt) between attempts is honoured promptly. No capability or site logic added. Regression coverage: engine/tests/test_rate_limit_backoff.py (deterministic, mocked sleep; includes base-validation and cancellation/timeout regressions).",
26
+ "engine/transport.py SessionPool.warmup(): bind allow_private = safety.allow_private_default() before use and qualify safety.DEFAULT_MAX_REDIRECTS: local fix for two NameErrors on the same call. warmup() raised NameError on every invocation that passed the safety gate, so per-(host, impersonate) root warmup never actually ran and engine/tests/test_u4.py failed on the vendored tree. Matches the already-correct qualification at transport.py get_or_create(). No capability or site logic added.",
27
+ "engine/bias_check.py URL_ALLOWLIST adds funcaptcha.com/api.funcaptcha.com (Arkose Labs WAF challenge endpoints already referenced by validators.py and waf_profiles.yaml) and r.jina.ai/jina.ai (generic headless rendering endpoint): local fix so the No-Site-Name gate passes on vendored code. Allowlist-only; both are vendor/infrastructure artifacts, not target-site preferences."
25
28
  ],
26
29
  "notes": "Runtime engine is invoked via `python3 -m engine \"<url>\" --json` with cwd=this directory and PYTHONPATH pointed at this directory. Phase 0-2 require python3 + curl_cffi; Phase 3 requires node + playwright/playwright-extra/puppeteer-extra-plugin-stealth installed under engine/templates. GJC never auto-installs these dependencies."
27
30
  }
@@ -54,6 +54,11 @@ URL_ALLOWLIST = {
54
54
  "www.google.com", "google.com",
55
55
  # Generic HTTP test endpoint for infrastructure / transport tests.
56
56
  "httpbin.org",
57
+ # WAF vendor challenge domains — product artifacts used in detectors,
58
+ # NOT target-site biases. Any site running Arkose Labs serves these.
59
+ "funcaptcha.com", "api.funcaptcha.com",
60
+ # Jina Reader — generic headless-Chrome rendering service (infra endpoint).
61
+ "r.jina.ai", "jina.ai",
57
62
  }
58
63
 
59
64
  # Files / dirs that must be clean.
@@ -31,6 +31,7 @@ No site-specific branching. Site knowledge enters only via:
31
31
  """
32
32
  from __future__ import annotations
33
33
 
34
+ import math
34
35
  import os
35
36
  import random
36
37
  import time
@@ -47,6 +48,34 @@ _OK_VALUES = (Verdict.STRONG_OK.value, Verdict.WEAK_OK.value)
47
48
  _TERMINAL_NONSUCCESS_VALUES = frozenset(v.value for v in TERMINAL_NONSUCCESS)
48
49
 
49
50
 
51
+ # --- 429 rate-limit backoff bounds -------------------------------------------
52
+ # The configured base (INSANE_RATE_LIMIT_BACKOFF_S) AND the final per-attempt
53
+ # delay are both clamped to a hard 30s ceiling so a hostile or mis-set
54
+ # environment value (huge, negative, NaN, or infinite) can never bypass the
55
+ # advertised bound, hang time.sleep, or crash the fetch chain. Keeping each
56
+ # sleep short and finite is also what makes the backoff cancellable: an
57
+ # abort/KeyboardInterrupt between attempts is honoured promptly rather than
58
+ # blocked behind an unbounded sleep.
59
+ _RATE_LIMIT_MAX_DELAY = 30.0
60
+ _RATE_LIMIT_DEFAULT_BASE = 2.0
61
+
62
+
63
+ def _clamp_rate_limit_base(raw) -> float:
64
+ """Parse INSANE_RATE_LIMIT_BACKOFF_S into a safe, finite, non-negative base.
65
+
66
+ Rejects (falls back to the default) any value that is non-numeric, NaN,
67
+ infinite, or negative; clamps a valid value to the hard ceiling so the
68
+ linear escalation (base * 1..5) can never exceed the advertised bound.
69
+ """
70
+ try:
71
+ value = float(raw)
72
+ except (TypeError, ValueError):
73
+ return _RATE_LIMIT_DEFAULT_BASE
74
+ if not math.isfinite(value) or value < 0:
75
+ return _RATE_LIMIT_DEFAULT_BASE
76
+ return min(value, _RATE_LIMIT_MAX_DELAY)
77
+
78
+
50
79
  # --- Referer strategies (name → function of original URL) --------------------
51
80
  def _self_root(url: str) -> str:
52
81
  from urllib.parse import urlsplit
@@ -450,6 +479,32 @@ def _fetch_core(
450
479
  def _jitter():
451
480
  time.sleep(random.uniform(_jmin / 1000.0, _jmax / 1000.0))
452
481
 
482
+ # 429 backoff: longer than normal jitter. Respects Retry-After header when
483
+ # available. The configured base is validated/clamped (_clamp_rate_limit_base)
484
+ # and the final delay is hard-capped to _RATE_LIMIT_MAX_DELAY (30s) so a
485
+ # pathological INSANE_RATE_LIMIT_BACKOFF_S can never bypass the advertised
486
+ # bound, hang time.sleep, or defeat cancellation.
487
+ _rl_base = _clamp_rate_limit_base(os.environ.get("INSANE_RATE_LIMIT_BACKOFF_S", "2.0"))
488
+ _rl_count = 0
489
+
490
+ def _rate_limit_backoff(resp=None):
491
+ nonlocal _rl_count
492
+ _rl_count += 1
493
+ delay = _rl_base * min(_rl_count, 5) # linear: 2s, 4s, 6s, 8s, 10s cap
494
+ if resp is not None:
495
+ try:
496
+ ra = {k.lower(): v for k, v in dict(getattr(resp, "headers", {}) or {}).items()}.get("retry-after", "")
497
+ if ra.isdigit():
498
+ delay = max(delay, min(int(ra), _RATE_LIMIT_MAX_DELAY))
499
+ except Exception:
500
+ pass
501
+ # Hard ceiling + NaN/negative guard: never hand time.sleep an unbounded
502
+ # or invalid value (it would hang or raise), and never exceed the
503
+ # advertised 30s bound.
504
+ if not math.isfinite(delay) or delay < 0:
505
+ delay = _RATE_LIMIT_DEFAULT_BASE
506
+ time.sleep(min(delay, _RATE_LIMIT_MAX_DELAY))
507
+
453
508
  # Surface profile-loader failures as a diagnostic trace entry (not counted
454
509
  # as a network attempt).
455
510
  load_err = last_load_error()
@@ -533,6 +588,10 @@ def _fetch_core(
533
588
  return _give_up(trace, profile_used, last_resp, last_attempt, best_suspect,
534
589
  planned=0, executed=curl_attempts, grid_exhausted=False,
535
590
  stop_reason=probe_attempt.verdict)
591
+ # Probe-phase 429: back off before entering the grid so the first
592
+ # grid candidate doesn't immediately hit the same rate window.
593
+ if probe_attempt.verdict == Verdict.RATE_LIMITED.value:
594
+ _rate_limit_backoff(probe_resp)
536
595
 
537
596
  # -------- Phase 2: detect + plan + execute ------------------------------
538
597
  if last_resp is not None:
@@ -571,13 +630,20 @@ def _fetch_core(
571
630
  if att.verdict in _TERMINAL_NONSUCCESS_VALUES:
572
631
  stop_reason = att.verdict
573
632
  break
633
+ # 429 rate-limit: transient — back off longer than normal jitter,
634
+ # then continue to the next candidate (a different TLS/referer
635
+ # combo may not be rate-limited). Do NOT break the grid.
636
+ if att.verdict == Verdict.RATE_LIMITED.value:
637
+ _rate_limit_backoff(resp)
638
+ continue
574
639
  # continuing → polite jitter (only on non-terminal failure)
575
640
  _jitter()
576
641
  else:
577
642
  grid_exhausted = True
578
643
  stop_reason = "exhausted"
579
644
 
580
- # If a terminal-nonsuccess (404/auth/429) stopped us, browser won't help.
645
+ # If a terminal-nonsuccess (404/auth) stopped us, browser won't help.
646
+ # NOTE: 429 is NOT terminal — browser fallback is still attempted.
581
647
  skip_browser = stop_reason in _TERMINAL_NONSUCCESS_VALUES
582
648
 
583
649
  # -------- Phase 3: Playwright fallback ----------------------------------
@@ -77,14 +77,16 @@ class SessionPool:
77
77
  if ent is None or ent.warmed:
78
78
  return False
79
79
  from . import safety
80
- ok, _reason = safety.classify_url(root_url, safety.allow_private_default())
80
+ allow_private = safety.allow_private_default()
81
+ ok, _reason = safety.classify_url(root_url, allow_private)
81
82
  if not ok:
82
83
  ent.warmed = True # don't retry a blocked root
83
84
  return False
84
85
  ent.warmed = True # mark first to avoid duplicate warmups under race
85
86
  def _do_get(u):
86
87
  return ent.session.get(u, timeout=timeout, allow_redirects=False)
87
- resp, err = self._fetch_following(_do_get, root_url, allow_private, DEFAULT_MAX_REDIRECTS, ent)
88
+ resp, err = self._fetch_following(_do_get, root_url, allow_private,
89
+ safety.DEFAULT_MAX_REDIRECTS, ent)
88
90
  return resp is not None and err is None
89
91
 
90
92
  def inject_cookies(self, host: str, impersonate: str,
@@ -120,6 +122,15 @@ class SessionPool:
120
122
  """GET via the pooled session (cookie + connection reuse), with an SSRF
121
123
  guard: the initial URL and EVERY redirect hop are validated against the
122
124
  private/loopback/link-local/metadata block-list before being fetched.
125
+
126
+ SECURITY NOTE: This is a resolver-level (pre-connect) check only. It is
127
+ NOT transport-bound DNS validation — a TOCTOU gap exists between
128
+ resolution and connection (DNS rebinding), and redirect-to-private
129
+ authority changes after the initial check are only caught at the next
130
+ hop boundary. The TypeScript bridge disables the real engine for this
131
+ reason; this Python guard is a defense-in-depth layer, not equivalent
132
+ protection against DNS rebinding or redirect-to-private attacks.
133
+
123
134
  Falls back to a one-shot get if no session could be created."""
124
135
  from . import safety
125
136
  if allow_private is None:
@@ -81,8 +81,11 @@ class Verdict(Enum):
81
81
 
82
82
 
83
83
  # Verdicts that mean "stop the grid — more TLS attempts cannot help".
84
+ # NOTE: RATE_LIMITED (429) is intentionally EXCLUDED — it is transient.
85
+ # The grid skips the rate-limited candidate, backs off, and continues;
86
+ # a different TLS/referer combo may not be rate-limited.
84
87
  TERMINAL_NONSUCCESS = frozenset({
85
- Verdict.AUTH_REQUIRED, Verdict.NOT_FOUND, Verdict.RATE_LIMITED,
88
+ Verdict.AUTH_REQUIRED, Verdict.NOT_FOUND,
86
89
  })
87
90
 
88
91