DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

Cf Handoff — DeepSeek Harness 插件(DSH Plugin)
DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录
← Plugins
C

dsh-cf-handoff

Cf Handoff

DSH web_fetch 的 Cloudflare 人工接管层:当抓取遇到 Cloudflare 中间页面时,在用户自己的浏览器中打开 URL,暂停代理,并根据人工回复继续执行。

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add github:wtwgo114514/dsh-cf-handoff#34a4e0c2b32c778d5b6ed6f77cb09a695fee50bf
README兼容性版本

兼容性与来源证明

Cf Handoff 以 dsh-cf-handoff 发布,当前版本为 0.1.0。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
any
发布来源
github
Registry 更新时间
2026/10/17

版本

0.1.0stable
2026/10/17

相关插件

正在加载相关插件…

最新版
0.1.0
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
未提供
文件数
未提供
Surface
any
许可证
MIT
发布源
github
GitHub
★ 0
周下载
0
最近提交
2026/10/17
查看源码 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题

相关插件

继续浏览 search-research 分类下经过校验的插件。

Ab Wechat Scrapedsh-ab-wechat-scrape--- description: "树外 wechat_scrape 工具:获取微信公众号文章,过滤推广区块,并将每篇文章保存为 Markdown 文件,供此机器的 dsh 配置文件维护者使用。" kind: "package-reference" ---Ab Market Researchdsh-ab-market-research一个树外 DeepSeek Harness 插件:将 trade_search 工具(多轮扩展方面的查询规划、出版商级别排序、来源多样性、JSON 快照)及其浏览器卡片集成在一个包中。Weknora@wxg-prc-cpg/dsh-weknora适用于 DeepSeek Harness (dsh) 的 WeKnora 知识检索工具:通过自有知识库进行语义搜索、文档阅读以及 RAG/代理回答。Free Searchdsh-free-searchDeepSeek Harness 的免费网页搜索:27 个引擎(Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel 无需密钥;通过 MCP 支持 Zhihu 全网/站内搜索,以及 Perplexity/SerpBase/DeepSeek(官方 + 原生工具/搜索)/You.com/Baidu/Kimi/Aliyun/Douba)

README

dsh-cf-handoff

🇨🇳 中文说明在这里 — README.zh-CN.md(讲清它干什么、怎么装、怎么用, 不要求读完全文)

A Cloudflare human-handoff layer for DeepSeek Harness web_fetch.

When a fetch hits a Cloudflare interstitial, the agent cannot get through by retrying — a human has to open the URL once in a real browser. This plugin detects the interstitial, opens the URL in the user's own default browser, pauses the agent on a question in the DSH Web GUI, and resumes the same fetch call from the human's answer.

Non-goal, stated up front: this plugin never tries to solve a challenge. It contains no solver, no CAPTCHA service, no browser automation, and no cookie theft. It detects, it hands off, it resumes. The human is the mechanism.

最近核对:2026-10-17(DSH 0.1.5-rc.2)。检测特征来自对真实站点的实测 响应,不是从文档推测的;见下文 "Verified against real responses"。 Cloudflare 的特征会变,所以这一行是这份文档里最需要定期更新的一行。


1. What the original spec assumed, and what the source actually says

The design brief for this plugin was written from 推测的 API and marked six assumptions 待核实. All six were checked against the installed source. Four were wrong, and two of those four were wrong in a way that invalidated the intended design — the plugin could not have been built as written.

#Assumption in the briefVerdictWhat the source actually provides
1agent/pre-step exists and can intercept tool results⚠️ exists, wrong roleIt is a step-admission waterfall, not a tool-result node. See below.
2Signature (session, step, next), step carries toolCalls/toolResults❌ wrong(payload: { agent, messages, turn, step, signal }, next). No tool results anywhere in it.
3next() supports { kind: 'skip' | 'reject' | 'replace' }❌ wrongPreStepDecision is { kind: 'reject' } | { kind: 'enter', messages }. Input rewriting is deliberately excluded.
4ctx.userQuestions.ask(session, {...})✅ exists, different call shapeask(request) — a single object. The signature was wrong, the seam is real and is the correct one.
5ctx.browser exposes navigate/read/isHeadless/setHeadless/currentUrl❌ does not existThere is no browser capability in DSH at all. ctx.web is request/response HTTP only.
6browser tools are named browser_*❌ does not existThe only web tools are web_search and web_fetch.

