@sema-agent/client-core 0.14.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 CHANGED
@@ -23,7 +23,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
23
23
 
24
24
  ## Scope
25
25
 
26
- **Version:** 0.13.0
26
+ **Version:** 0.14.0
27
27
 
28
28
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
29
29
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -232,6 +232,7 @@ public-surface guard checks that last one).
232
232
  | `scripts/run-client-core-failloud-test.mjs` | §C1/§C2: an empty `catch` with no comment anywhere inside it, a pure-swallow `catch` nobody reasoned about, and `void <write>` that really returns a Promise with no `.catch`. The exemption instrument is a comment saying why *this* failure may die; the documented-swallow count is a ratchet that only goes down |
233
233
  | `scripts/run-client-core-typeshape-test.mjs` | Type discipline as a guard rather than a build side effect: the set of enabled strict knobs (one silently switched off is red), `tsc --noEmit`, and export-surface ratchets for inline anonymous shapes (≥3 members), `unknown` leaving the surface, and bare `unknown` returns — zero slack in either direction |
234
234
  | `scripts/run-client-core-singleton-test.mjs` | Module-level singletons ⇄ `docs/refactor/p1-scan/singleton-manifest.json`, **both directions**: an unregistered singleton is red (registering it forces someone to answer "what if this got duplicated"), a stale entry is red, and the `dupRisk: high` count only goes down |
