@menghuan1918/dsh-apollo 0.0.0-stage → 0.3.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/LICENSE +674 -0
- package/README.md +232 -2
- package/cordis.patch.yml +78 -0
- package/docs/DEVELOPMENT.md +629 -0
- package/package.json +75 -4
- package/presets/eng/NOTICE +2 -0
- package/presets/eng/plugins/exit-guard/index.js +111 -0
- package/presets/eng/plugins/exit-guard/package.json +7 -0
- package/presets/eng/plugins/mode-instructions/index.js +48 -0
- package/presets/eng/plugins/mode-instructions/package.json +7 -0
- package/presets/eng/plugins/wait-subagent/index.js +228 -0
- package/presets/eng/plugins/wait-subagent/package.json +7 -0
- package/presets/eng-noask/NOTICE +2 -0
- package/presets/eng-noask/plugins/exit-guard/index.js +111 -0
- package/presets/eng-noask/plugins/exit-guard/package.json +7 -0
- package/presets/eng-noask/plugins/mode-instructions/index.js +48 -0
- package/presets/eng-noask/plugins/mode-instructions/package.json +7 -0
- package/presets/eng-noask/plugins/wait-subagent/index.js +228 -0
- package/presets/eng-noask/plugins/wait-subagent/package.json +7 -0
- package/presets/eng-noask.patch.yml +341 -0
- package/presets/eng.patch.yml +341 -0
- package/skills/brainstorming/SKILL.md +90 -0
- package/skills/brainstorming/spec-document-reviewer-prompt.md +50 -0
- package/skills/code-review/SKILL.md +90 -0
- package/skills/dev/SKILL.md +43 -0
- package/skills/diagnosing-bugs/SKILL.md +132 -0
- package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +55 -0
- package/skills/exec/SKILL.md +91 -0
- package/skills/handoff/SKILL.md +23 -0
- package/skills/main-agent-driven-development/SKILL.md +64 -0
- package/skills/tdd/SKILL.md +34 -0
- package/skills/tdd/mocking.md +59 -0
- package/skills/tdd/tests.md +77 -0
- package/src/cli/claude.js +166 -0
- package/src/cli/codex.js +164 -0
- package/src/cli/jsonl.js +216 -0
- package/src/cli-adapter.js +198 -0
- package/src/cli-sessions.js +69 -0
- package/src/client.js +815 -0
- package/src/config.js +301 -0
- package/src/dsh-home.js +38 -0
- package/src/index.js +181 -0
- package/src/panel.js +452 -0
- package/src/settings.js +191 -0
- package/src/subagents.js +199 -0
- package/third-party/LICENSE-MIT-dsh-standard-preset +21 -0
- package/third-party/NOTICE-mattpocock-skills +21 -0
package/src/panel.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-apollo (npm: @menghuan1918/dsh-apollo) —— 宿主半边只读面板(src/panel.js)。
|
|
3
|
+
*
|
|
4
|
+
* 注册仅限 loopback(或行配置 `trustedHosts` 列出的反向代理权威)+ 同源标志的
|
|
5
|
+
* fenced HTTP 路由 `/apollo/api/capabilities`,供浏览器半边(`exports["./client"]`
|
|
6
|
+
* 的 `plugins.row.config` 页面)读取**能力探针**:两个 CLI 后端配置的可执行文件
|
|
7
|
+
* 在 PATH 上的解析结果与版本、以及 Codex 的 profile 名称。
|
|
8
|
+
*
|
|
9
|
+
* 为什么是「能力」而不是「配置」:设置本身已经是正式 Config(volatile),读写
|
|
10
|
+
* 走 DSH 自己的 configForms/行配置页面(`src/settings.js` 只做一次旧文件导入),
|
|
11
|
+
* 不再需要本包自有的配置路由。这里只暴露**从本机探测出来的、设置表单无法自己
|
|
12
|
+
* 知道**的事实——用户手输的可执行文件名到底解析到了哪里、版本是什么、Codex 有
|
|
13
|
+
* 哪些 profile 可选。
|
|
14
|
+
*
|
|
15
|
+
* 暴露面(写死在契约里,测试守护):
|
|
16
|
+
* `{ executables: { codex: { executable, path, version }, claude: {...} },
|
|
17
|
+
* codexProfiles: string[] }`
|
|
18
|
+
* **绝不返回配置正文,也绝不返回任何凭据**:只回可执行文件名/路径/版本行与
|
|
19
|
+
* profile 名称(profile 文件内容只在本进程内用于抽名称)。只读:GET 之外的
|
|
20
|
+
* 方法一律 405,且从不读取请求体。
|
|
21
|
+
*
|
|
22
|
+
* 安全模型仍是 DNS-rebinding / 跨站防御而非认证(默认只信 loopback;经反向
|
|
23
|
+
* 代理域名访问 GUI 时把该权威加入行配置 `trustedHosts`,后果上限是泄露本机
|
|
24
|
+
* 上某个可执行文件的路径与版本号)。边界判定与 dsh-better-sidebar 的
|
|
25
|
+
* src/trust-fence.ts 同源(其源头为 BSD-3-Clause 的
|
|
26
|
+
* @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
|
|
27
|
+
*
|
|
28
|
+
* 职责边界:路由的**注册**由 `src/settings.js` 的 `mountSettings` 负责(一处
|
|
29
|
+
* 注册,避免 webServer 的重复 (kind,path) 抛错);本文件提供处理器、探针与
|
|
30
|
+
* `installOwnsHostTap`(`mountSettings` 会调用它,`mountPanel` 是兼容别名,
|
|
31
|
+
* 同一上下文重复调用只装一次)。
|
|
32
|
+
*
|
|
33
|
+
* 零第三方依赖:只用 node 内置模块。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { execFile } from 'node:child_process'
|
|
37
|
+
import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
38
|
+
import { homedir } from 'node:os'
|
|
39
|
+
import { delimiter, isAbsolute, join } from 'node:path'
|
|
40
|
+
import { currentConfig, normalizeConfig } from './config.js'
|
|
41
|
+
|
|
42
|
+
/** 宿主半边模块标识(用于 effect 标签与日志)。 */
|
|
43
|
+
export const name = 'apollo-panel'
|
|
44
|
+
|
|
45
|
+
/** 能力探针的完整路径。 */
|
|
46
|
+
export const CAPABILITIES_PATH = '/apollo/api/capabilities'
|
|
47
|
+
/** 注册在 webServer 上的前缀(精确路径由处理器自己校验)。 */
|
|
48
|
+
export const CAPABILITIES_PREFIX = '/apollo/api'
|
|
49
|
+
|
|
50
|
+
/** Codex 新版「一个 profile 一个文件」的后缀:`<CODEX_HOME>/<name>.config.toml`。 */
|
|
51
|
+
export const CODEX_PROFILE_SUFFIX = '.config.toml'
|
|
52
|
+
|
|
53
|
+
/** 版本探测的兜底超时(毫秒)。 */
|
|
54
|
+
export const VERSION_TIMEOUT_MS = 2000
|
|
55
|
+
/** 探针结果缓存时长(毫秒):避免每次打开设置页都 fork 两个 CLI。 */
|
|
56
|
+
export const CAPABILITY_TTL_MS = 30_000
|
|
57
|
+
/** 版本行最大长度(防止 CLI 打印整段 banner)。 */
|
|
58
|
+
const VERSION_MAX_CHARS = 120
|
|
59
|
+
/** 合法的 profile 名(同时挡住路径分隔符,名称只会被当显示文本用)。 */
|
|
60
|
+
const PROFILE_NAME = /^[A-Za-z0-9._-]+$/
|
|
61
|
+
|
|
62
|
+
// ── 部署标志 tap ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 部署标志 tap:在 `<head>` 起始处注入 boot 全局 `__DSH_TRANSPORT__.ownsHost`,
|
|
66
|
+
* 使官方 client 的 `connection.isLoopback` 判定为真。
|
|
67
|
+
*
|
|
68
|
+
* 背景:client 对「浏览器非 loopback 页面」把 settings 镜像/作用域设为 memory
|
|
69
|
+
* (不可用),官方没有受信反代配置口。经反向代理多设备访问时,声明「页面拥有
|
|
70
|
+
* 宿主」让设置读写回到 host 持久化——安全边界随反代信任,仅应在受信代理下
|
|
71
|
+
* 开启(行配置 `declareOwnsHost: true`,默认关闭)。
|
|
72
|
+
* 脚本位于所有 head 注入行之前,先于 boot 执行。
|
|
73
|
+
* @param html - 渲染后的 index HTML。
|
|
74
|
+
* @returns 插入了传输全局的 HTML。
|
|
75
|
+
*/
|
|
76
|
+
export function declareOwnsHostTap(html) {
|
|
77
|
+
const script = '<script>globalThis.__DSH_TRANSPORT__={ownsHost:true}</script>'
|
|
78
|
+
return /<head(?:\s[^>]*)?>/i.test(html)
|
|
79
|
+
? html.replace(/<head(?:\s[^>]*)?>/i, (open) => `${open}${script}`)
|
|
80
|
+
: `${script}${html}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 已经装过 tap 的插件上下文(防止 mountSettings 与 mountPanel 同时调用时装两次)。 */
|
|
84
|
+
const tappedContexts = new WeakSet()
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 装配 `declareOwnsHost` tap:行配置 `declareOwnsHost: true` 时给 index 注入
|
|
88
|
+
* boot 全局(一次性;同一插件上下文重复调用只装一次)。
|
|
89
|
+
*
|
|
90
|
+
* 由 `src/settings.js` 的 `mountSettings` 调用——宿主行只调 mountSettings 与
|
|
91
|
+
* mountSubagents 时 tap 也必须装上,否则反代部署下官方 client 会把设置镜像降级
|
|
92
|
+
* 成 memory。
|
|
93
|
+
* @param ctx - 插件上下文。
|
|
94
|
+
* @param config - 行配置(可能含 volatile 引用)。
|
|
95
|
+
*/
|
|
96
|
+
export function installOwnsHostTap(ctx, config) {
|
|
97
|
+
if (normalizeConfig(config).declareOwnsHost !== true) return
|
|
98
|
+
if (ctx !== null && typeof ctx === 'object') {
|
|
99
|
+
if (tappedContexts.has(ctx)) return
|
|
100
|
+
tappedContexts.add(ctx)
|
|
101
|
+
}
|
|
102
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
103
|
+
webCtx.effect(() => webCtx.webServer.tapIndex(declareOwnsHostTap), 'apollo-panel: declareOwnsHost tap')
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 宿主半边装配入口(兼容保留):等价于 {@link installOwnsHostTap}。
|
|
109
|
+
*
|
|
110
|
+
* 不注册 HTTP 路由——`/apollo/api/capabilities` 由 `src/settings.js` 的
|
|
111
|
+
* `mountSettings` 注册(一处注册,headless 组合里没有 webServer 就自然跳过)。
|
|
112
|
+
* 宿主行只调 `mountSettings` 即可;同时调用本函数也不会重复注入 tap。
|
|
113
|
+
* @param ctx - 插件上下文。
|
|
114
|
+
* @param config - 行配置(可能含 volatile 引用)。
|
|
115
|
+
*/
|
|
116
|
+
export function mountPanel(ctx, config) {
|
|
117
|
+
installOwnsHostTap(ctx, config)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 探针:PATH 解析 / 版本 / Codex profile 名 ───────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** 展开 `~` 前缀(不解析其他 shell 语法)。 */
|
|
123
|
+
function expandHome(value, home) {
|
|
124
|
+
if (value === '~') return home
|
|
125
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return join(home, value.slice(2))
|
|
126
|
+
return value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 是否是可执行普通文件(非 win32 要求 X_OK 位)。 */
|
|
130
|
+
function isExecutableFile(path) {
|
|
131
|
+
try {
|
|
132
|
+
if (!statSync(path).isFile()) return false
|
|
133
|
+
if (process.platform !== 'win32') accessSync(path, constants.X_OK)
|
|
134
|
+
return true
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 解析可执行文件:绝对/相对路径按路径校验,裸名字按 PATH 查找。
|
|
142
|
+
*
|
|
143
|
+
* 默认值只写命令名(`codex`/`claude`),**不写死本机路径**;用户可以在设置里
|
|
144
|
+
* 手输绝对路径或 `~/...`。
|
|
145
|
+
* @param executable - 配置里的可执行文件名或路径。
|
|
146
|
+
* @param options - 注入点:`env`(PATH/CODEX_HOME)、`home`(`~` 展开)、`cwd`(相对路径基准)。
|
|
147
|
+
* @returns 可执行文件的绝对路径,或 null。
|
|
148
|
+
*/
|
|
149
|
+
export function resolveExecutable(executable, options = {}) {
|
|
150
|
+
const env = options.env ?? process.env
|
|
151
|
+
const home = options.home ?? homedir()
|
|
152
|
+
if (typeof executable !== 'string') return null
|
|
153
|
+
const expanded = expandHome(executable.trim(), home)
|
|
154
|
+
if (expanded === '') return null
|
|
155
|
+
if (expanded.includes('/') || expanded.includes('\\')) {
|
|
156
|
+
const candidate = isAbsolute(expanded) ? expanded : join(options.cwd ?? process.cwd(), expanded)
|
|
157
|
+
return isExecutableFile(candidate) ? candidate : null
|
|
158
|
+
}
|
|
159
|
+
const extensions = process.platform === 'win32'
|
|
160
|
+
? String(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((entry) => entry !== '')
|
|
161
|
+
: ['']
|
|
162
|
+
for (const dir of String(env.PATH ?? '').split(delimiter)) {
|
|
163
|
+
if (dir === '') continue
|
|
164
|
+
for (const extension of extensions) {
|
|
165
|
+
const candidate = join(dir, expanded + extension)
|
|
166
|
+
if (isExecutableFile(candidate)) return candidate
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 取第一行非空文本做版本号:剥掉控制字符并截断。 */
|
|
173
|
+
function parseVersion(text) {
|
|
174
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
175
|
+
const line = raw.replace(/[\u0000-\u001f\u007f]/g, '').trim()
|
|
176
|
+
if (line === '') continue
|
|
177
|
+
return line.slice(0, VERSION_MAX_CHARS)
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 运行 `<path> --version` 取版本行(无 shell 展开,超时即杀)。
|
|
184
|
+
*
|
|
185
|
+
* 只有进程正常退出且确有输出时才返回版本;启动失败(EACCES/ENOEXEC/超时)
|
|
186
|
+
* 返回 null——探针失败不该让整个能力接口失败。
|
|
187
|
+
* @param path - 已解析的可执行文件路径。
|
|
188
|
+
* @param options - `timeoutMs` 覆盖。
|
|
189
|
+
* @returns 版本行,或 null。
|
|
190
|
+
*/
|
|
191
|
+
export function probeVersion(path, options = {}) {
|
|
192
|
+
const timeoutMs = options.timeoutMs ?? VERSION_TIMEOUT_MS
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
execFile(path, ['--version'], {
|
|
195
|
+
timeout: timeoutMs,
|
|
196
|
+
killSignal: 'SIGKILL',
|
|
197
|
+
maxBuffer: 64 * 1024,
|
|
198
|
+
windowsHide: true,
|
|
199
|
+
}, (error, stdout, stderr) => {
|
|
200
|
+
if (error !== null) {
|
|
201
|
+
resolve(null)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
resolve(parseVersion(String(stdout ?? '')) ?? parseVersion(String(stderr ?? '')))
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 读取一个文本文件的所有行;缺失或不可读返回空数组。 */
|
|
210
|
+
function readLines(path) {
|
|
211
|
+
try {
|
|
212
|
+
return readFileSync(path, 'utf8').split('\n')
|
|
213
|
+
} catch {
|
|
214
|
+
return []
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 是否是合法的 Codex profile 名(拒绝 `.`/`..`,名称只会当显示文本用)。 */
|
|
219
|
+
function isProfileName(value) {
|
|
220
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..' && PROFILE_NAME.test(value)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 列出 Codex profile **名称**(不读内容、不回内容)。
|
|
225
|
+
*
|
|
226
|
+
* 两种来源都兼容:
|
|
227
|
+
* - 新版 `<CODEX_HOME>/<name>.config.toml`(一个 profile 一个文件);
|
|
228
|
+
* - 旧版 `<CODEX_HOME>/config.toml` 里的 `[profiles.<name>]` 段。
|
|
229
|
+
* `CODEX_HOME` 缺省 `<home>/.codex`。
|
|
230
|
+
* @param options - 注入点:`env`、`home`。
|
|
231
|
+
* @returns 排序去重后的 profile 名。
|
|
232
|
+
*/
|
|
233
|
+
export function listCodexProfiles(options = {}) {
|
|
234
|
+
const env = options.env ?? process.env
|
|
235
|
+
const home = options.home ?? homedir()
|
|
236
|
+
const configured = typeof env.CODEX_HOME === 'string' ? env.CODEX_HOME.trim() : ''
|
|
237
|
+
const dir = configured === '' ? join(home, '.codex') : expandHome(configured, home)
|
|
238
|
+
const names = new Set()
|
|
239
|
+
let entries = []
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
242
|
+
} catch {
|
|
243
|
+
entries = []
|
|
244
|
+
}
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
if (!entry.isFile() || !entry.name.endsWith(CODEX_PROFILE_SUFFIX)) continue
|
|
247
|
+
const candidate = entry.name.slice(0, -CODEX_PROFILE_SUFFIX.length)
|
|
248
|
+
if (isProfileName(candidate)) names.add(candidate)
|
|
249
|
+
}
|
|
250
|
+
for (const line of readLines(join(dir, 'config.toml'))) {
|
|
251
|
+
const match = /^\s*\[\s*profiles\s*\.\s*(?:"([^"]+)"|'([^']+)'|([^\]\s]+))\s*\]/.exec(line)
|
|
252
|
+
if (match === null) continue
|
|
253
|
+
const candidate = match[1] ?? match[2] ?? match[3] ?? ''
|
|
254
|
+
if (isProfileName(candidate)) names.add(candidate)
|
|
255
|
+
}
|
|
256
|
+
return [...names].sort()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 同步调用并吞掉异常。 */
|
|
260
|
+
function attempt(run) {
|
|
261
|
+
try {
|
|
262
|
+
return run()
|
|
263
|
+
} catch {
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 异步调用并吞掉异常(拒绝)。 */
|
|
269
|
+
async function attemptAsync(run) {
|
|
270
|
+
try {
|
|
271
|
+
return await run()
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 采集能力探针数据(无缓存;`createCapabilitiesReader` 负责缓存)。
|
|
279
|
+
*
|
|
280
|
+
* 探针的每一步都独立失败:解析不到就是 `path: null`,版本取不到就是
|
|
281
|
+
* `version: null`,profile 目录不存在就是空列表——接口本身不因本机缺一个 CLI
|
|
282
|
+
* 而失败。
|
|
283
|
+
* @param config - 当前配置(含 volatile 引用也可)。
|
|
284
|
+
* @param deps - 注入点:`env`、`home`、`resolveExecutable`、`probeVersion`、
|
|
285
|
+
* `listCodexProfiles`、`versionTimeoutMs`。
|
|
286
|
+
* @returns `{ executables, codexProfiles }`(普通 JSON)。
|
|
287
|
+
*/
|
|
288
|
+
export async function collectCapabilities(config, deps = {}) {
|
|
289
|
+
const env = deps.env ?? process.env
|
|
290
|
+
const home = deps.home ?? homedir()
|
|
291
|
+
const resolve = deps.resolveExecutable ?? resolveExecutable
|
|
292
|
+
const probe = deps.probeVersion ?? probeVersion
|
|
293
|
+
const listProfiles = deps.listCodexProfiles ?? listCodexProfiles
|
|
294
|
+
const options = { env, home }
|
|
295
|
+
const backends = normalizeConfig(config).subagents
|
|
296
|
+
const executables = {}
|
|
297
|
+
for (const cli of ['codex', 'claude']) {
|
|
298
|
+
const executable = backends[cli].executable
|
|
299
|
+
const path = attempt(() => resolve(executable, options)) ?? null
|
|
300
|
+
const version = path === null
|
|
301
|
+
? null
|
|
302
|
+
: await attemptAsync(() => probe(path, { timeoutMs: deps.versionTimeoutMs ?? VERSION_TIMEOUT_MS })) ?? null
|
|
303
|
+
executables[cli] = {
|
|
304
|
+
executable,
|
|
305
|
+
path: typeof path === 'string' && path !== '' ? path : null,
|
|
306
|
+
version: typeof version === 'string' && version !== '' ? version : null,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const profiles = attempt(() => listProfiles(options))
|
|
310
|
+
return {
|
|
311
|
+
executables,
|
|
312
|
+
codexProfiles: Array.isArray(profiles) ? profiles.filter(isProfileName) : [],
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 探针缓存的键:两个可执行文件名 + PATH/CODEX_HOME/home 的变化都要失效。 */
|
|
317
|
+
function capabilityKey(config, deps) {
|
|
318
|
+
const env = deps.env ?? process.env
|
|
319
|
+
const backends = normalizeConfig(config).subagents
|
|
320
|
+
return JSON.stringify([
|
|
321
|
+
backends.codex.executable,
|
|
322
|
+
backends.claude.executable,
|
|
323
|
+
env.PATH ?? '',
|
|
324
|
+
env.CODEX_HOME ?? '',
|
|
325
|
+
deps.home ?? '',
|
|
326
|
+
])
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 造一个带 TTL 缓存与并发去重的探针读取器。
|
|
331
|
+
*
|
|
332
|
+
* 设置页每次挂载都会拉一次能力;同键的并发请求共享一次探测,TTL 内的重复
|
|
333
|
+
* 请求直接回缓存,避免反复 fork CLI。
|
|
334
|
+
* @param deps - 同 {@link collectCapabilities},另加 `ttlMs`、`now`(测试注入)。
|
|
335
|
+
* @returns `read(config)` → 探针数据。
|
|
336
|
+
*/
|
|
337
|
+
export function createCapabilitiesReader(deps = {}) {
|
|
338
|
+
const ttlMs = deps.ttlMs ?? CAPABILITY_TTL_MS
|
|
339
|
+
const now = deps.now ?? Date.now
|
|
340
|
+
let entry
|
|
341
|
+
let pending
|
|
342
|
+
return async function read(config) {
|
|
343
|
+
const key = capabilityKey(config, deps)
|
|
344
|
+
if (entry !== undefined && entry.key === key && now() - entry.at < ttlMs) return entry.value
|
|
345
|
+
if (pending !== undefined && pending.key === key) return pending.promise
|
|
346
|
+
const promise = collectCapabilities(config, deps).then((value) => {
|
|
347
|
+
entry = { key, at: now(), value }
|
|
348
|
+
return value
|
|
349
|
+
})
|
|
350
|
+
pending = { key, promise }
|
|
351
|
+
try {
|
|
352
|
+
return await promise
|
|
353
|
+
} finally {
|
|
354
|
+
if (pending !== undefined && pending.promise === promise) pending = undefined
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── 安检(loopback Host + 同源标志)─────────────────────────────────────────
|
|
360
|
+
// 改编自 dsh-better-sidebar src/trust-fence.ts(其源头为 BSD-3-Clause 的
|
|
361
|
+
// @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
|
|
362
|
+
|
|
363
|
+
function headerOf(req, name) {
|
|
364
|
+
const value = req.headers?.[name]
|
|
365
|
+
return typeof value === 'string' ? value : undefined
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parseAuthority(authority) {
|
|
369
|
+
try {
|
|
370
|
+
return new URL(`http://${authority}`)
|
|
371
|
+
} catch {
|
|
372
|
+
return undefined
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isLoopbackHostname(hostname) {
|
|
377
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
378
|
+
const parts = hostname.split('.')
|
|
379
|
+
return parts.length === 4
|
|
380
|
+
&& parts[0] === '127'
|
|
381
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Host 是 loopback(或配置的受信权威)且浏览器标志同源。 */
|
|
385
|
+
function isTrustedApiRequest(req, trustedHosts) {
|
|
386
|
+
const host = headerOf(req, 'host')
|
|
387
|
+
if (host === undefined) return false
|
|
388
|
+
const hostUrl = parseAuthority(host)
|
|
389
|
+
if (hostUrl === undefined) return false
|
|
390
|
+
const trusted = trustedHosts.some((entry) => {
|
|
391
|
+
const entryUrl = parseAuthority(entry)
|
|
392
|
+
return entryUrl !== undefined && entryUrl.host === hostUrl.host
|
|
393
|
+
})
|
|
394
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trusted) return false
|
|
395
|
+
if (headerOf(req, 'sec-fetch-site') === 'cross-site') return false
|
|
396
|
+
const origin = headerOf(req, 'origin')
|
|
397
|
+
if (origin === undefined) return true
|
|
398
|
+
try {
|
|
399
|
+
return new URL(origin).host === hostUrl.host
|
|
400
|
+
} catch {
|
|
401
|
+
return false
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── 路由 ─────────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
function writeJson(res, status, body) {
|
|
408
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
409
|
+
res.end(JSON.stringify(body))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 注册只读 `/apollo/api/capabilities` 路由(由 `src/settings.js` 调用)。
|
|
414
|
+
*
|
|
415
|
+
* 信任边界按请求重读活配置的 `trustedHosts`(反代部署下改完即时生效),读不到
|
|
416
|
+
* 活配置时退回 `configFor` 的规整值。
|
|
417
|
+
* @param ctx - 已注入 webServer 的插件上下文。
|
|
418
|
+
* @param configFor - 返回当前配置的函数(或配置值本身)。
|
|
419
|
+
* @param deps - 探针注入点;`read` 可直接替换整个读取器(测试用)。
|
|
420
|
+
* @returns disposer 由 `ctx.effect` 持有。
|
|
421
|
+
*/
|
|
422
|
+
export function registerCapabilitiesRoute(ctx, configFor, deps = {}) {
|
|
423
|
+
const read = deps.read ?? createCapabilitiesReader(deps)
|
|
424
|
+
const configOf = () => (typeof configFor === 'function' ? attempt(configFor) : configFor)
|
|
425
|
+
ctx.effect(() => ctx.webServer.register({
|
|
426
|
+
kind: 'prefix',
|
|
427
|
+
path: CAPABILITIES_PREFIX,
|
|
428
|
+
handler: async (req, res) => {
|
|
429
|
+
try {
|
|
430
|
+
const config = currentConfig(ctx, configOf())
|
|
431
|
+
if (!isTrustedApiRequest(req, config.trustedHosts)) {
|
|
432
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
436
|
+
if (pathname !== CAPABILITIES_PATH) {
|
|
437
|
+
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown apollo API path' } })
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (req.method !== 'GET') {
|
|
441
|
+
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
writeJson(res, 200, { ok: true, value: await read(config) })
|
|
445
|
+
} catch (error) {
|
|
446
|
+
// 探针已经各自吞掉失败;这里只剩意外错误——日志留细节,响应只给通用文案。
|
|
447
|
+
ctx.logger?.warn?.(`apollo-panel: capabilities probe failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
448
|
+
writeJson(res, 500, { ok: false, error: { code: 'probe-failed', message: 'capability probe failed' } })
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
}), 'apollo-panel: /apollo/api capabilities route')
|
|
452
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-apollo —— 设置装配(src/settings.js)。
|
|
3
|
+
*
|
|
4
|
+
* 本文件负责两件「必须等到服务可用才能做」的事,以及一次性的旧配置迁移:
|
|
5
|
+
*
|
|
6
|
+
* 1. **只读能力路由**:`ctx.inject(['webServer'])` → 注册
|
|
7
|
+
* `/apollo/api/capabilities`(处理器与探针在 `src/panel.js`,一处注册避免
|
|
8
|
+
* webServer 的重复 (kind,path) 抛错)。headless 组合没有 webServer 就自然
|
|
9
|
+
* 跳过,面板装配照常返回。
|
|
10
|
+
*
|
|
11
|
+
* 2. **行配置页面策略**:`ctx.inject(['settings'])` → 在本插件实例上登记
|
|
12
|
+
* `configure({ auto: false }, ctx.fiber)`:本行自带配置页(浏览器半边的
|
|
13
|
+
* `plugins.row.config`)就是它唯一的编辑入口,不需要 DSH 再按 schema 自动
|
|
14
|
+
* 生成一页。策略随本行的 effect 生命周期增删。
|
|
15
|
+
*
|
|
16
|
+
* 3. **旧 eng.json / atlas.json 一次性只读导入**:DSH 0.2 起设置就是正式
|
|
17
|
+
* Config(volatile,见 `src/config.js`),落盘由 configEditor 写进活动
|
|
18
|
+
* profile 的 cordis.patch.yml;`<dshHome>/eng.json` 与旧名 `atlas.json`
|
|
19
|
+
* 不再被写入(**只读**),但第一次装配时会把里面的 `exitGuard` 搬进正式
|
|
20
|
+
* 配置,之后旧的读取路径(预设本地守卫)即使被删也不丢开关。
|
|
21
|
+
*
|
|
22
|
+
* 迁移规则:
|
|
23
|
+
* - 只在 `settings` 服务可用、且 Loader 已把所有 entry 装配完(本行 fiber
|
|
24
|
+
* 进入 active,`describe()` 才认得它)之后跑一次;
|
|
25
|
+
* - **当前 profile 显式给出的值优先**:`exitGuard` 已存在于用户层(哪怕是
|
|
26
|
+
* 显式 `false`)时不覆盖——判断「存在」而不是「等于」,与 dsh-settings
|
|
27
|
+
* 的 override 语义一致;
|
|
28
|
+
* - 值已经与生效值相同(等于 schema 默认)时不写盘,避免制造无意义的
|
|
29
|
+
* profile 覆盖层;
|
|
30
|
+
* - 旧文件永不改写、永不删除;导入失败只告警,不影响插件装载。
|
|
31
|
+
*
|
|
32
|
+
* 持久化与生效:写入经 `settings.update`(= configEditor 校验完整候选后落盘 +
|
|
33
|
+
* Loader 调和)。因为 `exitGuard` 是 volatile 字段,写入不会重挂本行,运行中的
|
|
34
|
+
* 消费方(预设守卫、CLI 路由)下一次读取即拿到新值。
|
|
35
|
+
*
|
|
36
|
+
* 零第三方依赖:只用 node 内置模块 + 本包 `src/config.js`。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readFileSync } from 'node:fs'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
import { ENTRY_ID } from './config.js'
|
|
42
|
+
import { resolveDshHome } from './dsh-home.js'
|
|
43
|
+
import { installOwnsHostTap, registerCapabilitiesRoute } from './panel.js'
|
|
44
|
+
|
|
45
|
+
/** 旧配置文件名,按优先级排列(新名优先,旧名只读回退)。 */
|
|
46
|
+
export const LEGACY_FILES = Object.freeze(['eng.json', 'atlas.json'])
|
|
47
|
+
|
|
48
|
+
/** 读取一个 JSON 文件并确保顶层是普通对象;缺失/损坏/形状非法返回 undefined。 */
|
|
49
|
+
function readJsonObject(path) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
52
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
53
|
+
return raw
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 是否是普通数据对象。 */
|
|
60
|
+
function isRecord(value) {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 用户层里是否显式写了这个字段(存在即为覆盖,与值无关)。 */
|
|
65
|
+
function hasOwn(value, key) {
|
|
66
|
+
return isRecord(value) && Object.hasOwn(value, key)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 读取旧运行时配置文档(只读)。
|
|
71
|
+
*
|
|
72
|
+
* `eng.json` 优先,缺失或损坏时回退旧名 `atlas.json`;两者都没有返回 undefined。
|
|
73
|
+
* @param env - 读取 DSH_HOME 的进程环境(测试注入点)。
|
|
74
|
+
* @returns `{ file, path, value }` 或 undefined。
|
|
75
|
+
*/
|
|
76
|
+
export function readLegacyDocument(env = process.env) {
|
|
77
|
+
const home = resolveDshHome(env)
|
|
78
|
+
for (const file of LEGACY_FILES) {
|
|
79
|
+
const path = join(home, file)
|
|
80
|
+
const value = readJsonObject(path)
|
|
81
|
+
if (value !== undefined) return { file, path, value }
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 由旧文档算出要写进正式配置的补丁(纯函数,便于测试)。
|
|
88
|
+
*
|
|
89
|
+
* 目前唯一有对应关系的旧字段是 `exitGuard`(布尔)。规则:
|
|
90
|
+
* - 旧值不是布尔 → 不导入;
|
|
91
|
+
* - 用户层已显式给出 `exitGuard`(含显式 false)→ 不覆盖;
|
|
92
|
+
* - 生效值已与旧值相同 → 不写盘(避免无意义的 profile 覆盖层)。
|
|
93
|
+
* @param raw - 旧文档内容。
|
|
94
|
+
* @param user - 当前用户层(profile 覆盖层投影,仅含可编辑字段)。
|
|
95
|
+
* @param value - 当前生效值(仅含可编辑字段)。
|
|
96
|
+
* @returns 补丁对象;无需导入时为空对象。
|
|
97
|
+
*/
|
|
98
|
+
export function legacyPatch(raw, user, value) {
|
|
99
|
+
const patch = {}
|
|
100
|
+
if (typeof raw?.exitGuard !== 'boolean') return patch
|
|
101
|
+
if (hasOwn(user, 'exitGuard')) return patch
|
|
102
|
+
if (isRecord(value) && value.exitGuard === raw.exitGuard) return patch
|
|
103
|
+
patch.exitGuard = raw.exitGuard
|
|
104
|
+
return patch
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 取 Loader 服务(用于等待所有 entry 装配完);拿不到返回 undefined。 */
|
|
108
|
+
function loaderOf(ctx) {
|
|
109
|
+
try {
|
|
110
|
+
const direct = ctx.get?.('loader')
|
|
111
|
+
if (typeof direct?.await === 'function') return direct
|
|
112
|
+
} catch {
|
|
113
|
+
// 未提供 loader(测试 fake ctx)→ 退回 root。
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const root = ctx.root?.loader
|
|
117
|
+
return typeof root?.await === 'function' ? root : undefined
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 把旧文档里的字段一次性导入正式配置(需要 `settings` 服务)。
|
|
125
|
+
*
|
|
126
|
+
* 调用方保证「只调用一次」;本函数自身也幂等(用户层已有该字段就不写)。
|
|
127
|
+
* @param ctx - 已注入 `settings` 的上下文。
|
|
128
|
+
* @param env - 读取 DSH_HOME 的进程环境(测试注入点)。
|
|
129
|
+
* @returns `{ imported, reason, file?, patch? }`(不抛错,失败信息只用于日志)。
|
|
130
|
+
*/
|
|
131
|
+
export async function importLegacyConfig(ctx, env = process.env) {
|
|
132
|
+
const legacy = readLegacyDocument(env)
|
|
133
|
+
if (legacy === undefined) return { imported: false, reason: 'absent' }
|
|
134
|
+
const settings = ctx.settings
|
|
135
|
+
if (typeof settings?.describe !== 'function' || typeof settings.update !== 'function') {
|
|
136
|
+
return { imported: false, reason: 'unavailable', file: legacy.file }
|
|
137
|
+
}
|
|
138
|
+
// Loader 装配完成后 describe() 才认得出本行(fiber 必须 active)。
|
|
139
|
+
const loader = loaderOf(ctx)
|
|
140
|
+
if (loader !== undefined) await loader.await()
|
|
141
|
+
const descriptors = await settings.describe()
|
|
142
|
+
const descriptor = Array.isArray(descriptors)
|
|
143
|
+
? descriptors.find((row) => row?.ns === ENTRY_ID)
|
|
144
|
+
: undefined
|
|
145
|
+
if (descriptor === undefined) return { imported: false, reason: 'not-configurable', file: legacy.file }
|
|
146
|
+
const patch = legacyPatch(legacy.value, descriptor.user, descriptor.value)
|
|
147
|
+
if (Object.keys(patch).length === 0) return { imported: false, reason: 'no-change', file: legacy.file }
|
|
148
|
+
await settings.update(ENTRY_ID, patch, descriptor.revision)
|
|
149
|
+
return { imported: true, file: legacy.file, patch }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 装配设置侧:延迟注册只读能力路由、登记行配置页面策略、一次性导入旧配置。
|
|
154
|
+
*
|
|
155
|
+
* 由宿主行(`src/index.js` 的 `apply`)调用。所有动作都延迟到服务出现时进行:
|
|
156
|
+
* 没有 webServer / settings 的组合里本函数安静返回,插件其余部分照常工作。
|
|
157
|
+
* @param ctx - 插件上下文。
|
|
158
|
+
* @param config - 行配置(可能含 volatile 引用)。
|
|
159
|
+
* @param options - 注入点(测试用):`env`、`route`(探针依赖,透传
|
|
160
|
+
* {@link registerCapabilitiesRoute})。
|
|
161
|
+
*/
|
|
162
|
+
export function mountSettings(ctx, config, options = {}) {
|
|
163
|
+
const env = options.env ?? process.env
|
|
164
|
+
|
|
165
|
+
// 反代部署标志:宿主行只调 mountSettings 时也必须装上(否则设置镜像降级 memory)。
|
|
166
|
+
installOwnsHostTap(ctx, config)
|
|
167
|
+
|
|
168
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
169
|
+
registerCapabilitiesRoute(webCtx, () => config, options.route)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
let started = false
|
|
173
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
174
|
+
// 行配置页面策略:本行自带配置页,不需要自动生成的一页。
|
|
175
|
+
try {
|
|
176
|
+
const dispose = settingsCtx.settings.configure({ auto: false }, ctx.fiber)
|
|
177
|
+
settingsCtx.effect(() => dispose, 'apollo-settings: row config page policy')
|
|
178
|
+
} catch (error) {
|
|
179
|
+
settingsCtx.logger?.warn?.(`apollo-settings: settings page policy was not registered: ${error instanceof Error ? error.message : String(error)}`)
|
|
180
|
+
}
|
|
181
|
+
if (started) return
|
|
182
|
+
started = true
|
|
183
|
+
void importLegacyConfig(settingsCtx, env).then((result) => {
|
|
184
|
+
if (result.imported) {
|
|
185
|
+
settingsCtx.logger?.info?.(`apollo-settings: imported ${result.file} into the ${ENTRY_ID} config (exitGuard=${String(result.patch.exitGuard)})`)
|
|
186
|
+
}
|
|
187
|
+
}, (error) => {
|
|
188
|
+
settingsCtx.logger?.warn?.(`apollo-settings: legacy config import skipped: ${error instanceof Error ? error.message : String(error)}`)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
}
|