diff --git a/apps/desktop/electron/main/public-https-fetch.ts b/apps/desktop/electron/main/public-https-fetch.ts index def52e81d..4f717fb17 100644 --- a/apps/desktop/electron/main/public-https-fetch.ts +++ b/apps/desktop/electron/main/public-https-fetch.ts @@ -1,5 +1,11 @@ import { lookup as dnsLookup } from "node:dns/promises"; -import { isPublicIpLiteral, isSafePublicHttpsUrl } from "@pi-desktop/shared"; +import { + ErrorCodes, + PUBLIC_NETWORK_POLICY_ERROR, + isPublicIpLiteral, + isPublicNetworkPolicyFailure, + isSafePublicHttpsUrl, +} from "@pi-desktop/shared"; const DEFAULT_TIMEOUT_MS = 8_000; const MAX_HOPS = 5; @@ -14,14 +20,19 @@ export type PublicHttpsLookup = (host: string) => Promise Promise; +/** Why a source failed: the public-network guard refused it, or it errored. */ +export type SkillMarketFailureKind = "policy" | "network"; + export type SkillMarketSearchResult = { entries: SourcedSkillEntry[]; failedSources: string[]; + /** + * `failedSources` alone cannot tell the user why the market went quiet. Keyed + * by the same display name so the panel can explain a policy refusal (the + * local DNS lookup could not classify the host, which is what a proxy that + * answers DNS itself produces) apart from a source that is merely + * unreachable. Repeated names collapse, exactly as they already do in + * `failedSources`. + */ + failureKinds: Record; }; export type SkillMarketDocument = { @@ -34,6 +47,12 @@ const GITHUB_REPO = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[ const JSDELIVR_GH = /^https:\/\/cdn\.jsdelivr\.net\/gh\/([^/]+)\/([^/]+)@([^/]+)\/(.+)$/; const SKILL_FILE = /(?:^|\/)SKILL\.md$/; +function classifyFailure(error: unknown): SkillMarketFailureKind { + // Structural check so this module keeps no dependency on the client's + // `node:dns` import and stays testable as a pure module. + return isPublicNetworkPolicyFailure(error) ? "policy" : "network"; +} + const SKILL_CATEGORY_KEYWORDS: ReadonlyArray = [ ["data", ["data", "sql", "database", "postgres", "mongo", "redis", "analytics", "dataset", "spreadsheet", "excel", "xlsx", "csv", "dashboard", "chart", "visualization"]], ["workflow", ["workflow", "review", "planning", "brainstorm", "checklist", "process", "sop", "handoff", "standup", "retro", "discernment", "verification", "triage", "audit", "security", "incident"]], @@ -152,9 +171,20 @@ export function createSkillMarketAggregator(request: CatalogRequest) { async function search(query: string, sources: SkillMarketSource[]): Promise { const trimmed = query.trim().toLocaleLowerCase(); const usable = sources.filter((source) => isSafeSkillSourceUrl(source.url)); - const failedSources = sources - .filter((source) => !isSafeSkillSourceUrl(source.url)) - .map((source) => source.name); + // A source the syntactic guard never let out is a policy refusal, not a + // transport failure, and it must not be reported as an unreachable host. + const refused = sources.filter((source) => !isSafeSkillSourceUrl(source.url)); + const failedSources = refused.map((source) => source.name); + // Two sources can carry the same display name (a default source and a user + // source with the same label), and `failedSources` already cannot tell such + // a pair apart. The refusal is the one worth surfacing, so `policy` wins + // the merge. A `Map` plus `Object.fromEntries` also keeps a source called + // `__proto__` from disappearing into the prototype. + const failures = new Map(); + const markFailure = (name: string, kind: SkillMarketFailureKind) => { + failures.set(name, failures.get(name) === "policy" || kind === "policy" ? "policy" : "network"); + }; + for (const source of refused) markFailure(source.name, "policy"); const settled = await Promise.allSettled( usable.map(async (source) => { const entries = await loadSource(source); @@ -170,7 +200,9 @@ export function createSkillMarketAggregator(request: CatalogRequest) { const seen = new Set(); settled.forEach((result, index) => { if (result.status === "rejected") { - failedSources.push(usable[index].name); + const name = usable[index].name; + failedSources.push(name); + markFailure(name, classifyFailure(result.reason)); return; } for (const entry of result.value) { @@ -179,7 +211,7 @@ export function createSkillMarketAggregator(request: CatalogRequest) { entries.push(entry); } }); - return { entries, failedSources }; + return { entries, failedSources, failureKinds: Object.fromEntries(failures) }; } async function fetchEntryDocument(entry: SkillCatalogEntry): Promise { diff --git a/apps/desktop/src/components/settings/SkillMarketPanel.tsx b/apps/desktop/src/components/settings/SkillMarketPanel.tsx index 2a0a0e5f7..3039fc41b 100644 --- a/apps/desktop/src/components/settings/SkillMarketPanel.tsx +++ b/apps/desktop/src/components/settings/SkillMarketPanel.tsx @@ -27,6 +27,12 @@ import { } from "../icons"; import { Field, Input, TooltipButton, cx } from "../ui"; import { LatestWinsGate } from "../../lib/latest-wins"; +import { + classifySkillMarketFailure, + hasPolicyFailure, + skillMarketFailureDetail, + type SkillMarketFailureKind, +} from "../../lib/skill-market-failure"; const CATEGORIES: readonly SkillCatalogCategory[] = [ "workflow", @@ -61,9 +67,22 @@ type RemoteState = { status: "idle" | "loading" | "ready" | "error"; entries: MarketItem[]; failed: string[]; + /** Why each named source failed, so a policy/DNS refusal can be explained. */ + failureKinds: Record; + /** + * Query-level failure reason (bridge or preload unavailable). Such a rejection + * carries no per-source detail, and it used to be discarded with no trace. + */ + queryError: string; }; -const REMOTE_IDLE: RemoteState = { status: "idle", entries: [], failed: [] }; +const REMOTE_IDLE: RemoteState = { + status: "idle", + entries: [], + failed: [], + failureKinds: {}, + queryError: "", +}; const SOURCES_STORAGE_KEY = "pi.skill-market.sources.v1"; @@ -121,6 +140,10 @@ export function SkillMarketPanel({ const [installFor, setInstallFor] = useState(null); const [documentBody, setDocumentBody] = useState(null); const [documentTooLarge, setDocumentTooLarge] = useState(false); + const [previewFailure, setPreviewFailure] = useState<{ + kind: SkillMarketFailureKind; + detail: string; + } | null>(null); const [installing, setInstalling] = useState(false); const previewGate = useRef(new LatestWinsGate()); const [sources, setSources] = useState(loadSources); @@ -148,7 +171,13 @@ export function SkillMarketPanel({ const timer = setTimeout(() => { setRemote((current) => current.status === "idle" || current.status === "ready" - ? { status: "loading", entries: current.entries, failed: current.failed } + ? { + status: "loading", + entries: current.entries, + failed: current.failed, + failureKinds: current.failureKinds, + queryError: current.queryError, + } : current, ); api @@ -161,11 +190,21 @@ export function SkillMarketPanel({ status: entries.length === 0 && failed.length > 0 ? "error" : "ready", entries, failed, + failureKinds: result.failureKinds ?? {}, + queryError: "", }); } }) - .catch(() => { - if (!cancelled) setRemote({ status: "error", entries: [], failed: [] }); + .catch((error: unknown) => { + if (!cancelled) { + setRemote({ + status: "error", + entries: [], + failed: [], + failureKinds: {}, + queryError: skillMarketFailureDetail(error), + }); + } }); }, 350); return () => { @@ -201,11 +240,11 @@ export function SkillMarketPanel({ [visible, currentPage], ); - const openInstall = (entry: MarketItem) => { + const loadDocument = (entry: MarketItem) => { const token = previewGate.current.begin(); - setInstallFor(entry); setDocumentBody(null); setDocumentTooLarge(false); + setPreviewFailure(null); api .fetchSkillMarketDocument(entry) .then((document) => { @@ -214,14 +253,27 @@ export function SkillMarketPanel({ setDocumentBody(assembled.body); setDocumentTooLarge(assembled.tooLarge); }) - .catch(() => { - if (previewGate.current.isCurrent(token)) { - setDocumentBody(null); - setDocumentTooLarge(false); - } + .catch((error: unknown) => { + if (!previewGate.current.isCurrent(token)) return; + // Swallowing this left the sheet on a null body, so the install button + // sat disabled behind the word "Loading…" with no reason and no way to + // try again — the dead end issue #419 reports. + setPreviewFailure({ + kind: classifySkillMarketFailure(error), + detail: skillMarketFailureDetail(error), + }); }); }; + const openInstall = (entry: MarketItem) => { + setInstallFor(entry); + loadDocument(entry); + }; + + const retryPreview = () => { + if (installFor) loadDocument(installFor); + }; + const install = async () => { if (!installFor || installing) return; setInstalling(true); @@ -272,6 +324,14 @@ export function SkillMarketPanel({ setDraftSource({ name: "", url: "" }); }; + // A bare `remoteError` could not tell a policy refusal from a dead host, so + // the two now carry different copy and the policy case gets the proxy hint. + const remoteErrorText = () => { + if (remote.queryError) return t("settings.sklm.remoteErrorQuery"); + if (hasPolicyFailure(remote.failureKinds)) return t("settings.sklm.remoteErrorPolicy"); + return t("settings.sklm.remoteError"); + }; + const sourcesSheet = sourcesOpen ? (
{t("settings.sklm.preview")}
-
{documentBody ?? t("common.loading")}
+ {previewFailure ? ( +

+ {t( + previewFailure.kind === "policy" + ? "settings.sklm.previewPolicyError" + : "settings.sklm.previewError", + )} +

+ ) : ( +
{documentBody ?? t("common.loading")}
+ )} + {previewFailure?.kind === "policy" ? ( +

{t("settings.sklm.proxyHint")}

+ ) : null} + {previewFailure?.detail ? ( +

+ {t("settings.sklm.failureDetail")} + {previewFailure.detail} +

+ ) : null} + {previewFailure ? ( + + ) : null} {documentTooLarge ?

{t("settings.sklm.documentTooLarge")}

: null}
@@ -492,10 +581,31 @@ export function SkillMarketPanel({

) : null} {remote.status === "error" ? ( -

- {t("settings.sklm.remoteError")} - {remote.failed.length ? ` (${remote.failed.join(", ")})` : ""} -

+ <> +

+ {remoteErrorText()} + {remote.failed.length ? ` (${remote.failed.join(", ")})` : ""} +

+ {remote.queryError ? ( +

+ {t("settings.sklm.failureDetail")} + {remote.queryError} +

+ ) : null} + {hasPolicyFailure(remote.failureKinds) ? ( +

{t("settings.sklm.proxyHint")}

+ ) : null} + + ) : null} + {remote.status === "ready" && remote.failed.length ? ( + <> +

+ {t("settings.sklm.remotePartial", { names: remote.failed.join(", ") })} +

+ {hasPolicyFailure(remote.failureKinds) ? ( +

{t("settings.sklm.proxyHint")}

+ ) : null} + ) : null}
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 (