1.1 Assumption 5 and 6 are the load-bearing failures

DSH ships no browser control layer: no Playwright, no Puppeteer, no CDP, no WebDriver dependency, and no browser_* tool registration. The brief's "if headless, switch to a visible window and re-navigate" step is therefore physically unimplementable — there is no window to switch, and dsh-web-fetch-http keeps no cookie jar or session, so a challenge cleared in some other browser would not be reused by the provider anyway.

ctx.web is the whole network capability:

// @deepseek-ai/dsh-web/lib/types/index.d.ts
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;

WebFetchResult is { url, statusCode, body, truncated }. There is no headers field, so cf-mitigated, server: cloudflare, and cf-ray — the canonical header signals — are not observable at this layer. That single fact dictates the whole detection design: detection must work from the status code and the decoded body alone.

1.2 Assumption 1–3: the right seam is not agent/pre-step

agent/pre-step is dispatched with Scoped<Agent> and a payload of { agent, messages, turn, step, signal }, and its decision type is:

// @deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts
export type PreStepDecision = { kind: 'reject' } | {
  kind: 'enter';
  messages: UserMessage[];
  startsRequestSeries?: true;
};

It decides which messages enter a step. It never sees a tool result. The brief's assumption 3 (skip/reject/replace over tool results) does not exist in any form there.

The real tool-result seam is in dsh-tools:

'tools/pre-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>;
'tools/post-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>;

with

export type PreToolDecision =
  | { kind: 'allow' }
  | { kind: 'deny'; reason: string }
  | { kind: 'ask'; reason?: string };

export type PostToolDecision =
  | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
  | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
  | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] };

The PreToolDecision doc comment explains why rewriting is missing: "Input rewriting is excluded because arguments are already logged and presented."

1.3 Why this plugin wraps ctx.web.fetch instead of using tools/post-execute

tools/post-execute can replace a finished result — including the one projection the model sees — but it cannot make the tool run again. The brief's recovery requirement is "resume from the breakpoint, don't re-run the probe", and for a blocked page the only way to obtain content is to fetch again after the human acted. A post-execute listener would have to re-issue the fetch itself and could only ever fake resumption.

Wrapping ctx.web.fetch puts the pause-and-retry inside the original call: web_fetch awaits ctx.web.fetch(...), that await is suspended while the human answers, and the same call returns the real post-verification content. No second tool invocation, no fabricated result. That is what makes this a resumption rather than a retry loop.

1.4 It is deliberately not a second fetch provider

The obvious-looking approach — register a WebFetchProvider — is a trap. dsh-web's resolver (dsh-web/lib/index.js) fails with WEB_PROVIDER_AMBIGUOUS when fetchProvider is unconfigured and more than one provider is available, and registerProvider throws WEB_DUPLICATE_PROVIDER for a repeated id. Adding a provider would therefore change provider selection for every unrelated fetch in the process, and the change would outlive the plugin's usefulness. Decorating the service method is disposable and leaves the registry untouched.


2. The one thing that makes the handoff actually work

ctx.userQuestions.ask() is a scope-filtered waterfall. From dsh-user-questions/lib/index.js:

return await (agent === void 0
  ? this.ctx.waterfall("user-questions/request", request, noAnswerer)
  : this.ctx.waterfall(scopeTarget(agent, agent), "user-questions/request", { ...request, agent }, noAnswerer));

The Web GUI answerer is composed on the Agent-scoped waterfall, and user-questions/request is a mode: waterfall remote event in dsh-api-remotes. So a question asked without an agent never reaches the browser — it falls through to noAnswerer and rejects with NO_PROVIDER.

But WebRuntime.fetch(request, signal) receives only { url } and a signal. The agent is nowhere in that call, and WebRuntime is a root-level service, so context probing cannot recover it either.

The agent loop publishes it exactly one layer up. ToolExecution.agent is documented as "The agent on whose behalf the call runs (set by the agent loop)", and tools/pre-execute is dispatched with scopeTarget(this, exec.agent). So lib/agent-correlation.js observes that event and remembers which agent owns each in-flight call.

The signal is not a reliable key, and this is the one place where the obvious design is wrong. dsh-tool-web does hand exec.signal straight through:

const result = await ctx.web.fetch({ url: input.url }, exec.signal);

