@migor/cli 0.8.3 → 0.9.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 +207 -2
- package/README.md +5 -5
- package/dist/AGENTS.md +116 -8
- package/dist/cli.js +4 -2
- package/dist/create.js +18 -3
- package/dist/dev-protocol.js +33 -0
- package/dist/dev-runner.js +445 -0
- package/dist/dev.js +947 -63
- package/dist/inspector-page.html +687 -34
- package/dist/inspector-sink.js +4 -5
- package/dist/inspector.js +326 -19
- package/dist/panel-logic.js +248 -0
- package/dist/registry.js +10 -3
- package/dist/templates/README.md +18 -2
- package/dist/templates/capabilities/subagent/index.ts +5 -1
- package/dist/templates/capabilities/subagent/system.md +15 -0
- package/dist/templates/gitignore +3 -0
- package/dist/templates/package.json +2 -2
- package/dist/templates/src/app.ts +106 -0
- package/dist/templates/src/dev.config.ts +40 -0
- package/dist/templates/src/main.ts +8 -26
- package/dist/templates/src/registry.ts +3 -0
- package/dist/templates/src/session-store.ts +68 -0
- package/dist/templates/src/tools/read-file/index.ts +70 -0
- package/dist/templates.js +51 -4
- package/package.json +1 -1
- package/dist/inspector-preload.js +0 -31
package/dist/inspector-sink.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
export function createInspectSink(opts) {
|
|
2
2
|
const url = `http://${opts.host ?? '127.0.0.1'}:${opts.port}/ingest`;
|
|
3
|
+
const headers = { 'content-type': 'application/json' };
|
|
4
|
+
if (opts.token)
|
|
5
|
+
headers['x-agentia-token'] = opts.token;
|
|
3
6
|
return {
|
|
4
7
|
async export(trace) {
|
|
5
8
|
try {
|
|
6
9
|
// Node 18+ 全局 fetch;这里不做降级 —— dev 场景 Node 版本由 dev.ts 统一把关
|
|
7
|
-
await fetch(url, {
|
|
8
|
-
method: 'POST',
|
|
9
|
-
headers: { 'content-type': 'application/json' },
|
|
10
|
-
body: JSON.stringify(trace),
|
|
11
|
-
});
|
|
10
|
+
await fetch(url, { method: 'POST', headers, body: JSON.stringify(trace) });
|
|
12
11
|
}
|
|
13
12
|
catch {
|
|
14
13
|
/* 面板未开 / 已关:静默 */
|
package/dist/inspector.js
CHANGED
|
@@ -4,14 +4,37 @@
|
|
|
4
4
|
* 面板展示每次 run 的调用树:左边 run 列表、右边该 run 的能力执行
|
|
5
5
|
* (tool / skill / prompt / subagent 的入参、出参、耗时、token、cache、错误)。
|
|
6
6
|
*
|
|
7
|
-
* trace 由框架侧的 TraceSink 经 `POST /ingest` 投递 ——
|
|
8
|
-
*
|
|
7
|
+
* trace 由框架侧的 TraceSink 经 `POST /ingest` 投递 —— 注册那一头在 **dev runner** 里
|
|
8
|
+
* (`dev-runner.ts` 的 `registerTraceSink()`,从**用户项目**解析 `@migor/agentia` 并注册)。
|
|
9
|
+
* ⚠️ 早先那套 `NODE_OPTIONS=--import` + `inspector-preload.ts` 已随「CLI 自己拥有子进程」
|
|
10
|
+
* 一起删掉(理由见 dev.ts 文件头)—— 那个文件**不在仓库里**了,别再按它理解投递路径。
|
|
9
11
|
* 本服务不认识框架类型:入参按结构面(TraceLike)校验,CLI 因此保持零运行时依赖。
|
|
12
|
+
*
|
|
13
|
+
* 服务同时是**面板 → dev 环**的入口(`POST /run`):具体怎么驱动用户代码由 `dev.ts`
|
|
14
|
+
* 经 `DevHooks` 注入 —— 本文件不认识子进程、不认识 tsx,只做 HTTP 与校验。
|
|
15
|
+
*
|
|
16
|
+
* ## 鉴权(D0)
|
|
17
|
+
*
|
|
18
|
+
* 两道,都零依赖:
|
|
19
|
+
* 1. **`Origin` 校验**:缺省(同源导航 / 非浏览器客户端)或本机 origin 才放行。
|
|
20
|
+
* 这半条挡的是「开发者访问的任意网页往本地端口发跨源请求」—— 跨源 `fetch`
|
|
21
|
+
* 浏览器**必带** `Origin`。
|
|
22
|
+
* 2. **per-session token**:`dev.ts` 启动时生成随机 token,嵌进面板 URL。校验覆盖
|
|
23
|
+
* **所有**接口(不只是写接口)—— 读接口(`/api/runs`、trace 内容)同样不该对
|
|
24
|
+
* 本机任意网页开放,而校验成本为零。
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ token 的真正作用**不是补 `Origin` 的漏**,而是挡「本机**其它已沦陷的进程**」——
|
|
27
|
+
* 它们能直接 `curl`,没有 `Origin` 这个约束。写清这一点是为了避免后来者以为
|
|
28
|
+
* 「`Origin` 没挡住,是 token 救的」而做出错误的加固取舍。
|
|
10
29
|
*/
|
|
30
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
11
31
|
import { createServer } from 'node:http';
|
|
12
32
|
import { readFile } from 'node:fs/promises';
|
|
13
|
-
import {
|
|
33
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
34
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
35
|
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { DEFAULT_BUDGET, } from './dev-protocol.js';
|
|
37
|
+
import { rememberNote } from './panel-logic.js';
|
|
15
38
|
/** 入站校验:结构不符的 trace 直接拒(400),不让 NaN 之类的坏数据流进面板与 SSE 广播 */
|
|
16
39
|
function validateTrace(t) {
|
|
17
40
|
if (!t || typeof t !== 'object' || Array.isArray(t))
|
|
@@ -47,19 +70,125 @@ function isLocalHostHeader(host) {
|
|
|
47
70
|
.toLowerCase();
|
|
48
71
|
return name === 'localhost' || name === '127.0.0.1' || name === '::1';
|
|
49
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* `Origin` 校验(D0 的第一道)。
|
|
75
|
+
*
|
|
76
|
+
* 缺省 = 放行:同源导航与**非浏览器客户端**(`curl`、preload 的 `fetch`)都不带
|
|
77
|
+
* `Origin`,把它们拦掉等于把 dev 环弄坏。带 `Origin` 的一定是浏览器跨源/同源请求
|
|
78
|
+
* —— 那时只放行本机 origin。
|
|
79
|
+
*
|
|
80
|
+
* 注意 `Origin: null`(sandboxed iframe / file:// 页面)**不是**「缺省」:它是浏览器
|
|
81
|
+
* 明确告诉我们「这是个不该被信任的上下文」,一律拒。
|
|
82
|
+
*/
|
|
83
|
+
function isAllowedOrigin(origin) {
|
|
84
|
+
if (origin === undefined || origin === '')
|
|
85
|
+
return true;
|
|
86
|
+
let url;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(origin);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
94
|
+
return false;
|
|
95
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
96
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
97
|
+
}
|
|
98
|
+
/** token 的 cookie 名(面板首帧带 `?t=` 拿到它之后,后续请求自动带上) */
|
|
99
|
+
const TOKEN_COOKIE = 'agentia_dev_token';
|
|
100
|
+
/** token 的自定义头名(非浏览器客户端 / 脚本用;不进 URL、不留痕) */
|
|
101
|
+
const TOKEN_HEADER = 'x-agentia-token';
|
|
102
|
+
/** 定长比较:长度不同直接否(`timingSafeEqual` 对不等长会抛) */
|
|
103
|
+
function tokenEquals(a, b) {
|
|
104
|
+
const x = Buffer.from(a, 'utf8');
|
|
105
|
+
const y = Buffer.from(b, 'utf8');
|
|
106
|
+
if (x.length !== y.length)
|
|
107
|
+
return false;
|
|
108
|
+
return timingSafeEqual(x, y);
|
|
109
|
+
}
|
|
110
|
+
function cookieValue(header, name) {
|
|
111
|
+
if (!header)
|
|
112
|
+
return null;
|
|
113
|
+
for (const part of header.split(';')) {
|
|
114
|
+
const eq = part.indexOf('=');
|
|
115
|
+
if (eq < 0)
|
|
116
|
+
continue;
|
|
117
|
+
if (part.slice(0, eq).trim() === name)
|
|
118
|
+
return part.slice(eq + 1).trim() || null;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 目录判定要**解引用软链**:pnpm store / monorepo 里 `src/tools/foo` 常是软链,
|
|
124
|
+
* 它的 `isDirectory()` 为 false —— 直接用会把真目录静默漏掉(discover.ts 同一条教训)。
|
|
125
|
+
*/
|
|
126
|
+
function isDirLikeEntry(full, e) {
|
|
127
|
+
if (e.isDirectory())
|
|
128
|
+
return true;
|
|
129
|
+
if (!e.isSymbolicLink())
|
|
130
|
+
return false;
|
|
131
|
+
try {
|
|
132
|
+
return statSync(full).isDirectory();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
50
138
|
/** 环形缓冲上限:面板只保证「回看最近 N 条」,不做历史归档 */
|
|
51
139
|
const MAX_RUNS = 50;
|
|
140
|
+
/**
|
|
141
|
+
* CLI 侧记账的上限。与 `MAX_RUNS` 同量级但**独立**:note 可能永远等不到它的 trace
|
|
142
|
+
* (见 `rememberNote`),所以它有自己的一条淘汰线。
|
|
143
|
+
*/
|
|
144
|
+
const MAX_NOTES = MAX_RUNS;
|
|
52
145
|
/** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
146
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
147
|
+
const ASSETS = join(HERE, 'inspector');
|
|
148
|
+
const PAGE = join(HERE, 'inspector-page.html');
|
|
149
|
+
/**
|
|
150
|
+
* 静态资源白名单:**只有这里列出的名字**能被读出来。
|
|
151
|
+
*
|
|
152
|
+
* `panel-logic.js` 是 CLI 自己编译出来的模块(面板纯逻辑,单测直接 import 它),
|
|
153
|
+
* 落在 dist 根而不是 dist/inspector/ —— 与 `inspector-page.html` 同处一层。
|
|
154
|
+
*/
|
|
155
|
+
const STATIC = new Map([
|
|
156
|
+
['index.js', join(ASSETS, 'index.js')],
|
|
157
|
+
['view.js', join(ASSETS, 'view.js')],
|
|
158
|
+
['fromTrace.js', join(ASSETS, 'fromTrace.js')],
|
|
159
|
+
['summary.js', join(ASSETS, 'summary.js')],
|
|
160
|
+
['trace-view.css', join(ASSETS, 'trace-view.css')],
|
|
161
|
+
['panel-logic.js', join(HERE, 'panel-logic.js')],
|
|
162
|
+
]);
|
|
163
|
+
/** 没有 dev 钩子时的状态(面板据此隐藏输入条) */
|
|
164
|
+
function unavailableDevState() {
|
|
165
|
+
return {
|
|
166
|
+
available: false,
|
|
167
|
+
projectRoot: '',
|
|
168
|
+
home: '',
|
|
169
|
+
capabilities: [],
|
|
170
|
+
multiTurn: [],
|
|
171
|
+
warning: null,
|
|
172
|
+
defaultWorkdir: '',
|
|
173
|
+
budget: { ...DEFAULT_BUDGET },
|
|
174
|
+
running: false,
|
|
175
|
+
lastError: null,
|
|
176
|
+
// 没有 dev 环 ⇒ 没有会话可谈(面板只在 available 时才读它)
|
|
177
|
+
sessionId: '',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function summarize(t, note) {
|
|
57
181
|
const spans = t.spans || [];
|
|
58
182
|
const root = spans.find((s) => s.kind === 'run') || spans[0];
|
|
59
183
|
const startedAt = root ? root.startedAt : 0;
|
|
60
184
|
const endedAt = root && root.endedAt != null ? root.endedAt : startedAt;
|
|
61
185
|
const u = t.totalUsage || {};
|
|
62
186
|
const status = t.status || root?.status || 'ok';
|
|
187
|
+
const sid = root?.attributes?.['session.id'];
|
|
188
|
+
// 收尾原因由引擎写在 run 根 span 的 `stop_reason` 上(`engine/loop.ts` 的
|
|
189
|
+
// `recorder.setAttribute(rootId, 'stop_reason', …)`)—— 面板判中止**必须**看它,
|
|
190
|
+
// 因为 `aborted` 的 `ok` 也是 false(见 `RunSummary.stopReason`)
|
|
191
|
+
const sr = root?.attributes?.stop_reason;
|
|
63
192
|
return {
|
|
64
193
|
traceId: t.traceId,
|
|
65
194
|
status,
|
|
@@ -68,22 +197,45 @@ function summarize(t) {
|
|
|
68
197
|
ms: endedAt - startedAt,
|
|
69
198
|
tokens: (u.inputTokens || 0) + (u.outputTokens || 0),
|
|
70
199
|
ok: status !== 'error',
|
|
200
|
+
stopReason: typeof sr === 'string' && sr.length > 0 ? sr : null,
|
|
201
|
+
sessionId: typeof sid === 'string' && sid.length > 0 ? sid : null,
|
|
202
|
+
note,
|
|
71
203
|
};
|
|
72
204
|
}
|
|
205
|
+
/** 带 statusCode 的错误:让 handler 的 catch 回对应的码而不是一律 500 */
|
|
206
|
+
export class HttpError extends Error {
|
|
207
|
+
statusCode;
|
|
208
|
+
constructor(statusCode, message) {
|
|
209
|
+
super(message);
|
|
210
|
+
this.statusCode = statusCode;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
73
213
|
/**
|
|
74
214
|
* 起 inspector 服务。缺省监听 127.0.0.1 的随机空闲端口(port 0),
|
|
75
215
|
* 便于 dev 命令并发多开互不打架;实际端口经 resolve 的 port 返回。
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ `token` 缺省 = **不校验 token**(`Origin` 那道仍然生效)。这条留给
|
|
218
|
+
* 「程序内自用 / 测试」;`agentia dev` **一律传** —— 它是唯一会把这个端口
|
|
219
|
+
* 印给用户、并让面板能驱动真 agent 的路径。别在 dev 那条路上省这个参数。
|
|
76
220
|
*/
|
|
77
221
|
export function startInspector(opts = {}) {
|
|
78
222
|
const host = opts.host ?? '127.0.0.1';
|
|
79
223
|
const runs = new Map();
|
|
80
224
|
const order = []; // 到达顺序,用于淘汰与列表排序
|
|
225
|
+
const notes = new Map(); // CLI 侧记账(**有自己的上限**,见 rememberNote)
|
|
81
226
|
const clients = new Set(); // SSE 订阅者
|
|
227
|
+
const token = opts.token;
|
|
82
228
|
const broadcast = (s) => {
|
|
83
229
|
const chunk = `data: ${JSON.stringify(s)}\n\n`;
|
|
84
230
|
for (const c of clients)
|
|
85
231
|
c.write(chunk);
|
|
86
232
|
};
|
|
233
|
+
/** 命名事件 `dev`:run 生命周期与 runner 状态 —— 面板靠它显示「在跑 / 失败 / 重启」 */
|
|
234
|
+
const emitDev = (ev) => {
|
|
235
|
+
const chunk = `event: dev\ndata: ${JSON.stringify(ev)}\n\n`;
|
|
236
|
+
for (const c of clients)
|
|
237
|
+
c.write(chunk);
|
|
238
|
+
};
|
|
87
239
|
const json = (res, code, body) => {
|
|
88
240
|
const text = JSON.stringify(body);
|
|
89
241
|
res.writeHead(code, {
|
|
@@ -92,8 +244,12 @@ export function startInspector(opts = {}) {
|
|
|
92
244
|
});
|
|
93
245
|
res.end(text);
|
|
94
246
|
};
|
|
95
|
-
const text = (res, code, type, body) => {
|
|
96
|
-
res.writeHead(code, {
|
|
247
|
+
const text = (res, code, type, body, headers = {}) => {
|
|
248
|
+
res.writeHead(code, {
|
|
249
|
+
'content-type': type,
|
|
250
|
+
'content-length': Buffer.byteLength(body),
|
|
251
|
+
...headers,
|
|
252
|
+
});
|
|
97
253
|
res.end(body);
|
|
98
254
|
};
|
|
99
255
|
const readBody = (req, maxBytes = 8 * 1024 * 1024) => new Promise((resolve, reject) => {
|
|
@@ -102,8 +258,7 @@ export function startInspector(opts = {}) {
|
|
|
102
258
|
req.on('data', (c) => {
|
|
103
259
|
size += c.length;
|
|
104
260
|
if (size > maxBytes) {
|
|
105
|
-
const err = new
|
|
106
|
-
err.statusCode = 413; // 让 handler 的 catch 回 413 而非 500
|
|
261
|
+
const err = new HttpError(413, `请求 body 过大(上限 ${maxBytes} 字节)`);
|
|
107
262
|
reject(err);
|
|
108
263
|
req.destroy();
|
|
109
264
|
return;
|
|
@@ -113,13 +268,61 @@ export function startInspector(opts = {}) {
|
|
|
113
268
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
114
269
|
req.on('error', reject);
|
|
115
270
|
});
|
|
271
|
+
/** 校验 `RunRequest`:形状不对就 400,且说清是哪一项 —— 面板据此把原因显示给用户 */
|
|
272
|
+
const parseRunRequest = (raw) => {
|
|
273
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
274
|
+
throw new HttpError(400, 'body 必须是对象');
|
|
275
|
+
}
|
|
276
|
+
const b = raw;
|
|
277
|
+
if (typeof b.prompt !== 'string' || b.prompt.trim().length === 0) {
|
|
278
|
+
throw new HttpError(400, 'prompt 必须是非空字符串');
|
|
279
|
+
}
|
|
280
|
+
if (b.workdir !== undefined && typeof b.workdir !== 'string') {
|
|
281
|
+
throw new HttpError(400, 'workdir 必须是字符串');
|
|
282
|
+
}
|
|
283
|
+
if (b.multiTurn !== undefined && typeof b.multiTurn !== 'boolean') {
|
|
284
|
+
throw new HttpError(400, 'multiTurn 必须是布尔值');
|
|
285
|
+
}
|
|
286
|
+
let toolSources;
|
|
287
|
+
if (b.toolSources !== undefined) {
|
|
288
|
+
if (!Array.isArray(b.toolSources) || b.toolSources.some((s) => typeof s !== 'string')) {
|
|
289
|
+
throw new HttpError(400, 'toolSources 必须是字符串数组');
|
|
290
|
+
}
|
|
291
|
+
toolSources = b.toolSources;
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
prompt: b.prompt,
|
|
295
|
+
...(b.workdir === undefined ? {} : { workdir: b.workdir }),
|
|
296
|
+
...(toolSources === undefined ? {} : { toolSources }),
|
|
297
|
+
...(b.multiTurn === undefined ? {} : { multiTurn: b.multiTurn }),
|
|
298
|
+
};
|
|
299
|
+
};
|
|
116
300
|
const handler = async (req, res) => {
|
|
117
|
-
const
|
|
301
|
+
const url = new URL(req.url || '/', `http://${host}`);
|
|
302
|
+
const path = url.pathname;
|
|
118
303
|
try {
|
|
119
304
|
if (!isLocalHostHeader(req.headers.host)) {
|
|
120
305
|
json(res, 403, { error: '仅允许本机访问(Host 须为 localhost / 127.0.0.1 / [::1])' });
|
|
121
306
|
return;
|
|
122
307
|
}
|
|
308
|
+
if (!isAllowedOrigin(req.headers.origin)) {
|
|
309
|
+
json(res, 403, {
|
|
310
|
+
error: `Origin「${req.headers.origin}」不被允许(只接受本机 origin 或缺省)`,
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// token:URL query / 自定义头 / cookie 三选一(覆盖「面板首帧」与「脚本直连」两种用法)
|
|
315
|
+
const queryToken = url.searchParams.get('t');
|
|
316
|
+
const presented = queryToken ??
|
|
317
|
+
(typeof req.headers[TOKEN_HEADER] === 'string' ? req.headers[TOKEN_HEADER] : null) ??
|
|
318
|
+
cookieValue(req.headers.cookie, TOKEN_COOKIE);
|
|
319
|
+
if (token !== undefined && (presented === null || !tokenEquals(presented, token))) {
|
|
320
|
+
json(res, 403, {
|
|
321
|
+
error: '缺少或错误的 dev token。请用 `agentia dev` 打印的带 ?t= 的完整 URL 打开面板' +
|
|
322
|
+
'(token 会种进 cookie,之后刷新不带也行)',
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
123
326
|
if (req.method === 'POST' && path === '/ingest') {
|
|
124
327
|
const body = await readBody(req); // 超 413 走外层 catch
|
|
125
328
|
let trace;
|
|
@@ -138,11 +341,14 @@ export function startInspector(opts = {}) {
|
|
|
138
341
|
const valid = trace;
|
|
139
342
|
if (!runs.has(valid.traceId)) {
|
|
140
343
|
order.push(valid.traceId);
|
|
141
|
-
if (order.length > MAX_RUNS)
|
|
142
|
-
|
|
344
|
+
if (order.length > MAX_RUNS) {
|
|
345
|
+
const evicted = order.shift();
|
|
346
|
+
runs.delete(evicted);
|
|
347
|
+
notes.delete(evicted);
|
|
348
|
+
}
|
|
143
349
|
}
|
|
144
350
|
runs.set(valid.traceId, valid);
|
|
145
|
-
broadcast(summarize(valid));
|
|
351
|
+
broadcast(summarize(valid, notes.get(valid.traceId) ?? null));
|
|
146
352
|
json(res, 200, { ok: true });
|
|
147
353
|
return;
|
|
148
354
|
}
|
|
@@ -150,7 +356,7 @@ export function startInspector(opts = {}) {
|
|
|
150
356
|
json(res, 200, order
|
|
151
357
|
.slice()
|
|
152
358
|
.reverse()
|
|
153
|
-
.map((id) => summarize(runs.get(id))));
|
|
359
|
+
.map((id) => summarize(runs.get(id), notes.get(id) ?? null)));
|
|
154
360
|
return;
|
|
155
361
|
}
|
|
156
362
|
if (req.method === 'GET' && path.startsWith('/api/runs/')) {
|
|
@@ -163,6 +369,98 @@ export function startInspector(opts = {}) {
|
|
|
163
369
|
json(res, 200, t);
|
|
164
370
|
return;
|
|
165
371
|
}
|
|
372
|
+
if (req.method === 'GET' && path === '/api/dev') {
|
|
373
|
+
json(res, 200, opts.dev ? opts.dev.state() : unavailableDevState());
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 目录浏览器:`GET /api/fs?path=<绝对路径>` → 该目录下的**子目录名**(不含文件)。
|
|
378
|
+
*
|
|
379
|
+
* 为什么需要它:浏览器**拿不到**用户选的文件夹的绝对路径 —— `<input webkitdirectory>`
|
|
380
|
+
* 只给相对路径,File System Access API 只给 handle.name。而 dev 环要的是绝对路径
|
|
381
|
+
* (工具按它解析)。⇒ 要么让用户手打路径,要么服务端列目录。后者才是「工作目录是
|
|
382
|
+
* 主控件」(D3/D7)该有的手感。
|
|
383
|
+
*
|
|
384
|
+
* 只列**目录**、不读文件内容;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
|
|
385
|
+
* 越界不是威胁(本机 dev 工具,token 已经挡住了其它进程),但**不存在 / 不是目录**
|
|
386
|
+
* 要响亮报错 —— 静默回退到项目根会让 agent 对着错的目录乱写(D5)。
|
|
387
|
+
*/
|
|
388
|
+
if (req.method === 'GET' && path === '/api/fs') {
|
|
389
|
+
if (!opts.dev) {
|
|
390
|
+
json(res, 403, { error: '这个 inspector 不是 agentia dev 起的,不提供目录浏览' });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const target = url.searchParams.get('path');
|
|
394
|
+
if (!target) {
|
|
395
|
+
json(res, 400, { error: '缺少 path 参数' });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const abs = resolve(target);
|
|
399
|
+
let entries;
|
|
400
|
+
try {
|
|
401
|
+
if (!statSync(abs).isDirectory()) {
|
|
402
|
+
json(res, 400, { error: `不是文件夹:${abs}` });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
json(res, 400, { error: `读不了这个目录:${e.message}` });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const dirs = entries
|
|
412
|
+
.filter((e) => !e.name.startsWith('.') && isDirLikeEntry(join(abs, e.name), e))
|
|
413
|
+
.map((e) => e.name)
|
|
414
|
+
.sort();
|
|
415
|
+
json(res, 200, { path: abs, parent: dirname(abs), dirs });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (req.method === 'POST' && path === '/run') {
|
|
419
|
+
if (!opts.dev) {
|
|
420
|
+
json(res, 503, {
|
|
421
|
+
error: '这个 inspector 不是 agentia dev 起的(没有 runner),不能驱动 run',
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const body = await readBody(req);
|
|
426
|
+
let raw;
|
|
427
|
+
try {
|
|
428
|
+
raw = JSON.parse(body);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
json(res, 400, { error: 'body 不是合法 JSON' });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const ack = await opts.dev.run(parseRunRequest(raw));
|
|
435
|
+
json(res, 202, ack);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (req.method === 'POST' && path === '/run/abort') {
|
|
439
|
+
if (!opts.dev) {
|
|
440
|
+
json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// 202 而不是 200:中止是**请求已受理**,run 真正收尾要走 SSE 的 `run-done`
|
|
444
|
+
// (优雅路径要在回合边界才生效,不是同步的)。
|
|
445
|
+
json(res, 202, await opts.dev.abort());
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (req.method === 'POST' && path === '/session/clear') {
|
|
449
|
+
if (!opts.dev) {
|
|
450
|
+
json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
json(res, 200, await opts.dev.clearSession());
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (req.method === 'GET' && path === '/api/session') {
|
|
457
|
+
if (!opts.dev) {
|
|
458
|
+
json(res, 200, { session: null });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
json(res, 200, { session: await opts.dev.session() });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
166
464
|
if (req.method === 'GET' && path === '/stream') {
|
|
167
465
|
res.writeHead(200, {
|
|
168
466
|
'content-type': 'text/event-stream',
|
|
@@ -175,7 +473,12 @@ export function startInspector(opts = {}) {
|
|
|
175
473
|
return;
|
|
176
474
|
}
|
|
177
475
|
if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
178
|
-
|
|
476
|
+
// 首帧带了合法 token ⇒ 种进 cookie(SameSite=Strict + HttpOnly):
|
|
477
|
+
// 之后面板自己的 fetch / EventSource 自动带上,token 不进 URL 历史、不进截图。
|
|
478
|
+
const headers = token !== undefined && queryToken !== null
|
|
479
|
+
? { 'set-cookie': `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict` }
|
|
480
|
+
: {};
|
|
481
|
+
text(res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'), headers);
|
|
179
482
|
return;
|
|
180
483
|
}
|
|
181
484
|
if (req.method === 'GET' && STATIC.has(path.slice(1))) {
|
|
@@ -183,13 +486,13 @@ export function startInspector(opts = {}) {
|
|
|
183
486
|
const type = name.endsWith('.css')
|
|
184
487
|
? 'text/css; charset=utf-8'
|
|
185
488
|
: 'text/javascript; charset=utf-8';
|
|
186
|
-
text(res, 200, type, await readFile(
|
|
489
|
+
text(res, 200, type, await readFile(STATIC.get(name), 'utf8'));
|
|
187
490
|
return;
|
|
188
491
|
}
|
|
189
492
|
json(res, 404, { error: 'not found' });
|
|
190
493
|
}
|
|
191
494
|
catch (e) {
|
|
192
|
-
const status = e.statusCode ?? 500;
|
|
495
|
+
const status = e instanceof HttpError ? e.statusCode : (e.statusCode ?? 500);
|
|
193
496
|
json(res, status, { error: e.message });
|
|
194
497
|
}
|
|
195
498
|
};
|
|
@@ -203,6 +506,10 @@ export function startInspector(opts = {}) {
|
|
|
203
506
|
const port = addr && typeof addr === 'object' ? addr.port : (opts.port ?? 0);
|
|
204
507
|
resolve({
|
|
205
508
|
port,
|
|
509
|
+
emitDev,
|
|
510
|
+
noteRun: (traceId, note) => {
|
|
511
|
+
rememberNote(notes, traceId, note, MAX_NOTES);
|
|
512
|
+
},
|
|
206
513
|
close: () => new Promise((done) => {
|
|
207
514
|
for (const c of clients)
|
|
208
515
|
c.end();
|