@inobit/pi-instabox 0.0.0-stage → 0.1.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/README.zh-CN.md +215 -0
- package/package.json +64 -3
- package/src/boxes.ts +158 -0
- package/src/cli.ts +440 -0
- package/src/config.ts +310 -0
- package/src/devtools.ts +257 -0
- package/src/entry-render.ts +42 -0
- package/src/index.ts +1141 -0
- package/src/log.ts +203 -0
- package/src/pool.ts +651 -0
- package/src/remote.ts +678 -0
- package/src/resolve.ts +216 -0
- package/src/schema.ts +370 -0
- package/src/session-target.ts +100 -0
- package/src/template.ts +449 -0
- package/src/tools.ts +322 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 扩展配置与路径。
|
|
3
|
+
*
|
|
4
|
+
* 叠加顺序(后者覆盖前者):`DEFAULT_CONFIG` → `<agentDir>/extensions/pi-instabox/config.json`
|
|
5
|
+
* → `<cwd>/.pi/extensions/pi-instabox/config.json`(仅项目可信时)→ 环境变量 `INSTABOX_MCP_*`。
|
|
6
|
+
* 照 pi-todo / pi-nvim `src/config.ts` 范式:单测走纯函数 `normalizeConfig`,`loadConfig`
|
|
7
|
+
* 在 `VITEST` 下恒返回默认值+环境变量层(测试确定性),所有外部访问都走注入面便于离线测试。
|
|
8
|
+
*
|
|
9
|
+
* 路径布局(裁决 V3):配置进 `<agentDir>/extensions/pi-instabox/`,运行期状态与 helper 进
|
|
10
|
+
* `<agentDir>/pi-instabox/`,事件日志进 `<agentDir>/logs/pi-instabox/`(与
|
|
11
|
+
* `logs/pi-permission/` 的生态惯例一致:扩展目录只放配置,日志与 `pi-debug.log` 同级)。
|
|
12
|
+
* 所有路径单点在 `resolvePaths()`,其余模块只消费它的返回值。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 超时(毫秒):准备阶段总预算 + 各步单步上限(D4 唤醒链、D7 ⑤ 远端 MCP 客户端)。
|
|
21
|
+
*
|
|
22
|
+
* 两层语义不同,别按「各步相加」理解:`prepareMs` 是**一次工具调用准备阶段的截止时间**,
|
|
23
|
+
* 下面每个字段只是**单步上限**——某一步卡住时实际超时取 `min(该步上限, 剩余预算)`,
|
|
24
|
+
* 所以准备阶段的上界是 `prepareMs`,不是各字段之和。
|
|
25
|
+
*/
|
|
26
|
+
export interface InstaboxTimeouts {
|
|
27
|
+
/**
|
|
28
|
+
* 准备阶段总预算:查状态 → 唤醒链(`wake`/`start`/`resume`)→ 确认 `running` → 建连 共享
|
|
29
|
+
* 这一个截止时间。用户要求「准备阶段总共约 1 分钟」,投一次桌面调用不该等 8 分钟。
|
|
30
|
+
*/
|
|
31
|
+
prepareMs: number;
|
|
32
|
+
/** `instabox wake` 单步上限(只处理「自动暂停」;吃满即截止到 `prepareMs`) */
|
|
33
|
+
wakeMs: number;
|
|
34
|
+
/** `instabox start` / `instabox resume` 单步上限(整箱冷启) */
|
|
35
|
+
startMs: number;
|
|
36
|
+
/**
|
|
37
|
+
* spawn 后等远端 MCP 传输建链的单步上限。
|
|
38
|
+
*
|
|
39
|
+
* 唤醒由准备阶段的唤醒链完成(`wake`/`start`/`resume`),这里不必再覆盖官方文档里
|
|
40
|
+
* 「自动唤醒最多等 45s」那一段,只留「spawn → 首个字节」的余量。`remote.ts` 的
|
|
41
|
+
* `DEFAULT_TIMEOUTS.connect`(15s)只是该模块的兜底值,装配层(`pool.ts`)会用本字段
|
|
42
|
+
* 覆盖它,改这里才生效。
|
|
43
|
+
*/
|
|
44
|
+
connectMs: number;
|
|
45
|
+
/** `initialize` 请求上界 */
|
|
46
|
+
initializeMs: number;
|
|
47
|
+
/** `tools/list` 请求上界 */
|
|
48
|
+
listToolsMs: number;
|
|
49
|
+
/**
|
|
50
|
+
* `tools/call` 请求上界(桌面操作可能含截图,给得最宽)。
|
|
51
|
+
* 它属于工具调用本身,**不**在准备阶段的 `prepareMs` 里。
|
|
52
|
+
*/
|
|
53
|
+
callMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface InstaboxConfig {
|
|
57
|
+
/** 解析链靠后的兜底目标;空串=未配置(退到「`instabox ls` 第一台」) */
|
|
58
|
+
defaultBox: string;
|
|
59
|
+
/** DevTools 唯一对应的 box;空串=该能力整体不存在(不建目录、不写 helper、不提示) */
|
|
60
|
+
devToolsBox: string;
|
|
61
|
+
/** 指定了 devToolsBox 但远端没装 `chrome-devtools-mcp` 时,连接前尝试自动安装 */
|
|
62
|
+
autoInstallDevTools: boolean;
|
|
63
|
+
/** 钉死的远端 PATH(不同模板的 npm prefix 不同,`/opt/npm/bin` 与 `/usr/local/bin` 都出现过) */
|
|
64
|
+
remotePath: string;
|
|
65
|
+
/** 原样透传给 `chrome-devtools-mcp` 的参数 */
|
|
66
|
+
devToolsArgs: string[];
|
|
67
|
+
timeouts: InstaboxTimeouts;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_CONFIG: InstaboxConfig = {
|
|
71
|
+
defaultBox: "",
|
|
72
|
+
devToolsBox: "",
|
|
73
|
+
autoInstallDevTools: true,
|
|
74
|
+
remotePath: "/opt/npm/bin:/usr/local/bin:/usr/bin:/bin",
|
|
75
|
+
devToolsArgs: [
|
|
76
|
+
"--headless",
|
|
77
|
+
"--userDataDir=/data/home/.agent-chrome",
|
|
78
|
+
"--executablePath=/usr/bin/chromium",
|
|
79
|
+
"--viewport=1280x800",
|
|
80
|
+
"--no-usage-statistics",
|
|
81
|
+
],
|
|
82
|
+
timeouts: {
|
|
83
|
+
prepareMs: 60_000,
|
|
84
|
+
wakeMs: 20_000,
|
|
85
|
+
startMs: 30_000,
|
|
86
|
+
connectMs: 15_000,
|
|
87
|
+
initializeMs: 15_000,
|
|
88
|
+
listToolsMs: 15_000,
|
|
89
|
+
callMs: 120_000,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const TIMEOUT_KEYS = [
|
|
94
|
+
"prepareMs",
|
|
95
|
+
"wakeMs",
|
|
96
|
+
"startMs",
|
|
97
|
+
"connectMs",
|
|
98
|
+
"initializeMs",
|
|
99
|
+
"listToolsMs",
|
|
100
|
+
"callMs",
|
|
101
|
+
] as const;
|
|
102
|
+
|
|
103
|
+
/** agent 目录:`PI_CODING_AGENT_DIR` 优先,否则 `~/.pi/agent`(不可用 import.meta.url 推导:jiti 直载下不可靠) */
|
|
104
|
+
export function getAgentDir(): string {
|
|
105
|
+
const env = process.env.PI_CODING_AGENT_DIR;
|
|
106
|
+
if (env) {
|
|
107
|
+
// `~` / `~/x` 两种写法都按家目录展开(与 pi-todo / pi-nvim 一致)
|
|
108
|
+
if (env === "~" || env.startsWith("~/") || env.startsWith("~\\")) return path.join(os.homedir(), env.slice(2));
|
|
109
|
+
return env;
|
|
110
|
+
}
|
|
111
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 配置文件相对路径:全局层相对 `agentDir`,项目层相对 `<cwd>/.pi` */
|
|
115
|
+
const CONFIG_RELATIVE_PATH = path.join("extensions", "pi-instabox", "config.json");
|
|
116
|
+
|
|
117
|
+
/** 运行期目录名:状态进 `<agentDir>/pi-instabox`,日志进 `<agentDir>/logs/pi-instabox` */
|
|
118
|
+
const STATE_DIR_NAME = "pi-instabox";
|
|
119
|
+
|
|
120
|
+
export interface InstaboxPaths {
|
|
121
|
+
/** 配置文件(全局层;项目层在 `<cwd>/.pi/` 下同名) */
|
|
122
|
+
configFile: string;
|
|
123
|
+
/** 运行期状态目录(缓存与 helper 的父目录,按需创建) */
|
|
124
|
+
stateDir: string;
|
|
125
|
+
/** per-box 模板缓存 */
|
|
126
|
+
templateCacheFile: string;
|
|
127
|
+
/** DevTools helper 脚本(运行期产物,非配置) */
|
|
128
|
+
helperScript: string;
|
|
129
|
+
/** 事件日志目录(JSONL 由 `log.ts` 写入,按需创建) */
|
|
130
|
+
logDir: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolvePaths(agentDir: string): InstaboxPaths {
|
|
134
|
+
const stateDir = path.join(agentDir, STATE_DIR_NAME);
|
|
135
|
+
return {
|
|
136
|
+
configFile: path.join(agentDir, CONFIG_RELATIVE_PATH),
|
|
137
|
+
stateDir,
|
|
138
|
+
templateCacheFile: path.join(stateDir, "templates.json"),
|
|
139
|
+
helperScript: path.join(stateDir, "bin", "devtools-connect.sh"),
|
|
140
|
+
logDir: path.join(agentDir, "logs", STATE_DIR_NAME),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 单层覆盖:只含逐字段合法的值;非法字段直接缺席(=保留底层值) */
|
|
145
|
+
export interface InstaboxConfigOverlay {
|
|
146
|
+
defaultBox?: string;
|
|
147
|
+
devToolsBox?: string;
|
|
148
|
+
autoInstallDevTools?: boolean;
|
|
149
|
+
remotePath?: string;
|
|
150
|
+
devToolsArgs?: string[];
|
|
151
|
+
timeouts?: Partial<InstaboxTimeouts>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function asRecord(raw: unknown): Record<string, unknown> | undefined {
|
|
155
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
156
|
+
return raw as Record<string, unknown>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function asNonEmptyString(raw: unknown): string | undefined {
|
|
160
|
+
return typeof raw === "string" && raw !== "" ? raw : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function asPositiveInt(raw: unknown): number | undefined {
|
|
164
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) return undefined;
|
|
165
|
+
return raw;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 字符串数组:丢非字符串与空串;显式 `[]` 表示清空(保留),全非法输入回 undefined(保留底层值) */
|
|
169
|
+
function asStringList(raw: unknown): string[] | undefined {
|
|
170
|
+
if (!Array.isArray(raw)) return undefined;
|
|
171
|
+
const out: string[] = [];
|
|
172
|
+
for (const v of raw) {
|
|
173
|
+
if (typeof v === "string" && v !== "") out.push(v);
|
|
174
|
+
}
|
|
175
|
+
if (out.length === 0 && raw.length > 0) return undefined;
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function asTimeouts(raw: unknown): Partial<InstaboxTimeouts> | undefined {
|
|
180
|
+
const rec = asRecord(raw);
|
|
181
|
+
if (!rec) return undefined;
|
|
182
|
+
const out: Partial<InstaboxTimeouts> = {};
|
|
183
|
+
for (const key of TIMEOUT_KEYS) {
|
|
184
|
+
const n = asPositiveInt(rec[key]);
|
|
185
|
+
if (n !== undefined) out[key] = n;
|
|
186
|
+
}
|
|
187
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 纯校验(单层):任意 `unknown` 输入都不 throw,非法/未知字段缺席 */
|
|
191
|
+
export function normalizeConfigOverlay(raw: unknown): InstaboxConfigOverlay {
|
|
192
|
+
const rec = asRecord(raw);
|
|
193
|
+
if (!rec) return {};
|
|
194
|
+
const out: InstaboxConfigOverlay = {};
|
|
195
|
+
const defaultBox = rec["defaultBox"];
|
|
196
|
+
if (typeof defaultBox === "string") out.defaultBox = defaultBox;
|
|
197
|
+
const devToolsBox = rec["devToolsBox"];
|
|
198
|
+
if (typeof devToolsBox === "string") out.devToolsBox = devToolsBox;
|
|
199
|
+
const autoInstallDevTools = rec["autoInstallDevTools"];
|
|
200
|
+
if (typeof autoInstallDevTools === "boolean") out.autoInstallDevTools = autoInstallDevTools;
|
|
201
|
+
const remotePath = asNonEmptyString(rec["remotePath"]);
|
|
202
|
+
if (remotePath !== undefined) out.remotePath = remotePath;
|
|
203
|
+
const devToolsArgs = asStringList(rec["devToolsArgs"]);
|
|
204
|
+
if (devToolsArgs !== undefined) out.devToolsArgs = devToolsArgs;
|
|
205
|
+
const timeouts = asTimeouts(rec["timeouts"]);
|
|
206
|
+
if (timeouts !== undefined) out.timeouts = timeouts;
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 叠加一层:`timeouts` 逐字段深合并,数组/对象一律拷贝(不共享 `DEFAULT_CONFIG` 的引用) */
|
|
211
|
+
export function applyOverlay(base: InstaboxConfig, overlay: InstaboxConfigOverlay): InstaboxConfig {
|
|
212
|
+
return {
|
|
213
|
+
defaultBox: overlay.defaultBox ?? base.defaultBox,
|
|
214
|
+
devToolsBox: overlay.devToolsBox ?? base.devToolsBox,
|
|
215
|
+
autoInstallDevTools: overlay.autoInstallDevTools ?? base.autoInstallDevTools,
|
|
216
|
+
remotePath: overlay.remotePath ?? base.remotePath,
|
|
217
|
+
devToolsArgs: [...(overlay.devToolsArgs ?? base.devToolsArgs)],
|
|
218
|
+
timeouts: { ...base.timeouts, ...(overlay.timeouts ?? {}) },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 纯校验(单文件语义):`normalizeConfig(undefined)` 即 `DEFAULT_CONFIG` 的深拷贝 */
|
|
223
|
+
export function normalizeConfig(raw: unknown): InstaboxConfig {
|
|
224
|
+
return applyOverlay(DEFAULT_CONFIG, normalizeConfigOverlay(raw));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 环境变量名(`INSTABOX_MCP_AUTO_INSTALL` 取值 `0|1`,`INSTABOX_MCP_PATH` 只喂 helper 的 PATH) */
|
|
228
|
+
const ENV_KEYS = {
|
|
229
|
+
defaultBox: "INSTABOX_MCP_DEFAULT_BOX",
|
|
230
|
+
devToolsBox: "INSTABOX_MCP_DEVTOOLS_BOX",
|
|
231
|
+
autoInstall: "INSTABOX_MCP_AUTO_INSTALL",
|
|
232
|
+
remotePath: "INSTABOX_MCP_PATH",
|
|
233
|
+
} as const;
|
|
234
|
+
|
|
235
|
+
export type EnvLike = Record<string, string | undefined>;
|
|
236
|
+
|
|
237
|
+
/** 环境变量层:空串=未设置(忽略);`INSTABOX_MCP_AUTO_INSTALL` 只认 `0`/`1` */
|
|
238
|
+
export function normalizeEnvOverlay(env: EnvLike): InstaboxConfigOverlay {
|
|
239
|
+
const overlay: InstaboxConfigOverlay = {};
|
|
240
|
+
const defaultBox = env[ENV_KEYS.defaultBox];
|
|
241
|
+
if (defaultBox) overlay.defaultBox = defaultBox;
|
|
242
|
+
const devToolsBox = env[ENV_KEYS.devToolsBox];
|
|
243
|
+
if (devToolsBox) overlay.devToolsBox = devToolsBox;
|
|
244
|
+
const autoInstall = env[ENV_KEYS.autoInstall];
|
|
245
|
+
if (autoInstall === "0") overlay.autoInstallDevTools = false;
|
|
246
|
+
else if (autoInstall === "1") overlay.autoInstallDevTools = true;
|
|
247
|
+
const remotePath = env[ENV_KEYS.remotePath];
|
|
248
|
+
if (remotePath) overlay.remotePath = remotePath;
|
|
249
|
+
return overlay;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface LoadConfigOptions {
|
|
253
|
+
/** agent 目录(默认 `getAgentDir()`) */
|
|
254
|
+
agentDir?: string;
|
|
255
|
+
/** 项目根(默认 `process.cwd()`) */
|
|
256
|
+
cwd?: string;
|
|
257
|
+
/** 项目可信才读 `<cwd>/.pi/extensions/pi-instabox/config.json`(默认 false) */
|
|
258
|
+
isTrusted?: boolean;
|
|
259
|
+
/** 读文件(默认 `fs.readFileSync(file, "utf8")`;抛错视为该层不存在) */
|
|
260
|
+
readFile?: (file: string) => string;
|
|
261
|
+
/** 存在性检查(默认 `fs.existsSync`;false 即跳过该层) */
|
|
262
|
+
exists?: (file: string) => boolean;
|
|
263
|
+
/** 环境变量源(默认 `process.env`;**显式注入即跳过 VITEST 短路**,供离线测试逐层断言) */
|
|
264
|
+
env?: EnvLike;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readLayer(
|
|
268
|
+
file: string,
|
|
269
|
+
readFile: (file: string) => string,
|
|
270
|
+
exists: (file: string) => boolean,
|
|
271
|
+
): InstaboxConfigOverlay | undefined {
|
|
272
|
+
if (!exists(file)) return undefined;
|
|
273
|
+
let text: string;
|
|
274
|
+
try {
|
|
275
|
+
text = readFile(file);
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
let parsed: unknown;
|
|
280
|
+
try {
|
|
281
|
+
parsed = JSON.parse(text);
|
|
282
|
+
} catch {
|
|
283
|
+
// 非法 JSON 当该层不存在:配置文件写坏不能拖垮会话
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
return normalizeConfigOverlay(parsed);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 加载配置:`DEFAULT_CONFIG` → 全局层 → 项目层(仅 trusted)→ 环境变量层。
|
|
291
|
+
* `VITEST` 下(且未注入 `env`)只返回默认值+环境变量层,不碰文件系统。
|
|
292
|
+
*/
|
|
293
|
+
export function loadConfig(options: LoadConfigOptions = {}): InstaboxConfig {
|
|
294
|
+
const env = options.env ?? process.env;
|
|
295
|
+
if (options.env === undefined && process.env.VITEST) {
|
|
296
|
+
return applyOverlay(DEFAULT_CONFIG, normalizeEnvOverlay(env));
|
|
297
|
+
}
|
|
298
|
+
const readFile = options.readFile ?? ((file: string) => fs.readFileSync(file, "utf8"));
|
|
299
|
+
const exists = options.exists ?? ((file: string) => fs.existsSync(file));
|
|
300
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
301
|
+
const cwd = options.cwd ?? process.cwd();
|
|
302
|
+
const projectFile = path.join(cwd, ".pi", CONFIG_RELATIVE_PATH);
|
|
303
|
+
let merged = normalizeConfig(undefined);
|
|
304
|
+
for (const file of [resolvePaths(agentDir).configFile, options.isTrusted === true ? projectFile : undefined]) {
|
|
305
|
+
if (!file) continue;
|
|
306
|
+
const layer = readLayer(file, readFile, exists);
|
|
307
|
+
if (layer) merged = applyOverlay(merged, layer);
|
|
308
|
+
}
|
|
309
|
+
return applyOverlay(merged, normalizeEnvOverlay(env));
|
|
310
|
+
}
|
package/src/devtools.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — DevTools MCP:helper 脚本生成/写盘 + `registerMcpServer` 参数(方案 §4.6、§5.1、T13)。
|
|
3
|
+
*
|
|
4
|
+
* 契约:
|
|
5
|
+
* - helper 是**运行期产物**而非配置:落在 `<agentDir>/pi-instabox/bin/devtools-connect.sh`
|
|
6
|
+
* (`paths.helperScript`,裁决 V3)。每次加载 `mkdir -p` + 幂等覆盖 + `chmod 0o755`;
|
|
7
|
+
* 写盘失败只 `warn`,不 throw 到会话层。
|
|
8
|
+
* - 脚本的 stdout **只承载 JSON-RPC**:pi 把 MCP 的 `command` 直接 exec(不经 shell),
|
|
9
|
+
* 任何写到 stdout 的诊断都会破坏 MCP 握手,所以诊断一律 stderr。
|
|
10
|
+
* - 脚本自己把箱拉起来(`wake`,失败再 `start`):MCP 客户端不会唤醒箱,pi 也不会重连
|
|
11
|
+
* 失败的 server——箱暂停时若直接把检查/ssh 打过去,结果是 0 个工具且整场会话不再恢复。
|
|
12
|
+
* 两条命令都用 shell 的 `timeout`(有则用)封顶 {@link PREPARE_TIMEOUT_SECONDS} 秒。
|
|
13
|
+
* - PATH 走 `instabox exec --env PATH=…`(CLI 原生能力,方案 C-c「比 `env PATH=… cmd` 包装更干净」);
|
|
14
|
+
* 最后一段 `instabox ssh` 没有 `--env`,只能用 `env PATH=…` 前缀。
|
|
15
|
+
* - 内容全部来自参数(`remotePath` / `autoInstall`),本模块不读 `DEFAULT_CONFIG`;
|
|
16
|
+
* 也不做 box 存在性预校验:连接失败就是最好的报错(方案 §8.3)。
|
|
17
|
+
* - 零第三方**运行时**依赖:只用 `node:fs` / `node:path`(宿主类型 `McpServerConfig` 仅 `import type`,
|
|
18
|
+
* 编译后擦除),且都经注入面(默认实现见 {@link nodeHelperScriptFileSystem}),单测不落真实文件。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import type { McpServerConfig } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import type { InstaboxConfig, InstaboxPaths } from "./config.ts";
|
|
25
|
+
import { logEvent } from "./log.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 远端安装检查的 `instabox exec --timeout`(秒)。
|
|
29
|
+
*
|
|
30
|
+
* 真正上界不是 CLI 的 600 秒上限,而是 pi 的 **per-request timeout**
|
|
31
|
+
* ({@link DEVTOOLS_REQUEST_TIMEOUT_SECONDS} 秒,即注册配置的 `timeout`):MCP 客户端对
|
|
32
|
+
* `initialize` 也计时,而 helper 的唤醒/检查/安装全都发生在 `initialize` 应答之前
|
|
33
|
+
* (`chrome-devtools-mcp` 还没 exec 出来)。所以脚本预算必须小于那个 timeout。
|
|
34
|
+
*/
|
|
35
|
+
const CHECK_TIMEOUT_SECONDS = 10;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 安装本身的 `--timeout`(秒):`npm i -g` 要拉 tarball,但「检查 + 安装」(10 + 40 = 50 秒)
|
|
39
|
+
* 仍须和前面的唤醒一起留在 per-request timeout(180 秒)以内——唤醒段每条命令由
|
|
40
|
+
* `timeout` 封顶 {@link PREPARE_TIMEOUT_SECONDS} 秒(`wake` + `start` 最坏 2 × 60)。
|
|
41
|
+
*/
|
|
42
|
+
const INSTALL_TIMEOUT_SECONDS = 40;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* helper 自己拉起箱时的准备阶段上限(秒):`timeout` 命令包住每条 `instabox wake`/`start`,
|
|
46
|
+
* 让一条卡住的 CLI 不能把 MCP 握手拖到天荒地老(与 `DEFAULT_CONFIG.timeouts.prepareMs` 的
|
|
47
|
+
* 1 分钟对齐;本模块不读配置,故在这里单点维护)。
|
|
48
|
+
*
|
|
49
|
+
* 它**不是**真正的上界:真正的上界是 pi 的 MCP per-request timeout
|
|
50
|
+
* ({@link DEVTOOLS_REQUEST_TIMEOUT_SECONDS} 秒)——脚本整个前置段(唤醒 ×2 + 检查 + 安装)
|
|
51
|
+
* 都在 `initialize` 应答之前跑完。系统里没有 `timeout` 命令时就不包(退回旧行为)。
|
|
52
|
+
*/
|
|
53
|
+
const PREPARE_TIMEOUT_SECONDS = 60;
|
|
54
|
+
|
|
55
|
+
/** helper 权限位:MCP 的 `command` 被直接 exec,可执行位是硬要求。 */
|
|
56
|
+
const HELPER_MODE = 0o755;
|
|
57
|
+
|
|
58
|
+
/** MCP server 名:只允许这一种拼写(`instabox_devtools` 与之撞名,方案 C-e)。 */
|
|
59
|
+
const DEVTOOLS_SERVER_NAME = "instabox-devtools";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `registerMcpServer` 的每请求超时(秒):默认 60 偏紧,桌面/浏览器操作给 180(方案 C-d)。
|
|
63
|
+
* 它同时是 helper 脚本前置工作(唤醒 2 × {@link PREPARE_TIMEOUT_SECONDS} + 检查
|
|
64
|
+
* {@link CHECK_TIMEOUT_SECONDS} + 安装 {@link INSTALL_TIMEOUT_SECONDS},最坏 170 秒)
|
|
65
|
+
* 的总上界——四者之和必须留在它以内,改小这里必须同步压缩脚本预算。
|
|
66
|
+
*/
|
|
67
|
+
const DEVTOOLS_REQUEST_TIMEOUT_SECONDS = 180;
|
|
68
|
+
|
|
69
|
+
/** 脚本内的 PATH 变量名:`--env`/`env` 两处都引用它,改字面量只改这里。 */
|
|
70
|
+
const REMOTE_PATH_VAR = "remote_path";
|
|
71
|
+
|
|
72
|
+
export interface BuildHelperScriptOptions {
|
|
73
|
+
/** 远端 PATH(来自 `cfg.remotePath`,不硬编码) */
|
|
74
|
+
remotePath: string;
|
|
75
|
+
/** 远端缺 `chrome-devtools-mcp` 时是否尝试自动安装(来自 `cfg.autoInstallDevTools`) */
|
|
76
|
+
autoInstall: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* POSIX 单引号字面量:值内的 `'` 写成 `'\''`。
|
|
81
|
+
* 脚本里所有配置值插值都走这个函数,避免任何配置值能破出引号。
|
|
82
|
+
*/
|
|
83
|
+
function shSingleQuote(value: string): string {
|
|
84
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 安装命令(远端执行):`sudo -n env "PATH=<remotePath>"` 把 `--env` 给的 PATH 再显式交给 sudo。
|
|
89
|
+
*
|
|
90
|
+
* 理由:sudo 默认按 `secure_path` 重写 PATH,`--env PATH=…` 传进去的 npm 目录可能在 sudo 下丢失,
|
|
91
|
+
* `sudo -n npm` 就会 "command not found"。**待真机复核**:sandbox 镜像的 sudoers/`secure_path`
|
|
92
|
+
* 还没在实际 box 上验证过(未复核,属于经验性保险)。
|
|
93
|
+
*/
|
|
94
|
+
function buildInstallCommand(remotePath: string): string {
|
|
95
|
+
return `sudo -n env "PATH=${remotePath}" npm i -g chrome-devtools-mcp`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 生成 helper 脚本(纯函数,便于单测断言内容)。
|
|
100
|
+
*
|
|
101
|
+
* 脚本职责:`devtools-connect.sh <box> [chrome-devtools-mcp args...]` → 先 `wake`(失败再 `start`)
|
|
102
|
+
* 拉起箱 → 远端缺包时检查/可选安装 →
|
|
103
|
+
* `exec instabox ssh <box> -- env PATH=… chrome-devtools-mcp "$@"`,stdout 只留 JSON-RPC。
|
|
104
|
+
*/
|
|
105
|
+
export function buildHelperScript(options: BuildHelperScriptOptions): string {
|
|
106
|
+
const remotePath = shSingleQuote(options.remotePath);
|
|
107
|
+
const installBlock = options.autoInstall
|
|
108
|
+
? ` printf 'instabox-devtools: installing chrome-devtools-mcp on %s\\n' "$box" >&2
|
|
109
|
+
# sudo 按 secure_path 重写 PATH,故显式再传一次(待真机复核)。
|
|
110
|
+
instabox exec "$box" --env "PATH=$${REMOTE_PATH_VAR}" --timeout ${INSTALL_TIMEOUT_SECONDS} -- \\
|
|
111
|
+
sh -c ${shSingleQuote(buildInstallCommand(options.remotePath))} >&2 2>&1 || true`
|
|
112
|
+
: ` printf 'instabox-devtools: auto-install disabled (autoInstallDevTools=false)\\n' >&2`;
|
|
113
|
+
|
|
114
|
+
return `#!/bin/sh
|
|
115
|
+
# devtools-connect.sh —— 由 @inobit/pi-instabox 生成,勿手改(每次加载幂等覆盖)。
|
|
116
|
+
# 用法: devtools-connect.sh <box> [chrome-devtools-mcp args...]
|
|
117
|
+
#
|
|
118
|
+
# stdout 只承载 MCP 的 JSON-RPC 帧,诊断一律写 stderr(否则握手立刻崩)。
|
|
119
|
+
set -eu
|
|
120
|
+
|
|
121
|
+
if [ "$#" -lt 1 ]; then
|
|
122
|
+
printf 'usage: devtools-connect.sh <box> [chrome-devtools-mcp args...]\\n' >&2
|
|
123
|
+
exit 2
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
box="$1"
|
|
127
|
+
shift
|
|
128
|
+
${REMOTE_PATH_VAR}=${remotePath}
|
|
129
|
+
|
|
130
|
+
# 0) 先确保箱在运行:MCP 客户端不会自己唤醒,pi 也不会重连失败的 server。
|
|
131
|
+
# 失败不阻断,让后面的检查/ssh 自然报错。每条唤醒命令包一层 \`timeout\`
|
|
132
|
+
# (有就用,没有就不加):准备阶段上限 ${PREPARE_TIMEOUT_SECONDS}s,
|
|
133
|
+
# 真实上界仍是 pi 的 MCP per-request timeout。
|
|
134
|
+
if command -v timeout >/dev/null 2>&1; then TO="timeout ${PREPARE_TIMEOUT_SECONDS}"; else TO=""; fi
|
|
135
|
+
$TO instabox wake "$box" >/dev/null 2>&1 || $TO instabox start "$box" >/dev/null 2>&1 || true
|
|
136
|
+
|
|
137
|
+
# 远端缺 chrome-devtools-mcp 时最多处理一次;检查与安装失败都不阻断连接,
|
|
138
|
+
# 让真正的 "command not found" 原样冒给 MCP 客户端。
|
|
139
|
+
if ! instabox exec "$box" --env "PATH=$${REMOTE_PATH_VAR}" --timeout ${CHECK_TIMEOUT_SECONDS} -- \\
|
|
140
|
+
sh -c 'command -v chrome-devtools-mcp >/dev/null 2>&1' >/dev/null 2>&1; then
|
|
141
|
+
printf 'instabox-devtools: chrome-devtools-mcp missing on %s\\n' "$box" >&2
|
|
142
|
+
${installBlock}
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
exec instabox ssh "$box" -- env "PATH=$${REMOTE_PATH_VAR}" chrome-devtools-mcp "$@"
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `pi.registerMcpServer(name, config)` 的 stdio 参数;`exposure` 钉死 `codemode`(方案 §4.3)。
|
|
151
|
+
*
|
|
152
|
+
* 本地细化类型,构造点用 `satisfies McpServerConfig` 绑到宿主并集上(见
|
|
153
|
+
* {@link buildMcpServerConfig}),宿主改字段时这里先报错。
|
|
154
|
+
*/
|
|
155
|
+
export interface DevToolsMcpServerConfig {
|
|
156
|
+
/** helper 脚本绝对路径 */
|
|
157
|
+
command: string;
|
|
158
|
+
/** `[devToolsBox, ...devToolsArgs]` */
|
|
159
|
+
args: string[];
|
|
160
|
+
/** 远端工具既不声明给模型、也不进 codemode 描述,脚本用 `searchTools()` 发现(方案 R3) */
|
|
161
|
+
exposure: "codemode";
|
|
162
|
+
/** 每请求秒数 */
|
|
163
|
+
timeout: number;
|
|
164
|
+
/** 出现在系统提示 `mcp_servers` 段,必须自解释(方案 §4.3 备注) */
|
|
165
|
+
description: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface DevToolsMcpServerRegistration {
|
|
169
|
+
/** server 名(命名空间由此派生为 `mcp__instabox_devtools`) */
|
|
170
|
+
name: string;
|
|
171
|
+
config: DevToolsMcpServerConfig;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 构造远端 `chrome-devtools-mcp` 的注册参数。
|
|
176
|
+
*
|
|
177
|
+
* `box` 是唯一对应的箱名(flag / 命令解析后传入),缺省沿用 `cfg.devToolsBox`;为空时调用方
|
|
178
|
+
* 不该调用本函数(该能力整体不存在,方案 §4.3 验收③)。
|
|
179
|
+
* `args` 每次新建数组,不与 `cfg.devToolsArgs` 共享引用。
|
|
180
|
+
*/
|
|
181
|
+
export function buildMcpServerConfig(
|
|
182
|
+
cfg: InstaboxConfig,
|
|
183
|
+
paths: InstaboxPaths,
|
|
184
|
+
box: string = cfg.devToolsBox,
|
|
185
|
+
): DevToolsMcpServerRegistration {
|
|
186
|
+
// 双重 satisfies:本地细化类型 + 宿主 `McpServerConfig`(stdio 并集)。宿主增删字段或收窄
|
|
187
|
+
// `exposure` 时在编译期就炸,而不是留到 `registerMcpServer()` 或运行期。
|
|
188
|
+
const config = {
|
|
189
|
+
command: paths.helperScript,
|
|
190
|
+
args: [box, ...cfg.devToolsArgs],
|
|
191
|
+
exposure: "codemode",
|
|
192
|
+
timeout: DEVTOOLS_REQUEST_TIMEOUT_SECONDS,
|
|
193
|
+
description: `Remote Chromium on instabox '${box}' (fixed box): DOM/accessibility snapshots, clicks, element screenshots, CSS cascade, network, console, performance traces. Tools are not declared directly — discover them with describeNamespace("mcp__instabox_devtools") or searchTools("devtools").`,
|
|
194
|
+
} satisfies DevToolsMcpServerConfig satisfies McpServerConfig;
|
|
195
|
+
|
|
196
|
+
return { name: DEVTOOLS_SERVER_NAME, config };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 注入的文件系统:本模块只用这三个动作(不读回内容,覆盖写即是幂等)。 */
|
|
200
|
+
export interface HelperScriptFileSystem {
|
|
201
|
+
/** `mkdir -p` */
|
|
202
|
+
ensureDir: (dir: string) => void;
|
|
203
|
+
/** 覆盖写文本(编码 UTF-8) */
|
|
204
|
+
writeText: (file: string, text: string) => void;
|
|
205
|
+
/** 设置权限位 */
|
|
206
|
+
setMode: (file: string, mode: number) => void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 默认文件系统:`node:fs`(运行期唯一的外部依赖面)。 */
|
|
210
|
+
export const nodeHelperScriptFileSystem: HelperScriptFileSystem = {
|
|
211
|
+
ensureDir: (dir) => {
|
|
212
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
213
|
+
},
|
|
214
|
+
writeText: (file, text) => {
|
|
215
|
+
fs.writeFileSync(file, text, "utf8");
|
|
216
|
+
},
|
|
217
|
+
setMode: (file, mode) => {
|
|
218
|
+
fs.chmodSync(file, mode);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export interface EnsureHelperScriptOptions extends BuildHelperScriptOptions {
|
|
223
|
+
/** `resolvePaths()` 的返回值;本模块只消费 `helperScript` */
|
|
224
|
+
paths: Pick<InstaboxPaths, "helperScript">;
|
|
225
|
+
/** 注入的文件系统,默认 {@link nodeHelperScriptFileSystem} */
|
|
226
|
+
fs?: HelperScriptFileSystem;
|
|
227
|
+
/** 失败出口(目录/写盘/权限任何一步失败都只在这里报一次,不 throw) */
|
|
228
|
+
warn?: (message: string) => void;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface EnsureHelperScriptResult {
|
|
232
|
+
ok: boolean;
|
|
233
|
+
/** 失败原因(`ok: false` 时必有) */
|
|
234
|
+
error?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 把 helper 写到 `paths.helperScript`:`mkdir -p` 父目录 → 覆盖写 → `chmod 0o755`。
|
|
239
|
+
* 三步共用一个 try:任一步失败都只 `warn` 一次并返回 `{ ok: false, error }`。
|
|
240
|
+
*/
|
|
241
|
+
export function ensureHelperScript(options: EnsureHelperScriptOptions): EnsureHelperScriptResult {
|
|
242
|
+
const file = options.paths.helperScript;
|
|
243
|
+
const io = options.fs ?? nodeHelperScriptFileSystem;
|
|
244
|
+
const script = buildHelperScript({ remotePath: options.remotePath, autoInstall: options.autoInstall });
|
|
245
|
+
try {
|
|
246
|
+
io.ensureDir(path.dirname(file));
|
|
247
|
+
io.writeText(file, script);
|
|
248
|
+
io.setMode(file, HELPER_MODE);
|
|
249
|
+
logEvent("devtools.helper", { ok: true, path: file });
|
|
250
|
+
return { ok: true };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
253
|
+
logEvent("devtools.helper", { ok: false, path: file, error: message });
|
|
254
|
+
options.warn?.(`无法写入 DevTools helper ${file}: ${message}`);
|
|
255
|
+
return { ok: false, error: message };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 目标 entry 的转录渲染(R5)。
|
|
3
|
+
*
|
|
4
|
+
* 宿主的 `EntryRenderer` 必须返回一个 `Component`(`render(width)` + `invalidate()`):
|
|
5
|
+
* 返回字符串会被当真组件用而崩。`Text` 的第 4 参是背景色函数、不是 `Theme`,
|
|
6
|
+
* 所以着色在构造之前用 `theme.fg("dim", line)` 完成,第 4 参永不传。
|
|
7
|
+
* 工厂注入 `Text` 构造器,单测据此断言构造实参而不真渲染。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { EntryRenderer } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Text as HostText, type Component } from "@earendil-works/pi-tui";
|
|
12
|
+
import { TARGET_ENTRY, type TargetEntryData } from "./session-target.ts";
|
|
13
|
+
|
|
14
|
+
/** `Text` 构造器的最小形状;第 4 参是背景色函数,本模块永不使用 */
|
|
15
|
+
export type TextConstructor = new (
|
|
16
|
+
text?: string,
|
|
17
|
+
paddingX?: number,
|
|
18
|
+
paddingY?: number,
|
|
19
|
+
customBgFn?: (text: string) => string,
|
|
20
|
+
) => Component;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 目标 entry 的渲染器工厂。
|
|
24
|
+
* 只认 `customType === TARGET_ENTRY`,其余条目返回 undefined,交回宿主默认渲染;
|
|
25
|
+
* `CustomEntry.data` 可缺省,取不到非空箱名时同样返回 undefined(不抛)。
|
|
26
|
+
*/
|
|
27
|
+
export function createTargetRenderer(TextCtor: TextConstructor): EntryRenderer<unknown> {
|
|
28
|
+
return (entry, _options, theme) => {
|
|
29
|
+
if (entry.customType !== TARGET_ENTRY) return undefined;
|
|
30
|
+
const box = (entry.data as Partial<TargetEntryData> | undefined)?.box;
|
|
31
|
+
if (typeof box !== "string" || box === "") return undefined;
|
|
32
|
+
return new TextCtor(theme.fg("dim", `instabox target: ${box}`), 0, 0);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 宿主默认版本:接 pi-tui 的真 `Text`,T15 一行接线:
|
|
38
|
+
* `pi.registerEntryRenderer(TARGET_ENTRY, createTargetRendererWithHostText())`。
|
|
39
|
+
*/
|
|
40
|
+
export function createTargetRendererWithHostText(): EntryRenderer<unknown> {
|
|
41
|
+
return createTargetRenderer(HostText);
|
|
42
|
+
}
|