@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.
- package/CHANGELOG.md +18 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +8 -1
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
- package/dist/types/sdk/session.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +1 -1
- package/dist/types/task/provider-retry-status.d.ts +13 -0
- package/dist/types/task/types.d.ts +3 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +1 -0
- package/src/cli/notify-cli.ts +1 -0
- package/src/config/model-profiles.ts +7 -7
- package/src/config/model-registry.ts +4 -2
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/gjc-runtime/deep-interview-draft.ts +2 -1
- package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
- package/src/gjc-runtime/state-writer.ts +1 -1
- package/src/hooks/skill-state.ts +23 -1
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/modes/controllers/selector-controller.ts +38 -8
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/architect.md +8 -1
- package/src/prompts/agents/critic.md +8 -1
- package/src/prompts/agents/planner.md +8 -1
- package/src/sdk/broker/identity.ts +22 -12
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/index.ts +60 -5
- package/src/sdk/bus/notification-service.ts +19 -5
- package/src/sdk/bus/operator-runtime.ts +31 -5
- package/src/sdk/bus/telegram-cli.ts +6 -13
- package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +13 -7
- package/src/sdk/bus/telegram-daemon-control.ts +14 -5
- package/src/sdk/bus/telegram-daemon.ts +667 -118
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +12 -0
- package/src/session/agent-session.ts +54 -10
- package/src/session/internal/managed-session-scope.ts +133 -117
- package/src/skill-state/workflow-mutation-guard.ts +41 -7
- package/src/task/executor.ts +141 -39
- package/src/task/provider-retry-status.ts +88 -0
- package/src/task/render.ts +45 -26
- package/src/task/types.ts +3 -0
- package/src/tools/bash-allowed-prefixes.ts +45 -1
- package/src/tools/index.ts +3 -1
- package/src/tools/subagent-render.ts +74 -16
- package/src/tools/subagent.ts +2 -0
- package/vendor/insane-search/MANIFEST.json +4 -1
- package/vendor/insane-search/engine/bias_check.py +5 -0
- package/vendor/insane-search/engine/fetch_chain.py +67 -1
- package/vendor/insane-search/engine/transport.py +13 -2
- 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)
|
|
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
|
|
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
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
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,
|
|
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(
|
|
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
|
|
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(
|
|
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
|
},
|
package/src/tools/subagent.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
88
|
+
Verdict.AUTH_REQUIRED, Verdict.NOT_FOUND,
|
|
86
89
|
})
|
|
87
90
|
|
|
88
91
|
|