diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts
index a4293fd43..964e3dfff 100644
--- a/apps/desktop/src/lib/api.ts
+++ b/apps/desktop/src/lib/api.ts
@@ -719,10 +719,15 @@ export const api = {
// --- Skill market ----------------------------------------------------------
searchSkillMarket: (query: string, sources: { id: string; name: string; url: string }[]) =>
- invoke<{ entries: SkillCatalogEntry[]; failedSources?: string[] }>(
- IPC.invoke.skillMarketSearch,
- { query, sources },
- ),
+ invoke<{
+ entries: SkillCatalogEntry[];
+ failedSources?: string[];
+ /**
+ * Why each named source failed, so the market can explain a policy/DNS
+ * refusal instead of reporting every source as merely unreachable.
+ */
+ failureKinds?: Record;
+ }>(IPC.invoke.skillMarketSearch, { query, sources }),
/** Fetch one catalog document (frontmatter split off) for preview/install. */
fetchSkillMarketDocument: (entry: SkillCatalogEntry) =>
invoke<{ name?: string; description?: string; body: string; resources?: Array<{ path: string; body: string }> }>(
diff --git a/apps/desktop/src/lib/skill-market-failure.ts b/apps/desktop/src/lib/skill-market-failure.ts
new file mode 100644
index 000000000..233496580
--- /dev/null
+++ b/apps/desktop/src/lib/skill-market-failure.ts
@@ -0,0 +1,42 @@
+import { ErrorCodes } from "@pi-desktop/shared";
+
+/**
+ * Why a skill market request failed, as far as the renderer can tell.
+ *
+ * `policy` means the main process refused the fetch inside its public-network
+ * guard, so the request never reached the network. The guard classifies the
+ * host with a *local* DNS lookup, while the request itself would have gone
+ * through the configured proxy (ADR 0177, ADR 0243). A user behind a proxy or
+ * TUN resolver that answers DNS itself — Clash fake-IP in `198.18.0.0/15`, a
+ * corporate split resolver, an offline resolver — gets a policy refusal for a
+ * URL that opens fine in their browser. That distinction is the whole point of
+ * surfacing this: it tells the user to look at the proxy setting instead of
+ * assuming the source is down.
+ */
+export type SkillMarketFailureKind = "policy" | "network";
+
+/** Classify a rejected `api.fetchSkillMarketDocument` / search call. */
+export function classifySkillMarketFailure(error: unknown): SkillMarketFailureKind {
+ const code = (error as { code?: unknown } | null | undefined)?.code;
+ return code === ErrorCodes.NETWORK_POLICY_BLOCKED ? "policy" : "network";
+}
+
+/**
+ * The main-process reason, kept verbatim for the install sheet. It is internal
+ * English text, but it names the failing host only — the URL shown next to it
+ * in the sheet is already the same public HTTPS address, and the guard rejects
+ * credential-bearing URLs, so this adds no secret to the screen.
+ */
+export function skillMarketFailureDetail(error: unknown): string {
+ if (error instanceof Error && error.message) return error.message;
+ if (typeof error === "string") return error;
+ return "";
+}
+
+/**
+ * Whether a list of failed sources contains at least one policy refusal, which
+ * is what makes the app-level proxy hint worth showing.
+ */
+export function hasPolicyFailure(kinds: Record | undefined): boolean {
+ return Object.values(kinds ?? {}).includes("policy");
+}
diff --git a/apps/desktop/src/styles/settings.css b/apps/desktop/src/styles/settings.css
index f8348789d..1e1400534 100644
--- a/apps/desktop/src/styles/settings.css
+++ b/apps/desktop/src/styles/settings.css
@@ -3044,6 +3044,11 @@
font-size: var(--text-xs);
}
+/* A failed preview must read as an error, not as a muted side note. */
+.sklm-note.is-error {
+ color: var(--ds-error);
+}
+
.sklm-note-label {
flex: 0 0 auto;
color: var(--ds-text-primary);
diff --git a/apps/desktop/test/public-https-fetch.test.mjs b/apps/desktop/test/public-https-fetch.test.mjs
index d3e958f69..d9ff5846e 100644
--- a/apps/desktop/test/public-https-fetch.test.mjs
+++ b/apps/desktop/test/public-https-fetch.test.mjs
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
+import { ErrorCodes } from "@pi-desktop/shared";
import {
createPublicHttpsClient,
PublicNetworkPolicyError,
@@ -83,3 +84,28 @@ test("does not retry policy failures", async () => {
);
assert.equal(calls, 1);
});
+
+test("a policy refusal carries the stable error code the renderer classifies on", async () => {
+ // Issue #419: the renderer can only tell a local-DNS policy refusal (what a
+ // proxied user hits) from an ordinary failure by this code, because `wrap()`
+ // forwards nothing but code and message across the IPC boundary.
+ const client = createPublicHttpsClient({
+ fetchImpl: async () => jsonResponse(200, { ok: true }),
+ lookupImpl: async () => [{ address: "198.18.0.4" }],
+ });
+ await assert.rejects(
+ () => client.request("https://cdn.jsdelivr.net/gh/x/SKILL.md", "text"),
+ (error) =>
+ error instanceof PublicNetworkPolicyError &&
+ error.errorCode === ErrorCodes.NETWORK_POLICY_BLOCKED &&
+ /private address/.test(error.message),
+ );
+});
+
+test("a syntactic URL refusal carries the same code", async () => {
+ const client = createPublicHttpsClient({ fetchImpl: async () => jsonResponse(200, "x") });
+ await assert.rejects(
+ () => client.assertPublicUrl("http://127.0.0.1/catalog.json"),
+ (error) => error.errorCode === ErrorCodes.NETWORK_POLICY_BLOCKED,
+ );
+});
diff --git a/apps/desktop/test/skill-market-failure.test.mjs b/apps/desktop/test/skill-market-failure.test.mjs
new file mode 100644
index 000000000..b8abf6d66
--- /dev/null
+++ b/apps/desktop/test/skill-market-failure.test.mjs
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { ErrorCodes } from "@pi-desktop/shared";
+import {
+ classifySkillMarketFailure,
+ hasPolicyFailure,
+ skillMarketFailureDetail,
+} from "../src/lib/skill-market-failure.ts";
+
+/**
+ * Regression cover for issue #419: behind a proxy the main process can refuse a
+ * skill market fetch inside its local-DNS public-network guard, and the install
+ * sheet used to swallow that rejection and leave the install button disabled
+ * with no reason. The renderer can only tell the two cases apart by the stable
+ * error code the IPC wrapper forwards, so that mapping is what gets pinned here.
+ */
+
+test("a policy refusal is told apart from an ordinary network failure", () => {
+ const policy = Object.assign(new Error("hostname resolves to a private address: api.github.com -> 198.18.0.4"), {
+ code: ErrorCodes.NETWORK_POLICY_BLOCKED,
+ });
+ assert.equal(classifySkillMarketFailure(policy), "policy");
+
+ for (const other of [
+ Object.assign(new Error("responded 502"), { code: ErrorCodes.INTERNAL }),
+ Object.assign(new Error("responded 404"), { code: ErrorCodes.NOT_FOUND }),
+ new Error("no code at all"),
+ "a bare string",
+ undefined,
+ null,
+ ]) {
+ assert.equal(classifySkillMarketFailure(other), "network", String(other));
+ }
+});
+
+test("the sheet keeps the main-process reason and never invents one", () => {
+ const policy = Object.assign(new Error("hostname does not resolve: api.github.com"), {
+ code: ErrorCodes.NETWORK_POLICY_BLOCKED,
+ });
+ assert.equal(skillMarketFailureDetail(policy), "hostname does not resolve: api.github.com");
+ assert.equal(skillMarketFailureDetail("plain"), "plain");
+ assert.equal(skillMarketFailureDetail(undefined), "");
+ assert.equal(skillMarketFailureDetail({}), "");
+});
+
+test("the app-level proxy hint appears only when a source was actually refused", () => {
+ assert.equal(hasPolicyFailure({ "anthropics/skills": "policy" }), true);
+ assert.equal(hasPolicyFailure({ "anthropics/skills": "network" }), false);
+ assert.equal(hasPolicyFailure({}), false);
+ assert.equal(hasPolicyFailure(undefined), false);
+});
diff --git a/apps/desktop/test/skill-market-panel.test.mjs b/apps/desktop/test/skill-market-panel.test.mjs
index 2e39b1271..3bb08df38 100644
--- a/apps/desktop/test/skill-market-panel.test.mjs
+++ b/apps/desktop/test/skill-market-panel.test.mjs
@@ -75,3 +75,58 @@ test("preview race gate tokens guard in-flight responses and close invalidates t
assert.match(panel, /setDocumentBody\(null\)/);
assert.match(panel, /setDocumentTooLarge\(false\)/);
});
+
+test("a failed preview is reported instead of silently disabling install", () => {
+ // Issue #419: the catch used to reset the body and say nothing, so the
+ // install button sat disabled behind the word "Loading…" with no reason and
+ // no way to try again. The preview-before-save gate itself is intentional and
+ // stays — what changes is that a failure is now legible and recoverable.
+ assert.match(panel, /setPreviewFailure\(\{/);
+ assert.match(panel, /kind: classifySkillMarketFailure\(error\)/);
+ assert.match(panel, /detail: skillMarketFailureDetail\(error\)/);
+ assert.match(panel, /settings\.sklm\.previewError/);
+ assert.match(panel, /settings\.sklm\.previewPolicyError/);
+ assert.match(panel, /settings\.sklm\.proxyHint/);
+ assert.match(panel, /settings\.sklm\.failureDetail/);
+ assert.match(panel, /settings\.sklm\.retryPreview/);
+ assert.match(panel, /role="alert"/);
+ assert.match(panel, /disabled=\{installing \|\| documentBody === null \|\| documentTooLarge\}/);
+ assert.match(panel, /const retryPreview = \(\) => \{/);
+ assert.match(panel, /if \(installFor\) loadDocument\(installFor\)/);
+});
+
+test("the market list explains a policy refusal instead of a bare unreachable", () => {
+ assert.match(panel, /hasPolicyFailure\(remote\.failureKinds\)/);
+ assert.match(panel, /settings\.sklm\.remoteErrorPolicy/);
+ assert.match(panel, /settings\.sklm\.remoteErrorQuery/);
+ // A partial outage (some sources up, some refused) used to say nothing at all.
+ assert.match(panel, /remote\.status === "ready" && remote\.failed\.length/);
+ assert.match(panel, /settings\.sklm\.remotePartial/);
+ assert.match(panel, /failureKinds: result\.failureKinds \?\? \{\}/);
+ // The whole-query rejection used to be discarded with no trace at all.
+ assert.match(panel, /queryError: skillMarketFailureDetail\(error\)/);
+});
+
+test("every shipped locale carries the new skill market strings", async () => {
+ const { readFile } = await import("node:fs/promises");
+ const ids = ["en", "zh-CN", "zh-TW", "de", "es", "fr", "ko", "tr"];
+ const keys = [
+ "previewError",
+ "previewPolicyError",
+ "proxyHint",
+ "failureDetail",
+ "retryPreview",
+ "remoteErrorPolicy",
+ "remoteErrorQuery",
+ "remotePartial",
+ ];
+ for (const id of ids) {
+ const locale = await readFile(
+ new URL(`../../../packages/i18n/src/locales/${id}/index.ts`, import.meta.url),
+ "utf8",
+ );
+ for (const key of keys) {
+ assert.match(locale, new RegExp(`${key}: "`), `${id} ${key}`);
+ }
+ }
+});
diff --git a/apps/desktop/test/skill-market-scan.test.mjs b/apps/desktop/test/skill-market-scan.test.mjs
index de4d26104..7f81ce740 100644
--- a/apps/desktop/test/skill-market-scan.test.mjs
+++ b/apps/desktop/test/skill-market-scan.test.mjs
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createSkillMarketAggregator } from "../electron/main/skill-market-scan.ts";
+import { PublicNetworkPolicyError } from "../electron/main/public-https-fetch.ts";
const source = {
id: "anthropics-skills",
@@ -101,3 +102,47 @@ test("main-process aggregator routes through the public-network client", async (
assert.match(src, /createSkillMarketAggregator\(client\.request\)/);
assert.doesNotMatch(src, /node:https|node:http|axios|got\(/);
});
+
+test("a failed source reports whether the policy or the transport refused it", async () => {
+ const aggregator = createSkillMarketAggregator(async (url) => {
+ if (url.includes("blocked.example")) {
+ throw new PublicNetworkPolicyError(
+ "hostname resolves to a private address: blocked.example -> 198.18.0.4",
+ );
+ }
+ throw new Error("responded 502");
+ });
+ const result = await aggregator.search("", [
+ { id: "blocked", name: "blocked/repo", url: "https://blocked.example/catalog.json" },
+ { id: "down", name: "down/repo", url: "https://down.example/catalog.json" },
+ { id: "local", name: "local", url: "https://127.0.0.1/catalog.json" },
+ ]);
+ assert.deepEqual(result.entries, []);
+ assert.deepEqual([...result.failedSources].sort(), ["blocked/repo", "down/repo", "local"]);
+ // Issue #419: a policy refusal must not be indistinguishable from a dead host
+ // or from a source that never left the syntactic guard.
+ assert.deepEqual(result.failureKinds, {
+ "blocked/repo": "policy",
+ "down/repo": "network",
+ local: "policy",
+ });
+});
+
+test("a repeated display name keeps the refusal, and a hostile name stays own", async () => {
+ const aggregator = createSkillMarketAggregator(async () => {
+ throw new Error("responded 502");
+ });
+ const result = await aggregator.search("", [
+ { id: "a", name: "same", url: "https://127.0.0.1/catalog.json" },
+ { id: "b", name: "same", url: "https://down.example/catalog.json" },
+ { id: "c", name: "__proto__", url: "https://127.0.0.1/catalog.json" },
+ ]);
+ // `failedSources` cannot tell the two "same" sources apart, so the kind that
+ // is worth surfacing (the refusal) must survive the merge.
+ assert.equal(result.failureKinds.same, "policy");
+ // A source named `__proto__` must land as an own property, not vanish into
+ // the prototype where `Object.values` would never see it.
+ assert.equal(Object.hasOwn(result.failureKinds, "__proto__"), true);
+ assert.equal(result.failureKinds.__proto__, "policy");
+ assert.deepEqual(Object.values(result.failureKinds), ["policy", "policy"]);
+});
diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md
index c859a6e53..7e9b1529e 100644
--- a/docs/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/spec/03-runtime/01-ipc-protocol.md
@@ -1456,10 +1456,16 @@ state is pruned during the next scan.
Desktop-only skill market channels (not host RPC) live on Electron IPC:
-- `pi-desktop/skill/market/search` — `{ query, sources[] }` → `{ entries, failedSources }`.
- Main aggregates builtin-safe catalog JSON and GitHub repo SKILL.md scans.
- Source URLs must pass the public-HTTPS policy (ADR 0243). One failing source
- is dropped; the rest still return.
+- `pi-desktop/skill/market/search` — `{ query, sources[] }` →
+ `{ entries, failedSources, failureKinds }`. Main aggregates builtin-safe
+ catalog JSON and GitHub repo SKILL.md scans. Source URLs must pass the
+ public-HTTPS policy (ADR 0243). One failing source is dropped; the rest still
+ return. `failureKinds` maps each name in `failedSources` to `policy` (the
+ public-network guard refused it, so the request never left the process) or
+ `network`, which is what lets the panel explain a policy/DNS refusal — the
+ case a proxied user hits — instead of reporting every source as unreachable.
+ A guard refusal also surfaces as `NETWORK_POLICY_BLOCKED` (spec 08 §3.1), the
+ code the install sheet classifies a failed preview on.
- `pi-desktop/skill/market/fetch` — `{ entry }` → `{ name?, description?, body, resources? }`.
Main fetches the document over the same policy, splits frontmatter, and may
attach sibling `.md` files from a jsDelivr listing. The renderer installs
diff --git a/docs/spec/03-runtime/08-error-codes.md b/docs/spec/03-runtime/08-error-codes.md
index 33bd913ef..0411d32ce 100644
--- a/docs/spec/03-runtime/08-error-codes.md
+++ b/docs/spec/03-runtime/08-error-codes.md
@@ -66,6 +66,7 @@ registered; reserved codes in §3.7 remain intentionally absent from
| `APPROVAL_STALE` | no | RACP: the approval was already settled or belongs to an older turn |
| `PAYLOAD_TOO_LARGE` | no | RACP: a frame exceeded the negotiated size bound |
| `TIMEOUT` | yes | generic timeout |
+| `NETWORK_POLICY_BLOCKED` | no | the main-process public-network guard refused a fetch: the URL failed the syntactic public-HTTPS check, or the local DNS lookup could not classify the host as public (ADR 0243). A desktop-only code; retrying cannot succeed until the address or the resolver changes. |
| `HOST_SHUTTING_DOWN` | yes | the host received EOF and is draining; the call was refused rather than started |
| `RATE_LIMITED` | yes | a per-caller host budget (plugin session import, batch operations) was exceeded inside its window |
| `LIMIT_EXCEEDED` | no | a payload exceeded a fixed host bound (item count, byte size, or a 64 MiB NDJSON request line) and was refused |
diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md
index 35f649505..f8a80aa9f 100644
--- a/docs/spec/04-ux/06-settings-ia.md
+++ b/docs/spec/04-ux/06-settings-ia.md
@@ -395,7 +395,12 @@ system while preserving their different data ownership:
English-titled offline fallback. Default GitHub sources are queried with
user-added sources; a remote badge uses `sourceId`, not id collision with
builtin rows. Documents that would exceed the 128 KiB host cap cannot be
- installed. Back reloads the skill list.
+ installed. A preview that fails is reported in the sheet with its reason and
+ a Retry action — the install button may sit disabled, but never without an
+ explanation — and a market whose sources were refused by the public-network
+ guard says so instead of calling every source unreachable, because a proxied
+ user sees that refusal while the same URL opens in their browser (ADR 0177).
+ Back reloads the skill list.
- The Subagents create/edit sheet pins a model with a searchable, provider-
grouped anchored menu — the same option-menu control the service picker uses
— over the configured, runnable models the Composer offers, plus an
diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md
index 72e05134d..48823a6fc 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -12297,13 +12297,18 @@ plugin-form fixtures in an isolated temporary directory at runtime.
- **Expected**: Every bypass form is rejected. A public CDN URL is accepted.
DNS that yields a private address and a redirect onto loopback both throw a
policy error without fetching the private target. Policy failures are not
- retried.
+ retried. Each refusal carries `NETWORK_POLICY_BLOCKED` (spec 08 §3.1) so the
+ install sheet can name the reason and offer a retry instead of leaving the
+ install button disabled with no explanation, and the market list can tell a
+ refused source apart from a merely unreachable one.
- **Specs linked**: `05-security/01-security.md`, ADR 0243,
`03-runtime/01-ipc-protocol.md` §12b
- **Acceptance**: Security, Quality
- **Milestone**: M6+
- **Status**: Automated (`pnpm test:e2e:skill-market`,
`apps/desktop/test/public-https-fetch.test.mjs`,
+ `apps/desktop/test/skill-market-scan.test.mjs`,
+ `apps/desktop/test/skill-market-failure.test.mjs`,
`packages/shared/src/public-network.test.ts`)
#### E2E-SKILL-MARKET-EXPANSION: Adjacent markdown resources inline before install
diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
index fadb1b076..d22402e64 100644
--- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
@@ -1209,8 +1209,11 @@ ASCII slug:frontmatter `name` 能 slugify 时用它,否则 `SKILL.md` 用技
桌面专用技能市场通道(不是 host RPC)走 Electron IPC:
-- `pi-desktop/skill/market/search` — `{ query, sources[] }` → `{ entries, failedSources }`。
+- `pi-desktop/skill/market/search` — `{ query, sources[] }` →
+ `{ entries, failedSources, failureKinds }`。
主进程聚合目录 JSON 与 GitHub 仓库 SKILL.md 扫描。源 URL 必须通过公网 HTTPS 策略(ADR 0243)。单源失败只丢掉该源。
+ `failureKinds` 把 `failedSources` 中的每个名字映射到 `policy`(公网策略守卫拒绝,请求从未离开进程)或 `network`;面板据此区分策略/DNS 拒绝(即代理用户的典型情况)与单纯不可达。
+ 守卫拒绝会以 `NETWORK_POLICY_BLOCKED`(spec 08 §3.1)暴露,安装面板正是按该错误码分类。
- `pi-desktop/skill/market/fetch` — `{ entry }` → `{ name?, description?, body, resources? }`。
主进程按同一策略拉取文档、拆 frontmatter,并可能附上 jsDelivr 目录中的兄弟 `.md`。渲染层通过现有 `skills.create` 安装。该策略即主进程公网网络客户端:语法 URL 防护、DNS 分类、逐跳重定向复核与响应上限——渲染层绝不直接触网。目录 id 会净化为 host `valid_capability_id`。
diff --git a/docs/zh-CN/spec/03-runtime/08-error-codes.md b/docs/zh-CN/spec/03-runtime/08-error-codes.md
index 089b85bcd..3c059c4db 100644
--- a/docs/zh-CN/spec/03-runtime/08-error-codes.md
+++ b/docs/zh-CN/spec/03-runtime/08-error-codes.md
@@ -68,6 +68,7 @@ type AppError = {
| `APPROVAL_STALE` | 不 | RACP:审批已被处理或属于更早的回合 |
| `PAYLOAD_TOO_LARGE` | 不 | RACP:帧超过协商的大小上限 |
| `TIMEOUT` | 是的 | 通用超时 |
+| `NETWORK_POLICY_BLOCKED` | 不 | 主进程公网策略守卫拒绝了一次抓取:URL 未通过公网 HTTPS 语法检查,或本地 DNS 解析无法把该主机判定为公网地址(ADR 0243)。仅桌面端使用;在地址或解析器改变前,重试不会成功。 |
| `HOST_SHUTTING_DOWN` | 是的 | 主机收到 EOF 正在排空;调用被拒绝而不是被启动 |
| `RATE_LIMITED` | 是的 | 某个按调用方计的主机预算(插件会话导入、批量操作)在其窗口内被超出 |
| `LIMIT_EXCEEDED` | 不 | 载荷超过了固定的主机上限(条目数、字节数)并被拒绝 |
diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md
index 01ccd5bab..833145822 100644
--- a/docs/zh-CN/spec/04-ux/06-settings-ia.md
+++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md
@@ -323,7 +323,7 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历
20. 命令 Shell 选择保留平台有效的目录 ID,仅在状态有额外信息时公开
默认、不可用、回退或无实际 Shell 文案,并且从不授权过时的 ID/dialect
21. 信息页提供「问题反馈」,打开已预填版本和操作系统的 GitHub bug 表单;设置搜索可索引该行
-22. 技能页的市场视图浏览公网 HTTPS 目录、预览组装后的文档,并只通过 `skills.create` 安装;超限展开文档拒绝写入,来源角标跟随 `sourceId`
+22. 技能页的市场视图浏览公网 HTTPS 目录、预览组装后的文档,并只通过 `skills.create` 安装;超限展开文档拒绝写入,来源角标跟随 `sourceId`;预览失败时面板给出可读原因与重试入口(安装按钮可以禁用,但不得无解释地禁用);来源被公网策略守卫拒绝时明确说明,而不是笼统报「不可达」
## 5. Chrome 指标常规
diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
index f9b0b1087..586f5ad55 100644
--- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
@@ -7283,11 +7283,11 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture
- **前提条件**:共享 public-network helper,以及可注入 fetch/DNS 的主进程公网 HTTPS 客户端。
- **步骤**:1)分类 trailing-dot localhost、IPv4 回环、IPv4-mapped IPv6、ULA、link-local、RFC1918 与 `http://`。2)将公网主机名解析到私网 A 记录。3)跟随 Location 为 `https://127.0.0.1/` 的 302。
-- **预期**:上述绕过形态全部拒绝;公共 CDN 放行。解析到私网地址或 redirect 到回环会抛出策略错误,且不会请求私网目标。策略失败不重试。
+- **预期**:上述绕过形态全部拒绝;公共 CDN 放行。解析到私网地址或 redirect 到回环会抛出策略错误,且不会请求私网目标。策略失败不重试。每次拒绝都带上 `NETWORK_POLICY_BLOCKED`(spec 08 §3.1),使安装面板能给出原因并提供重试,而不是让安装按钮无解释地保持禁用;市场列表也能把被拒绝的源与单纯不可达的源区分开。
- **链接规格**:`05-security/01-security.md`、ADR 0243、`03-runtime/01-ipc-protocol.md` §12b
- **验收**:Security、Quality
- **里程碑**:M6+
-- **状态**:已自动化(`pnpm test:e2e:skill-market`、`apps/desktop/test/public-https-fetch.test.mjs`、`packages/shared/src/public-network.test.ts`)
+- **状态**:已自动化(`pnpm test:e2e:skill-market`、`apps/desktop/test/public-https-fetch.test.mjs`、`apps/desktop/test/skill-market-scan.test.mjs`、`apps/desktop/test/skill-market-failure.test.mjs`、`packages/shared/src/public-network.test.ts`)
#### E2E-SKILL-MARKET-EXPANSION:相邻 markdown 资源在安装前内联
diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts
index 7d0d434ea..c206b5a8d 100644
--- a/packages/i18n/src/locales/de/index.ts
+++ b/packages/i18n/src/locales/de/index.ts
@@ -640,6 +640,14 @@ sklm: {
builtinHint: "Mit der App ausgeliefert, immer verfügbar",
remoteLoading: "Katalogquelle wird geladen…",
remoteError: "Katalogquelle nicht erreichbar – integrierte Auswahl wird angezeigt",
+ previewError: "Das Dokument dieser Skill konnte nicht geladen werden – nichts wurde installiert.",
+ previewPolicyError: "Die Adressprüfung der App hat dieses Dokument blockiert – nichts wurde installiert.",
+ proxyHint: "Diese Prüfung löst den Host lokal auf, der Download selbst würde über Ihren Proxy laufen. Bei Proxy oder VPN: Einstellungen → Allgemein → Netzwerk prüfen.",
+ failureDetail: "Details: ",
+ retryPreview: "Erneut versuchen",
+ remoteErrorPolicy: "Katalogquellen wurden von der Adressprüfung der App blockiert – integrierte Auswahl wird angezeigt",
+ remoteErrorQuery: "Der Skill-Markt konnte nicht abgefragt werden – integrierte Auswahl wird angezeigt",
+ remotePartial: "Einige Katalogquellen sind nicht verfügbar ({{names}})",
},
"title": "Einstellungen",
"providers": "KI-Anbieter",
@@ -2139,6 +2147,7 @@ sklm: {
"PROVIDER_RATE_LIMITED": "Der KI-Anbieter begrenzt Anfragen. Warten Sie einen Moment und versuchen Sie es erneut.",
"PROVIDER_ERROR": "Der KI-Anbieter hat einen Fehler zurückgegeben.",
"NETWORK_ERROR": "Der KI-Anbieter kann nicht erreicht werden. Überprüfen Sie Ihr Netzwerk oder Ihre Basis-URL.",
+ NETWORK_POLICY_BLOCKED: "Die Adressprüfung der App hat diese Anfrage blockiert. Bei Proxy oder VPN: Einstellungen → Allgemein → Netzwerk prüfen.",
"TIMEOUT": "Bei der Anfrage an den KI-Anbieter ist eine Zeitüberschreitung aufgetreten.",
"STREAM_FAILED": "Die Antwort wurde unterbrochen.",
"EMPTY_MODEL_RESPONSE": "Das Modell beendete seinen Zug zweimal hintereinander, ohne etwas zu sagen. Versuchen Sie es erneut oder formulieren Sie Ihre Anfrage um.",
diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts
index 53464dbb3..4a031fcfc 100644
--- a/packages/i18n/src/locales/en/index.ts
+++ b/packages/i18n/src/locales/en/index.ts
@@ -647,6 +647,14 @@ sklm: {
builtinHint: "Ships with the app and is always available",
remoteLoading: "Loading the catalog source…",
remoteError: "Catalog source unreachable — showing built-in picks",
+ previewError: "Could not load this skill's document — nothing was installed.",
+ previewPolicyError: "The app's own address check blocked this document — nothing was installed.",
+ proxyHint: "That check resolves the host locally, while the download itself would use your proxy. If you use a proxy or VPN, check Settings → General → Network.",
+ failureDetail: "Details: ",
+ retryPreview: "Retry",
+ remoteErrorPolicy: "Catalog sources were blocked by the app's address check — showing built-in picks",
+ remoteErrorQuery: "Could not query the skill market — showing built-in picks",
+ remotePartial: "Some catalog sources are unavailable ({{names}})",
},
title: "Settings",
providers: "AI providers",
@@ -2179,6 +2187,7 @@ importConfirm: "Imported extensions run inside the agent process with the same a
PROVIDER_RATE_LIMITED: "The AI provider is rate-limiting requests. Wait a moment and try again.",
PROVIDER_ERROR: "The AI provider returned an error.",
NETWORK_ERROR: "Can't reach the AI provider. Check your network or base URL.",
+ NETWORK_POLICY_BLOCKED: "The app's address check blocked this request. Behind a proxy or VPN, check Settings → General → Network.",
TIMEOUT: "The request to the AI provider timed out.",
STREAM_FAILED: "The reply was interrupted.",
EMPTY_MODEL_RESPONSE:
diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts
index bdc44b7b2..57184fab3 100644
--- a/packages/i18n/src/locales/es/index.ts
+++ b/packages/i18n/src/locales/es/index.ts
@@ -640,6 +640,14 @@ sklm: {
builtinHint: "Viene con la aplicación y siempre está disponible",
remoteLoading: "Cargando la fuente del catálogo…",
remoteError: "Fuente no disponible: se muestran las selecciones integradas",
+ previewError: "No se pudo cargar el documento de este skill; no se instaló nada.",
+ previewPolicyError: "La comprobación de direcciones de la aplicación bloqueó este documento; no se instaló nada.",
+ proxyHint: "Esa comprobación resuelve el host de forma local, mientras que la descarga usaría tu proxy. Si usas proxy o VPN, revisa Configuración → General → Red.",
+ failureDetail: "Detalles: ",
+ retryPreview: "Reintentar",
+ remoteErrorPolicy: "La comprobación de direcciones de la aplicación bloqueó las fuentes del catálogo: se muestran las selecciones integradas",
+ remoteErrorQuery: "No se pudo consultar el mercado de skills: se muestran las selecciones integradas",
+ remotePartial: "Algunas fuentes del catálogo no están disponibles ({{names}})",
},
"title": "Configuración",
"providers": "Proveedores de IA",
@@ -2139,6 +2147,7 @@ sklm: {
"PROVIDER_RATE_LIMITED": "El proveedor de IA limita la velocidad de las solicitudes. Espere un momento y vuelva a intentarlo.",
"PROVIDER_ERROR": "El proveedor de IA devolvió un error.",
"NETWORK_ERROR": "No se puede comunicar con el proveedor de IA. Verifique su red o URL base.",
+ NETWORK_POLICY_BLOCKED: "La comprobación de direcciones de la aplicación bloqueó esta solicitud. Si usas proxy o VPN, revisa Configuración → General → Red.",
"TIMEOUT": "Se agotó el tiempo de espera de la solicitud al proveedor de IA.",
"STREAM_FAILED": "La respuesta fue interrumpida.",
"EMPTY_MODEL_RESPONSE": "El modelo terminó su turno sin decir nada, dos veces seguidas. Inténtelo de nuevo o reformule su solicitud.",
diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts
index b316d7fe0..3de016f58 100644
--- a/packages/i18n/src/locales/fr/index.ts
+++ b/packages/i18n/src/locales/fr/index.ts
@@ -640,6 +640,14 @@ sklm: {
builtinHint: "Fournie avec l'application, toujours disponible",
remoteLoading: "Chargement de la source du catalogue…",
remoteError: "Source inaccessible – sélection intégrée affichée",
+ previewError: "Impossible de charger le document de cette skill ; rien n'a été installé.",
+ previewPolicyError: "Le contrôle d'adresse de l'application a bloqué ce document ; rien n'a été installé.",
+ proxyHint: "Ce contrôle résout l'hôte localement, alors que le téléchargement passerait par votre proxy. Si vous utilisez un proxy ou un VPN, vérifiez Paramètres → Général → Réseau.",
+ failureDetail: "Détails : ",
+ retryPreview: "Réessayer",
+ remoteErrorPolicy: "Les sources du catalogue ont été bloquées par le contrôle d'adresse de l'application – sélection intégrée affichée",
+ remoteErrorQuery: "Impossible d'interroger le marché de skills – sélection intégrée affichée",
+ remotePartial: "Certaines sources du catalogue sont indisponibles ({{names}})",
},
"title": "Paramètres",
"providers": "Fournisseurs d'IA",
@@ -2139,6 +2147,7 @@ sklm: {
"PROVIDER_RATE_LIMITED": "Le fournisseur d'IA limite le débit des requêtes. Attendez un moment et réessayez.",
"PROVIDER_ERROR": "Le fournisseur d'IA a renvoyé une erreur.",
"NETWORK_ERROR": "Impossible de joindre le fournisseur d'IA. Vérifiez votre réseau ou votre URL de base.",
+ NETWORK_POLICY_BLOCKED: "Le contrôle d'adresse de l'application a bloqué cette requête. Si vous utilisez un proxy ou un VPN, vérifiez Paramètres → Général → Réseau.",
"TIMEOUT": "La demande adressée au fournisseur d'IA a expiré.",
"STREAM_FAILED": "La réponse a été interrompue.",
"EMPTY_MODEL_RESPONSE": "Le modèle a terminé son tour sans rien dire, deux fois de suite. Réessayez ou reformulez votre demande.",
diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts
index fdd1487ce..2e92e6d8a 100644
--- a/packages/i18n/src/locales/ko/index.ts
+++ b/packages/i18n/src/locales/ko/index.ts
@@ -649,6 +649,14 @@ sklm: {
builtinHint: "앱에 포함되어 항상 사용 가능",
remoteLoading: "카탈로그 소스를 불러오는 중…",
remoteError: "소스에 연결할 수 없어 내장 목록을 표시합니다",
+ previewError: "이 스킬의 문서를 불러오지 못했습니다. 아무것도 설치되지 않았습니다.",
+ previewPolicyError: "앱의 주소 검사가 이 문서를 차단했습니다. 아무것도 설치되지 않았습니다.",
+ proxyHint: "이 검사는 호스트를 로컬에서 조회하지만, 다운로드 자체는 프록시를 사용합니다. 프록시나 VPN을 사용한다면 설정 → 일반 → 네트워크를 확인하세요.",
+ failureDetail: "세부 정보: ",
+ retryPreview: "다시 시도",
+ remoteErrorPolicy: "앱의 주소 검사가 카탈로그 소스를 차단했습니다. 내장 목록을 표시합니다",
+ remoteErrorQuery: "스킬 마켓을 조회하지 못했습니다. 내장 목록을 표시합니다",
+ remotePartial: "일부 카탈로그 소스를 사용할 수 없습니다 ({{names}})",
},
title: "설정",
providers: "AI 프로바이더",
@@ -2178,6 +2186,7 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이
PROVIDER_RATE_LIMITED: "AI 프로바이더가 요청을 제한하고 있습니다. 잠시 후 다시 시도하세요.",
PROVIDER_ERROR: "AI 프로바이더에서 오류를 반환했습니다.",
NETWORK_ERROR: "AI 프로바이더에 연결할 수 없습니다. 네트워크 또는 기본 URL을 확인하세요.",
+ NETWORK_POLICY_BLOCKED: "앱의 주소 검사가 이 요청을 차단했습니다. 프록시나 VPN을 사용한다면 설정 → 일반 → 네트워크를 확인하세요.",
TIMEOUT: "AI 프로바이더 요청 시간이 초과되었습니다.",
STREAM_FAILED: "답변이 중단되었습니다.",
EMPTY_MODEL_RESPONSE:
diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts
index 91b800fd0..5062b70fd 100644
--- a/packages/i18n/src/locales/tr/index.ts
+++ b/packages/i18n/src/locales/tr/index.ts
@@ -649,6 +649,14 @@ sklm: {
builtinHint: "Uygulamayla gelir, her zaman kullanılabilir",
remoteLoading: "Katalog kaynağı yükleniyor…",
remoteError: "Kaynak erişilemiyor – yerleşik seçkiler gösteriliyor",
+ previewError: "Bu skilin belgesi yüklenemedi; hiçbir şey kurulmadı.",
+ previewPolicyError: "Uygulamanın adres denetimi bu belgeyi engelledi; hiçbir şey kurulmadı.",
+ proxyHint: "Bu denetim ana makineyi yerel olarak çözer, indirme ise proxy üzerinden yapılır. Proxy veya VPN kullanıyorsanız Ayarlar → Genel → Ağ bölümüne bakın.",
+ failureDetail: "Ayrıntılar: ",
+ retryPreview: "Yeniden dene",
+ remoteErrorPolicy: "Uygulamanın adres denetimi katalog kaynaklarını engelledi – yerleşik seçkiler gösteriliyor",
+ remoteErrorQuery: "Skill market sorgulanamadı – yerleşik seçkiler gösteriliyor",
+ remotePartial: "Bazı katalog kaynakları kullanılamıyor ({{names}})",
},
title: "Ayarlar",
providers: "AI servisleri",
@@ -2178,6 +2186,7 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç
PROVIDER_RATE_LIMITED: "AI servisi istekleri hız sınırlıyor. Biraz bekleyip yeniden deneyin.",
PROVIDER_ERROR: "AI servisi bir hata döndürdü.",
NETWORK_ERROR: "AI servisine ulaşılamıyor. Ağınızı veya temel URL’yi kontrol edin.",
+ NETWORK_POLICY_BLOCKED: "Uygulamanın adres denetimi bu isteği engelledi. Proxy veya VPN kullanıyorsanız Ayarlar → Genel → Ağ bölümüne bakın.",
TIMEOUT: "AI servisine istek zaman aşımına uğradı.",
STREAM_FAILED: "Yanıt kesildi.",
EMPTY_MODEL_RESPONSE:
diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts
index e6bd69d2b..d922d939f 100644
--- a/packages/i18n/src/locales/zh-CN/index.ts
+++ b/packages/i18n/src/locales/zh-CN/index.ts
@@ -644,6 +644,14 @@ sklm: {
builtinHint: "随应用分发,始终可用",
remoteLoading: "正在加载目录源…",
remoteError: "目录源暂不可达,已显示内置精选",
+ previewError: "无法加载该技能的文档,未安装任何内容。",
+ previewPolicyError: "应用自身的地址校验阻止了该文档,未安装任何内容。",
+ proxyHint: "该校验在本地解析域名,而下载本身会走你的代理。若使用代理或 VPN,请检查 设置 → 常规 → 网络。",
+ failureDetail: "详情: ",
+ retryPreview: "重试",
+ remoteErrorPolicy: "目录源被应用的地址校验阻止,已显示内置精选",
+ remoteErrorQuery: "无法查询技能市场,已显示内置精选",
+ remotePartial: "部分目录源不可用({{names}})",
},
title: "设置",
providers: "AI 服务",
@@ -2148,6 +2156,7 @@ sklm: {
PROVIDER_RATE_LIMITED: "AI 服务触发了限流,请稍后再试。",
PROVIDER_ERROR: "AI 服务返回了错误。",
NETWORK_ERROR: "无法连接 AI 服务,请检查网络或接口地址。",
+ NETWORK_POLICY_BLOCKED: "应用的地址校验阻止了该请求。若使用代理或 VPN,请检查 设置 → 常规 → 网络。",
TIMEOUT: "请求 AI 服务超时。",
STREAM_FAILED: "回复中断了。",
EMPTY_MODEL_RESPONSE: "模型连续两轮都没有输出内容。可以重试,或换一种说法。",
diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts
index a01d72260..976cf8c20 100644
--- a/packages/i18n/src/locales/zh-TW/index.ts
+++ b/packages/i18n/src/locales/zh-TW/index.ts
@@ -644,6 +644,14 @@ sklm: {
builtinHint: "隨應用程式發布,始終可用",
remoteLoading: "正在載入目錄源…",
remoteError: "目錄源暫不可達,已顯示內建精選",
+ previewError: "無法載入該技能的文檔,未安裝任何內容。",
+ previewPolicyError: "應用程式自身的地址校驗阻止了該文檔,未安裝任何內容。",
+ proxyHint: "該校驗在本機解析網域,而下載本身會走你的代理。若使用代理或 VPN,請檢查 設定 → 常規 → 網路。",
+ failureDetail: "詳情: ",
+ retryPreview: "重試",
+ remoteErrorPolicy: "目錄源被應用程式的地址校驗阻止,已顯示內建精選",
+ remoteErrorQuery: "無法查詢技能市場,已顯示內建精選",
+ remotePartial: "部分目錄源不可用({{names}})",
},
title: "設定",
providers: "AI 服務",
@@ -2146,6 +2154,7 @@ sklm: {
PROVIDER_RATE_LIMITED: "AI 服務觸發了限流,請稍後再試。",
PROVIDER_ERROR: "AI 服務返回了錯誤。",
NETWORK_ERROR: "無法連線 AI 服務,請檢查網路或介面地址。",
+ NETWORK_POLICY_BLOCKED: "應用程式的地址校驗阻止了該請求。若使用代理或 VPN,請檢查 設定 → 常規 → 網路。",
TIMEOUT: "請求 AI 服務超時。",
STREAM_FAILED: "回覆中斷了。",
EMPTY_MODEL_RESPONSE: "模型連續兩輪都沒有輸出內容。可以重試,或換一種說法。",
diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts
index e3d55daca..4a5608f75 100644
--- a/packages/shared/src/errors.ts
+++ b/packages/shared/src/errors.ts
@@ -41,6 +41,15 @@ export const ErrorCodes = {
CONFLICT: "CONFLICT",
TIMEOUT: "TIMEOUT",
NETWORK_ERROR: "NETWORK_ERROR",
+ /**
+ * The main-process public-network guard refused a fetch: the URL failed the
+ * syntactic public-HTTPS check, or a local DNS lookup could not classify the
+ * host as public. Users behind a proxy that answers DNS itself (Clash
+ * fake-IP, a TUN resolver, a corporate split resolver) hit this even though
+ * the same URL opens in a browser, because the guard resolves locally while
+ * `net.fetch` goes through the proxy (ADR 0177, ADR 0243).
+ */
+ NETWORK_POLICY_BLOCKED: "NETWORK_POLICY_BLOCKED",
AGENT_BUSY: "AGENT_BUSY",
AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
TURN_NOT_FOUND: "TURN_NOT_FOUND",
diff --git a/packages/shared/src/public-network.test.ts b/packages/shared/src/public-network.test.ts
index 4235836a8..c6c25a081 100644
--- a/packages/shared/src/public-network.test.ts
+++ b/packages/shared/src/public-network.test.ts
@@ -4,7 +4,9 @@ import {
classifyIpLiteral,
isPublicHttpsUrl,
isPublicIpLiteral,
+ PUBLIC_NETWORK_POLICY_ERROR,
isPublicHostname,
+ isPublicNetworkPolicyFailure,
} from "./public-network.js";
describe("public network address policy", () => {
@@ -82,4 +84,17 @@ describe("public network address policy", () => {
expect(isPublicHostname("registry.example.")).toBe(true);
expect(isPublicHostname("0x7f000001")).toBe(false);
});
+
+ it("recognizes a policy refusal without importing the client", () => {
+ // The skill market aggregator classifies failures with this structural
+ // check so it can stay free of the client's `node:dns` import (issue #419).
+ const refusal = Object.assign(new Error("hostname does not resolve: x"), {
+ name: PUBLIC_NETWORK_POLICY_ERROR,
+ });
+ expect(isPublicNetworkPolicyFailure(refusal)).toBe(true);
+ expect(isPublicNetworkPolicyFailure(new Error("responded 502"))).toBe(false);
+ expect(isPublicNetworkPolicyFailure(null)).toBe(false);
+ expect(isPublicNetworkPolicyFailure("PublicNetworkPolicyError")).toBe(false);
+ expect(PUBLIC_NETWORK_POLICY_ERROR).toBe("PublicNetworkPolicyError");
+ });
});
diff --git a/packages/shared/src/public-network.ts b/packages/shared/src/public-network.ts
index 00f55e63b..9dbd3417b 100644
--- a/packages/shared/src/public-network.ts
+++ b/packages/shared/src/public-network.ts
@@ -136,6 +136,23 @@ export function isPublicHttpsUrl(value: string): boolean {
/** Backward-compatible descriptive alias for the generic public URL guard. */
export const isSafePublicHttpsUrl = isPublicHttpsUrl;
+/**
+ * Name the public-network client stamps on its refusals. A caller that must
+ * stay free of that client's `node:dns` dependency — the skill market
+ * aggregator, which is exercised as a pure module — classifies with this
+ * instead of importing the client.
+ */
+export const PUBLIC_NETWORK_POLICY_ERROR = "PublicNetworkPolicyError";
+
+/** Structural check for a public-network refusal, without importing the client. */
+export function isPublicNetworkPolicyFailure(error: unknown): boolean {
+ return (
+ typeof error === "object" &&
+ error !== null &&
+ (error as { name?: unknown }).name === PUBLIC_NETWORK_POLICY_ERROR
+ );
+}
+
function parseIpv4(value: string): number | null {
const parts = value.split(".");
if (