but by the time the tool body runs, exec.signal is no longer the signal tools/pre-execute saw. ToolRegistry.dispatchToolBody fuses the caller's signal with whatever an around-wrapper installed and overwrites the field:

const wrapperSignal = exec.signal;
const fused = fuseToolSignals(state.callerSignal, wrapperSignal);
exec.signal = fused.signal;              // ← a brand-new AbortController signal
const returned = await tool.execute(exec.arguments, exec);
// finally: exec.signal = wrapperSignal

fuseToolSignals returns the caller's signal unchanged only when caller === wrapper. With any around-wrapper in play — and the real Web deployment has one — the tool body receives a fresh signal that nothing outside that call ever saw. Measured on a real run:

agent associated; matched by URL only (1 record(s), signal present);
scoped ask: NO_PROVIDER; unscoped ask: NO_PROVIDER

Identity missed while the call was plainly in flight. So correlation is two tiered, and the URL tier is the one that works:

  1. signal identity — exact, and the only key that is provably about this call. Cheap to keep, and it is what a deployment without around-wrappers gets.
  2. URL, only when unambiguous — one URL names exactly one agent. Two agents fetching the same URL resolve to nothing rather than to the wrong session: a question about one page must never pop up in another agent's conversation.

A miss returns nothing, and the controller then reports the fetch as unavailable (coded web_cloudflare_handoff_unavailable) rather than pretending to have asked a question nobody saw. The coded message carries the correlation diagnostic, so "the observer never fired" and "the observer fired but no tier matched" are distinguishable without another restart — those two faults used to look identical from the fetch side.

Two consequences worth knowing:

  • If the agent cannot be recovered, the question would be asked into a void. The controller does not silently proceed: it returns a coded error naming the URL so the agent can surface it to the user through ask_user_question instead.
  • A fetch made by an owned child agent throws DELEGATED_CALLER. Falling back to an unscoped question there would pop a question about a subagent's URL into the parent's session, so that case is reported as unavailable rather than quietly redirected.

3. Using the user's own browser

DSH has no browser, but the user is already running one — they are reading the DSH Web GUI in it. The handoff uses two real host capabilities, and neither is a browser control layer:

  1. The platform's "open with the default application" verb, invoked by lib/native-open.js. On Windows that is powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath <path>", which hands the file to the .html association → the default browser.
  2. The question card in the Web GUI, which renders markdown.

3.1 Why a staged bootstrap page, and not just "open the URL"

The opener takes a path. On Windows it runs powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath <path>", and Invoke-Item resolves its argument as a provider path. Measured on the target machine:

ArgumentResult
a real .html fileexit 0, silent, opens via the .html file association → default browser
file:///C:/Users/.../x.htmlCannot find drive. A drive with the name 'file' does not exist. — exit 1
https://example.comCannot find drive. A drive with the name 'https' does not exist. — exit 1

A URL cannot be opened. So lib/browser-open.js writes a small local HTML page that links the real URL (and, when autoNavigate is on, navigates to it immediately), then opens that file. The staged page is created under the OS temp directory with a dsh-cf-handoff- prefix and is removed by the plugin's disposer.

3.1.1 Why the opener is not openNativePath

The first version imported @deepseek-ai/dsh-native-command and called its openNativePath. In the real deployment — a plugin installed with link: — that throws on every handoff:

Error: Cloudflare challenge on https://community.cloudflare.com/ — the page content was NOT retrieved.
...
The browser could not be opened automatically (Cannot find package
'@deepseek-ai/dsh-native-command' imported from D:\桌面\工作区\dsh-cf-handoff\lib\index.js).

Bare-specifier resolution walks up from the importing file. A link:-installed plugin sits outside the profile's node_modules, so the harness package is not reachable from it; declaring it a dependency would only produce a second copy and still not the profile's instance. The behaviour is therefore reproduced in lib/native-open.js — twenty lines, no runtime dependency, same commands — and test/acceptance-browser.mjs is what checks the replacement against this machine. test/native-open.test.mjs pins the exact argument vectors so the table above keeps applying to the shipped code.

Writing the replacement turned up a second measurement worth keeping. spawn options change what "the launch failed" even means:

spawn optionsexit code for a URL argument
stdio: 'pipe'1 (stderr carries Cannot find drive)
stdio: 'ignore'1
stdio: 'ignore', detached: true0

