From a521cfc31d3ff327a0045fa6ac2ac0499a07858f Mon Sep 17 00:00:00 2001 From: Yisi Liu Date: Sat, 5 Sep 2026 14:08:16 +0800 Subject: [PATCH 1/2] feat(daemon): per-site navigation pacing and security-block circuit breaker Sites with velocity-based risk control (xiaohongshu, weibo) soft-block accounts that navigate faster than a human. Adapters already jitter their own settle waits, but nothing spaced navigations across separate CLI invocations - a batch caller fires them back-to-back, which is exactly the pattern the xiaohongshu pitfalls doc warns escalates toward account restrictions. The daemon is the one local process that sees every CLI client (same argument as session-lease.ts), so it now enforces two rules centrally, via a pure injectable-clock module (src/site-pacing.ts): - Adapter `navigate` dispatches for a paced (contextId, site) are spaced by a jittered per-site interval (xiaohongshu 1.5-3s, weibo 1-2s). Concurrent CLI processes serialize into consecutive slots without locks; navigations arriving after the interval pass with zero delay. Only `navigate` is paced - evaluates against a warm tab load no pages. - Two SECURITY_BLOCK outcomes within 10 minutes open a randomized 5-10 min cooldown during which paced navigations fail fast with a machine-readable `security_cooldown` error carrying retryAfterMs, instead of hammering a hot risk state. Any successful command resets the counter. execution.ts reports outcomes best-effort on command settle (SECURITY_BLOCK errors and successes only - unrelated failures say nothing about the site's risk state). Pacing events surface in the daemon's /logs ring buffer (its own stdio is discarded). OPENCLI_PACING=off at daemon startup disables the layer. Co-Authored-By: Claude Fable 5 --- src/browser/daemon-client.ts | 30 ++++++ src/daemon.ts | 64 +++++++++++++ src/execution.test.ts | 76 +++++++++++++++- src/execution.ts | 13 ++- src/site-pacing.test.ts | 172 +++++++++++++++++++++++++++++++++++ src/site-pacing.ts | 157 ++++++++++++++++++++++++++++++++ 6 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 src/site-pacing.test.ts create mode 100644 src/site-pacing.ts diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index da66788ad..6bb56cda5 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -92,6 +92,36 @@ export async function releaseSiteSessionLease(params: { } } +/** + * Best-effort pacing outcome report on adapter command completion. Feeds the + * daemon's per-site security-block circuit breaker (see site-pacing.ts); a + * lost report only costs the breaker one sample, so this must never block or + * fail the caller. + */ +export async function reportPacingOutcome(params: { + session: string; + outcome: 'ok' | 'security_block'; + contextId?: string; +}): Promise { + try { + await requestDaemon('/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: generateId(), + action: 'pacing-report', + session: params.session, + surface: 'adapter', + outcome: params.outcome, + ...(params.contextId ? { contextId: params.contextId } : {}), + }), + timeout: 2000, + }); + } catch { + // Best-effort: the breaker just misses one sample. + } +} + /** * Transport-level deadlines share one source of truth: `body.timeout` (seconds). * The daemon arms its per-command timer from it, the extension derives its CDP diff --git a/src/daemon.ts b/src/daemon.ts index 83793493a..8872e5a9f 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -42,6 +42,12 @@ import { getSessionLeaseKey, isSessionLeaseCommand, } from './session-lease.js'; +import { + SitePacer, + buildSecurityCooldownFailure, + classifyPacedNavigation, + parseSiteFromSession, +} from './site-pacing.js'; const PORT = DEFAULT_DAEMON_PORT; if (!isIgnorableDaemonPortEnv(process.env.OPENCLI_DAEMON_PORT)) { @@ -92,6 +98,11 @@ const pending = new Map(); // session-lease.ts). const sessionLeases = new SessionLeaseRegistry(); +// Per-site navigation pacing + security-block circuit breaker (site-pacing.ts). +// Kill switch is read once at daemon startup: OPENCLI_PACING=off disables it. +const sitePacer = new SitePacer(); +const sitePacingEnabled = process.env.OPENCLI_PACING !== 'off'; + /** A TTL-stale lease holder with a command still in flight is alive, not dead. */ function runHasPendingWork(runId: string): boolean { for (const entry of pending.values()) { @@ -365,6 +376,27 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise return; } + // ─── Site pacing: outcome report ───────────────────────────────── + // Daemon-local, never dispatched to the extension. Feeds the per-site + // security-block circuit breaker; unpaced sites are ignored inside + // reportOutcome. Best-effort contextId resolution — a report for a + // profile that just disconnected is simply dropped. + if (body.action === 'pacing-report') { + const site = parseSiteFromSession(body.session); + const outcome = body.outcome === 'security_block' || body.outcome === 'ok' ? body.outcome : null; + if (site && outcome) { + const reportRoute = resolveExtensionConnection( + typeof body.contextId === 'string' ? body.contextId : undefined, + undefined, + ); + const ctx = reportRoute.connection?.contextId + ?? (typeof body.contextId === 'string' && body.contextId ? body.contextId : null); + if (ctx) sitePacer.reportOutcome(ctx, site, outcome, Date.now()); + } + jsonResponse(res, 200, { id: body.id, ok: true }); + return; + } + const route = resolveExtensionConnection( typeof body.contextId === 'string' ? body.contextId : undefined, typeof body.preferredContextId === 'string' ? body.preferredContextId : undefined, @@ -419,6 +451,38 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise leaseRunId = body.runId; } + // ─── Site pacing: navigation slots + security cooldown ─────────── + // Space adapter navigations for velocity-sensitive sites and fail fast + // while a security cooldown is open (site-pacing.ts). Applies to + // `navigate` only — evaluates against a warm tab load no pages. + if (sitePacingEnabled) { + const pacedSite = classifyPacedNavigation(body); + if (pacedSite) { + const pacing = sitePacer.acquireNavigationSlot(route.connection.contextId, pacedSite, Date.now()); + if (!pacing.granted) { + const failure = buildSecurityCooldownFailure(pacedSite, pacing.retryAfterMs); + // The daemon's own stdio is discarded (spawned with stdio: 'ignore'); + // the /logs ring buffer is the only operator-visible channel. + pushLog({ level: 'warn', msg: `[pacing] ${pacedSite} navigate refused — security cooldown, retry in ${Math.ceil(pacing.retryAfterMs / 1000)}s`, ts: Date.now() }); + log.warn(`[daemon] ${pacedSite} navigate refused — security cooldown, retry in ${Math.ceil(pacing.retryAfterMs / 1000)}s`); + jsonResponse(res, failure.status, { + id: body.id, + ok: false, + errorCode: failure.errorCode, + error: failure.message, + errorHint: failure.errorHint, + retryAfterMs: failure.retryAfterMs, + }); + return; + } + if (pacing.delayMs > 0) { + pushLog({ level: 'info', msg: `[pacing] spacing ${pacedSite} navigate by ${Math.round(pacing.delayMs)}ms`, ts: Date.now() }); + log.info(`[daemon] pacing ${pacedSite} navigate by ${Math.round(pacing.delayMs)}ms`); + await new Promise((resolve) => setTimeout(resolve, pacing.delayMs)); + } + } + } + // Absolute deadline wins over the legacy duration field: all hops share // one wall clock, so remaining budget absorbs queueing/transit time. const timeoutMs = typeof body.deadlineAt === 'number' && body.deadlineAt > 0 diff --git a/src/execution.test.ts b/src/execution.test.ts index 0635ab375..d63cccafa 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -4,7 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { CliCommand } from './registry.js'; import { coerceAndValidateArgs, executeCommand, prepareCommandArgs } from './execution.js'; -import { ArgumentError, TimeoutError, toEnvelope } from './errors.js'; +import { ArgumentError, CliError, TimeoutError, toEnvelope } from './errors.js'; import { cli, Strategy } from './registry.js'; import { withTimeoutMs } from './runtime.js'; import * as runtime from './runtime.js'; @@ -966,3 +966,77 @@ describe('executeCommand — persistent write lease release', () => { vi.restoreAllMocks(); }); }); + +describe('executeCommand — pacing outcome report', () => { + function pacedReadCmd(name: string, func: () => Promise): CliCommand { + return cli({ + site: 'test-pacing-report', + name, + access: 'read', + description: 'test pacing outcome report', + browser: true, + strategy: Strategy.PUBLIC, + func, + }); + } + + it('reports ok after a successful adapter browser command', async () => { + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn({} as any)); + const reportSpy = vi.spyOn(daemonClient, 'reportPacingOutcome').mockResolvedValue(undefined); + + await executeCommand(pacedReadCmd('pace-ok', async () => [{ ok: true }]), {}); + + expect(reportSpy).toHaveBeenCalledTimes(1); + expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ + session: expect.stringMatching(/^site:test-pacing-report/), + outcome: 'ok', + })); + vi.restoreAllMocks(); + }); + + it('reports security_block when the command fails with a SECURITY_BLOCK error', async () => { + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn({} as any)); + const reportSpy = vi.spyOn(daemonClient, 'reportPacingOutcome').mockResolvedValue(undefined); + + const cmd = pacedReadCmd('pace-block', async () => { + throw new CliError('SECURITY_BLOCK', 'risk control blocked the page'); + }); + await expect(executeCommand(cmd, {})).rejects.toThrow('risk control'); + + expect(reportSpy).toHaveBeenCalledTimes(1); + expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'security_block' })); + vi.restoreAllMocks(); + }); + + it('reports nothing for other failures — unrelated errors must not reset the breaker', async () => { + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn({} as any)); + const reportSpy = vi.spyOn(daemonClient, 'reportPacingOutcome').mockResolvedValue(undefined); + + const cmd = pacedReadCmd('pace-other-failure', async () => { + throw new BrowserCommandError('boom', 'attach_failed'); + }); + await expect(executeCommand(cmd, {})).rejects.toThrow('boom'); + + expect(reportSpy).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('reports nothing for non-browser commands', async () => { + const reportSpy = vi.spyOn(daemonClient, 'reportPacingOutcome').mockResolvedValue(undefined); + const cmd = cli({ + site: 'test-pacing-report', + name: 'pace-non-browser', + access: 'read', + description: 'non-browser command', + browser: false, + strategy: Strategy.PUBLIC, + func: async () => [{ ok: true }], + }); + await executeCommand(cmd, {}); + expect(reportSpy).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); diff --git a/src/execution.ts b/src/execution.ts index 4163173bd..6a768cd45 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -26,11 +26,11 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { executePipeline } from './pipeline/index.js'; -import { adapterLoadError, ArgumentError, CommandExecutionError, SessionBusyError, attachTraceReceipt, getErrorMessage } from './errors.js'; +import { adapterLoadError, ArgumentError, CliError, CommandExecutionError, SessionBusyError, attachTraceReceipt, getErrorMessage } from './errors.js'; import { shouldUseBrowserSession } from './capabilityRouting.js'; import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT, type BrowserWindowMode } from './runtime.js'; import { profileRouteParams, resolveProfileSelection } from './browser/profile.js'; -import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, releaseSiteSessionLease, setDaemonCommandTimeoutSeconds, setDaemonRunContext } from './browser/daemon-client.js'; +import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, releaseSiteSessionLease, reportPacingOutcome, setDaemonCommandTimeoutSeconds, setDaemonRunContext } from './browser/daemon-client.js'; import { emitHook, type HookContext } from './hooks.js'; import { log } from './logger.js'; import { isElectronApp } from './electron-apps.js'; @@ -443,6 +443,15 @@ export async function executeCommand( // result-evicted, anywhere in the cause chain) means the browser-side // command may STILL be running against the persistent tab; there is // nothing to await client-side, so the TTL is the quiet period. + // Pacing outcome report (best-effort, never awaited): the daemon's + // per-site circuit breaker counts SECURITY_BLOCK endings and resets on + // success. Other failures say nothing about the site's risk state, and + // an unknown-outcome ending reports nothing either. + if (browserRunError === undefined) { + void reportPacingOutcome({ session, outcome: 'ok', ...(contextId ? { contextId } : {}) }); + } else if (browserRunError instanceof CliError && browserRunError.code === 'SECURITY_BLOCK') { + void reportPacingOutcome({ session, outcome: 'security_block', ...(contextId ? { contextId } : {}) }); + } if (leaseRun) { if (adapterStillRunning && adapterRun) { const runId = leaseRun.runId; diff --git a/src/site-pacing.test.ts b/src/site-pacing.test.ts new file mode 100644 index 000000000..490c89da0 --- /dev/null +++ b/src/site-pacing.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import { + PACING_BREAKER_THRESHOLD, + PACING_BREAKER_WINDOW_MS, + PACING_COOLDOWN_MAX_MS, + PACING_COOLDOWN_MIN_MS, + SECURITY_COOLDOWN_CODE, + SITE_PACING_RULES, + SitePacer, + buildSecurityCooldownFailure, + classifyPacedNavigation, + parseSiteFromSession, +} from './site-pacing.js'; + +const RULES = { paced: { minIntervalMs: 1000, maxIntervalMs: 2000 } }; +const CTX = 'ctx-1'; + +describe('parseSiteFromSession', () => { + it('extracts the site from persistent and ephemeral adapter session names', () => { + expect(parseSiteFromSession('site:xiaohongshu')).toBe('xiaohongshu'); + expect(parseSiteFromSession('site:weibo:3d6f0d5e-uuid')).toBe('weibo'); + }); + it('returns null for non-adapter sessions and non-strings', () => { + expect(parseSiteFromSession('my-browser-session')).toBeNull(); + expect(parseSiteFromSession('')).toBeNull(); + expect(parseSiteFromSession(undefined)).toBeNull(); + expect(parseSiteFromSession(42)).toBeNull(); + }); +}); + +describe('SitePacer navigation slots', () => { + it('lets unpaced sites through with zero delay', () => { + const pacer = new SitePacer(RULES, () => 0.5); + expect(pacer.acquireNavigationSlot(CTX, 'unlisted', 1_000)).toEqual({ granted: true, delayMs: 0 }); + }); + + it('grants the first navigation immediately', () => { + const pacer = new SitePacer(RULES, () => 0.5); + expect(pacer.acquireNavigationSlot(CTX, 'paced', 1_000)).toEqual({ granted: true, delayMs: 0 }); + }); + + it('spaces an immediate follow-up navigation by the jittered interval', () => { + const pacer = new SitePacer(RULES, () => 0.5); // interval = 1000 + 0.5*1000 = 1500 + pacer.acquireNavigationSlot(CTX, 'paced', 1_000); + expect(pacer.acquireNavigationSlot(CTX, 'paced', 1_000)).toEqual({ granted: true, delayMs: 1_500 }); + }); + + it('serializes concurrent bursts into consecutive slots', () => { + const pacer = new SitePacer(RULES, () => 0); // interval = 1000 + pacer.acquireNavigationSlot(CTX, 'paced', 1_000); + expect(pacer.acquireNavigationSlot(CTX, 'paced', 1_000)).toEqual({ granted: true, delayMs: 1_000 }); + expect(pacer.acquireNavigationSlot(CTX, 'paced', 1_000)).toEqual({ granted: true, delayMs: 2_000 }); + }); + + it('does not delay a navigation that arrives after the interval has already passed', () => { + const pacer = new SitePacer(RULES, () => 1); // interval = 2000 + pacer.acquireNavigationSlot(CTX, 'paced', 1_000); + expect(pacer.acquireNavigationSlot(CTX, 'paced', 5_000)).toEqual({ granted: true, delayMs: 0 }); + }); + + it('partitions slots by contextId — two Chrome profiles never pace each other', () => { + const pacer = new SitePacer(RULES, () => 0.5); + pacer.acquireNavigationSlot('ctx-a', 'paced', 1_000); + expect(pacer.acquireNavigationSlot('ctx-b', 'paced', 1_000)).toEqual({ granted: true, delayMs: 0 }); + }); +}); + +describe('SitePacer circuit breaker', () => { + it('opens after the threshold of security blocks inside the window', () => { + const pacer = new SitePacer(RULES, () => 0); // cooldown = PACING_COOLDOWN_MIN_MS + for (let i = 0; i < PACING_BREAKER_THRESHOLD; i++) { + pacer.reportOutcome(CTX, 'paced', 'security_block', 1_000 + i); + } + const outcome = pacer.acquireNavigationSlot(CTX, 'paced', 2_000); + expect(outcome.granted).toBe(false); + if (!outcome.granted) { + expect(outcome.retryAfterMs).toBe(PACING_COOLDOWN_MIN_MS - (2_000 - (1_000 + PACING_BREAKER_THRESHOLD - 1))); + } + }); + + it('randomizes the open duration within the documented bounds', () => { + const pacer = new SitePacer(RULES, () => 1); + for (let i = 0; i < PACING_BREAKER_THRESHOLD; i++) { + pacer.reportOutcome(CTX, 'paced', 'security_block', 1_000); + } + const outcome = pacer.acquireNavigationSlot(CTX, 'paced', 1_000); + expect(outcome.granted).toBe(false); + if (!outcome.granted) expect(outcome.retryAfterMs).toBe(PACING_COOLDOWN_MAX_MS); + }); + + it('an ok outcome resets the block counter', () => { + const pacer = new SitePacer(RULES, () => 0); + pacer.reportOutcome(CTX, 'paced', 'security_block', 1_000); + pacer.reportOutcome(CTX, 'paced', 'ok', 2_000); + pacer.reportOutcome(CTX, 'paced', 'security_block', 3_000); + expect(pacer.acquireNavigationSlot(CTX, 'paced', 3_500).granted).toBe(true); + }); + + it('prunes blocks older than the window instead of counting them', () => { + const pacer = new SitePacer(RULES, () => 0); + pacer.reportOutcome(CTX, 'paced', 'security_block', 1_000); + pacer.reportOutcome(CTX, 'paced', 'security_block', 1_000 + PACING_BREAKER_WINDOW_MS + 1); + expect(pacer.acquireNavigationSlot(CTX, 'paced', 1_000 + PACING_BREAKER_WINDOW_MS + 2).granted).toBe(true); + }); + + it('closes again once the open period has elapsed', () => { + const pacer = new SitePacer(RULES, () => 0); + for (let i = 0; i < PACING_BREAKER_THRESHOLD; i++) { + pacer.reportOutcome(CTX, 'paced', 'security_block', 1_000); + } + expect(pacer.acquireNavigationSlot(CTX, 'paced', 1_000 + PACING_COOLDOWN_MIN_MS + 1).granted).toBe(true); + }); + + it('ignores reports for unpaced sites', () => { + const pacer = new SitePacer(RULES, () => 0); + for (let i = 0; i < 10; i++) pacer.reportOutcome(CTX, 'unlisted', 'security_block', 1_000); + expect(pacer.acquireNavigationSlot(CTX, 'unlisted', 1_001)).toEqual({ granted: true, delayMs: 0 }); + }); + + it('partitions breaker state by contextId', () => { + const pacer = new SitePacer(RULES, () => 0); + for (let i = 0; i < PACING_BREAKER_THRESHOLD; i++) { + pacer.reportOutcome('ctx-a', 'paced', 'security_block', 1_000); + } + expect(pacer.acquireNavigationSlot('ctx-b', 'paced', 1_001).granted).toBe(true); + }); +}); + +describe('classifyPacedNavigation', () => { + const rules = { xiaohongshu: { minIntervalMs: 1000, maxIntervalMs: 2000 } }; + it('matches adapter navigate dispatches for paced sites, persistent or ephemeral', () => { + expect(classifyPacedNavigation({ action: 'navigate', surface: 'adapter', session: 'site:xiaohongshu' }, rules)).toBe('xiaohongshu'); + expect(classifyPacedNavigation({ action: 'navigate', surface: 'adapter', session: 'site:xiaohongshu:uuid-1' }, rules)).toBe('xiaohongshu'); + }); + it('ignores non-navigate actions, non-adapter surfaces, and unpaced sites', () => { + expect(classifyPacedNavigation({ action: 'exec', surface: 'adapter', session: 'site:xiaohongshu' }, rules)).toBeNull(); + expect(classifyPacedNavigation({ action: 'navigate', surface: 'browser', session: 'site:xiaohongshu' }, rules)).toBeNull(); + expect(classifyPacedNavigation({ action: 'navigate', surface: 'adapter', session: 'site:weibo' }, rules)).toBeNull(); + expect(classifyPacedNavigation({ action: 'navigate', surface: 'adapter' }, rules)).toBeNull(); + expect(classifyPacedNavigation({}, rules)).toBeNull(); + }); +}); + +describe('buildSecurityCooldownFailure', () => { + it('produces a machine-readable 429 with a whole-seconds retry hint', () => { + const failure = buildSecurityCooldownFailure('xiaohongshu', 90_500); + expect(failure.status).toBe(429); + expect(failure.errorCode).toBe(SECURITY_COOLDOWN_CODE); + expect(failure.retryAfterMs).toBe(90_500); + expect(failure.message).toContain('xiaohongshu'); + expect(failure.errorHint).toContain('91'); + }); +}); + +describe('shipped pacing defaults', () => { + it('covers xiaohongshu and weibo within the documented pitfall bounds', () => { + // sitemaps/xiaohongshu/pitfalls.md: keep 1-2s between consecutive requests. + for (const site of ['xiaohongshu', 'weibo']) { + const rule = SITE_PACING_RULES[site]; + expect(rule, `${site} missing from SITE_PACING_RULES`).toBeDefined(); + expect(rule.minIntervalMs).toBeGreaterThanOrEqual(1_000); + expect(rule.maxIntervalMs).toBeLessThanOrEqual(3_000); + expect(rule.minIntervalMs).toBeLessThan(rule.maxIntervalMs); + } + }); + it('exposes a machine-readable cooldown error code and sane breaker constants', () => { + expect(SECURITY_COOLDOWN_CODE).toBe('security_cooldown'); + expect(PACING_BREAKER_THRESHOLD).toBeGreaterThanOrEqual(2); + expect(PACING_BREAKER_WINDOW_MS).toBeGreaterThan(0); + expect(PACING_COOLDOWN_MIN_MS).toBeLessThan(PACING_COOLDOWN_MAX_MS); + }); +}); diff --git a/src/site-pacing.ts b/src/site-pacing.ts new file mode 100644 index 000000000..f68051cd8 --- /dev/null +++ b/src/site-pacing.ts @@ -0,0 +1,157 @@ +/** + * Per-site navigation pacing and security-block circuit breaker. + * + * Sites with velocity-based risk control (xiaohongshu, weibo) soft-block + * accounts that navigate faster than a human. The adapters already jitter + * their own settle waits, but nothing spaced *separate CLI invocations* — + * a batch caller could fire navigations back-to-back. The daemon is the one + * local process that sees every CLI client (same argument as + * session-lease.ts), so it enforces the pitfall-doc rules centrally: + * + * - consecutive `navigate` dispatches for the same (contextId, site) are + * spaced by a jittered per-site interval, and + * - repeated SECURITY_BLOCK outcomes open a randomized cooldown during + * which navigations fail fast with `security_cooldown` + retryAfterMs + * instead of hammering a hot risk state (escalation path per #842/#677). + * + * Pure logic, no I/O, injectable clock/rand — testable without Chrome. + */ + +export interface SitePacingRule { + minIntervalMs: number; + maxIntervalMs: number; +} + +/** + * Sites with documented velocity-based risk control. Bounds follow the + * site's own pitfall docs (sitemaps/xiaohongshu/pitfalls.md: keep 1-2s + * between consecutive requests). + */ +export const SITE_PACING_RULES: Readonly> = { + xiaohongshu: { minIntervalMs: 1500, maxIntervalMs: 3000 }, + weibo: { minIntervalMs: 1000, maxIntervalMs: 2000 }, +}; + +/** Machine-readable error code for the breaker-open fast-fail response. */ +export const SECURITY_COOLDOWN_CODE = 'security_cooldown'; + +/** Security blocks inside this window count toward opening the breaker. */ +export const PACING_BREAKER_WINDOW_MS = 10 * 60_000; +/** Blocks inside the window needed to open the breaker. */ +export const PACING_BREAKER_THRESHOLD = 2; +/** Randomized breaker-open duration bounds. */ +export const PACING_COOLDOWN_MIN_MS = 5 * 60_000; +export const PACING_COOLDOWN_MAX_MS = 10 * 60_000; + +/** `site:xiaohongshu` / `site:weibo:` → site name; anything else → null. */ +export function parseSiteFromSession(session: unknown): string | null { + if (typeof session !== 'string') return null; + const match = /^site:([^:]+)/.exec(session); + return match ? match[1] : null; +} + +/** + * Daemon-side hook predicate: an adapter `navigate` dispatch whose session + * belongs to a paced site. Everything else (evaluates, browser-surface + * commands, unpaced sites) passes through unexamined. + */ +export function classifyPacedNavigation( + body: { action?: unknown; surface?: unknown; session?: unknown }, + rules: Readonly> = SITE_PACING_RULES, +): string | null { + if (body.action !== 'navigate' || body.surface !== 'adapter') return null; + const site = parseSiteFromSession(body.session); + return site && rules[site] ? site : null; +} + +export interface SecurityCooldownFailure { + message: string; + errorCode: typeof SECURITY_COOLDOWN_CODE; + errorHint: string; + retryAfterMs: number; + status: 429; +} + +/** Fast-fail response body for a navigation refused while the breaker is open. */ +export function buildSecurityCooldownFailure(site: string, retryAfterMs: number): SecurityCooldownFailure { + const retryAfterS = Math.ceil(retryAfterMs / 1000); + return { + message: `${site} is cooling down after repeated security blocks; navigation refused to avoid escalating risk control.`, + errorCode: SECURITY_COOLDOWN_CODE, + errorHint: `Retry after ${retryAfterS}s. Repeated blocks escalate toward account restrictions — do not lower the cooldown.`, + retryAfterMs, + status: 429, + }; +} + +export type PacingOutcome = + | { granted: true; delayMs: number } + | { granted: false; retryAfterMs: number }; + +export class SitePacer { + private readonly rules: Readonly>; + private readonly rand: () => number; + /** Last assigned navigation slot per contextId␟site. */ + private readonly lastSlot = new Map(); + /** Recent security-block timestamps per contextId␟site. */ + private readonly blocks = new Map(); + /** Breaker-open deadline per contextId␟site. */ + private readonly openUntil = new Map(); + + constructor(rules: Readonly> = SITE_PACING_RULES, rand: () => number = Math.random) { + this.rules = rules; + this.rand = rand; + } + + private key(contextId: string, site: string): string { + return `${contextId}␟${site}`; + } + + /** + * Reserve the next navigation slot for (contextId, site). Concurrent + * callers get consecutive slots (lastSlot advances on every grant), so a + * burst of CLI invocations serializes without locks. Returns the delay the + * caller must wait before dispatching, or a breaker rejection. + */ + acquireNavigationSlot(contextId: string, site: string, now: number): PacingOutcome { + const rule = this.rules[site]; + if (!rule) return { granted: true, delayMs: 0 }; + const key = this.key(contextId, site); + + const open = this.openUntil.get(key); + if (open !== undefined) { + if (open > now) return { granted: false, retryAfterMs: open - now }; + this.openUntil.delete(key); + this.blocks.delete(key); + } + + const interval = rule.minIntervalMs + this.rand() * (rule.maxIntervalMs - rule.minIntervalMs); + const previous = this.lastSlot.get(key); + const slot = previous === undefined ? now : Math.max(now, previous + interval); + this.lastSlot.set(key, slot); + return { granted: true, delayMs: slot - now }; + } + + /** + * Record a command outcome. `security_block` outcomes accumulate toward the + * breaker; any `ok` clears the count (the site let us back in — the state + * is not hot). Unpaced sites are ignored entirely. + */ + reportOutcome(contextId: string, site: string, outcome: 'ok' | 'security_block', now: number): void { + if (!this.rules[site]) return; + const key = this.key(contextId, site); + if (outcome === 'ok') { + this.blocks.delete(key); + return; + } + const recent = (this.blocks.get(key) ?? []).filter((t) => now - t < PACING_BREAKER_WINDOW_MS); + recent.push(now); + if (recent.length >= PACING_BREAKER_THRESHOLD) { + const openMs = PACING_COOLDOWN_MIN_MS + this.rand() * (PACING_COOLDOWN_MAX_MS - PACING_COOLDOWN_MIN_MS); + this.openUntil.set(key, now + openMs); + this.blocks.delete(key); + return; + } + this.blocks.set(key, recent); + } +} From 43fe7c77dd0df8247f186c5f2085c33f04899691 Mon Sep 17 00:00:00 2001 From: Yisi Liu Date: Tue, 29 Sep 2026 16:46:12 +0800 Subject: [PATCH 2/2] docs(pacing): stop citing the sitemaps path removed in #2539 Co-Authored-By: Claude Fable 5 --- src/site-pacing.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/site-pacing.ts b/src/site-pacing.ts index f68051cd8..8a0985599 100644 --- a/src/site-pacing.ts +++ b/src/site-pacing.ts @@ -24,8 +24,9 @@ export interface SitePacingRule { /** * Sites with documented velocity-based risk control. Bounds follow the - * site's own pitfall docs (sitemaps/xiaohongshu/pitfalls.md: keep 1-2s - * between consecutive requests). + * previously documented xiaohongshu operating rule of keeping 1-2s between + * consecutive requests (the sitemaps/ docs that recorded it were removed + * in #2539; the observed risk-control behavior stands). */ export const SITE_PACING_RULES: Readonly> = { xiaohongshu: { minIntervalMs: 1500, maxIntervalMs: 3000 },