dsh-session-guard 0.1.2 → 0.2.0-beta.1

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,101 @@
1
+ /**
2
+ * dsh-session-guard — 端点目录(host 适配层,读上游只读信息,全部 fail-open)。
3
+ *
4
+ * 端点来源(见 findings F4):
5
+ * 1. `ctx.get('llm')` 懒取(**不进 inject**:llm 缺失时降级为纯 id 判定,保持 fail-open);
6
+ * 2. `llm.listConfigurableProviders()` 按 `entry.provider === id` 找到目录条目
7
+ * (`{ settingsNs, settingsPath }`);
8
+ * 3. `ctx.settings.get(settingsNs)` 读该命名空间解析值,按 `settingsPath` 逐层取 profile 的 `baseURL`;
9
+ * 4. 交给纯函数 `classifyProvider`。
10
+ *
11
+ * 只读非密字段(`baseURL`),绝不触碰 `apiKeyEnv` 的值。
12
+ * `llm-deepseek` / `llm-pi-ai` 的内部结构属上游实现细节:任何异常 / 形状不符
13
+ * 一律降级为「无端点」,由 `classifyProvider` 走 id / 内置端点兜底,**绝不抛出**。
14
+ *
15
+ * 每次请求实时计算(两次内存读 + 一次目录枚举),不缓存 → 免疫 provider 配置热改。
16
+ */
17
+ import { classifyProvider } from './provider.js'
18
+
19
+ /** 逐层按 path 取值;任一层不是对象即返回 undefined。 */
20
+ function pickPath(root, path) {
21
+ let cur = root
22
+ for (const key of path) {
23
+ if (cur === null || typeof cur !== 'object') return undefined
24
+ cur = cur[key]
25
+ }
26
+ return cur
27
+ }
28
+
29
+ /**
30
+ * @param {object} deps
31
+ * @param {object} deps.ctx host context(需 `get` / `settings`,两者缺失均降级)
32
+ * @param {()=>object} deps.getSettings 读实时配置(officialProviders / officialBaseURLs)
33
+ * @param {(msg:string)=>void} [deps.warn] 诊断日志
34
+ * @returns {{classify:(providerId:string)=>{official:boolean,matchedBy:string}, describe:(providerId:string)=>object, endpointOf:(providerId:string)=>string|null, entries:()=>object[]}}
35
+ */
36
+ export function createProviderDirectory({ ctx, getSettings, warn }) {
37
+ function readCfg() {
38
+ try {
39
+ const v = getSettings()
40
+ return v && typeof v === 'object' ? v : {}
41
+ } catch {
42
+ return {}
43
+ }
44
+ }
45
+
46
+ /** 目录条目(llm 服务缺失 / 抛错 → 空数组)。 */
47
+ function entries() {
48
+ try {
49
+ const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : null
50
+ if (!llm || typeof llm.listConfigurableProviders !== 'function') return []
51
+ const list = llm.listConfigurableProviders()
52
+ return Array.isArray(list) ? list : []
53
+ } catch {
54
+ return []
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 读某路由的实时端点(`baseURL`);取不到返回 null。
60
+ * @param {string} providerId
61
+ * @returns {string|null}
62
+ */
63
+ function endpointOf(providerId) {
64
+ if (typeof providerId !== 'string' || providerId === '') return null
65
+ try {
66
+ const entry = entries().find((e) => e && e.provider === providerId)
67
+ if (!entry) return null
68
+ const settings = ctx && ctx.settings
69
+ if (!settings || typeof settings.get !== 'function') return null
70
+ const section = settings.get(entry.settingsNs)
71
+ const profile = pickPath(section, Array.isArray(entry.settingsPath) ? entry.settingsPath : [])
72
+ const baseURL = profile && typeof profile === 'object' ? profile.baseURL : undefined
73
+ return typeof baseURL === 'string' && baseURL.trim() !== '' ? baseURL : null
74
+ } catch (e) {
75
+ if (warn) warn(`provider directory lookup failed for "${providerId}": ${String(e && e.message || e)}`)
76
+ return null
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 判定某路由是否官方源(端点优先 + id 兜底)。
82
+ * @param {string} providerId
83
+ * @returns {{official:boolean, matchedBy:'explicit'|'endpoint'|'endpoint-default'|'route-id'|'unknown'}}
84
+ */
85
+ function classify(providerId) {
86
+ const cfg = readCfg()
87
+ return classifyProvider(providerId, {
88
+ endpoint: endpointOf(providerId),
89
+ officialProviders: cfg.officialProviders,
90
+ officialHosts: cfg.officialBaseURLs,
91
+ })
92
+ }
93
+
94
+ /** 带可解释信息的判定结果(日志 / 路由诊断用)。 */
95
+ function describe(providerId) {
96
+ const r = classify(providerId)
97
+ return { provider: providerId, official: r.official, matchedBy: r.matchedBy, endpoint: endpointOf(providerId) }
98
+ }
99
+
100
+ return { classify, describe, endpointOf, entries }
101
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * dsh-session-guard — 官方 provider 判定(纯函数,零依赖,可单测)。
3
+ *
4
+ * 目标:判断「这次请求真正要去的路由」是不是 DeepSeek 官方源。
5
+ *
6
+ * 判定口径(优先级从高到低):
7
+ * 1. `officialProviders` 显式名单(用户最高优先,精确 id 匹配)
8
+ * 2. 实时端点 `baseURL`(归一化 host 后与官方 host 名单比对)
9
+ * 3. 内置端点兜底(catalog 默认端点,如 pi-ai 的 `deepseek`)
10
+ * 4. 内置 id 名单(`deepseek-official`)
11
+ * 5. 未知 → 非官方
12
+ *
13
+ * 端点优先的理由(见 findings F3):
14
+ * - 名为 `deepseek-official` 但把 `baseURL` 改到中转的配置必须**不拦**(第 2 步先命中);
15
+ * - pi-ai 内置 `deepseek` 路由的 catalog 端点就是官方 API,只按 id 会**漏拦**(第 3 步兜底)。
16
+ *
17
+ * 纯函数:不读 ctx / settings / 网络,输入即输出,便于穷举判定矩阵。
18
+ */
19
+
20
+ /** 内置官方 provider id 名单(catalog 路由 id)。 */
21
+ export const BUILTIN_OFFICIAL_IDS = Object.freeze(['deepseek-official'])
22
+
23
+ /**
24
+ * 内置路由 → 默认端点映射。
25
+ * - `deepseek-official`:`dsh-llm-deepseek` 的 `PUBLIC_BASE_URL`(schema 无默认值,用户没配时用它兜底)。
26
+ * - `deepseek`:pi-ai 内置 catalog 的两个模型 `baseUrl` 均为官方端点(本机 pi-ai 0.82.1 实测)。
27
+ */
28
+ export const BUILTIN_OFFICIAL_ENDPOINTS = Object.freeze({
29
+ 'deepseek-official': 'https://api.deepseek.com',
30
+ deepseek: 'https://api.deepseek.com',
31
+ })
32
+
33
+ /** 默认官方端点 host 名单(`officialBaseURLs` 设置项可增删)。 */
34
+ export const DEFAULT_OFFICIAL_HOSTS = Object.freeze(['api.deepseek.com'])
35
+
36
+ /** `matchedBy` 的全部合法取值。 */
37
+ export const MATCHED_BY = Object.freeze(['explicit', 'endpoint', 'endpoint-default', 'route-id', 'unknown'])
38
+
39
+ /**
40
+ * 归一化端点 → host(小写、无协议、无 www.、无端口、无路径)。
41
+ *
42
+ * 接受 `https://API.DeepSeek.com/v1`、`//host/path`、`host:8200`、裸 `host`;
43
+ * 非法值(空串 / null / 含空格 / 非字符串)返回 `null`。
44
+ * @param {unknown} raw 原始端点(provider 的 `baseURL`)
45
+ * @returns {string|null} 归一化后的 host,或 null
46
+ */
47
+ export function normalizeEndpoint(raw) {
48
+ if (typeof raw !== 'string') return null
49
+ let s = raw.trim().toLowerCase()
50
+ if (s === '') return null
51
+ // 剥协议(http:// https:// 或协议相对 //)
52
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
53
+ s = s.replace(/^\/\//, '')
54
+ // 剥 userinfo(user:pass@host)
55
+ const at = s.indexOf('@')
56
+ if (at !== -1) s = s.slice(at + 1)
57
+ // 剥路径 / 查询 / 片段
58
+ s = s.split(/[/?#]/, 1)[0]
59
+ if (s === '') return null
60
+ // 剥端口(IPv6 形如 [::1]:8200 只取括号内)
61
+ if (s.startsWith('[')) {
62
+ const close = s.indexOf(']')
63
+ if (close === -1) return null
64
+ const host6 = s.slice(1, close)
65
+ return /^[0-9a-f:.]+$/.test(host6) ? host6 : null
66
+ }
67
+ s = s.replace(/:\d+$/, '')
68
+ if (s === '') return null
69
+ // 剥前导 www.
70
+ s = s.replace(/^www\./, '')
71
+ if (s === '') return null
72
+ // host 合法性:字母数字 + 连字符 + 点(拒绝空格、下划线、非 ASCII 等)
73
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/.test(s)) return null
74
+ return s
75
+ }
76
+
77
+ /**
78
+ * host 是否属于官方端点名单(精确匹配,名单项同样归一化)。
79
+ * @param {string|null} host 归一化 host
80
+ * @param {readonly string[]|undefined} hosts 官方 host 名单(可含协议/端口,会被归一化)
81
+ * @returns {boolean}
82
+ */
83
+ export function isOfficialEndpoint(host, hosts) {
84
+ if (typeof host !== 'string' || host === '') return false
85
+ const list = Array.isArray(hosts) && hosts.length > 0 ? hosts : DEFAULT_OFFICIAL_HOSTS
86
+ for (const item of list) {
87
+ const norm = normalizeEndpoint(item)
88
+ if (norm !== null && norm === host) return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ /**
94
+ * 五级判定:这次请求的目标 provider 是否官方源。
95
+ * @param {string} providerId 路由 id(`next()` 返回值里的 `config.provider`)
96
+ * @param {object} [options]
97
+ * @param {string|null} [options.endpoint] 实时端点(settings 里的 `baseURL`,取不到为 null)
98
+ * @param {readonly string[]} [options.officialProviders] 用户显式追加的官方 id
99
+ * @param {readonly string[]} [options.officialHosts] 官方端点 host 名单
100
+ * @param {readonly string[]} [options.builtinIds] 内置官方 id 名单
101
+ * @param {Record<string,string>} [options.builtinEndpoints] 内置路由 → 默认端点
102
+ * @returns {{official: boolean, matchedBy: 'explicit'|'endpoint'|'endpoint-default'|'route-id'|'unknown'}}
103
+ */
104
+ export function classifyProvider(providerId, options = {}) {
105
+ const id = typeof providerId === 'string' ? providerId.trim() : ''
106
+ const explicit = Array.isArray(options.officialProviders) ? options.officialProviders : []
107
+ const hosts = options.officialHosts
108
+ const builtinIds = Array.isArray(options.builtinIds) ? options.builtinIds : BUILTIN_OFFICIAL_IDS
109
+ const builtinEndpoints =
110
+ options.builtinEndpoints && typeof options.builtinEndpoints === 'object'
111
+ ? options.builtinEndpoints
112
+ : BUILTIN_OFFICIAL_ENDPOINTS
113
+
114
+ // 1) 用户显式名单最高优先(精确 id,大小写敏感以保持可预测)
115
+ if (id !== '' && explicit.some((x) => typeof x === 'string' && x === id)) {
116
+ return { official: true, matchedBy: 'explicit' }
117
+ }
118
+
119
+ // 2) 实时端点优先于 id:中转改到别处 → 不拦
120
+ const endpointHost = normalizeEndpoint(options.endpoint)
121
+ if (endpointHost !== null) {
122
+ return { official: isOfficialEndpoint(endpointHost, hosts), matchedBy: 'endpoint' }
123
+ }
124
+
125
+ // 3) 内置端点兜底(catalog 默认端点,插件看不到 settings 时的官方路由)
126
+ const builtinRaw = id !== '' ? builtinEndpoints[id] : undefined
127
+ const builtinHost = normalizeEndpoint(builtinRaw)
128
+ if (builtinHost !== null) {
129
+ return { official: isOfficialEndpoint(builtinHost, hosts), matchedBy: 'endpoint-default' }
130
+ }
131
+
132
+ // 4) 内置 id 兜底
133
+ if (id !== '' && builtinIds.some((x) => typeof x === 'string' && x === id)) {
134
+ return { official: true, matchedBy: 'route-id' }
135
+ }
136
+
137
+ // 5) 未知 → 非官方(fail-open:非官方即放行,绝不在未知路径上拦)
138
+ return { official: false, matchedBy: 'unknown' }
139
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * dsh-session-guard — 请求级守卫(`agent/request` waterfall,host)。
3
+ *
4
+ * 为什么需要它(findings F6):现有 tick 只在 `NORMAL → PAUSED_PEAK` 跳变时处理
5
+ * `status === 'running'` 的会话,入峰后新启动的会话、以及入峰后切到官方源的会话
6
+ * 都漏网。请求级兜底覆盖这两个缺口。
7
+ *
8
+ * 判定顺序(**必须先 await next()**,见 findings F2:`installModelSelection` 会在
9
+ * waterfall 内把 provider/model 覆盖成用户在 UI 选的值):
10
+ * 1. `providerGuard !== true` → 原样放行(退回现有纯时间判定)
11
+ * 2. 非 root agent 且 `guardSubagents === false` → 放行
12
+ * 3. 非高峰(含周末 / disabled)→ 放行
13
+ * 4. 目标 provider 非官方 → 放行(本地/第三方源照常跑)
14
+ * 5. 高峰 + 官方 → 拦截:hold(默认,挂起不报错)或 error(抛 PeakDeferredError)
15
+ *
16
+ * **互斥铁律**(findings F7):hold 路径**严禁**调用 `gate.stopNextTurn` /
17
+ * `pauseGate.pause`——暂停要等安全边界,而请求被挂住后永远到不了安全边界,双方互等。
18
+ *
19
+ * 任何判定异常都 fail-open 放行(绝不因为本插件让正常请求失败)。
20
+ */
21
+ import { shouldPause } from './time.js'
22
+ import { PeakDeferredError } from './deferrals.js'
23
+
24
+ /**
25
+ * 默认 root 判定:优先 `agents.roots()`,查不到再看 `agents.list()`;
26
+ * 两边都查不到 → 视为 root(fail-open 到「纳入守卫」)。
27
+ * @param {object} ctx
28
+ * @returns {(agent:object)=>boolean}
29
+ */
30
+ export function makeIsRoot(ctx) {
31
+ return function isRoot(agent) {
32
+ try {
33
+ const agents = ctx && ctx.agents
34
+ const id = String((agent && agent.id) ?? '')
35
+ if (!agents || id === '') return true
36
+ const roots = typeof agents.roots === 'function' ? agents.roots() : []
37
+ if (Array.isArray(roots) && roots.some((a) => String((a && a.id) ?? '') === id)) return true
38
+ const all = typeof agents.list === 'function' ? agents.list() : []
39
+ if (Array.isArray(all) && all.some((a) => String((a && a.id) ?? '') === id)) return false
40
+ return true
41
+ } catch {
42
+ return true
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * @param {object} deps
49
+ * @param {object} deps.ctx host context(需 `on` / 可选 `agents`)
50
+ * @param {()=>object} deps.getSettings 读实时配置
51
+ * @param {{classify:(id:string)=>{official:boolean,matchedBy:string}}} deps.directory 端点目录
52
+ * @param {ReturnType<import('./deferrals.js').createDeferrals>} deps.deferrals 延后登记表
53
+ * @param {{stopNextTurn:(id:string,opts?:object)=>unknown}} [deps.gate] 会话门(**仅 error 模式**使用)
54
+ * @param {object} [deps.logger]
55
+ * @param {()=>Date} [deps.clock]
56
+ * @param {(agent:object)=>boolean} [deps.isRoot]
57
+ * @param {(ms:number)=>void} [deps.scheduleRelease] 挂起后请求一次退峰释放检查
58
+ * @returns {{install:()=>()=>void, handle:(payload:object, next:Function)=>Promise<unknown>, stats:()=>object}}
59
+ */
60
+ export function createRequestGuard({
61
+ ctx,
62
+ getSettings,
63
+ directory,
64
+ deferrals,
65
+ gate,
66
+ logger,
67
+ clock = () => new Date(),
68
+ isRoot,
69
+ scheduleRelease,
70
+ }) {
71
+ const rootCheck = isRoot ?? makeIsRoot(ctx)
72
+ const counters = { pass: 0, hold: 0, error: 0, skippedSubagent: 0, failOpen: 0 }
73
+
74
+ function readCfg() {
75
+ try {
76
+ const v = getSettings()
77
+ return v && typeof v === 'object' ? v : {}
78
+ } catch {
79
+ return {}
80
+ }
81
+ }
82
+
83
+ /** 同步决策:pass / error / hold。 */
84
+ function decide(payload, config) {
85
+ const cfg = readCfg()
86
+ if (cfg.providerGuard !== true) return { action: 'pass', why: 'provider-guard-off' }
87
+
88
+ const agent = payload && payload.agent
89
+ const sessionId = String((agent && agent.id) ?? '')
90
+ if (sessionId === '') {
91
+ logger?.warn?.('[session-guard] agent/request without agent.id — provider guard skipped')
92
+ counters.failOpen += 1
93
+ return { action: 'pass', why: 'no-session-id' }
94
+ }
95
+ if (cfg.guardSubagents === false && !rootCheck(agent)) {
96
+ counters.skippedSubagent += 1
97
+ return { action: 'pass', why: 'subagent' }
98
+ }
99
+
100
+ const verdict = shouldPause(cfg, clock())
101
+ if (!verdict.pause) return { action: 'pass', why: verdict.reason }
102
+
103
+ const cls = directory.classify(config && config.provider)
104
+ if (!cls.official) {
105
+ logger?.info?.(`[session-guard] peak but target "${String(config && config.provider)}" is not official (matchedBy=${cls.matchedBy}) — pass`)
106
+ return { action: 'pass', why: 'not-official' }
107
+ }
108
+
109
+ const info = {
110
+ provider: (config && config.provider) ?? null,
111
+ model: (config && config.model) ?? null,
112
+ config,
113
+ matchedBy: cls.matchedBy,
114
+ }
115
+ const mode = cfg.deferredMode === 'error' ? 'error' : 'hold'
116
+ const resume = cfg.deferredResume !== false
117
+
118
+ // 退峰不自动续跑:hold 也无法自动放行 → 直接转 error(避免无限挂起)
119
+ if (mode === 'hold' && resume) {
120
+ counters.hold += 1
121
+ logger?.info?.(`[session-guard] peak + official "${info.provider}/${info.model}" — holding request of ${sessionId} (matchedBy=${cls.matchedBy})`)
122
+ const promise = deferrals.hold(sessionId, info, payload && payload.signal)
123
+ if (scheduleRelease) {
124
+ try {
125
+ scheduleRelease()
126
+ } catch (e) {
127
+ logger?.warn?.(`[session-guard] release scheduling failed: ${String(e && e.message || e)}`)
128
+ }
129
+ }
130
+ return { action: 'hold', promise }
131
+ }
132
+
133
+ // error 模式(或 deferredResume=false):记延后 + 抛可识别的错误
134
+ counters.error += 1
135
+ deferrals.remember(sessionId, info)
136
+ // 互斥铁律只约束 hold 路径;error 路径回合立刻结束,调门控是安全的。
137
+ if (gate && typeof gate.stopNextTurn === 'function') {
138
+ try {
139
+ const r = gate.stopNextTurn(sessionId, { mode: cfg.pauseMode, reason: cfg.pauseReason })
140
+ if (r && typeof r.then === 'function') {
141
+ r.then(undefined, (e) => logger?.warn?.(`[session-guard] stopNextTurn failed: ${String(e && e.message || e)}`))
142
+ }
143
+ } catch (e) {
144
+ logger?.warn?.(`[session-guard] stopNextTurn threw: ${String(e && e.message || e)}`)
145
+ }
146
+ }
147
+ logger?.info?.(`[session-guard] peak + official "${info.provider}/${info.model}" — deferring ${sessionId} with error (mode=${mode}, resume=${resume})`)
148
+ const detail = resume
149
+ ? `peak hours: ${info.provider}/${info.model} deferred to off-peak`
150
+ : `peak hours: ${info.provider}/${info.model} deferred; auto-resume disabled`
151
+ return { action: 'error', error: new PeakDeferredError(detail, { sessionId, ...info, config: undefined }) }
152
+ }
153
+
154
+ /** 一次 waterfall 调用。 */
155
+ async function handle(payload, next) {
156
+ const config = await next()
157
+ let decision
158
+ try {
159
+ decision = decide(payload, config)
160
+ } catch (e) {
161
+ // 判定异常 → fail-open 放行;但自己的延后错误必须抛出
162
+ if (e && e.code === 'PEAK_DEFERRED') throw e
163
+ counters.failOpen += 1
164
+ logger?.warn?.(`[session-guard] request guard failed open: ${String(e && e.message || e)}`)
165
+ return config
166
+ }
167
+ if (decision.action === 'pass') {
168
+ counters.pass += 1
169
+ return config
170
+ }
171
+ if (decision.action === 'error') throw decision.error
172
+ // hold:挂起 promise 的 resolve 值就是原 config(字段未被篡改)
173
+ return decision.promise
174
+ }
175
+
176
+ /** 注册 waterfall 监听;返回 disposer(同时释放全部挂起,避免 promise 泄漏)。 */
177
+ function install() {
178
+ if (!ctx || typeof ctx.on !== 'function') return () => {}
179
+ const off = ctx.on('agent/request', (payload, next) => handle(payload, next))
180
+ return () => {
181
+ if (typeof off === 'function') off()
182
+ try {
183
+ deferrals.rejectAll('guard-disposed')
184
+ } catch {
185
+ /* 释放失败不阻断卸载 */
186
+ }
187
+ }
188
+ }
189
+
190
+ return { install, handle, decide, stats: () => ({ ...counters }) }
191
+ }