detached: true looks like the harmless choice — "let the opener outlive the caller" — and it silently turns a failed launch into an apparent success, so the control assertion in test/acceptance-browser.mjs passed while no window opened. The shipped opener does not detach, and a test pins options.detached to undefined with that reason attached. It does not need to detach: it awaits the launcher's exit, and the browser is started by the shell association, not by this process.

3.2 The browser is an accelerator, never a dependency

Every failure path keeps the handoff alive, because the question itself always carries the URL:

  • opener unavailable or throws → the notice says so, and the URL is still handed over in the question;
  • openBrowser: false → no browser attempt at all;
  • headless deployment → set openBrowser: false; the user opens the URL from the question card in whatever browser they have.

A second handoff for the same URL within five minutes reuses the tab that is already on screen instead of spawning another. That window exists because of what the first real acceptance run looked like: the user cleared the check, chose verified, the retry was challenged again (see §5.3 — that is expected), and the next fetch opened a third tab for a page the user was already looking at. One tab per page, not one tab per attempt. A different URL, or the same URL after the window, stages its own page; reopenAfterMs: 0 disables the reuse.

Staged pages live in %TEMP%/dsh-cf-handoff-* (os.tmpdir() on POSIX) and are removed when the plugin is disposed. A hard kill leaves the directory behind — it is one ~2 KB HTML file, and the OS temp sweeper will eventually take it. If you restart DSH a lot, sweep them yourself; the plugin deliberately does not delete directories at startup, because a second running instance may have a live page in there.


4. Detection

lib/cf-detect.js is pure, dependency-free, and I/O-free, so it is unit-testable against recorded responses.

detectCfInterstitial({ statusCode, body }, { minScore }) // → { kind, score, signals, summary }
isCloudflareChallenge(html, url)
isCloudflareChallengeUrl(url)
couldBeCloudflareInterstitial(url, body)

Verdict kinds, ordered from "worth a human" to "not Cloudflare's doing":

kindmeaninghandoff?
challengean interactive interstitial a human can clearyes
blockCloudflare refused outright (1020 / firewall rule)no — no human clears it
page-erroran edge error page that merely mentions Cloudflareno
noneno signalno

Scoring: each matching rule adds a weight; DEFAULT_MIN_SCORE = 12, and an interstitial status code (403/429/503) adds a bonus only when the body already carries a signal. Strong markers (_cf_chl_opt, /cdn-cgi/challenge-platform) are worth 8–10; the phrasal evidence (Just a moment..., checking your browser, Turnstile embeds) 6–8; weak hints (Attention Required!) 4–5.

4.1 Verified against real responses

The tuning is not guesswork. Four real fetches, recorded while building this:

URLResponseRequired verdict
https://community.cloudflare.com/HTTP 403, body is the bare Just a moment... shell — no JavaScript runs, so only the title remainschallenge (scores 14)
https://www.g2.com/HTTP 403, Please enable JS and disable any ad blockerchallenge
https://developers.cloudflare.com/fundamentals/reference/error-codes/HTTP 404, a large real document that says "Cloudflare" hundreds of timesnone — this is the false-positive test
https://discord.com/api/v10/gatewaytransport failurenot a Cloudflare matter

4.2 Avoiding false positives

The dangerous failure is stalling a turn on a page that never needed a human, so the rules are written to require structural evidence:

  • Title-derived rules require a blocked status code by default. A blog post titled "Just a moment..." or prose explaining "checking your browser" must not trip detection — a case that is explicitly unit-tested.
  • The mere presence of the word "Cloudflare" scores nothing.
  • A truncated body is never inspected: a truncated interstitial shell can be missing the very marker that identifies it, so classifying it would be a guess. The plugin warns and passes the response through.
  • isCloudflareChallenge(html, url) returns true only for a challenge verdict. The recall-oriented couldBeCloudflareInterstitial is a separate function, so a URL-only hit can never be mistaken for a confirmed challenge.

4.3 Extending the markers

Feature lists age: Cloudflare changes its interstitial markup. Rules live in one exported table at the top of lib/cf-detect.js; add an entry with a weight and a kind, and cover it in test/detect.test.mjs. Prefer a structural selector (an element id, a script path) over a sentence — sentences get translated and reworded.


5. Where the waiting happens, and why nothing hangs forever

5.1 The 30-second ceiling that decides the whole architecture