235
+ | `scripts/run-catalog-loader-gates-test.mjs` | The model-catalog candidate chain (`loadCatalogWithSources`) and the provider device-code seam: offline ⇒ `bundled` with an honest `online.reason`, a good source ⇒ `online` plus a cache write, a second offline run ⇒ `cacheHit`; the three hostile source shapes (malformed JSON, `schemaVersion: 99`, off-domain `http`) each fall through to the bundled table, and an off-allowlist target is **never dialled** — including a `302` to another host, proven by a real loopback server's hit counter staying at zero; a one-byte edit to `catalog.sha256` drops that source while an unavailable sidecar only warns; and the device-code poller's `pending → ok` / `expired` arms run against a real loopback HTTP server with an injected clock |
235
236
  | `scripts/run-abortable-sleep-test.mjs` | The shared `abortableSleep(ms, signal)` leaf (consumed by `workflowClient.ts` and `agentSession/backgroundView.ts`'s poll backoff): normal timeout resolution, immediate wake-up on `abort` mid-wait, `clearTimeout` really firing on that path, and a post-resolve late abort staying a no-op |
236
237
 
237
238
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
package/dist/index.d.ts CHANGED
@@ -223,5 +223,7 @@ export * from './seatContract.js';
223
223
  export * from './agentSession/contract.js';
224
224
  export * from './agentSession/backgroundView.js';
225
225
  export * from './model/catalog.js';
226
+ export * from './model/catalogLoader.js';
227
+ export * from './model/providerAuth.js';
226
228
  export * from './websearch/searchProviderPresets.js';
227
229
  export * from './env/localeGeo.js';
package/dist/index.js CHANGED
@@ -324,6 +324,19 @@ export * from './agentSession/backgroundView.js';
324
324
  // 🔴 绝不半解析:schemaVersion 超区间/任一行形状坏 ⇒ 整份弃用走兜底(端渲「内置版本(离线)」
325
325
  // 靠 `source` + `online.reason`,所以「静默用兜底」在本层是可观测的)。
326
326
  export * from './model/catalog.js';
327
+ // ── design/166(2026-08-04):目录客户端消费链 —— 候选链 loader + 供应商凭证双轨 seam ──────────
328
+ // 🔴 `model/catalogLoader.ts` 是 catalog.ts 的**宿主面**,不是第二个解析器:候选链遍历 +
329
+ // 传输硬门(https + 域白名单,**重定向目标过同一道门**)+ `catalog.sha256` 旁签 + 缓存信封,
330
+ // 载荷判决(schemaVersion/逐行形状/整份弃)全部委托 `resolveModelCatalog`,一行不重写。
331
+ // 🔴 缓存**落盘**走注入口 `CatalogCachePort`(本包 index 闭包零 Node 内建是机械门守着的硬法;
332
+ // tmp+rename 的原子性是实现方的契约,参考实现在 catalogLoader.ts 的类型注释里)。
333
+ export * from './model/catalogLoader.js';
334
+ // 🔴 `model/providerAuth.ts` 与 registry SSO(cloudAuth)**物理分文件、零共享代码**
335
+ // ([reuse-single-sso-no-parallel-auth]:它是模型供应商凭证轨,不是 sema 登录轨)。
336
+ // `DEVICE_AUTH_PROVIDERS` 今天是**空表**(宁空勿假:没有我们自己名下的 client_id 之前,
337
+ // 填别的编辑器插件的 app id = 冒用,与 §6 判 C 档出局同一条判据),因此
338
+ // `supportsDeviceCodeAuth` 对每一家如实返回 false、UI 不显示该选项 —— 注册一家 = 加一行。
339
+ export * from './model/providerAuth.js';
327
340
  // ── 搜索 provider 目录 + 地域预选(2026-07-31)──────────────────────────────────────────────────
328
341
  // `websearch/searchProviderPresets` = model 目录的**同形不同表**姊妹件:数据在 json、类型与查询
329
342
  // 在 ts。它编译出来的不是模型目录而是**引擎部署 env**(`WEB_SEARCH_*`)。
@@ -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
+ }
@@ -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>;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 包内实现的设备码 provider 表。
3
+ * 🔴 **今天是空的,理由见文件头**(宁空勿假:没有我们自己名下的 client_id 之前,填一行 = 冒用)。
4
+ * 加一家 = 加一行;端点必须 https(门 ④a 逐行校)。
5
+ */
6
+ export const DEVICE_AUTH_PROVIDERS = [];
7
+ /** 包内是否实现了这一家(能力真值的一半)。 */
8
+ export function deviceAuthProviderFor(providerId) {
9
+ return DEVICE_AUTH_PROVIDERS.find((p) => p.providerId === providerId);
10
+ }
11
+ /**
12
+ * UI 是否显示「设备码登录」= **目录说有** ∧ **包里有实现**。
13
+ * 任一缺席 ⇒ false(见文件头「能力真值」段)。
14
+ */
15
+ export function supportsDeviceCodeAuth(providerId, catalogHint) {
16
+ if (catalogHint?.kind !== 'device_code')
17
+ return false;
18
+ return deviceAuthProviderFor(providerId) !== undefined;
19
+ }
20
+ /**
21
+ * 一家 provider 摆给用户的轨(顺序 = UI 呈现序)。`api_key` 恒在;`device_code` 只在能力真值成立时出。
22
+ */
23
+ export function providerAuthMethods(preset) {
24
+ const out = [{ kind: 'api_key', env: preset.authEnv }];
25
+ if (supportsDeviceCodeAuth(preset.id, preset.deviceAuth)) {
26
+ out.push({ kind: 'device_code', providerId: preset.id });
27
+ }
28
+ return out;
29
+ }
30
+ /** 轮询间隔下限(RFC 8628 建议 5s;服务端给更大的值时听服务端的)。 */
31
+ export const DEVICE_CODE_MIN_INTERVAL_MS = 5000;
32
+ /** 会话墙钟上限(design/166 §5:15min)。服务端 `expires_in` 更短时听服务端的。 */
33
+ export const DEVICE_CODE_MAX_LIFETIME_MS = 15 * 60 * 1000;
34
+ /** 429 / slow_down 的退避增量(RFC 8628 §3.5 就是「加 5 秒」)。 */
35
+ export const DEVICE_CODE_BACKOFF_STEP_MS = 5000;
36
+ /** 单次 HTTP 预算。 */
37
+ export const DEVICE_CODE_HTTP_TIMEOUT_MS = 10_000;
38
+ /** 异常 → 短 detail(绝不带栈、绝不带响应体)。 */
39
+ function shortError(e) {
40
+ return (e instanceof Error ? e.message : String(e)).slice(0, 160);
41
+ }
42
+ /** POST application/x-www-form-urlencoded,收 JSON。返回 {status, body}(body 解析不动 ⇒ null)。 */
43
+ async function postForm(url, form, fetchImpl, timeoutMs, signal) {
44
+ const controller = new AbortController();
45
+ const onAbort = () => controller.abort();
46
+ if (signal.aborted)
47
+ controller.abort();
48
+ else
49
+ signal.addEventListener('abort', onAbort, { once: true });
50
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
51
+ try {
52
+ const res = await fetchImpl(url, {
53
+ method: 'POST',
54
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
55
+ body: new URLSearchParams(form).toString(),
56
+ signal: controller.signal,
57
+ });
58
+ const text = await res.text();
59
+ let body = null;
60
+ try {
61
+ body = JSON.parse(text);
62
+ }
63
+ catch {
64
+ body = null; // 非 JSON 响应 ⇒ 当作「说不出所以然」,由调用方按 status 判(绝不回显正文)
65
+ }
66
+ return { status: res.status, body };
67
+ }
68
+ finally {
69
+ clearTimeout(timer);
70
+ signal.removeEventListener('abort', onAbort);
71
+ }
72
+ }
73
+ /**
74
+ * 开一次设备码会话 —— **端点由调用方给**(包内表里那一行,或门的合成 descriptor)。
75
+ * 🔴 端点是**编译进包的常量**,不是远端数据,所以本函数不对它再做白名单;真正的门是
76
+ * 「client_id/端点绝不目录化下发」(design/165 §2 红线)+ 表内每行必须 https(门 ④a)。
77
+ *
78
+ * 失败(网络 / 非 2xx / 缺 user_code)⇒ **throw**(§C1 三选一里的 throw):开不出会话就是开不出,
79
+ * 绝不返回一个永远 pending 的假会话。
80
+ */
81
+ export async function openDeviceCodeSession(endpoints, opts) {
82
+ const fetchImpl = opts.fetchImpl ?? fetch;
83
+ const timeoutMs = opts.timeoutMs ?? DEVICE_CODE_HTTP_TIMEOUT_MS;
84
+ const abort = new AbortController();
85
+ const started = opts.nowMs();
86
+ const { status, body } = await postForm(endpoints.deviceCodeUrl, { client_id: endpoints.clientId, ...(endpoints.scope !== undefined ? { scope: endpoints.scope } : {}) }, fetchImpl, timeoutMs, abort.signal);
87
+ if (status < 200 || status >= 300) {
88
+ throw new Error(`device code request failed: HTTP ${status}`);
89
+ }
90
+ const grant = (body ?? {});
91
+ const deviceCode = typeof grant.device_code === 'string' ? grant.device_code : '';
92
+ const userCode = typeof grant.user_code === 'string' ? grant.user_code : '';
93
+ const verificationUrl = typeof grant.verification_uri_complete === 'string'
94
+ ? grant.verification_uri_complete
95
+ : typeof grant.verification_uri === 'string'
96
+ ? grant.verification_uri
97
+ : '';
98
+ if (deviceCode === '' || userCode === '' || verificationUrl === '') {
99
+ throw new Error('device code response missing device_code / user_code / verification_uri');
100
+ }
101
+ const serverLifetimeMs = typeof grant.expires_in === 'number' && grant.expires_in > 0 ? grant.expires_in * 1000 : undefined;
102
+ const lifetimeMs = Math.min(serverLifetimeMs ?? DEVICE_CODE_MAX_LIFETIME_MS, DEVICE_CODE_MAX_LIFETIME_MS);
103
+ const serverInterval = typeof grant.interval === 'number' && grant.interval > 0 ? grant.interval * 1000 : undefined;
104
+ const session = {
105
+ userCode,
106
+ verificationUrl,
107
+ intervalMs: Math.max(serverInterval ?? DEVICE_CODE_MIN_INTERVAL_MS, DEVICE_CODE_MIN_INTERVAL_MS),
108
+ expiresAtMs: started + lifetimeMs,
109
+ poll: async () => poll(),
110
+ cancel: () => {
111
+ if (terminal === null)
112
+ terminal = { status: 'cancelled', detail: 'cancelled by the host' };
113
+ abort.abort();
114
+ },
115
+ };
116
+ /** 终态一旦落定就恒定(拿到 key 之后再 poll 不重复拨号)。 */
117
+ let terminal = null;
118
+ /** 下一次允许真正拨号的墙钟时刻(间隔是硬的,不靠调用方自律)。 */
119
+ let nextDialAtMs = started;
120
+ async function poll() {
121
+ if (terminal !== null)
122
+ return terminal;
123
+ const now = opts.nowMs();
124
+ if (now >= session.expiresAtMs) {
125
+ // 本地上限先于服务端判:上限是我们对用户的承诺,不该靠服务端良心。
126
+ terminal = { status: 'expired', detail: 'device code session exceeded its lifetime — start a new one' };
127
+ return terminal;
128
+ }
129
+ if (now < nextDialAtMs) {
130
+ return { status: 'pending', detail: 'polled before the interval elapsed — no request was sent' };
131
+ }
132
+ let res;
133
+ try {
134
+ res = await postForm(endpoints.tokenUrl, {
135
+ client_id: endpoints.clientId,
136
+ device_code: deviceCode,
137
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
138
+ }, fetchImpl, timeoutMs, abort.signal);
139
+ }
140
+ catch (e) {
141
+ if (terminal !== null)
142
+ return terminal; // cancel() 把在飞请求打断了 —— 终态已落定
143
+ // 单次网络失败不是终态:设备码流本就是长轮询,下一拍再试(退避一格避免打点)。
144
+ nextDialAtMs = opts.nowMs() + session.intervalMs;
145
+ return { status: 'pending', detail: `poll failed, will retry: ${shortError(e)}` };
146
+ }
147
+ if (terminal !== null)
148
+ return terminal;
149
+ nextDialAtMs = opts.nowMs() + session.intervalMs;
150
+ const token = (res.body ?? {});
151
+ const err = typeof token.error === 'string' ? token.error : '';
152
+ if (res.status === 429 || err === 'slow_down') {
153
+ session.intervalMs += DEVICE_CODE_BACKOFF_STEP_MS;
154
+ nextDialAtMs = opts.nowMs() + session.intervalMs;
155
+ return { status: 'pending', detail: 'rate limited — backing off' };
156
+ }
157
+ if (typeof token.access_token === 'string' && token.access_token.length > 0) {
158
+ terminal = { status: 'ok', key: token.access_token };
159
+ return terminal;
160
+ }
161
+ if (err === 'authorization_pending')
162
+ return { status: 'pending' };
163
+ if (err === 'expired_token') {
164
+ terminal = { status: 'expired', detail: 'the device code expired — start a new one' };
165
+ return terminal;
166
+ }
167
+ if (err === 'access_denied') {
168
+ terminal = { status: 'denied', detail: 'the user declined the authorization request' };
169
+ return terminal;
170
+ }
171
+ if (res.status < 200 || res.status >= 300) {
172
+ return { status: 'pending', detail: `poll got HTTP ${res.status}, will retry` };
173
+ }
174
+ // 认不出的 error 码:不冒充终态(别把一个我们没见过的码说成「过期了」),下一拍再试。
175
+ return { status: 'pending', detail: err === '' ? 'unrecognised token response' : `unrecognised error: ${err}` };
176
+ }
177
+ return session;
178
+ }
179
+ /**
180
+ * 按 providerId 开设备码会话(UI 入口)。
181
+ * 包内没有这一家 ⇒ **throw**(UI 本就不该显示这个选项;走到这里说明能力判据被绕过了)。
182
+ */
183
+ export async function beginDeviceCodeAuth(providerId, opts) {
184
+ const provider = deviceAuthProviderFor(providerId);
185
+ if (provider === undefined) {
186
+ throw new Error(`device code auth is not supported for provider "${providerId}" in this build ` +
187
+ '(DEVICE_AUTH_PROVIDERS has no entry — the UI must gate on supportsDeviceCodeAuth())');
188
+ }
189
+ return openDeviceCodeSession(provider.endpoints, opts);
190
+ }
@@ -25,7 +25,23 @@ export type ProviderPreset = {
25
25
  consoleUrl?: string;
26
26
  /** 没账号时的注册入口。与 `consoleUrl` 同页的家不重复填(缺席 = 用 consoleUrl 那条)。 */
27
27
  signupUrl?: string;
28
+ /**
29
+ * 🆕 design/165 §2 + design/166 §5(2026-08-04):**设备码能力开关**(B 档)。
30
+ *
31
+ * 🔴 它只是**开关**,不携带 client_id / token 端点 / authorize 端点 —— 那些编译进
32
+ * `model/providerAuth.ts` 的 `DEVICE_AUTH_PROVIDERS`。理由:目录是远端可改的数据,
33
+ * 让它决定鉴权端点 = 把钓鱼面下发给客户端(design/165 §2 红线④,CI 的 validate.mjs
34
+ * 里有对应的机械不变式)。
35
+ * 🔴 能力真值 = **本键在场 ∧ 包内有实现**(`supportsDeviceCodeAuth`)——目录先行不造 affordance。
36
+ */
37
+ deviceAuth?: ProviderDeviceAuthHint;
28
38
  };
39
+ /** 目录里的设备码能力开关(只有 kind 与文档链接;凭证参数一律不进目录)。 */
40
+ export interface ProviderDeviceAuthHint {
41
+ kind: 'device_code';
42
+ /** 该家自己的设备码说明页(纯链接;拿不准就不填,绝不编 URL)。 */
43
+ docsUrl?: string;
44
+ }
29
45
  export type ModelFamily = {
30
46
  id: string;
31
47
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",