@sema-agent/client-core 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * catalogLoader.ts — **目录候选链的宿主 loader**(design/166 §1-§3,2026-08-04)。
3
+ *
4
+ * ## 它与 `catalog.ts` 的分工(🔴 委托,不平行重做)
5
+ *
6
+ * `catalog.ts` 已经是三层源解析器:`resolveModelCatalog`(线上→包内→用户覆盖三层合并)+
7
+ * `validateOnlineCatalog`(schemaVersion 区间 / 逐行 `isProviderRow` / **绝不半解析** / 不回显载荷)
8
+ * + `CatalogFetchJson` 注入端口 + `CatalogSource`·`CatalogRejectReason` 两张词表。
9
+ * 本文件**只补 catalog.ts 声明不管的宿主面**,四件事:
10
+ *
11
+ * ① **候选链遍历**(`DEFAULT_CATALOG_SOURCES` = raw.githubusercontent → jsDelivr,可配);
12
+ * ② **传输安全硬门**:https + 域白名单(拨号**之前**判,域外源连请求都不发)——
13
+ * 而且**重定向目标过同一道门**(fetch 默认跟随重定向,不设门 = 白名单可被 302 绕过);
14
+ * ③ **`catalog.sha256` 旁签**(同源同路径,SHA-256 hex 比对);
15
+ * ④ **缓存**(`configHome/cache/model-catalog.json`)的信封与读回语义。
16
+ *
17
+ * 载荷面的判决(schemaVersion / 逐行形状 / 整份弃 / 不回显)**一行都不在这里重写** ——
18
+ * 本文件拿到字节流之后做的唯一一件事是 `JSON.parse`,然后把结果交给 `resolveModelCatalog`,
19
+ * 由它去 `validateOnlineCatalog`。UI 渲染也只认 `ModelCatalogResult.source` + `online.reason`
20
+ * (+ 本文件加的 `cacheHit`),**不发明第二套 origin 词表**。
21
+ *
22
+ * ## 🔴 为什么缓存的**落盘动作**在宿主而不在这里(与设计档 §3 的偏差,记在这)
23
+ *
24
+ * 设计档 §3 写的是「loader 原子写(tmp+rename)」。但本包有一条机械门守着的硬法:
25
+ * `src/**` 的 index 值级闭包**零 Node 内建**(`scripts/run-client-core-portability-test.mjs`
26
+ * ①c 段),因为同一份代码要在浏览器/桌面渲染进程里跑。`node:fs` 一进来,那道门当场红。
27
+ * ⇒ 落盘走**注入口** `CatalogCachePort`(与 `host.ts` 的 `FsPort`、`catalog.ts` 的
28
+ * `CatalogFetchJson` 同一条纪律:能力由宿主注入,库不自己 `require('fs')`)。
29
+ * **tmp+rename 的原子性是 `writeAtomic` 实现方的契约**,本文件在类型注释里把它写成要求;
30
+ * Node 宿主的十行参考实现见 `CatalogCachePort.writeAtomic` 的注释。
31
+ * 缺席该口 ⇒ 缓存腿整条不启用(`cacheHit` 键**缺席**,不是 `false` —— 「没查过」和「查过没用上」
32
+ * 是两件事,[honest-absence-not-fabricated-zero])。
33
+ *
34
+ * ## 🔴 `forceRefresh` 为什么没有(与设计档 §1 签名的偏差)
35
+ *
36
+ * 本 loader **恒先走网络**,缓存只在候选链**全败**时顶上(设计档 §3 自己就是这么定的读时机)。
37
+ * 也就是说没有任何一条「优先吃缓存」的路径可供 `forceRefresh` 去绕过 —— 收下这个参数只会
38
+ * 得到一个恒为空操作的旋钮,而空操作的旋钮是**假 affordance**(调用方以为自己强制刷新了)。
39
+ * 真要「手动刷新」,直接再调一次本函数就是最新语义。
40
+ *
41
+ * ## 时钟
42
+ *
43
+ * 与 `catalog.ts` 同一口径:**绝不偷读时钟**。`nowMs` 缺席 ⇒ 缓存信封不写 `fetchedAt`、
44
+ * 陈旧判定不做(`cacheStale` 键缺席),而不是 `Date.now()` 兜底。
45
+ */
46
+ import { type EnvLike } from '../hostEnv.js';
47
+ import { type ModelCatalogResult, type OnlineCatalogDoc } from './catalog.js';
48
+ import type { ProviderPreset } from './providerPresets.js';
49
+ /**
50
+ * 默认候选链(design/166 §1;clay 裁「可配置,默认 github」)。
51
+ * 第二跳 jsDelivr 对 gh 仓是被动 CDN、零接入成本 —— 这就是大陆可达性的零维护替身。
52
+ * 🔴 顺序即优先级:逐源尝试,前一跳成了就不拨下一跳。
53
+ */
54
+ export declare const DEFAULT_CATALOG_SOURCES: readonly string[];
55
+ /** 默认域白名单 = 默认链两跳的 host。用户显式配置的源 host 在运行期并入(用户自担)。 */
56
+ export declare const CATALOG_DEFAULT_HOSTS: readonly string[];
57
+ /** 候选链的 env 键(一键安装脚本改这里;settings.json `env` 块同名同义,见 design/166 §2)。 */
58
+ export declare const CATALOG_SOURCES_ENV = "SEMA_CATALOG_SOURCES";
59
+ /** 每源传输预算(design/166 §1:onboard 不能被网络拖住)。 */
60
+ export declare const DEFAULT_CATALOG_TIMEOUT_MS = 3500;
61
+ /** 缓存相对 configHome 的落点(design/166 §3)。 */
62
+ export declare const CATALOG_CACHE_RELATIVE_PATH = "cache/model-catalog.json";
63
+ /** 缓存陈旧阈值:30 天(照用,但如实标 stale)。 */
64
+ export declare const CATALOG_CACHE_STALE_MS: number;
65
+ /**
66
+ * 单源的**传输层**结局。
67
+ *
68
+ * 🔴 它**不是**第二套 origin 词表:UI 渲染「目录从哪来」恒读 `ModelCatalogResult.source` +
69
+ * `online.reason` + `cacheHit`。本词表只服务于 **doctor 的逐源分诊**(「两跳分别为什么没成」),
70
+ * 那是 `catalog.ts` 的单 URL 视角在结构上说不出来的量 —— 它只有一个 `online` 结局位。
71
+ */
72
+ export type CatalogSourceOutcome = 'ok' | 'insecure-url' | 'host-not-allowed' | 'redirect-blocked' | 'redirect-opaque' | 'redirect-loop' | 'http-error' | 'network-error' | 'invalid-json' | 'sha-mismatch';
73
+ /** 一次逐源尝试的留痕(doctor 用;🔴 绝不带载荷内容,只带判决与被截断的 message)。 */
74
+ export interface CatalogSourceAttempt {
75
+ url: string;
76
+ outcome: CatalogSourceOutcome;
77
+ /** HTTP 状态码(只在真收到响应时在场)。 */
78
+ status?: number;
79
+ /** 旁签是否**真的比对过**(只在 `outcome:'ok'` 时在场;false = sha 拿不到,已记 warn 放行)。 */
80
+ shaChecked?: boolean;
81
+ /** 人话细节(判决相关短语 / 被截断的错误 message)。 */
82
+ detail?: string;
83
+ }
84
+ /** 缓存文件的信封(design/166 §3)。`fetchedAt` 缺席 = 写入时宿主没给时钟。 */
85
+ export interface CatalogCacheEnvelope {
86
+ fetchedAt?: number;
87
+ sourceUrl: string;
88
+ catalog: OnlineCatalogDoc;
89
+ }
90
+ /**
91
+ * 缓存口 —— 宿主注入(本包零 `node:fs`,理由见文件头)。
92
+ *
93
+ * `writeAtomic` 的契约是**原子替换**(临时文件 + rename),不是 `writeFile` 的别名:
94
+ * 半截文件会让下一次冷启动读到一份坏缓存。Node 宿主的参考实现:
95
+ * ```js
96
+ * async writeAtomic(path, text) {
97
+ * await mkdir(dirname(path), { recursive: true })
98
+ * const tmp = `${path}.${process.pid}.tmp`
99
+ * await writeFile(tmp, text, 'utf8')
100
+ * await rename(tmp, path) // 同目录 rename = 原子
101
+ * }
102
+ * ```
103
+ * 两个动词都允许同步或异步实现;抛异常由 loader 收成 warning(缓存是纵深,不是主路径)。
104
+ */
105
+ export interface CatalogCachePort {
106
+ read(path: string): string | null | Promise<string | null>;
107
+ writeAtomic(path: string, text: string): void | Promise<void>;
108
+ }
109
+ /** `loadCatalogWithSources` 的入参。 */
110
+ export interface LoadCatalogOptions {
111
+ /** 缓存落点的根(`configHome/cache/model-catalog.json`)。缺席 ⇒ 缓存腿不启用。 */
112
+ configHome?: string;
113
+ /** 覆盖候选链(缺省 `DEFAULT_CATALOG_SOURCES`;env 恒赢本项,见 `resolveCatalogSources`)。 */
114
+ sources?: readonly string[];
115
+ /** 宿主 env(缺省 `hostEnv()`)。 */
116
+ env?: EnvLike;
117
+ /** 每源传输预算(缺省 `DEFAULT_CATALOG_TIMEOUT_MS`)。 */
118
+ timeoutMs?: number;
119
+ /** 宿主时钟。缺席 ⇒ 不算 ageMs、不写 fetchedAt、不判 stale(绝不偷读时钟)。 */
120
+ nowMs?: number;
121
+ /** 传输注入口(缺省全局 `fetch`;本包既有姿势 = limitsWire/detachWire 的 `fetchImpl`)。 */
122
+ fetchImpl?: typeof fetch;
123
+ /** 缓存口。缺席 ⇒ 缓存腿整条不启用(`cacheHit` 键缺席)。 */
124
+ cache?: CatalogCachePort;
125
+ /** 用户本地覆盖层,原样透传给 `resolveModelCatalog`。 */
126
+ overrides?: readonly ProviderPreset[];
127
+ }
128
+ /**
129
+ * 结果 = `ModelCatalogResult`(来源标注/新鲜度/合并表全归 catalog.ts)+ 宿主面四位。
130
+ * 🔴 只**加**位不改位:端渲染仍读 `source` / `online.reason`。
131
+ */
132
+ export interface LoadedModelCatalog extends ModelCatalogResult {
133
+ /** 真正被吃下的那一跳(线上成功或缓存自述的来源);全败 ⇒ 缺席。 */
134
+ sourceUrl?: string;
135
+ /** 这份线上载荷是不是从缓存顶上来的。**缓存口缺席 ⇒ 本键缺席**(没查过 ≠ 查过没用上)。 */
136
+ cacheHit?: boolean;
137
+ /** 缓存是否已过 30 天(照用但如实标)。只在 `cacheHit:true` 且宿主给了时钟时在场。 */
138
+ cacheStale?: boolean;
139
+ /** 逐源留痕(doctor 分诊用)。 */
140
+ attempts: CatalogSourceAttempt[];
141
+ /** 非致命的诚实记账(sha 拿不到 / 缓存读写失败 / 缓存半截)。 */
142
+ warnings: string[];
143
+ }
144
+ /** `SEMA_CATALOG_SOURCES` 的逗号列表解析(去空白、丢空项;缺席 ⇒ 空列表)。 */
145
+ export declare function parseCatalogSources(raw: string | undefined | null): string[];
146
+ /** `resolveCatalogSources` 的入参。 */
147
+ export interface ResolveCatalogSourcesOptions {
148
+ env?: EnvLike;
149
+ sources?: readonly string[];
150
+ }
151
+ /**
152
+ * 候选链优先级(design/166 §2,高→低):env `SEMA_CATALOG_SOURCES` > 调用方(settings)> 默认链。
153
+ * 🔴 「配了但解析出空列表」按**没配**处理:空链会把目录腿整条静默关掉,而调用方以为自己配了。
154
+ */
155
+ export declare function resolveCatalogSources(opts?: ResolveCatalogSourcesOptions): string[];
156
+ /**
157
+ * 传输面硬门:**https + host 在白名单内**。拨号之前判 —— 不合格的地址连请求都不发。
158
+ * 🔴 白名单是**域**的门、https 是**协议**的门,两道都要过:白名单里的 host 走 http 照样拒
159
+ * (明文信道下目录可被改写成钓鱼网关,而目录决定的正是出站地址)。
160
+ */
161
+ export declare function isAllowedCatalogUrl(url: string, allowedHosts: ReadonlySet<string>): boolean;
162
+ /** 旁签地址 = 同源同路径换后缀(design/165 §4:`dist/catalog.sha256`)。 */
163
+ export declare function catalogShaUrlFor(catalogUrl: string): string;
164
+ /** 缓存文件绝对路径(design/166 §3)。POSIX 分隔符 —— Node 侧 `path.join` 对它是幂等的。 */
165
+ export declare function catalogCachePath(configHome: string): string;
166
+ /**
167
+ * 解析出一份可用的 provider 目录 —— **候选链 + 传输硬门 + 旁签 + 缓存**,载荷判决全委托
168
+ * `resolveModelCatalog`。🔴 **永不 reject**:onboard 不能因为网络死。
169
+ */
170
+ export declare function loadCatalogWithSources(opts?: LoadCatalogOptions): Promise<LoadedModelCatalog>;
@@ -0,0 +1,382 @@
1
+ /**
2
+ * catalogLoader.ts — **目录候选链的宿主 loader**(design/166 §1-§3,2026-08-04)。
3
+ *
4
+ * ## 它与 `catalog.ts` 的分工(🔴 委托,不平行重做)
5
+ *
6
+ * `catalog.ts` 已经是三层源解析器:`resolveModelCatalog`(线上→包内→用户覆盖三层合并)+
7
+ * `validateOnlineCatalog`(schemaVersion 区间 / 逐行 `isProviderRow` / **绝不半解析** / 不回显载荷)
8
+ * + `CatalogFetchJson` 注入端口 + `CatalogSource`·`CatalogRejectReason` 两张词表。
9
+ * 本文件**只补 catalog.ts 声明不管的宿主面**,四件事:
10
+ *
11
+ * ① **候选链遍历**(`DEFAULT_CATALOG_SOURCES` = raw.githubusercontent → jsDelivr,可配);
12
+ * ② **传输安全硬门**:https + 域白名单(拨号**之前**判,域外源连请求都不发)——
13
+ * 而且**重定向目标过同一道门**(fetch 默认跟随重定向,不设门 = 白名单可被 302 绕过);
14
+ * ③ **`catalog.sha256` 旁签**(同源同路径,SHA-256 hex 比对);
15
+ * ④ **缓存**(`configHome/cache/model-catalog.json`)的信封与读回语义。
16
+ *
17
+ * 载荷面的判决(schemaVersion / 逐行形状 / 整份弃 / 不回显)**一行都不在这里重写** ——
18
+ * 本文件拿到字节流之后做的唯一一件事是 `JSON.parse`,然后把结果交给 `resolveModelCatalog`,
19
+ * 由它去 `validateOnlineCatalog`。UI 渲染也只认 `ModelCatalogResult.source` + `online.reason`
20
+ * (+ 本文件加的 `cacheHit`),**不发明第二套 origin 词表**。
21
+ *
22
+ * ## 🔴 为什么缓存的**落盘动作**在宿主而不在这里(与设计档 §3 的偏差,记在这)
23
+ *
24
+ * 设计档 §3 写的是「loader 原子写(tmp+rename)」。但本包有一条机械门守着的硬法:
25
+ * `src/**` 的 index 值级闭包**零 Node 内建**(`scripts/run-client-core-portability-test.mjs`
26
+ * ①c 段),因为同一份代码要在浏览器/桌面渲染进程里跑。`node:fs` 一进来,那道门当场红。
27
+ * ⇒ 落盘走**注入口** `CatalogCachePort`(与 `host.ts` 的 `FsPort`、`catalog.ts` 的
28
+ * `CatalogFetchJson` 同一条纪律:能力由宿主注入,库不自己 `require('fs')`)。
29
+ * **tmp+rename 的原子性是 `writeAtomic` 实现方的契约**,本文件在类型注释里把它写成要求;
30
+ * Node 宿主的十行参考实现见 `CatalogCachePort.writeAtomic` 的注释。
31
+ * 缺席该口 ⇒ 缓存腿整条不启用(`cacheHit` 键**缺席**,不是 `false` —— 「没查过」和「查过没用上」
32
+ * 是两件事,[honest-absence-not-fabricated-zero])。
33
+ *
34
+ * ## 🔴 `forceRefresh` 为什么没有(与设计档 §1 签名的偏差)
35
+ *
36
+ * 本 loader **恒先走网络**,缓存只在候选链**全败**时顶上(设计档 §3 自己就是这么定的读时机)。
37
+ * 也就是说没有任何一条「优先吃缓存」的路径可供 `forceRefresh` 去绕过 —— 收下这个参数只会
38
+ * 得到一个恒为空操作的旋钮,而空操作的旋钮是**假 affordance**(调用方以为自己强制刷新了)。
39
+ * 真要「手动刷新」,直接再调一次本函数就是最新语义。
40
+ *
41
+ * ## 时钟
42
+ *
43
+ * 与 `catalog.ts` 同一口径:**绝不偷读时钟**。`nowMs` 缺席 ⇒ 缓存信封不写 `fetchedAt`、
44
+ * 陈旧判定不做(`cacheStale` 键缺席),而不是 `Date.now()` 兜底。
45
+ */
46
+ import { hostEnv } from '../hostEnv.js';
47
+ import { resolveModelCatalog, } from './catalog.js';
48
+ /**
49
+ * 默认候选链(design/166 §1;clay 裁「可配置,默认 github」)。
50
+ * 第二跳 jsDelivr 对 gh 仓是被动 CDN、零接入成本 —— 这就是大陆可达性的零维护替身。
51
+ * 🔴 顺序即优先级:逐源尝试,前一跳成了就不拨下一跳。
52
+ */
53
+ export const DEFAULT_CATALOG_SOURCES = [
54
+ 'https://raw.githubusercontent.com/sema-agent/sema-model-catalog/main/dist/catalog.json',
55
+ 'https://cdn.jsdelivr.net/gh/sema-agent/sema-model-catalog@main/dist/catalog.json',
56
+ ];
57
+ /** 默认域白名单 = 默认链两跳的 host。用户显式配置的源 host 在运行期并入(用户自担)。 */
58
+ export const CATALOG_DEFAULT_HOSTS = ['raw.githubusercontent.com', 'cdn.jsdelivr.net'];
59
+ /** 候选链的 env 键(一键安装脚本改这里;settings.json `env` 块同名同义,见 design/166 §2)。 */
60
+ export const CATALOG_SOURCES_ENV = 'SEMA_CATALOG_SOURCES';
61
+ /** 每源传输预算(design/166 §1:onboard 不能被网络拖住)。 */
62
+ export const DEFAULT_CATALOG_TIMEOUT_MS = 3500;
63
+ /** 缓存相对 configHome 的落点(design/166 §3)。 */
64
+ export const CATALOG_CACHE_RELATIVE_PATH = 'cache/model-catalog.json';
65
+ /** 缓存陈旧阈值:30 天(照用,但如实标 stale)。 */
66
+ export const CATALOG_CACHE_STALE_MS = 30 * 24 * 60 * 60 * 1000;
67
+ /** 单源允许的重定向跳数上限(有界:302 环不许把 onboard 转死)。 */
68
+ const MAX_REDIRECTS = 5;
69
+ /** 异常 → 短 detail(只取 message 前段,绝不带栈、绝不带载荷;与 catalog.ts 同一口径)。 */
70
+ function shortError(e) {
71
+ return (e instanceof Error ? e.message : String(e)).slice(0, 160);
72
+ }
73
+ /** `SEMA_CATALOG_SOURCES` 的逗号列表解析(去空白、丢空项;缺席 ⇒ 空列表)。 */
74
+ export function parseCatalogSources(raw) {
75
+ if (typeof raw !== 'string')
76
+ return [];
77
+ return raw
78
+ .split(',')
79
+ .map((s) => s.trim())
80
+ .filter((s) => s.length > 0);
81
+ }
82
+ /**
83
+ * 候选链优先级(design/166 §2,高→低):env `SEMA_CATALOG_SOURCES` > 调用方(settings)> 默认链。
84
+ * 🔴 「配了但解析出空列表」按**没配**处理:空链会把目录腿整条静默关掉,而调用方以为自己配了。
85
+ */
86
+ export function resolveCatalogSources(opts) {
87
+ const env = opts?.env ?? hostEnv();
88
+ const fromEnv = parseCatalogSources(env[CATALOG_SOURCES_ENV]);
89
+ if (fromEnv.length > 0)
90
+ return fromEnv;
91
+ const given = (opts?.sources ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
92
+ if (given.length > 0)
93
+ return given;
94
+ return [...DEFAULT_CATALOG_SOURCES];
95
+ }
96
+ /** 取 URL 的 host(含 port);解析不动 ⇒ undefined。 */
97
+ function hostOf(url) {
98
+ let parsed;
99
+ try {
100
+ parsed = new URL(url);
101
+ }
102
+ catch {
103
+ parsed = undefined; // 解析不动 = 不是合法 URL,调用方按「拒」处理(判决在 isAllowedCatalogUrl)
104
+ }
105
+ return parsed?.host;
106
+ }
107
+ /**
108
+ * 传输面硬门:**https + host 在白名单内**。拨号之前判 —— 不合格的地址连请求都不发。
109
+ * 🔴 白名单是**域**的门、https 是**协议**的门,两道都要过:白名单里的 host 走 http 照样拒
110
+ * (明文信道下目录可被改写成钓鱼网关,而目录决定的正是出站地址)。
111
+ */
112
+ export function isAllowedCatalogUrl(url, allowedHosts) {
113
+ let parsed;
114
+ try {
115
+ parsed = new URL(url.trim());
116
+ }
117
+ catch {
118
+ parsed = undefined; // 非法 URL ⇒ 拒(下面那一行判它)
119
+ }
120
+ if (parsed === undefined)
121
+ return false;
122
+ if (parsed.protocol !== 'https:')
123
+ return false;
124
+ return allowedHosts.has(parsed.host);
125
+ }
126
+ /** 旁签地址 = 同源同路径换后缀(design/165 §4:`dist/catalog.sha256`)。 */
127
+ export function catalogShaUrlFor(catalogUrl) {
128
+ return catalogUrl.endsWith('.json') ? `${catalogUrl.slice(0, -'.json'.length)}.sha256` : `${catalogUrl}.sha256`;
129
+ }
130
+ /** 缓存文件绝对路径(design/166 §3)。POSIX 分隔符 —— Node 侧 `path.join` 对它是幂等的。 */
131
+ export function catalogCachePath(configHome) {
132
+ return `${configHome.replace(/\/+$/, '')}/${CATALOG_CACHE_RELATIVE_PATH}`;
133
+ }
134
+ /** SHA-256 hex(WebCrypto;Node 18+ 与浏览器都有。拿不到实现 ⇒ null = 本次不比对)。 */
135
+ async function sha256Hex(text) {
136
+ const g = globalThis;
137
+ const subtle = g.crypto?.subtle;
138
+ const Enc = g.TextEncoder;
139
+ if (subtle === undefined || Enc === undefined)
140
+ return null;
141
+ const digest = await subtle.digest('SHA-256', new Enc().encode(text));
142
+ let out = '';
143
+ for (const b of new Uint8Array(digest))
144
+ out += b.toString(16).padStart(2, '0');
145
+ return out;
146
+ }
147
+ /** sha 文件内容 → hex(容忍 `<hex> <filename>` 的 shasum 形与大小写)。认不出 ⇒ null。 */
148
+ function parseShaFile(text) {
149
+ const m = /\b([0-9a-fA-F]{64})\b/.exec(text);
150
+ return m?.[1] === undefined ? null : m[1].toLowerCase();
151
+ }
152
+ async function guardedGet(url, allowedHosts, fetchImpl, timeoutMs) {
153
+ let current = url;
154
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
155
+ if (!isAllowedCatalogUrl(current, allowedHosts)) {
156
+ const insecure = !/^https:/i.test(current.trim());
157
+ return {
158
+ outcome: hop === 0 ? (insecure ? 'insecure-url' : 'host-not-allowed') : 'redirect-blocked',
159
+ detail: hop === 0
160
+ ? `source rejected before dialing: ${insecure ? 'not https' : `host not allowlisted (${hostOf(current) ?? 'unparsable'})`}`
161
+ : `redirect target rejected: ${hostOf(current) ?? 'unparsable'}`,
162
+ };
163
+ }
164
+ const controller = new AbortController();
165
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
166
+ let res;
167
+ let dialError;
168
+ try {
169
+ // 🔴 `redirect:'manual'` 是这道门的**执行体**:默认的 'follow' 会让 undici/浏览器替我们
170
+ // 跟到域外去(白名单被 302 绕过);手动跟 = 每一跳都回到上面那道 isAllowedCatalogUrl。
171
+ res = await fetchImpl(current, { method: 'GET', redirect: 'manual', signal: controller.signal });
172
+ }
173
+ catch (e) {
174
+ dialError = shortError(e);
175
+ }
176
+ finally {
177
+ clearTimeout(timer);
178
+ }
179
+ if (res === undefined)
180
+ return { outcome: 'network-error', detail: dialError ?? 'dial failed' };
181
+ // 浏览器的 `redirect:'manual'` 给的是 opaqueredirect(status 0、无头)——看不见目标就
182
+ // **拒**(fail-closed):看不见的重定向恰恰是这道门要防的那种。
183
+ if (res.status === 0)
184
+ return { outcome: 'redirect-opaque', status: 0, detail: 'opaque redirect: target not inspectable' };
185
+ if (res.status >= 300 && res.status < 400) {
186
+ const loc = res.headers.get('location');
187
+ if (loc === null || loc.length === 0) {
188
+ return { outcome: 'http-error', status: res.status, detail: 'redirect without Location' };
189
+ }
190
+ let next;
191
+ try {
192
+ next = new URL(loc, current).toString();
193
+ }
194
+ catch (e) {
195
+ return { outcome: 'redirect-blocked', status: res.status, detail: `unparsable Location: ${shortError(e)}` };
196
+ }
197
+ current = next;
198
+ continue;
199
+ }
200
+ if (res.status < 200 || res.status >= 300) {
201
+ return { outcome: 'http-error', status: res.status, detail: `HTTP ${res.status}` };
202
+ }
203
+ let text;
204
+ let readError;
205
+ try {
206
+ text = await res.text();
207
+ }
208
+ catch (e) {
209
+ readError = shortError(e);
210
+ }
211
+ if (text === undefined)
212
+ return { outcome: 'network-error', status: res.status, detail: readError ?? 'body read failed' };
213
+ return { outcome: 'ok', status: res.status, finalUrl: current, text };
214
+ }
215
+ return { outcome: 'redirect-loop', detail: `more than ${MAX_REDIRECTS} redirects` };
216
+ }
217
+ /**
218
+ * 解析出一份可用的 provider 目录 —— **候选链 + 传输硬门 + 旁签 + 缓存**,载荷判决全委托
219
+ * `resolveModelCatalog`。🔴 **永不 reject**:onboard 不能因为网络死。
220
+ */
221
+ export async function loadCatalogWithSources(opts) {
222
+ const sources = resolveCatalogSources({
223
+ ...(opts?.env !== undefined ? { env: opts.env } : {}),
224
+ ...(opts?.sources !== undefined ? { sources: opts.sources } : {}),
225
+ });
226
+ // 白名单 = 默认两域 + **用户显式配置的源 host**(显式意愿=自担;doctor 如实标 user-configured)。
227
+ const allowedHosts = new Set(CATALOG_DEFAULT_HOSTS);
228
+ for (const s of sources) {
229
+ const h = hostOf(s);
230
+ if (h !== undefined)
231
+ allowedHosts.add(h);
232
+ }
233
+ const fetchImpl = opts?.fetchImpl ?? fetch;
234
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
235
+ const attempts = [];
236
+ const warnings = [];
237
+ let hit = null;
238
+ let lastDialed;
239
+ for (const src of sources) {
240
+ if (hit !== null)
241
+ break;
242
+ const got = await guardedGet(src, allowedHosts, fetchImpl, timeoutMs);
243
+ if (got.outcome !== 'insecure-url' && got.outcome !== 'host-not-allowed')
244
+ lastDialed = src;
245
+ if (got.outcome !== 'ok' || got.text === undefined) {
246
+ attempts.push({
247
+ url: src,
248
+ outcome: got.outcome,
249
+ ...(got.status !== undefined ? { status: got.status } : {}),
250
+ ...(got.detail !== undefined ? { detail: got.detail } : {}),
251
+ });
252
+ continue;
253
+ }
254
+ const finalUrl = got.finalUrl ?? src;
255
+ let doc;
256
+ let parseError;
257
+ try {
258
+ doc = JSON.parse(got.text);
259
+ }
260
+ catch (e) {
261
+ parseError = shortError(e);
262
+ }
263
+ if (parseError !== undefined) {
264
+ attempts.push({ url: src, outcome: 'invalid-json', ...(got.status !== undefined ? { status: got.status } : {}), detail: parseError });
265
+ continue;
266
+ }
267
+ // 旁签:同源同路径。拿不到 ⇒ 记 warn 放行(design/165 §4:它是防传输损坏的纵深,
268
+ // 不是防主动篡改 —— 把「拿不到」当「篡改」会让 CDN 的半更新窗把整条链打死)。
269
+ const shaGot = await guardedGet(catalogShaUrlFor(finalUrl), allowedHosts, fetchImpl, timeoutMs);
270
+ let shaChecked = false;
271
+ if (shaGot.outcome === 'ok' && shaGot.text !== undefined) {
272
+ const expected = parseShaFile(shaGot.text);
273
+ const actual = await sha256Hex(got.text);
274
+ if (expected === null) {
275
+ warnings.push(`catalog sha256 sidecar for ${finalUrl} is not a hex digest — payload accepted without the checksum`);
276
+ }
277
+ else if (actual === null) {
278
+ warnings.push('no WebCrypto SHA-256 in this host — catalog payload accepted without the checksum');
279
+ }
280
+ else if (expected !== actual) {
281
+ attempts.push({ url: src, outcome: 'sha-mismatch', detail: 'catalog.sha256 does not match the payload digest' });
282
+ continue;
283
+ }
284
+ else {
285
+ shaChecked = true;
286
+ }
287
+ }
288
+ else {
289
+ warnings.push(`catalog sha256 sidecar unavailable for ${finalUrl} (${shaGot.outcome}) — payload accepted without the checksum`);
290
+ }
291
+ attempts.push({ url: src, outcome: 'ok', ...(got.status !== undefined ? { status: got.status } : {}), shaChecked });
292
+ hit = { url: finalUrl, doc, raw: got.text, shaChecked };
293
+ }
294
+ // ── 缓存腿(口缺席 ⇒ 整条不启用,cacheHit 键缺席)────────────────────────────────────────
295
+ const cache = opts?.cache;
296
+ const cachePath = opts?.configHome !== undefined ? catalogCachePath(opts.configHome) : undefined;
297
+ let cacheHit = cache !== undefined ? false : undefined;
298
+ let cacheStale;
299
+ let cachedSourceUrl;
300
+ if (hit === null && cache !== undefined && cachePath !== undefined) {
301
+ let text = null;
302
+ let readError;
303
+ try {
304
+ text = await cache.read(cachePath);
305
+ }
306
+ catch (e) {
307
+ readError = shortError(e);
308
+ }
309
+ if (readError !== undefined)
310
+ warnings.push(`catalog cache read failed: ${readError}`);
311
+ if (typeof text === 'string' && text.length > 0) {
312
+ let env;
313
+ let envError;
314
+ try {
315
+ env = JSON.parse(text);
316
+ }
317
+ catch (e) {
318
+ envError = shortError(e);
319
+ }
320
+ if (envError !== undefined)
321
+ warnings.push(`catalog cache is not parsable JSON (ignored): ${envError}`);
322
+ const doc = env?.catalog;
323
+ const url = env?.sourceUrl;
324
+ if (doc !== undefined && typeof url === 'string' && url.length > 0) {
325
+ // 缓存里那份**就是**当初的线上载荷 —— 照样过 validateOnlineCatalog(下面同一条路),
326
+ // 不给它开后门:一份当年合法、今天已超区间的文档必须照样被拒。
327
+ hit = { url, doc, raw: text, shaChecked: false };
328
+ cacheHit = true;
329
+ cachedSourceUrl = url;
330
+ if (opts?.nowMs !== undefined && typeof env?.fetchedAt === 'number') {
331
+ cacheStale = opts.nowMs - env.fetchedAt > CATALOG_CACHE_STALE_MS;
332
+ }
333
+ }
334
+ else if (envError === undefined) {
335
+ warnings.push('catalog cache envelope missing sourceUrl/catalog (ignored)');
336
+ }
337
+ }
338
+ }
339
+ // ── 委托:载荷判决 / 三层合并 / 来源标注全在 resolveModelCatalog ──────────────────────────
340
+ // 传给它的 `fetchJson` 只是「把已经拿到的这一份交出去」的闭包 —— 候选链与传输门是本文件的活,
341
+ // 校验与合并是它的活,两边不重叠也不互相重写。
342
+ const payload = hit;
343
+ const onlineUrl = payload?.url ?? lastDialed ?? sources[0];
344
+ const resolveOpts = {
345
+ ...(onlineUrl !== undefined ? { onlineUrl } : {}),
346
+ fetchJson: async () => {
347
+ if (payload === null)
348
+ throw new Error(`all ${sources.length} catalog source(s) failed; see attempts[]`);
349
+ return payload.doc;
350
+ },
351
+ ...(opts?.overrides !== undefined ? { overrides: opts.overrides } : {}),
352
+ ...(opts?.nowMs !== undefined ? { nowMs: opts.nowMs } : {}),
353
+ };
354
+ const base = await resolveModelCatalog(resolveOpts);
355
+ // 线上腿真的被接受了才写缓存(被 validateOnlineCatalog 拒掉的载荷绝不进缓存 ——
356
+ // 否则下一次断网时我们会把一份已知不合格的文档当兜底)。
357
+ if (base.online.ok && payload !== null && cacheHit !== true && cache !== undefined && cachePath !== undefined) {
358
+ const envelope = {
359
+ ...(opts?.nowMs !== undefined ? { fetchedAt: opts.nowMs } : {}),
360
+ sourceUrl: payload.url,
361
+ catalog: payload.doc,
362
+ };
363
+ let writeError;
364
+ try {
365
+ await cache.writeAtomic(cachePath, JSON.stringify(envelope));
366
+ }
367
+ catch (e) {
368
+ writeError = shortError(e);
369
+ }
370
+ if (writeError !== undefined)
371
+ warnings.push(`catalog cache write failed: ${writeError}`);
372
+ }
373
+ const sourceUrl = base.online.ok ? (cachedSourceUrl ?? payload?.url) : undefined;
374
+ return {
375
+ ...base,
376
+ ...(sourceUrl !== undefined ? { sourceUrl } : {}),
377
+ ...(cacheHit !== undefined ? { cacheHit } : {}),
378
+ ...(cacheStale !== undefined ? { cacheStale } : {}),
379
+ attempts,
380
+ warnings,
381
+ };
382
+ }