An in-call wait is the obvious design, and on web_fetch it does not work. The first version of this plugin did exactly that, and a real GUI session produced this instead of a question card:

Error: tool call timed out after 30000ms

The cause is a host row, @deepseek-ai/dsh-tool-call-timeout-policy:

// @deepseek-ai/dsh-tool-call-timeout-policy/lib/index.js:123-130
const timeoutMs = ctx.tools.get(exec.name, exec.agent)?.timeoutMs;
if (timeoutMs === void 0) return next();
const d = /* ... */ deadline(exec.signal, timeoutMs, TOOL_TIMEOUT) /* ... */;
if (timeoutOf(d.signal, 'TOOL_TIMEOUT') !== void 0) return toolTimeoutResult(timeoutMs);

toolTimeoutResult (:93-94) renders tool call timed out after ${timeoutMs}ms. web_fetch takes its budget from fetchTimeoutMs, default DEFAULT_WEB_TOOL_TIMEOUT_MS = 30000 (@deepseek-ai/dsh-tool-web/lib/index.js:850, attached as ToolDefinition.timeoutMs at :801).

So 30 seconds is a hard ceiling, and exceeding it does not merely fail: the policy aborts the call and substitutes its own message, discarding whatever the plugin wanted to say — including the URL the user needs. A handoff that waits ~90 s for a person therefore produces strictly less information than no handoff at all.

ask_user_question is not subject to this, because @deepseek-ai/dsh-tool-ask-user declares no timeoutMs and the policy returns next() for an undefined budget. That asymmetry is the design:

modewho waits for the humanwhen to use it
notify (default)nobody inside the fetch — the agent then awaits the human with ask_user_question, which has no budgetanywhere web_fetch keeps its default 30 s budget
inlinethe fetch itself, then retries once after the answeronly when the calling tool's budget is raised above minHumanBudgetMs

In notify mode the fetch still does everything it can: it stages the bootstrap page, opens the browser, and throws a coded notice carrying the URL and telling the agent to await the human. It just refuses to be the wait.

inline mode is guarded rather than trusted. Before asking, it reads the budget the policy will enforce and refuses to start a wait it cannot finish:

