@sema-agent/client-core 0.13.0 → 0.15.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.
- package/README.md +3 -1
- package/dist/adapt/arms.d.ts +10 -1
- package/dist/adapt/arms.js +12 -2
- package/dist/adapt/panelTasks.d.ts +6 -1
- package/dist/adapt/textStream.d.ts +15 -1
- package/dist/adapt/turnFlags.d.ts +5 -1
- package/dist/adapt/wireShapes.d.ts +4 -1
- package/dist/adapt.d.ts +1 -1
- package/dist/adapt.js +7 -2
- package/dist/adapter/downstream/eventToSdkMessage.d.ts +4 -2
- package/dist/adapter/downstream/eventToSdkMessage.js +9 -1
- package/dist/adapter/downstream/terminalToSdkResult.js +4 -2
- package/dist/adapter/runStream.d.ts +8 -1
- package/dist/adapter/runStream.js +70 -4
- package/dist/adapter/types.d.ts +26 -0
- package/dist/hitl/approvalsFeed.d.ts +22 -1
- package/dist/hitl/approvalsFeed.js +76 -5
- package/dist/hitl/frameRouter.js +49 -8
- package/dist/hitl/gateLedger.d.ts +13 -1
- package/dist/hitl/gateLedger.js +2 -2
- package/dist/hitl/parkResolver.js +10 -1
- package/dist/hitl/planReviewWire.js +48 -20
- package/dist/hitl/toolApprovalWire.js +11 -1
- package/dist/hooksWireCaps.js +10 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/liveQuestionStore.d.ts +13 -0
- package/dist/liveQuestionStore.js +15 -0
- package/dist/model/catalogLoader.d.ts +170 -0
- package/dist/model/catalogLoader.js +382 -0
- package/dist/model/providerAuth.d.ts +155 -0
- package/dist/model/providerAuth.js +190 -0
- package/dist/model/providerPresets.d.ts +16 -0
- package/dist/notifications.d.ts +15 -1
- package/dist/notifications.js +90 -10
- package/dist/printToolResultFrame.d.ts +12 -4
- package/dist/printToolResultFrame.js +11 -21
- package/dist/unrefTimer.d.ts +14 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providerAuth.ts — **模型供应商凭证轨**的双轨 seam(design/166 §5「B 档:设备码流」,2026-08-04)。
|
|
3
|
+
*
|
|
4
|
+
* ## 边界(先说清楚它**不是**什么)
|
|
5
|
+
*
|
|
6
|
+
* 🔴 [reuse-single-sso-no-parallel-auth]:registry SSO(cloudAuth,「登录 sema」那条)**绝不复用、
|
|
7
|
+
* 绝不被改造**。本文件是**模型供应商**的凭证轨(拿一把 API key 去调某家模型),与 registry 登录轨
|
|
8
|
+
* 物理分文件、零共享代码。两者唯一的相似点是「都会弹一个浏览器」,那不构成复用理由。
|
|
9
|
+
*
|
|
10
|
+
* 🔴 design/166 §6 已定谳:**授权码 + PKCE(C 档)不做、不留 seam、不留枚举位** —— Anthropic 侧
|
|
11
|
+
* 条款明文禁止第三方代理其订阅凭证,而公开实现全都依赖冒用官方 client_id + 伪装 UA。本文件里
|
|
12
|
+
* 因此**只有** api_key 与 device_code 两轨,没有第三个 kind 位。
|
|
13
|
+
*
|
|
14
|
+
* ## 能力真值 = 目录键 ∧ 包内实现(两者与)
|
|
15
|
+
*
|
|
16
|
+
* 目录(design/165 §2)里的 `deviceAuth` 键**只是能力开关**,client_id 与端点**绝不目录化下发**
|
|
17
|
+
* (远端可改鉴权端点 = 钓鱼面)。所以 UI 是否显示「Sign in with device code」的判据是
|
|
18
|
+
* `supportsDeviceCodeAuth(id, preset.deviceAuth)`:
|
|
19
|
+
* · 目录说有、包里没有 ⇒ **不显示**(防目录先行造出一个点了没反应的假 affordance);
|
|
20
|
+
* · 包里有、目录没说 ⇒ **不显示**(那家还没被目录确认支持)。
|
|
21
|
+
*
|
|
22
|
+
* ## 🔴 `DEVICE_AUTH_PROVIDERS` 今天是**空表**(宁空勿假 —— 这是有意的,不是没写完)
|
|
23
|
+
*
|
|
24
|
+
* 设计档写「首发 1 家:GitHub Copilot 形」。落码时的事实是:device flow 需要一个 **client_id**,
|
|
25
|
+
* 而公开可查到的 Copilot client_id 全部是**别的编辑器插件自己的** app id(copilot.vim / VS Code
|
|
26
|
+
* 扩展)。把它们编进我们的包 = 以别人的 app 身份向 GitHub 发起授权,与 §6 判 C 档出局时用的
|
|
27
|
+
* **同一条判据**(「冒用官方 client_id」是主动欺骗,不是灰区)。我们自己名下的 OAuth app 尚未
|
|
28
|
+
* 申请 ⇒ 这一格的诚实值是**空**,而不是先填一个能跑的别人的号。
|
|
29
|
+
*
|
|
30
|
+
* 空表不是把这条腿废了:轮询器、退避、上限、终态词表全部在位并被门覆盖(门用一份合成 descriptor
|
|
31
|
+
* 跑真 HTTP)。**注册一家 = 往这张表加一行**,零代码改动。在那之前 `supportsDeviceCodeAuth`
|
|
32
|
+
* 对每一家如实返回 false,UI 不显示该选项 —— 这正是「能力真值 = 两者与」自带的正确降级。
|
|
33
|
+
*
|
|
34
|
+
* ## key 到手之后
|
|
35
|
+
*
|
|
36
|
+
* 🔴 **落盘不在包内做**。`poll()` 把 key 原样返给宿主,宿主走**与手填完全相同的那条路径**
|
|
37
|
+
* (壳 = `applyOnboard.ts` 写 settings `env.MODEL_API_KEY`)。两树都没有 auth.json 实体,
|
|
38
|
+
* 本文件也不新建任何持久面(design/166 §5 复核 M4:零新持久面,禁造)。
|
|
39
|
+
*/
|
|
40
|
+
import type { ProviderPreset, ProviderDeviceAuthHint } from './providerPresets.js';
|
|
41
|
+
/**
|
|
42
|
+
* 一个 provider 可用的凭证轨。
|
|
43
|
+
* `api_key` 是现状唯一轨(手填,env 名来自 preset.authEnv);`device_code` 是 B 档新轨。
|
|
44
|
+
*/
|
|
45
|
+
export type ProviderAuthMethod = {
|
|
46
|
+
kind: 'api_key';
|
|
47
|
+
env: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'device_code';
|
|
50
|
+
providerId: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* 设备码端点三件套 —— **编译进包**(design/165 §2 红线:client_id / token 端点 / authorize 端点
|
|
54
|
+
* 一律不进目录)。
|
|
55
|
+
*/
|
|
56
|
+
export interface DeviceCodeEndpoints {
|
|
57
|
+
clientId: string;
|
|
58
|
+
/** RFC 8628 device authorization endpoint。 */
|
|
59
|
+
deviceCodeUrl: string;
|
|
60
|
+
/** token endpoint(轮询这一个)。 */
|
|
61
|
+
tokenUrl: string;
|
|
62
|
+
/** 申请的 scope(留空 = 不发这个参数)。 */
|
|
63
|
+
scope?: string;
|
|
64
|
+
}
|
|
65
|
+
/** 包内登记的一家设备码 provider。 */
|
|
66
|
+
export interface DeviceAuthProvider {
|
|
67
|
+
/** 与目录/preset 的 `id` 同一个命名空间(用户 settings 里的持久引用)。 */
|
|
68
|
+
providerId: string;
|
|
69
|
+
label: string;
|
|
70
|
+
endpoints: DeviceCodeEndpoints;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 包内实现的设备码 provider 表。
|
|
74
|
+
* 🔴 **今天是空的,理由见文件头**(宁空勿假:没有我们自己名下的 client_id 之前,填一行 = 冒用)。
|
|
75
|
+
* 加一家 = 加一行;端点必须 https(门 ④a 逐行校)。
|
|
76
|
+
*/
|
|
77
|
+
export declare const DEVICE_AUTH_PROVIDERS: readonly DeviceAuthProvider[];
|
|
78
|
+
/** 包内是否实现了这一家(能力真值的一半)。 */
|
|
79
|
+
export declare function deviceAuthProviderFor(providerId: string): DeviceAuthProvider | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* UI 是否显示「设备码登录」= **目录说有** ∧ **包里有实现**。
|
|
82
|
+
* 任一缺席 ⇒ false(见文件头「能力真值」段)。
|
|
83
|
+
*/
|
|
84
|
+
export declare function supportsDeviceCodeAuth(providerId: string, catalogHint: ProviderDeviceAuthHint | undefined): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* 一家 provider 摆给用户的轨(顺序 = UI 呈现序)。`api_key` 恒在;`device_code` 只在能力真值成立时出。
|
|
87
|
+
*/
|
|
88
|
+
export declare function providerAuthMethods(preset: ProviderPreset): ProviderAuthMethod[];
|
|
89
|
+
/**
|
|
90
|
+
* 一次 `poll()` 的结局。
|
|
91
|
+
*
|
|
92
|
+
* 🔴 比设计档草案多两个终态,理由是诚实:草案只有 `pending|ok|expired`,于是
|
|
93
|
+
* 「用户点了拒绝」与「会话被本地取消」都只能冒充 `expired`(= 骗用户「过期了,再来一次」,
|
|
94
|
+
* 而事实是再来一次还会被拒 / 是他自己取消的)。两者各占一位:
|
|
95
|
+
* · `denied` —— 授权服务器说 access_denied;
|
|
96
|
+
* · `cancelled` —— 本地调过 `cancel()`。
|
|
97
|
+
*/
|
|
98
|
+
export type DeviceCodeStatus = 'pending' | 'ok' | 'expired' | 'denied' | 'cancelled';
|
|
99
|
+
export interface DeviceCodePollResult {
|
|
100
|
+
status: DeviceCodeStatus;
|
|
101
|
+
/** 只在 `ok` 时在场。🔴 宿主拿去走与手填同一条落盘路径;包内零持久面。 */
|
|
102
|
+
key?: string;
|
|
103
|
+
/** 人话细节(分诊用;绝不带 key、绝不带完整响应体)。 */
|
|
104
|
+
detail?: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 一次设备码会话。**宿主驱动**:UI 渲大字码 + URL,自己按 `intervalMs` 起节拍调 `poll()`
|
|
108
|
+
* (等待型判据,不固定拍)。包内不起定时器 —— 定时器是宿主资产(与 `TimersPort` 同一条纪律)。
|
|
109
|
+
*/
|
|
110
|
+
export interface DeviceCodeSession {
|
|
111
|
+
/** 用户要在浏览器里输入的大字码。 */
|
|
112
|
+
userCode: string;
|
|
113
|
+
/** 用户要打开的验证页。 */
|
|
114
|
+
verificationUrl: string;
|
|
115
|
+
/** 建议轮询间隔(毫秒)。429 / slow_down 之后会**变大**,宿主每拍都该重读这个值。 */
|
|
116
|
+
intervalMs: number;
|
|
117
|
+
/** 本会话的墙钟终点(min(服务端 expires_in, 15min 上限))。 */
|
|
118
|
+
expiresAtMs: number;
|
|
119
|
+
poll(): Promise<DeviceCodePollResult>;
|
|
120
|
+
cancel(): void;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 设备码流的宿主注入面。
|
|
124
|
+
* 🔴 `nowMs` **必填**:5s 间隔与 15min 上限都是墙钟判据,而本包绝不偷读时钟
|
|
125
|
+
* (与 `catalog.ts` / `AdapterContext.now` 同一口径)。宿主传 `() => Date.now()`。
|
|
126
|
+
*/
|
|
127
|
+
export interface DeviceCodeAuthOptions {
|
|
128
|
+
nowMs: () => number;
|
|
129
|
+
/** 传输注入口(缺省全局 `fetch`)。 */
|
|
130
|
+
fetchImpl?: typeof fetch;
|
|
131
|
+
/** 单次 HTTP 预算(缺省 `DEVICE_CODE_HTTP_TIMEOUT_MS`)。 */
|
|
132
|
+
timeoutMs?: number;
|
|
133
|
+
}
|
|
134
|
+
/** 轮询间隔下限(RFC 8628 建议 5s;服务端给更大的值时听服务端的)。 */
|
|
135
|
+
export declare const DEVICE_CODE_MIN_INTERVAL_MS = 5000;
|
|
136
|
+
/** 会话墙钟上限(design/166 §5:15min)。服务端 `expires_in` 更短时听服务端的。 */
|
|
137
|
+
export declare const DEVICE_CODE_MAX_LIFETIME_MS: number;
|
|
138
|
+
/** 429 / slow_down 的退避增量(RFC 8628 §3.5 就是「加 5 秒」)。 */
|
|
139
|
+
export declare const DEVICE_CODE_BACKOFF_STEP_MS = 5000;
|
|
140
|
+
/** 单次 HTTP 预算。 */
|
|
141
|
+
export declare const DEVICE_CODE_HTTP_TIMEOUT_MS = 10000;
|
|
142
|
+
/**
|
|
143
|
+
* 开一次设备码会话 —— **端点由调用方给**(包内表里那一行,或门的合成 descriptor)。
|
|
144
|
+
* 🔴 端点是**编译进包的常量**,不是远端数据,所以本函数不对它再做白名单;真正的门是
|
|
145
|
+
* 「client_id/端点绝不目录化下发」(design/165 §2 红线)+ 表内每行必须 https(门 ④a)。
|
|
146
|
+
*
|
|
147
|
+
* 失败(网络 / 非 2xx / 缺 user_code)⇒ **throw**(§C1 三选一里的 throw):开不出会话就是开不出,
|
|
148
|
+
* 绝不返回一个永远 pending 的假会话。
|
|
149
|
+
*/
|
|
150
|
+
export declare function openDeviceCodeSession(endpoints: DeviceCodeEndpoints, opts: DeviceCodeAuthOptions): Promise<DeviceCodeSession>;
|
|
151
|
+
/**
|
|
152
|
+
* 按 providerId 开设备码会话(UI 入口)。
|
|
153
|
+
* 包内没有这一家 ⇒ **throw**(UI 本就不该显示这个选项;走到这里说明能力判据被绕过了)。
|
|
154
|
+
*/
|
|
155
|
+
export declare function beginDeviceCodeAuth(providerId: string, opts: DeviceCodeAuthOptions): Promise<DeviceCodeSession>;
|