@angelky7/dsh-browser-control 1.7.4 → 1.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/index.js +285 -8
- package/lib/types/index.d.ts +8 -1
- package/lib/types/index.js +112 -7
- package/lib/types/journal.d.ts +45 -0
- package/lib/types/journal.js +128 -0
- package/llms.txt +1 -1
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -269,10 +269,11 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
269
269
|
| `browser_metrics` | Read performance counters (DOM nodes / JS heap / layout & script durations, Performance.getMetrics, read-only; puts numbers behind "this page is heavy", v1.7.1+) |
|
|
270
270
|
| `browser_drag` | Drag from/to a selector or viewport point: `auto` synthesizes html5 dragstart→dragover→drop for `draggable="true"` sources (no drop unless accepted), otherwise sends real mouse input (press, interpolated moves, release — sliders/canvas/sortable widgets). **Plugin-side only** over the cdp channel embedded in extension v1.7.2, v1.7.3+ |
|
|
271
271
|
| `browser_audit` | Read this plugin session's in-memory audit trail — every command sent or refused (redacted params, `code`/`effectState`, duration), capped at 200 entries and wiped on restart; typed text and cookie values are redacted, eval expressions truncated. Plugin-local read, works in read-only mode (v1.7.4+) |
|
|
272
|
+
| `browser_journal` | Read the **persistent journal across dsh restarts** (`<stateDir>/journal.jsonl`, last 1000 entries, grouped by run) — ask it after a restart "what was I doing in which tab"; same redaction; plugin-local disk read that works with the extension offline (v1.7.5+) |
|
|
272
273
|
| `browser_cleanup` | Clean up temp files |
|
|
273
274
|
| `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
|
|
274
275
|
|
|
275
|
-
|
|
276
|
+
28 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
|
|
276
277
|
|
|
277
278
|
## Architecture
|
|
278
279
|
|
package/README.md
CHANGED
|
@@ -262,11 +262,12 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
262
262
|
| `browser_metrics` | 读性能计数器(DOM 节点数 / JS 堆 / 布局与脚本耗时,Performance.getMetrics,只读;让「这页面很重」有数字说话,v1.7.1+) |
|
|
263
263
|
| `browser_drag` | 拖拽:from/to 用选择器或视口坐标;`auto` 对 `draggable="true"` 的源走 html5 事件合成(dragstart→dragover→drop,未接受就不 drop),其余走真实鼠标输入(按下→插值移动→抬起,滑杆/画布/排序组件用这个)。**纯插件实现**(经 v1.7.2 预埋的 cdp 通道),v1.7.3+ |
|
|
264
264
|
| `browser_audit` | 读本次插件会话的内存审计账本:发过/被拒的每条命令(参数脱敏、`code`/`effectState`、耗时),最多 200 条、dsh 重启即清;输入的文字与 cookie 值打码、eval 表达式截断。纯本地读,只读档下照常可用(v1.7.4+) |
|
|
265
|
+
| `browser_journal` | 读**跨 dsh 重启**的持久命令账本(`<stateDir>/journal.jsonl`,最多 1000 条,按运行分组)——重启后先问它「上个会话在哪个标签页干了什么」;同样的脱敏规则;纯本地磁盘读,**扩展离线也能用**(v1.7.5+) |
|
|
265
266
|
| `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
|
|
266
267
|
| `browser_cleanup` | 清理临时文件 |
|
|
267
268
|
| `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
|
|
268
269
|
|
|
269
|
-
共
|
|
270
|
+
共 28 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
|
|
270
271
|
|
|
271
272
|
## 架构
|
|
272
273
|
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import z from "@deepseek-ai/schemastery";
|
|
4
5
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
@@ -1013,6 +1014,127 @@ var AuditLog = class {
|
|
|
1013
1014
|
}
|
|
1014
1015
|
};
|
|
1015
1016
|
//#endregion
|
|
1017
|
+
//#region lib/types/journal.js
|
|
1018
|
+
/**
|
|
1019
|
+
* 会话恢复(v1.7.5):把审计账本持久化到磁盘,dsh 重启以后还查得到上一个会话做过什么。
|
|
1020
|
+
*
|
|
1021
|
+
* 为什么是文件而不是数据库:一条命令一行 JSONL,出问题时能直接打开看、能 grep、
|
|
1022
|
+
* 写坏了最多丢一行(读到坏行跳过,不炸整本账)。上限 1000 行,超了在**启动时**
|
|
1023
|
+
* 裁掉最旧的——运行中不裁剪,append 永远是纯追加。
|
|
1024
|
+
*
|
|
1025
|
+
* 隐私与审计同一套:写盘的就是 AuditLog 里那份脱敏后的摘要(输入值/cookie 值已
|
|
1026
|
+
* 打码),journal 层不再碰原始参数。文件在 <stateDir>/journal.jsonl,stateDir
|
|
1027
|
+
* 默认 ~/.dsh-browser-control,可在 profile 的 browser-bridge 配置里改。
|
|
1028
|
+
*/
|
|
1029
|
+
/** 磁盘上最多保留的条数(启动时裁剪)。 */
|
|
1030
|
+
const JOURNAL_MAX_LINES = 1e3;
|
|
1031
|
+
/** 防御性上限:正常文件远小于它;超过就只读尾巴,别为了一本账吃掉内存。 */
|
|
1032
|
+
const JOURNAL_MAX_BYTES = 8388608;
|
|
1033
|
+
function isEntry(value) {
|
|
1034
|
+
if (value === null || typeof value !== "object") return false;
|
|
1035
|
+
const v = value;
|
|
1036
|
+
return typeof v.run === "string" && typeof v.command === "string" && typeof v.seq === "number" && typeof v.t === "number" && typeof v.params === "string" && typeof v.ok === "boolean" && typeof v.durationMs === "number";
|
|
1037
|
+
}
|
|
1038
|
+
/** 拆行并解析;坏行(截断、手改坏)跳过——一本账不能因为一行烂掉就全废。 */
|
|
1039
|
+
function parseLines(text) {
|
|
1040
|
+
const out = [];
|
|
1041
|
+
for (const line of text.split("\n")) {
|
|
1042
|
+
if (line.trim().length === 0) continue;
|
|
1043
|
+
try {
|
|
1044
|
+
const parsed = JSON.parse(line);
|
|
1045
|
+
if (isEntry(parsed)) out.push(parsed);
|
|
1046
|
+
} catch {}
|
|
1047
|
+
}
|
|
1048
|
+
return out;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* 打开(并按需裁剪)一个 journal 目录。
|
|
1052
|
+
* @param stateDir - 目录(不存在会创建);journal 文件是 `<stateDir>/journal.jsonl`。
|
|
1053
|
+
* @param log - 一行式日志;打开/裁剪的结论写进去。
|
|
1054
|
+
* @returns 带 run 编号的追加器;`close()` 等所有在途写入落盘。
|
|
1055
|
+
*/
|
|
1056
|
+
var Journal = class Journal {
|
|
1057
|
+
/** 这次运行的编号:启动时刻(毫秒精度;秒级精度下两次启动同秒会撞号)。 */
|
|
1058
|
+
runId;
|
|
1059
|
+
file;
|
|
1060
|
+
log;
|
|
1061
|
+
chain = Promise.resolve();
|
|
1062
|
+
warned = false;
|
|
1063
|
+
constructor(file, runId, log) {
|
|
1064
|
+
this.file = file;
|
|
1065
|
+
this.runId = runId;
|
|
1066
|
+
this.log = log;
|
|
1067
|
+
}
|
|
1068
|
+
static async open(stateDir, log) {
|
|
1069
|
+
await mkdir(stateDir, { recursive: true });
|
|
1070
|
+
const file = path.join(stateDir, "journal.jsonl");
|
|
1071
|
+
let existing = [];
|
|
1072
|
+
try {
|
|
1073
|
+
const text = await readFile(file, "utf8");
|
|
1074
|
+
existing = parseLines(text.length > JOURNAL_MAX_BYTES ? text.slice(-8388608) : text);
|
|
1075
|
+
} catch {}
|
|
1076
|
+
if (existing.length > 1e3) {
|
|
1077
|
+
const kept = existing.slice(existing.length - JOURNAL_MAX_LINES);
|
|
1078
|
+
const tmp = `${file}.tmp`;
|
|
1079
|
+
await writeFile(tmp, kept.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8");
|
|
1080
|
+
await rename(tmp, file);
|
|
1081
|
+
log(`journal: pruned ${existing.length - kept.length} old entries (${JOURNAL_MAX_LINES} kept)`);
|
|
1082
|
+
} else if (existing.length > 0) {
|
|
1083
|
+
const last = existing[existing.length - 1];
|
|
1084
|
+
log(`journal: restored context — ${existing.length} entries on disk, last from run ${last.run} (#${last.seq} ${last.command})`);
|
|
1085
|
+
}
|
|
1086
|
+
return new Journal(file, (/* @__PURE__ */ new Date()).toISOString().slice(0, 23).replace("T", " "), log);
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* 追加一条(异步、排队写;失败只告警一次,绝不阻塞命令路径)。
|
|
1090
|
+
* @param entry - AuditLog 刚记下的条目(参数已脱敏)。
|
|
1091
|
+
*/
|
|
1092
|
+
append(entry) {
|
|
1093
|
+
const line = `${JSON.stringify({
|
|
1094
|
+
run: this.runId,
|
|
1095
|
+
...entry
|
|
1096
|
+
})}\n`;
|
|
1097
|
+
this.chain = this.chain.then(() => appendFile(this.file, line, "utf8")).catch((error) => {
|
|
1098
|
+
if (!this.warned) {
|
|
1099
|
+
this.warned = true;
|
|
1100
|
+
this.log(`journal: write failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
/** 等在途写入落盘(teardown / 测试用)。 */
|
|
1105
|
+
async close() {
|
|
1106
|
+
await this.chain.catch(() => {});
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
/**
|
|
1110
|
+
* 读 journal 的尾部(不要求 Journal 实例——读在线程外也能工作,扩展离线也行)。
|
|
1111
|
+
* @param stateDir - journal 所在目录。
|
|
1112
|
+
* @param filter - tabId / 命令子串 / 条数(默认 50,最大 500;取最新)。
|
|
1113
|
+
*/
|
|
1114
|
+
async function readJournal(stateDir, filter = {}) {
|
|
1115
|
+
let text;
|
|
1116
|
+
try {
|
|
1117
|
+
text = await readFile(path.join(stateDir, "journal.jsonl"), "utf8");
|
|
1118
|
+
} catch {
|
|
1119
|
+
return {
|
|
1120
|
+
total: 0,
|
|
1121
|
+
entries: []
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
if (text.length > JOURNAL_MAX_BYTES) text = text.slice(-8388608);
|
|
1125
|
+
let matched = parseLines(text);
|
|
1126
|
+
if (filter.tabId !== void 0) matched = matched.filter((e) => e.tabId === filter.tabId);
|
|
1127
|
+
if (filter.command !== void 0 && filter.command.length > 0) {
|
|
1128
|
+
const needle = filter.command.toLowerCase();
|
|
1129
|
+
matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
|
|
1130
|
+
}
|
|
1131
|
+
const limit = Math.min(500, Math.max(1, Math.trunc(filter.limit ?? 50)));
|
|
1132
|
+
return {
|
|
1133
|
+
total: matched.length,
|
|
1134
|
+
entries: matched.slice(Math.max(0, matched.length - limit))
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
//#endregion
|
|
1016
1138
|
//#region lib/types/index.js
|
|
1017
1139
|
/**
|
|
1018
1140
|
* Browser-bridge plugin: one local WebSocket endpoint the DSH Browser Control
|
|
@@ -1065,6 +1187,7 @@ const Config = z.object({
|
|
|
1065
1187
|
port: z.number().step(1).min(1024).max(65535).default(9777),
|
|
1066
1188
|
token: z.string().default("dsh-local"),
|
|
1067
1189
|
shotsDir: z.string().default("dsh-browser-shots"),
|
|
1190
|
+
stateDir: z.string().default(path.join(homedir(), ".dsh-browser-control")),
|
|
1068
1191
|
readOnly: z.boolean().default(false),
|
|
1069
1192
|
allowScript: z.boolean().default(true)
|
|
1070
1193
|
});
|
|
@@ -1140,6 +1263,8 @@ var BridgeController = class {
|
|
|
1140
1263
|
lastError;
|
|
1141
1264
|
chain = Promise.resolve();
|
|
1142
1265
|
current;
|
|
1266
|
+
journal;
|
|
1267
|
+
journalKey = "";
|
|
1143
1268
|
/** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
|
|
1144
1269
|
audit = new AuditLog();
|
|
1145
1270
|
constructor(log) {
|
|
@@ -1149,6 +1274,17 @@ var BridgeController = class {
|
|
|
1149
1274
|
get shotsDir() {
|
|
1150
1275
|
return this.current === void 0 ? void 0 : path.resolve(this.current.shotsDir);
|
|
1151
1276
|
}
|
|
1277
|
+
/** 审计账本落盘目录;配置到达之前是 undefined。 */
|
|
1278
|
+
get stateDir() {
|
|
1279
|
+
return this.current === void 0 ? void 0 : path.resolve(this.current.stateDir);
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Record one audit entry in memory and, when the journal is open, on disk.
|
|
1283
|
+
* Journal appends are queued and never block the command path.
|
|
1284
|
+
*/
|
|
1285
|
+
recordAudit(input) {
|
|
1286
|
+
this.journal?.append(this.audit.record(input));
|
|
1287
|
+
}
|
|
1152
1288
|
/**
|
|
1153
1289
|
* Current policy flags. Tools that compose several commands (browser_drag
|
|
1154
1290
|
* runs `cdp`/`eval`) read this to refuse with a tool-level message; the
|
|
@@ -1186,7 +1322,7 @@ var BridgeController = class {
|
|
|
1186
1322
|
*/
|
|
1187
1323
|
requireExtensionCommand(command, since) {
|
|
1188
1324
|
if (this.hasExtensionCommand(command)) return;
|
|
1189
|
-
this.
|
|
1325
|
+
this.recordAudit({
|
|
1190
1326
|
command,
|
|
1191
1327
|
params: "",
|
|
1192
1328
|
ok: false,
|
|
@@ -1216,6 +1352,18 @@ var BridgeController = class {
|
|
|
1216
1352
|
}
|
|
1217
1353
|
async reconcileNow(config) {
|
|
1218
1354
|
const shotsDir = path.resolve(config.shotsDir);
|
|
1355
|
+
const stateDir = path.resolve(config.stateDir);
|
|
1356
|
+
if (stateDir !== this.journalKey) {
|
|
1357
|
+
const previousJournal = this.journal;
|
|
1358
|
+
this.journal = void 0;
|
|
1359
|
+
this.journalKey = stateDir;
|
|
1360
|
+
await previousJournal?.close();
|
|
1361
|
+
try {
|
|
1362
|
+
this.journal = await Journal.open(stateDir, this.log);
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
this.log(`journal: disabled — ${errorMessage(error)}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1219
1367
|
const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : "";
|
|
1220
1368
|
if (key === this.serverKey) return;
|
|
1221
1369
|
const previous = this.server;
|
|
@@ -1267,7 +1415,7 @@ var BridgeController = class {
|
|
|
1267
1415
|
signal,
|
|
1268
1416
|
timeoutMs
|
|
1269
1417
|
});
|
|
1270
|
-
this.
|
|
1418
|
+
this.recordAudit({
|
|
1271
1419
|
command,
|
|
1272
1420
|
tabId,
|
|
1273
1421
|
params: summary,
|
|
@@ -1278,7 +1426,7 @@ var BridgeController = class {
|
|
|
1278
1426
|
} catch (error) {
|
|
1279
1427
|
const code = error.code;
|
|
1280
1428
|
const effectState = error.effectState;
|
|
1281
|
-
this.
|
|
1429
|
+
this.recordAudit({
|
|
1282
1430
|
command,
|
|
1283
1431
|
tabId,
|
|
1284
1432
|
params: summary,
|
|
@@ -1321,7 +1469,7 @@ var BridgeController = class {
|
|
|
1321
1469
|
if (dir === void 0) throw classify("cleanup", /* @__PURE__ */ new Error("browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录"));
|
|
1322
1470
|
try {
|
|
1323
1471
|
const result = await cleanupArtifacts({ shotsDir: dir });
|
|
1324
|
-
this.
|
|
1472
|
+
this.recordAudit({
|
|
1325
1473
|
command: "cleanup",
|
|
1326
1474
|
params: "{}",
|
|
1327
1475
|
ok: true,
|
|
@@ -1334,7 +1482,7 @@ var BridgeController = class {
|
|
|
1334
1482
|
} catch (error) {
|
|
1335
1483
|
const code = error.code;
|
|
1336
1484
|
const effectState = error.effectState;
|
|
1337
|
-
this.
|
|
1485
|
+
this.recordAudit({
|
|
1338
1486
|
command: "cleanup",
|
|
1339
1487
|
params: "{}",
|
|
1340
1488
|
ok: false,
|
|
@@ -1347,11 +1495,15 @@ var BridgeController = class {
|
|
|
1347
1495
|
}
|
|
1348
1496
|
}
|
|
1349
1497
|
/** Stop the listener; safe to call repeatedly and during teardown. */
|
|
1350
|
-
stop() {
|
|
1498
|
+
async stop() {
|
|
1351
1499
|
const previous = this.server;
|
|
1352
1500
|
this.server = void 0;
|
|
1353
1501
|
this.serverKey = "";
|
|
1354
|
-
|
|
1502
|
+
await (previous?.stop() ?? Promise.resolve());
|
|
1503
|
+
const journal = this.journal;
|
|
1504
|
+
this.journal = void 0;
|
|
1505
|
+
this.journalKey = "";
|
|
1506
|
+
await journal?.close();
|
|
1355
1507
|
}
|
|
1356
1508
|
};
|
|
1357
1509
|
function errorMessage(error) {
|
|
@@ -3295,6 +3447,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
3295
3447
|
}));
|
|
3296
3448
|
registerDragTool(ctx, controller);
|
|
3297
3449
|
registerAuditTool(ctx, controller);
|
|
3450
|
+
registerJournalTool(ctx, controller);
|
|
3298
3451
|
}
|
|
3299
3452
|
/** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
|
|
3300
3453
|
async function cdpCall(ctrl, tabId, method, params, signal) {
|
|
@@ -3736,6 +3889,130 @@ function registerAuditTool(ctx, controller) {
|
|
|
3736
3889
|
}
|
|
3737
3890
|
}));
|
|
3738
3891
|
}
|
|
3892
|
+
function registerJournalTool(ctx, controller) {
|
|
3893
|
+
ctx.tools.register(defineTool({
|
|
3894
|
+
name: "browser_journal",
|
|
3895
|
+
description: "Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover \"what was I doing\" after a restart.",
|
|
3896
|
+
parameters: {
|
|
3897
|
+
command: {
|
|
3898
|
+
type: "string",
|
|
3899
|
+
description: "Only entries whose command contains this substring."
|
|
3900
|
+
},
|
|
3901
|
+
tabId: {
|
|
3902
|
+
type: "number",
|
|
3903
|
+
description: "Only entries that carried this tabId."
|
|
3904
|
+
},
|
|
3905
|
+
limit: {
|
|
3906
|
+
type: "number",
|
|
3907
|
+
description: "Max entries to return (newest last), 1–500; default 50."
|
|
3908
|
+
}
|
|
3909
|
+
},
|
|
3910
|
+
output: {
|
|
3911
|
+
schema: {
|
|
3912
|
+
type: "object",
|
|
3913
|
+
additionalProperties: false,
|
|
3914
|
+
properties: {
|
|
3915
|
+
total: {
|
|
3916
|
+
type: "number",
|
|
3917
|
+
required: true
|
|
3918
|
+
},
|
|
3919
|
+
shown: {
|
|
3920
|
+
type: "number",
|
|
3921
|
+
required: true
|
|
3922
|
+
},
|
|
3923
|
+
runs: {
|
|
3924
|
+
type: "number",
|
|
3925
|
+
required: true
|
|
3926
|
+
},
|
|
3927
|
+
entries: {
|
|
3928
|
+
type: "array",
|
|
3929
|
+
required: true,
|
|
3930
|
+
items: {
|
|
3931
|
+
type: "object",
|
|
3932
|
+
additionalProperties: false,
|
|
3933
|
+
properties: {
|
|
3934
|
+
run: {
|
|
3935
|
+
type: "string",
|
|
3936
|
+
required: true
|
|
3937
|
+
},
|
|
3938
|
+
seq: {
|
|
3939
|
+
type: "number",
|
|
3940
|
+
required: true
|
|
3941
|
+
},
|
|
3942
|
+
t: {
|
|
3943
|
+
type: "number",
|
|
3944
|
+
required: true
|
|
3945
|
+
},
|
|
3946
|
+
command: {
|
|
3947
|
+
type: "string",
|
|
3948
|
+
required: true
|
|
3949
|
+
},
|
|
3950
|
+
tabId: { type: "number" },
|
|
3951
|
+
params: {
|
|
3952
|
+
type: "string",
|
|
3953
|
+
required: true
|
|
3954
|
+
},
|
|
3955
|
+
ok: {
|
|
3956
|
+
type: "boolean",
|
|
3957
|
+
required: true
|
|
3958
|
+
},
|
|
3959
|
+
code: { type: "string" },
|
|
3960
|
+
effectState: { type: "string" },
|
|
3961
|
+
error: { type: "string" },
|
|
3962
|
+
durationMs: {
|
|
3963
|
+
type: "number",
|
|
3964
|
+
required: true
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
},
|
|
3971
|
+
render: (_args, value) => {
|
|
3972
|
+
const v = value;
|
|
3973
|
+
if (v.entries.length === 0) return [{
|
|
3974
|
+
type: "text",
|
|
3975
|
+
text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.`
|
|
3976
|
+
}];
|
|
3977
|
+
const lines = [];
|
|
3978
|
+
let run = "";
|
|
3979
|
+
for (const e of v.entries) {
|
|
3980
|
+
if (e.run !== run) {
|
|
3981
|
+
run = e.run;
|
|
3982
|
+
lines.push(`── run ${run}`);
|
|
3983
|
+
}
|
|
3984
|
+
const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
|
|
3985
|
+
const outcome = e.ok ? "ok" : `FAIL ${e.code ?? "error"}`;
|
|
3986
|
+
lines.push(`#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}`);
|
|
3987
|
+
}
|
|
3988
|
+
return [{
|
|
3989
|
+
type: "text",
|
|
3990
|
+
text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}`)
|
|
3991
|
+
}];
|
|
3992
|
+
}
|
|
3993
|
+
},
|
|
3994
|
+
presentCall: () => ({
|
|
3995
|
+
card: "generic",
|
|
3996
|
+
title: "Browser journal",
|
|
3997
|
+
kind: "other"
|
|
3998
|
+
}),
|
|
3999
|
+
async execute(args) {
|
|
4000
|
+
const dir = controller.stateDir;
|
|
4001
|
+
if (dir === void 0) throw new Error("browser-bridge is not configured yet — the journal has no stateDir to read");
|
|
4002
|
+
const { total, entries } = await readJournal(dir, {
|
|
4003
|
+
command: args.command,
|
|
4004
|
+
tabId: args.tabId,
|
|
4005
|
+
limit: args.limit
|
|
4006
|
+
});
|
|
4007
|
+
return {
|
|
4008
|
+
total,
|
|
4009
|
+
shown: entries.length,
|
|
4010
|
+
runs: new Set(entries.map((e) => e.run)).size,
|
|
4011
|
+
entries
|
|
4012
|
+
};
|
|
4013
|
+
}
|
|
4014
|
+
}));
|
|
4015
|
+
}
|
|
3739
4016
|
/** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
|
|
3740
4017
|
function apply(ctx, config) {
|
|
3741
4018
|
const resolved = config;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -40,7 +40,14 @@ export interface Config {
|
|
|
40
40
|
/**
|
|
41
41
|
* Directory screenshots are written to and `cleanup` clears. Relative paths
|
|
42
42
|
* resolve against the process working directory at resolve time.
|
|
43
|
-
*/
|
|
43
|
+
*/
|
|
44
|
+
shotsDir?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Directory the command journal is written to (v1.7.5 session recovery).
|
|
47
|
+
* Defaults to `~/.dsh-browser-control` — stable across restarts regardless
|
|
48
|
+
* of the process working directory.
|
|
49
|
+
*/
|
|
50
|
+
stateDir?: string;
|
|
44
51
|
/**
|
|
45
52
|
* Read-only mode: refuse every command that would change the page or the
|
|
46
53
|
* browser (click / input / press / nav / scroll / tabs.* / emulate / dialog /
|
package/lib/types/index.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* @module @deepseek-ai/dsh-browser-bridge
|
|
19
19
|
*/
|
|
20
20
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
21
22
|
import path from 'node:path';
|
|
22
23
|
import z from '@deepseek-ai/schemastery';
|
|
23
24
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
@@ -25,6 +26,7 @@ import { BridgeServer, cleanupArtifacts } from "./server.js";
|
|
|
25
26
|
import { BridgeError, classify } from "./errors.js";
|
|
26
27
|
import { policyRefusal } from "./policy.js";
|
|
27
28
|
import { AUDIT_MAX_ENTRIES, AuditLog, summarizeAuditParams } from "./audit.js";
|
|
29
|
+
import { Journal, readJournal } from "./journal.js";
|
|
28
30
|
/** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
|
|
29
31
|
const NAV_MAX_MS = 120_000;
|
|
30
32
|
/**
|
|
@@ -55,6 +57,8 @@ export const Config = z.object({
|
|
|
55
57
|
port: z.number().step(1).min(1024).max(65_535).default(9777),
|
|
56
58
|
token: z.string().default('dsh-local'),
|
|
57
59
|
shotsDir: z.string().default('dsh-browser-shots'),
|
|
60
|
+
// 审计账本落盘目录(v1.7.5 会话恢复)。默认放用户主目录,跟 dsh 的启动目录无关。
|
|
61
|
+
stateDir: z.string().default(path.join(homedir(), '.dsh-browser-control')),
|
|
58
62
|
readOnly: z.boolean().default(false),
|
|
59
63
|
allowScript: z.boolean().default(true),
|
|
60
64
|
});
|
|
@@ -117,6 +121,8 @@ class BridgeController {
|
|
|
117
121
|
lastError;
|
|
118
122
|
chain = Promise.resolve();
|
|
119
123
|
current;
|
|
124
|
+
journal;
|
|
125
|
+
journalKey = '';
|
|
120
126
|
/** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
|
|
121
127
|
audit = new AuditLog();
|
|
122
128
|
constructor(log) {
|
|
@@ -126,6 +132,17 @@ class BridgeController {
|
|
|
126
132
|
get shotsDir() {
|
|
127
133
|
return this.current === undefined ? undefined : path.resolve(this.current.shotsDir);
|
|
128
134
|
}
|
|
135
|
+
/** 审计账本落盘目录;配置到达之前是 undefined。 */
|
|
136
|
+
get stateDir() {
|
|
137
|
+
return this.current === undefined ? undefined : path.resolve(this.current.stateDir);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Record one audit entry in memory and, when the journal is open, on disk.
|
|
141
|
+
* Journal appends are queued and never block the command path.
|
|
142
|
+
*/
|
|
143
|
+
recordAudit(input) {
|
|
144
|
+
this.journal?.append(this.audit.record(input));
|
|
145
|
+
}
|
|
129
146
|
/**
|
|
130
147
|
* Current policy flags. Tools that compose several commands (browser_drag
|
|
131
148
|
* runs `cdp`/`eval`) read this to refuse with a tool-level message; the
|
|
@@ -165,7 +182,7 @@ class BridgeController {
|
|
|
165
182
|
if (this.hasExtensionCommand(command))
|
|
166
183
|
return;
|
|
167
184
|
// 预检拒绝也要上账:这是「升级插件后模型发现功能不可用」的最常见现场。
|
|
168
|
-
this.
|
|
185
|
+
this.recordAudit({
|
|
169
186
|
command,
|
|
170
187
|
params: '',
|
|
171
188
|
ok: false,
|
|
@@ -197,6 +214,20 @@ class BridgeController {
|
|
|
197
214
|
}
|
|
198
215
|
async reconcileNow(config) {
|
|
199
216
|
const shotsDir = path.resolve(config.shotsDir);
|
|
217
|
+
const stateDir = path.resolve(config.stateDir);
|
|
218
|
+
if (stateDir !== this.journalKey) {
|
|
219
|
+
const previousJournal = this.journal;
|
|
220
|
+
this.journal = undefined;
|
|
221
|
+
this.journalKey = stateDir;
|
|
222
|
+
await previousJournal?.close();
|
|
223
|
+
try {
|
|
224
|
+
this.journal = await Journal.open(stateDir, this.log);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// 账本打不开不是让桥起不来的理由:降级为纯内存审计。
|
|
228
|
+
this.log(`journal: disabled — ${errorMessage(error)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
200
231
|
const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : '';
|
|
201
232
|
if (key === this.serverKey)
|
|
202
233
|
return;
|
|
@@ -244,13 +275,13 @@ class BridgeController {
|
|
|
244
275
|
throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
|
|
245
276
|
}
|
|
246
277
|
const result = await server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
|
|
247
|
-
this.
|
|
278
|
+
this.recordAudit({ command, tabId, params: summary, ok: true, durationMs: Date.now() - started });
|
|
248
279
|
return result;
|
|
249
280
|
}
|
|
250
281
|
catch (error) {
|
|
251
282
|
const code = error.code;
|
|
252
283
|
const effectState = error.effectState;
|
|
253
|
-
this.
|
|
284
|
+
this.recordAudit({
|
|
254
285
|
command,
|
|
255
286
|
tabId,
|
|
256
287
|
params: summary,
|
|
@@ -297,7 +328,7 @@ class BridgeController {
|
|
|
297
328
|
throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
|
|
298
329
|
try {
|
|
299
330
|
const result = await cleanupArtifacts({ shotsDir: dir });
|
|
300
|
-
this.
|
|
331
|
+
this.recordAudit({ command: 'cleanup', params: '{}', ok: true, durationMs: Date.now() - started });
|
|
301
332
|
return result;
|
|
302
333
|
}
|
|
303
334
|
catch (error) {
|
|
@@ -309,7 +340,7 @@ class BridgeController {
|
|
|
309
340
|
catch (error) {
|
|
310
341
|
const code = error.code;
|
|
311
342
|
const effectState = error.effectState;
|
|
312
|
-
this.
|
|
343
|
+
this.recordAudit({
|
|
313
344
|
command: 'cleanup',
|
|
314
345
|
params: '{}',
|
|
315
346
|
ok: false,
|
|
@@ -322,11 +353,15 @@ class BridgeController {
|
|
|
322
353
|
}
|
|
323
354
|
}
|
|
324
355
|
/** Stop the listener; safe to call repeatedly and during teardown. */
|
|
325
|
-
stop() {
|
|
356
|
+
async stop() {
|
|
326
357
|
const previous = this.server;
|
|
327
358
|
this.server = undefined;
|
|
328
359
|
this.serverKey = '';
|
|
329
|
-
|
|
360
|
+
await (previous?.stop() ?? Promise.resolve());
|
|
361
|
+
const journal = this.journal;
|
|
362
|
+
this.journal = undefined;
|
|
363
|
+
this.journalKey = '';
|
|
364
|
+
await journal?.close();
|
|
330
365
|
}
|
|
331
366
|
}
|
|
332
367
|
function errorMessage(error) {
|
|
@@ -1747,6 +1782,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1747
1782
|
}));
|
|
1748
1783
|
registerDragTool(ctx, controller);
|
|
1749
1784
|
registerAuditTool(ctx, controller);
|
|
1785
|
+
registerJournalTool(ctx, controller);
|
|
1750
1786
|
}
|
|
1751
1787
|
/* ------------------- browser_drag: composed plugin-side over the cdp channel ------------------- */
|
|
1752
1788
|
/** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
|
|
@@ -2078,6 +2114,75 @@ function registerAuditTool(ctx, controller) {
|
|
|
2078
2114
|
},
|
|
2079
2115
|
}));
|
|
2080
2116
|
}
|
|
2117
|
+
function registerJournalTool(ctx, controller) {
|
|
2118
|
+
ctx.tools.register(defineTool({
|
|
2119
|
+
name: 'browser_journal',
|
|
2120
|
+
description: 'Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover "what was I doing" after a restart.',
|
|
2121
|
+
parameters: {
|
|
2122
|
+
command: { type: 'string', description: 'Only entries whose command contains this substring.' },
|
|
2123
|
+
tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
|
|
2124
|
+
limit: { type: 'number', description: 'Max entries to return (newest last), 1–500; default 50.' },
|
|
2125
|
+
},
|
|
2126
|
+
output: {
|
|
2127
|
+
schema: {
|
|
2128
|
+
type: 'object',
|
|
2129
|
+
additionalProperties: false,
|
|
2130
|
+
properties: {
|
|
2131
|
+
total: { type: 'number', required: true },
|
|
2132
|
+
shown: { type: 'number', required: true },
|
|
2133
|
+
runs: { type: 'number', required: true },
|
|
2134
|
+
entries: {
|
|
2135
|
+
type: 'array',
|
|
2136
|
+
required: true,
|
|
2137
|
+
items: {
|
|
2138
|
+
type: 'object',
|
|
2139
|
+
additionalProperties: false,
|
|
2140
|
+
properties: {
|
|
2141
|
+
run: { type: 'string', required: true },
|
|
2142
|
+
seq: { type: 'number', required: true },
|
|
2143
|
+
t: { type: 'number', required: true },
|
|
2144
|
+
command: { type: 'string', required: true },
|
|
2145
|
+
tabId: { type: 'number' },
|
|
2146
|
+
params: { type: 'string', required: true },
|
|
2147
|
+
ok: { type: 'boolean', required: true },
|
|
2148
|
+
code: { type: 'string' },
|
|
2149
|
+
effectState: { type: 'string' },
|
|
2150
|
+
error: { type: 'string' },
|
|
2151
|
+
durationMs: { type: 'number', required: true },
|
|
2152
|
+
},
|
|
2153
|
+
},
|
|
2154
|
+
},
|
|
2155
|
+
},
|
|
2156
|
+
},
|
|
2157
|
+
render: (_args, value) => {
|
|
2158
|
+
const v = value;
|
|
2159
|
+
if (v.entries.length === 0) {
|
|
2160
|
+
return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.` }];
|
|
2161
|
+
}
|
|
2162
|
+
const lines = [];
|
|
2163
|
+
let run = '';
|
|
2164
|
+
for (const e of v.entries) {
|
|
2165
|
+
if (e.run !== run) {
|
|
2166
|
+
run = e.run;
|
|
2167
|
+
lines.push(`── run ${run}`);
|
|
2168
|
+
}
|
|
2169
|
+
const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
|
|
2170
|
+
const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}`;
|
|
2171
|
+
lines.push(`#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}`);
|
|
2172
|
+
}
|
|
2173
|
+
return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}`) }];
|
|
2174
|
+
},
|
|
2175
|
+
},
|
|
2176
|
+
presentCall: () => ({ card: 'generic', title: 'Browser journal', kind: 'other' }),
|
|
2177
|
+
async execute(args) {
|
|
2178
|
+
const dir = controller.stateDir;
|
|
2179
|
+
if (dir === undefined)
|
|
2180
|
+
throw new Error('browser-bridge is not configured yet — the journal has no stateDir to read');
|
|
2181
|
+
const { total, entries } = await readJournal(dir, { command: args.command, tabId: args.tabId, limit: args.limit });
|
|
2182
|
+
return { total, shown: entries.length, runs: new Set(entries.map((e) => e.run)).size, entries };
|
|
2183
|
+
},
|
|
2184
|
+
}));
|
|
2185
|
+
}
|
|
2081
2186
|
/** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
|
|
2082
2187
|
export function apply(ctx, config) {
|
|
2083
2188
|
const resolved = config;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AuditEntry } from './audit.ts';
|
|
2
|
+
/** 磁盘上最多保留的条数(启动时裁剪)。 */
|
|
3
|
+
export declare const JOURNAL_MAX_LINES = 1000;
|
|
4
|
+
/** 一行 = 一次运行的编号 + 审计条目。 */
|
|
5
|
+
export interface JournalEntry extends AuditEntry {
|
|
6
|
+
/** 这次插件运行的身份(启动时刻,ISO 秒)。跨运行恢复靠它分组。 */
|
|
7
|
+
run: string;
|
|
8
|
+
}
|
|
9
|
+
export interface JournalFilter {
|
|
10
|
+
tabId?: number;
|
|
11
|
+
command?: string;
|
|
12
|
+
limit?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 打开(并按需裁剪)一个 journal 目录。
|
|
16
|
+
* @param stateDir - 目录(不存在会创建);journal 文件是 `<stateDir>/journal.jsonl`。
|
|
17
|
+
* @param log - 一行式日志;打开/裁剪的结论写进去。
|
|
18
|
+
* @returns 带 run 编号的追加器;`close()` 等所有在途写入落盘。
|
|
19
|
+
*/
|
|
20
|
+
export declare class Journal {
|
|
21
|
+
/** 这次运行的编号:启动时刻(毫秒精度;秒级精度下两次启动同秒会撞号)。 */
|
|
22
|
+
readonly runId: string;
|
|
23
|
+
private readonly file;
|
|
24
|
+
private readonly log;
|
|
25
|
+
private chain;
|
|
26
|
+
private warned;
|
|
27
|
+
private constructor();
|
|
28
|
+
static open(stateDir: string, log: (line: string) => void): Promise<Journal>;
|
|
29
|
+
/**
|
|
30
|
+
* 追加一条(异步、排队写;失败只告警一次,绝不阻塞命令路径)。
|
|
31
|
+
* @param entry - AuditLog 刚记下的条目(参数已脱敏)。
|
|
32
|
+
*/
|
|
33
|
+
append(entry: AuditEntry): void;
|
|
34
|
+
/** 等在途写入落盘(teardown / 测试用)。 */
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 读 journal 的尾部(不要求 Journal 实例——读在线程外也能工作,扩展离线也行)。
|
|
39
|
+
* @param stateDir - journal 所在目录。
|
|
40
|
+
* @param filter - tabId / 命令子串 / 条数(默认 50,最大 500;取最新)。
|
|
41
|
+
*/
|
|
42
|
+
export declare function readJournal(stateDir: string, filter?: JournalFilter): Promise<{
|
|
43
|
+
total: number;
|
|
44
|
+
entries: JournalEntry[];
|
|
45
|
+
}>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话恢复(v1.7.5):把审计账本持久化到磁盘,dsh 重启以后还查得到上一个会话做过什么。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是文件而不是数据库:一条命令一行 JSONL,出问题时能直接打开看、能 grep、
|
|
5
|
+
* 写坏了最多丢一行(读到坏行跳过,不炸整本账)。上限 1000 行,超了在**启动时**
|
|
6
|
+
* 裁掉最旧的——运行中不裁剪,append 永远是纯追加。
|
|
7
|
+
*
|
|
8
|
+
* 隐私与审计同一套:写盘的就是 AuditLog 里那份脱敏后的摘要(输入值/cookie 值已
|
|
9
|
+
* 打码),journal 层不再碰原始参数。文件在 <stateDir>/journal.jsonl,stateDir
|
|
10
|
+
* 默认 ~/.dsh-browser-control,可在 profile 的 browser-bridge 配置里改。
|
|
11
|
+
*/
|
|
12
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
/** 磁盘上最多保留的条数(启动时裁剪)。 */
|
|
15
|
+
export const JOURNAL_MAX_LINES = 1000;
|
|
16
|
+
/** 防御性上限:正常文件远小于它;超过就只读尾巴,别为了一本账吃掉内存。 */
|
|
17
|
+
const JOURNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
function isEntry(value) {
|
|
19
|
+
if (value === null || typeof value !== 'object')
|
|
20
|
+
return false;
|
|
21
|
+
const v = value;
|
|
22
|
+
return typeof v.run === 'string'
|
|
23
|
+
&& typeof v.command === 'string'
|
|
24
|
+
&& typeof v.seq === 'number'
|
|
25
|
+
&& typeof v.t === 'number'
|
|
26
|
+
&& typeof v.params === 'string'
|
|
27
|
+
&& typeof v.ok === 'boolean'
|
|
28
|
+
&& typeof v.durationMs === 'number';
|
|
29
|
+
}
|
|
30
|
+
/** 拆行并解析;坏行(截断、手改坏)跳过——一本账不能因为一行烂掉就全废。 */
|
|
31
|
+
function parseLines(text) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
if (line.trim().length === 0)
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(line);
|
|
38
|
+
if (isEntry(parsed))
|
|
39
|
+
out.push(parsed);
|
|
40
|
+
}
|
|
41
|
+
catch { /* 坏行跳过 */ }
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 打开(并按需裁剪)一个 journal 目录。
|
|
47
|
+
* @param stateDir - 目录(不存在会创建);journal 文件是 `<stateDir>/journal.jsonl`。
|
|
48
|
+
* @param log - 一行式日志;打开/裁剪的结论写进去。
|
|
49
|
+
* @returns 带 run 编号的追加器;`close()` 等所有在途写入落盘。
|
|
50
|
+
*/
|
|
51
|
+
export class Journal {
|
|
52
|
+
/** 这次运行的编号:启动时刻(毫秒精度;秒级精度下两次启动同秒会撞号)。 */
|
|
53
|
+
runId;
|
|
54
|
+
file;
|
|
55
|
+
log;
|
|
56
|
+
chain = Promise.resolve();
|
|
57
|
+
warned = false;
|
|
58
|
+
constructor(file, runId, log) {
|
|
59
|
+
this.file = file;
|
|
60
|
+
this.runId = runId;
|
|
61
|
+
this.log = log;
|
|
62
|
+
}
|
|
63
|
+
static async open(stateDir, log) {
|
|
64
|
+
await mkdir(stateDir, { recursive: true });
|
|
65
|
+
const file = path.join(stateDir, 'journal.jsonl');
|
|
66
|
+
let existing = [];
|
|
67
|
+
try {
|
|
68
|
+
const text = await readFile(file, 'utf8');
|
|
69
|
+
existing = parseLines(text.length > JOURNAL_MAX_BYTES ? text.slice(-JOURNAL_MAX_BYTES) : text);
|
|
70
|
+
}
|
|
71
|
+
catch { /* 首次运行没有文件 */ }
|
|
72
|
+
if (existing.length > JOURNAL_MAX_LINES) {
|
|
73
|
+
const kept = existing.slice(existing.length - JOURNAL_MAX_LINES);
|
|
74
|
+
const tmp = `${file}.tmp`;
|
|
75
|
+
await writeFile(tmp, kept.map((e) => `${JSON.stringify(e)}\n`).join(''), 'utf8');
|
|
76
|
+
await rename(tmp, file);
|
|
77
|
+
log(`journal: pruned ${existing.length - kept.length} old entries (${JOURNAL_MAX_LINES} kept)`);
|
|
78
|
+
}
|
|
79
|
+
else if (existing.length > 0) {
|
|
80
|
+
const last = existing[existing.length - 1];
|
|
81
|
+
log(`journal: restored context — ${existing.length} entries on disk, last from run ${last.run} (#${last.seq} ${last.command})`);
|
|
82
|
+
}
|
|
83
|
+
return new Journal(file, new Date().toISOString().slice(0, 23).replace('T', ' '), log);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 追加一条(异步、排队写;失败只告警一次,绝不阻塞命令路径)。
|
|
87
|
+
* @param entry - AuditLog 刚记下的条目(参数已脱敏)。
|
|
88
|
+
*/
|
|
89
|
+
append(entry) {
|
|
90
|
+
const line = `${JSON.stringify({ run: this.runId, ...entry })}\n`;
|
|
91
|
+
this.chain = this.chain.then(() => appendFile(this.file, line, 'utf8')).catch((error) => {
|
|
92
|
+
// 账本写不进去不是命令失败的理由:告警一次,继续跑。
|
|
93
|
+
if (!this.warned) {
|
|
94
|
+
this.warned = true;
|
|
95
|
+
this.log(`journal: write failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** 等在途写入落盘(teardown / 测试用)。 */
|
|
100
|
+
async close() {
|
|
101
|
+
await this.chain.catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 读 journal 的尾部(不要求 Journal 实例——读在线程外也能工作,扩展离线也行)。
|
|
106
|
+
* @param stateDir - journal 所在目录。
|
|
107
|
+
* @param filter - tabId / 命令子串 / 条数(默认 50,最大 500;取最新)。
|
|
108
|
+
*/
|
|
109
|
+
export async function readJournal(stateDir, filter = {}) {
|
|
110
|
+
let text;
|
|
111
|
+
try {
|
|
112
|
+
text = await readFile(path.join(stateDir, 'journal.jsonl'), 'utf8');
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return { total: 0, entries: [] };
|
|
116
|
+
}
|
|
117
|
+
if (text.length > JOURNAL_MAX_BYTES)
|
|
118
|
+
text = text.slice(-JOURNAL_MAX_BYTES);
|
|
119
|
+
let matched = parseLines(text);
|
|
120
|
+
if (filter.tabId !== undefined)
|
|
121
|
+
matched = matched.filter((e) => e.tabId === filter.tabId);
|
|
122
|
+
if (filter.command !== undefined && filter.command.length > 0) {
|
|
123
|
+
const needle = filter.command.toLowerCase();
|
|
124
|
+
matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
|
|
125
|
+
}
|
|
126
|
+
const limit = Math.min(500, Math.max(1, Math.trunc(filter.limit ?? 50)));
|
|
127
|
+
return { total: matched.length, entries: matched.slice(Math.max(0, matched.length - limit)) };
|
|
128
|
+
}
|
package/llms.txt
CHANGED
|
@@ -42,7 +42,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
|
|
|
42
42
|
|
|
43
43
|
## 关键文件
|
|
44
44
|
|
|
45
|
-
- `src/index.ts` -
|
|
45
|
+
- `src/index.ts` - 28 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
|
|
46
46
|
- `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
|
|
47
47
|
- `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
|
|
48
48
|
- `src/ws.ts` - RFC 6455 帧编解码
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@angelky7/dsh-browser-control",
|
|
3
3
|
"description": "Local WebSocket bridge between dsh and the DSH Browser Control browser extension, with model-facing browser_* tools and a Settings-managed on/off switch",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"build": "tsc -p tsconfig.json && tsdown",
|
|
38
38
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
39
|
"check": "npm run typecheck && npm run build && publint",
|
|
40
|
-
"verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs && node verify/verify_drag.mjs && node verify/verify_audit.mjs && node verify/verify_compat_matrix.mjs"
|
|
40
|
+
"verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs && node verify/verify_drag.mjs && node verify/verify_audit.mjs && node verify/verify_journal.mjs && node verify/verify_compat_matrix.mjs"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public",
|