the tool call is budgeted at 30000ms but a human round trip needs at least 120000ms,
so an in-call wait would be killed by the timeout policy and its generic message
would replace this one (raise tool-web's fetchTimeoutMs to allow it)

Refusing early is strictly better than being killed mid-question, because an early refusal still carries the URL.

The table below lists inline mode's outcomes. In notify mode the fetch never asks: it reaches the top of the table's report path and hands the human to the agent.

SituationBehaviour
User says the check is clearthe same fetch call retries once; if the content now arrives, that content is returned
Still challenged after the retryfails with code web_cloudflare_challenge and the URL, telling the agent the page is the verification step
User pastes the page contentreturned immediately as a synthesized result — the network is not re-queried
User declinesfails with code web_cloudflare_handoff_refused and their reason
No answerer reachablefails with code web_cloudflare_handoff_unavailable plus the URL
Cloudflare hard-blocksfails with code web_blocked_by_cloudflare; the user is never asked, because no human action clears a firewall rule
Turn cancelledASK_ABORTED propagates untouched; staged files are cleaned up

The retry budget is per fetch call (maxAttempts, default 1), so a page that re-challenges cannot be reset into a loop by re-triggering the interstitial. A budget of 0 is legal and produces a report-only plugin.

The distinction that matters: skip never yields empty content that the model might misread as "the page was blank". Every non-recovery path is a coded error carrying the URL.

5.2 A code the agent cannot read is not a code

Those codes are only useful if the agent actually sees them, and the first end-to-end run proved it did not. A thrown tool error reaches the model as the literal text:

// @deepseek-ai/dsh-tools/lib/index.js:3496
text: `Error: ${message}`

The code survives only as a structured field (result.error.info.code, :3496–:3501); it is never interpolated into that text. Handed a real challenge, the agent quoted this plugin's message and reported the inner NO_PROVIDER as the failure code, because that was the only code-shaped string it could see — web_cloudflare_handoff_unavailable was invisible to it.

So fail() in lib/provider.js appends the code to the message text as well as setting it on the error:

const text = message.includes(code) ? message : `${message} [${code}]`;

Every documented code therefore appears twice: machine-readable on the error object, and readable in the text the model gets. The table above describes what the agent can actually read, which is the only version of that promise worth making.

The two headless runs, verbatim, are the whole argument — the only difference between them is that one line of fail():

# before — asked for "its exact error code", the agent answered:
| `NO_PROVIDER` | … the harness-level code for the failed handoff |

# after — the same task, same site, same challenge:
Error: Cloudflare handoff could not reach the user: … [web_cloudflare_handoff_unavailable]
Notes for interpretation: … the bracketed tag `[web_cloudflare_handoff_unavailable]`
is the failure code.

Same detection, same fallback, same URL. The code became actionable only when it moved into the text. test/acceptance-headless.mjs now asserts it stays there.

5.3 Why "verified, retry" is not always enough — and why pasting is the primary path

This layer does not transfer the clearance the human earned. It cannot: dsh-web-fetch-http builds an Undici request with no cookie jar and no session ("Requests carry no browser cookies or ambient credentials"), and the challenge clearance lives in a cf_clearance cookie plus a browser fingerprint, in the user's browser profile — not in DSH. So the retry is a fresh, stateless request that may itself be challenged again.

The README is explicit about this rather than hiding it, because it decides which answer the user should give:

  • A page that only gates the first load (a one-time interstitial, a redirect that stamps a query parameter) clears on the retry. That is the happy path.
  • A page that challenges every cold request — which is most Just a moment... interstitials — will keep being challenged no matter how many times the human clears it in their own browser.

For the second case the paste path is not a fallback, it is the answer: the human is already looking at the real page in the real browser, so the content they can copy is content this layer can never fetch. The question is worded to make that the obvious move ("paste the page content as your answer"), and classifyPastedBody sends markup back down the same rendering path the network result would have taken.

This is also why the plugin refuses to "solve" Cloudflare: a solver would have to forge exactly the fingerprint and cookie it is honest about not having.


6. Install

The package ships its own cordis.patch.yml and declares dsh.bundle.patch, so it mounts as a profile layer rather than needing a hand-written row:

dsh plugin add <path-or-tarball> --profile web

From a clone, that is just dsh plugin add ./dsh-cf-handoff --profile web; the profile records the spec verbatim, so a link:-style development checkout keeps picking up edits without reinstalling.

dsh plugin add reconciles dsh.profile.bundles against the installed state and appends this package automatically, because the resolved manifest declares dsh.bundle. Verify with:

dsh plugin list --profile web     # dsh-cf-handoff must appear under bundles

If you would rather mount it by hand, the row is:

# profile cordis.patch.yml
- insert:
    - id: cf-handoff
      name: dsh-cf-handoff

The row needs no particular position: DSH activates rows by service availability, and the plugin declares inject: ['web', 'userQuestions', 'tools'].

An id-targeted override replaces the row's config; it does not merge key by key. Overriding only minScore in your profile layer therefore drops the other keys back to the plugin's own schema defaults, not to the values in the bundle layer. Restate every key you care about:

# profile cordis.patch.yml — headless deployment, all three keys restated
- id: cf-handoff
  config:
    minScore: 12
    maxAttempts: 1
    openBrowser: false

Every file in the layer is read live from the installed directory, so a link: install needs no reinstall after an edit. A lib/*.js edit still needs a process restart: patchReload: live reloads the patch layer, not the ESM module graph, and Node caches imported modules for the life of the process. This one was learned by measuring it — a diagnostic string added to handoff.js did not show up in the next fetch until the process bounced, which is exactly the kind of "it must have hot-reloaded" assumption this document exists to avoid.

Configuration

keydefaultmeaning
enabledtruemaster switch
minScore12detection threshold; raise to 16+ for fewer handoffs, lower to ~6 for maximum recall
modenotifynotify = report and return, agent awaits the human; inline = wait inside the fetch (needs a raised tool budget — see §5.1)
minHumanBudgetMs120000smallest tool-call budget an inline wait may start inside
maxAttempts1handoffs allowed per fetch call; 0 = report only (inline mode)
openBrowsertrueopen a staged bootstrap page in the default browser
autoNavigatetruethe staged page navigates to the URL on load instead of waiting for a click
reopenAfterMs300000reuse the tab already open for this URL instead of staging another page (§3.2); 0 disables the reuse
tmpRootOS tempwhere staged pages are written

Both modes stage and open the browser; the difference is only who waits.

Per-preset note

tool-web composes web_fetch per agent preset, and the pentest preset ships fetch: false. The plugin is preset-independent: with web_fetch off, nothing reaches the seam and it stays idle.


7. Module map

fileresponsibility
lib/index.jsCordis plugin: config, wiring, lifecycle
lib/cf-detect.jspure detection — rules, scoring, verdicts
lib/agent-correlation.jsremembers the agent behind each in-flight fetch, by signal then by unambiguous URL
lib/agent-scope.jsscope probing, used as a fallback for agent recovery
lib/browser-open.jsstaged bootstrap page + opener call
lib/native-open.jsthe opener itself: platform "open with the default application" verbs
lib/handoff.jsthe pause: question construction, answer parsing, retry budget
lib/provider.jsctx.web.fetch decorator, error surface, disposer

The split is deliberate: cf-detect.js is pure so detection can be tested from recorded fixtures without a runtime, while handoff.js/provider.js own everything that needs one. native-open.js is separate from browser-open.js because only the former touches the host.

8. Tests

npm test                        # node --test "test/*.test.mjs" — 114 tests, no network, no runtime
npm run test:smoke              # live wiring against the installed Cordis / WebRuntime / UserQuestionService
npm run acceptance:headless     # a real headless DSH run against a real Cloudflare site
npm run acceptance:browser      # the real opener on this machine — OPENS ONE BROWSER WINDOW

detect.test.mjs covers the four recorded real responses above, the false-positive regression, URL markers, truncation, and rule weighting. handoff.test.mjs covers answer parsing, pasted-body classification, question construction, and the DELEGATED_CALLER / NO_PROVIDER branches. provider.test.mjs covers the intercept end to end over a fake runtime: retry budget, pasted short-circuit, coded failures, idempotent install, and disposal. agent-correlation.test.mjs covers both correlation tiers, parallel fetches, the ambiguity refusal, the aborted-call eviction, and the observer's disposer. native-open.test.mjs pins the opener to the exact platform command vectors and to the spawn options (stdio: 'ignore', no detached), so the §3.1 and §3.1.1 tables stay true of the shipped code — not just of the harness package it was copied from. browser-open.test.mjs covers the bootstrap page (escaping, the optional self-navigation, a hostile detection reason) and the tab-reuse rule of §3.2, over an injected opener and a fake clock.

test/live-smoke.mjs is the one that matters for the claims this design rests on, because the unit tests fake both sides of every seam. It loads the real @deepseek-ai/cordis, dsh-web, dsh-user-questions and dsh-scope from the installed profile and asserts:

  • ctx.web.fetch really is a replaceable own method on the live WebRuntime, and the wrapper still runs dsh-web's own provider resolution.
  • scopeTarget(agent, agent) really does admit an answerer registered on that agent's scope, and carrierKeyOf on that carrier returns the agent — which is what lib/agent-scope.js's fallback depends on.
  • tools/pre-execute observed from a plain context really does reach the correlation table, keyed by the signal the tool hands to web.fetch. If that admission rule went the other way the observer would record nothing and every handoff would degrade to "no answerer" — a failure that looks like the network, not like a bug.
  • The question the handoff builds survives to the answerer with its id, header, both option labels and the clickable URL link intact.

Two facts it pinned down that a fake cannot show, both of which changed the code:

  • A question asked without an agent does not reach the browser. The GUI answerer is composed on the Agent-scoped waterfall, so ask({ questions }) with no agent falls through to noAnswerer. This is why lib/agent-correlation.js exists at all.
  • exec.signal at tools/pre-execute is not the signal the tool body receives. dispatchToolBody overwrites the field with a fused signal. This is not visible from any seam above the tool body, which is why the code shipped first with a signal-only key and then failed on a real page while every fake-based test passed. Correlation now falls back to the URL, and the coded failure reports which tier matched.
  • request.questions arrives as the live service's own array proxy. Indexing it works; reading named fields off the elements later does not. Anything that wants to inspect a question must read it while the request is in hand.

Neither the smoke test nor the unit tests reach the network or open a window; openBrowser: false exercises exactly the headless path that must work with no opener available.

CI (.github/workflows/ci.yml) runs npm test on Node 22 and 24, on both Linux and Windows. Node 20 is deliberately out of the matrix: npm test hands a glob to node --test, which Node 20 reads as a literal path (Could not find '.../test/*.test.mjs') because glob expansion in the test runner only arrived in Node 21 — and 20 is past end of life anyway.

The other three commands stay local by design: they need an installed DSH profile, a real dsh binary, and — for the last one — a desktop session.

8.1 The two acceptance scenarios

These are the only tests that use the real world. Both are opt-in because one spends model calls and the other opens a window.

test/acceptance-headless.mjs runs dsh --profile headless twice against live sites — a guarded one that serves a genuine interstitial, and a plain one — and asserts on what the agent could actually read. It needs the plugin installed into that profile first (dsh plugin add <dir> --profile headless). A headless run has no GUI answerer, so it exercises the branch that must report rather than hang, and that is the only branch reachable without a human at the keyboard. It is also the regression guard for §5.2 — remove the code from the message and this script fails while every unit test still passes.

test/acceptance-browser.mjs calls the plugin's own shipped opener (lib/native-open.js, the exact function lib/index.js wires) and opens one browser window. It exists because every other test injects a fake opener, so none of them show that "the user's default browser opens the blocked URL" is true of the code that actually runs. It also asserts the control: handing a URL straight to the opener fails, which is what pins the staged-file design to execution rather than to reading. §3.1.1 explains why this file stopped importing @deepseek-ai/dsh-native-command: the import is what failed in the real install.

8.2 What is still unverified

One live round trip has now been done, by hand, in the Web GUI. It went: a real web_fetch of a guarded site → challenge detected → a browser tab opened from the staged page → the notice returned with the code and the URL → the agent asked, with the URL as a clickable detail link → the human chose verified → the retry ran → the retry was challenged again, which is exactly what §5.3 predicts for a stateless fetch. So the mechanical loop closes; what it cannot do is carry a clearance it never had.

What that leaves unverified:

  • The paste path. It has never been exercised end to end by a human. Everything up to and including classifyPastedBody is covered by unit tests, but no live run has pasted a real page into the answer box.
  • The model following the instruction is still model behaviour. The notice is asserted word-for-word (test/provider.test.mjs) and ask_user_question with an agent is proven to reach the Web GUI answerer (test/live-smoke.mjs); whether a given model actually asks is not something a test here can force.
  • A page that stops challenging. No fixture can promise it, so the "verified → content" branch is verified only against a fixture provider in test/live-smoke.mjs, never against a live site.

inline mode's human round trip is likewise only partly observable from a script: the plugin side is covered (ask → answer → retry), but a real clear requires a page that stops challenging.


9. Files

pathrole
lib/the plugin (see §7)
test/unit, smoke and acceptance suites (§8)
cordis.patch.ymlthe profile-layer row (§6)
tsconfig.jsoneditor/opt-in JSDoc checking only — there is no build step, and TypeScript is not a dependency
package.jsonthe dsh.bundle.patch declaration is what makes §6's one-liner mount the layer
LICENSEMIT

10. Localization

All human-facing text is hard-coded English in 0.1.0. That is the obvious contribution if this is going to fit a non-English deployment, so here is the map.

wherewhatconstraint
lib/handoff.js buildHandoffQuestions()the question's header, question, and the three option labels verified / paste / skipthe labels are the answer protocol — the GUI returns the selected label
lib/handoff.js parseAnswer()accepts those labels and tolerant synonyms (done, passed, ok, verified, skip, refused, …)add translated labels here, keep the English ones. It already recognizes a few Chinese done words (完成 好了 过了 通过 重试) but no Chinese refusal words — that is the first gap to close
lib/handoff.js classifyPastedBody()decides whether a pasted body is a real page or a challenge againit reads page structure, not language; no translation needed
lib/handoff.js buildChallengeNotice()the instruction the agent reads before it asksmodel-facing, not human-facing — translate only if your models do better that way
lib/provider.jscoded failure messagesthe bracketed code must survive translation (§5.2)

Two rules for a localization patch:

  1. Never translate the bracketed codes. web_cloudflare_challenge, web_cloudflare_handoff_refused, web_blocked_by_cloudflare and friends are contract: the model reads them and test/provider.test.mjs matches them.
  2. Translate a label and its parser in the same commit. An option whose label no parser recognizes does not fail loudly — it degrades every handoff to "the channel returned an empty answer", which reads like a transport bug.

detail carries the blocked URL as a markdown link, and the DSH Web GUI renders markdown, so the click-through needs no translation at all.