yunti-browser-runtime 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/LICENSE +21 -0
- package/README.md +256 -0
- package/bin/yunti-browser-runtime.js +86 -0
- package/docs/EXECUTION_PLAN.md +1278 -0
- package/docs/INSTALL.md +205 -0
- package/docs/PROJECT_INTENT.md +44 -0
- package/docs/PROJECT_STATUS.md +263 -0
- package/docs/PUBLISHING_BLOCKERS.md +110 -0
- package/docs/RELEASE.md +148 -0
- package/docs/ROADMAP.md +42 -0
- package/docs/SECURITY.md +56 -0
- package/docs/TOOL_GUIDE.md +69 -0
- package/extension/background.js +55 -0
- package/extension/cdp.js +582 -0
- package/extension/content.css +9 -0
- package/extension/content.js +946 -0
- package/extension/manifest.json +35 -0
- package/extension/network-monitor.js +140 -0
- package/extension/popup.css +66 -0
- package/extension/popup.html +30 -0
- package/extension/popup.js +55 -0
- package/extension/session-manager.js +332 -0
- package/extension/settings.js +94 -0
- package/extension/tool-handlers.js +1158 -0
- package/lib/runtime-paths.js +39 -0
- package/mcp/bridge-hub.js +604 -0
- package/mcp/http-server.js +326 -0
- package/mcp/json-rpc.js +35 -0
- package/mcp/memory.js +126 -0
- package/mcp/redaction.js +94 -0
- package/mcp/server.js +269 -0
- package/mcp/tools.js +1092 -0
- package/package.json +60 -0
- package/scripts/check-package-metadata.js +131 -0
- package/scripts/check-published-package.js +113 -0
- package/scripts/doctor.js +163 -0
- package/scripts/package-extension.js +137 -0
- package/scripts/print-config.js +116 -0
- package/scripts/release-check.js +472 -0
- package/skills/yunti-browser-runtime/SKILL.md +77 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import http from "node:http"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import {
|
|
4
|
+
BridgeHub,
|
|
5
|
+
DEFAULT_SESSION_TTL_MS,
|
|
6
|
+
DEFAULT_TOOL_TIMEOUT_MS,
|
|
7
|
+
normalizeRouteUserId,
|
|
8
|
+
} from "./bridge-hub.js"
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_HOST =
|
|
11
|
+
process.env.YUNTI_BROWSER_BRIDGE_HOST || "127.0.0.1"
|
|
12
|
+
export const DEFAULT_PORT = Number(process.env.YUNTI_BROWSER_BRIDGE_PORT || 48887)
|
|
13
|
+
export const BRIDGE_TOKEN_HEADER = "x-yunti-browser-token"
|
|
14
|
+
const DEFAULT_ALLOWED_ORIGINS = [
|
|
15
|
+
"http://127.0.0.1",
|
|
16
|
+
"http://localhost",
|
|
17
|
+
"http://[::1]",
|
|
18
|
+
"chrome-extension://*",
|
|
19
|
+
"moz-extension://*",
|
|
20
|
+
]
|
|
21
|
+
const RELAY_URL = normalizeBaseUrl(process.env.YUNTI_BROWSER_RELAY_URL || "")
|
|
22
|
+
const RELAY_TOKEN = process.env.YUNTI_BROWSER_RELAY_TOKEN || ""
|
|
23
|
+
const SERVER_NAME = process.env.YUNTI_BROWSER_SERVER_NAME || "Yunti Browser Runtime"
|
|
24
|
+
const _ROUTE_USER_ID = process.env.YUNTI_BROWSER_USER_ID || "local"
|
|
25
|
+
const _ROUTE_USER_NAME = process.env.YUNTI_BROWSER_USER_NAME || "local"
|
|
26
|
+
const MAX_JSON_BYTES = 2 * 1024 * 1024
|
|
27
|
+
const SESSION_CLEANUP_INTERVAL_MS = Number(
|
|
28
|
+
process.env.YUNTI_BROWSER_SESSION_CLEANUP_INTERVAL_MS || 15_000
|
|
29
|
+
)
|
|
30
|
+
const PROTECTED_BRIDGE_PATHS = new Set([
|
|
31
|
+
"/sessions",
|
|
32
|
+
"/sessions/register",
|
|
33
|
+
"/sessions/activate",
|
|
34
|
+
"/extension/poll",
|
|
35
|
+
"/extension/result",
|
|
36
|
+
"/extension/network-event",
|
|
37
|
+
"/extension/cdp-event",
|
|
38
|
+
"/extension/console-event",
|
|
39
|
+
"/mcp/request",
|
|
40
|
+
"/mcp/local-tool",
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
function normalizeBaseUrl(value) {
|
|
44
|
+
return String(value || "")
|
|
45
|
+
.trim()
|
|
46
|
+
.replace(/\/+$/, "")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeBridgeToken(value) {
|
|
50
|
+
return String(value || "").trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseAllowedOrigins(value = process.env.YUNTI_BROWSER_BRIDGE_ALLOW_ORIGINS) {
|
|
54
|
+
const raw = String(value || "").trim()
|
|
55
|
+
if (!raw) return DEFAULT_ALLOWED_ORIGINS
|
|
56
|
+
return raw
|
|
57
|
+
.split(/[\n,]/)
|
|
58
|
+
.map((item) => item.trim())
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isLocalHttpOrigin(origin) {
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(origin)
|
|
65
|
+
return (
|
|
66
|
+
["http:", "https:"].includes(url.protocol) &&
|
|
67
|
+
["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname)
|
|
68
|
+
)
|
|
69
|
+
} catch {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function originMatchesPattern(origin, pattern) {
|
|
75
|
+
if (!origin) return true
|
|
76
|
+
const pat = String(pattern || "").trim()
|
|
77
|
+
if (!pat) return false
|
|
78
|
+
if (pat === "*") return true
|
|
79
|
+
if (pat === "chrome-extension://*") return origin.startsWith("chrome-extension://")
|
|
80
|
+
if (pat === "moz-extension://*") return origin.startsWith("moz-extension://")
|
|
81
|
+
if (pat.endsWith("://*")) return origin.startsWith(pat.slice(0, -1))
|
|
82
|
+
if (origin === pat) return true
|
|
83
|
+
return isLocalHttpOrigin(origin) && isLocalHttpOrigin(pat)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isAllowedOrigin(origin, allowOrigins) {
|
|
87
|
+
if (!origin) return true
|
|
88
|
+
return allowOrigins.some((pattern) => originMatchesPattern(origin, pattern))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bridgeCorsHeaders(req, allowOrigins) {
|
|
92
|
+
const origin = String(req.headers.origin || "")
|
|
93
|
+
const headers = {
|
|
94
|
+
"content-type": "application/json; charset=utf-8",
|
|
95
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
96
|
+
"access-control-allow-headers": `content-type, ${BRIDGE_TOKEN_HEADER}`,
|
|
97
|
+
vary: "Origin",
|
|
98
|
+
}
|
|
99
|
+
if (isAllowedOrigin(origin, allowOrigins)) {
|
|
100
|
+
headers["access-control-allow-origin"] = origin || "null"
|
|
101
|
+
}
|
|
102
|
+
return headers
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sendJson(req, res, statusCode, value, { allowOrigins = DEFAULT_ALLOWED_ORIGINS } = {}) {
|
|
106
|
+
const body = JSON.stringify(value)
|
|
107
|
+
res.writeHead(statusCode, bridgeCorsHeaders(req, allowOrigins))
|
|
108
|
+
res.end(body)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function bridgeRequestToken(req) {
|
|
112
|
+
const headerValue = req.headers[BRIDGE_TOKEN_HEADER]
|
|
113
|
+
if (Array.isArray(headerValue)) return normalizeBridgeToken(headerValue[0])
|
|
114
|
+
if (headerValue) return normalizeBridgeToken(headerValue)
|
|
115
|
+
const authorization = String(req.headers.authorization || "")
|
|
116
|
+
const match = authorization.match(/^Bearer\s+(.+)$/i)
|
|
117
|
+
return match ? normalizeBridgeToken(match[1]) : ""
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isAuthorizedBridgeRequest(req, bridgeToken) {
|
|
121
|
+
const token = normalizeBridgeToken(bridgeToken)
|
|
122
|
+
return Boolean(token && bridgeRequestToken(req) === token)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function limitedHealth() {
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
name: "yunti-browser-runtime-bridge",
|
|
129
|
+
authorized: false,
|
|
130
|
+
auth: {
|
|
131
|
+
required: true,
|
|
132
|
+
header: BRIDGE_TOKEN_HEADER,
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readJson(req) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
let total = 0
|
|
140
|
+
const chunks = []
|
|
141
|
+
req.on("data", (chunk) => {
|
|
142
|
+
total += chunk.length
|
|
143
|
+
if (total > MAX_JSON_BYTES) {
|
|
144
|
+
reject(new Error("request body too large"))
|
|
145
|
+
req.destroy()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
chunks.push(chunk)
|
|
149
|
+
})
|
|
150
|
+
req.on("end", () => {
|
|
151
|
+
const raw = Buffer.concat(chunks).toString("utf8")
|
|
152
|
+
if (!raw.trim()) {
|
|
153
|
+
resolve({})
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
resolve(JSON.parse(raw))
|
|
158
|
+
} catch (error) {
|
|
159
|
+
reject(error)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
req.on("error", reject)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function startBridgeServer({
|
|
167
|
+
host = DEFAULT_HOST,
|
|
168
|
+
port = DEFAULT_PORT,
|
|
169
|
+
bridgeToken = null,
|
|
170
|
+
allowOrigins = parseAllowedOrigins(),
|
|
171
|
+
sessionTtlMs = DEFAULT_SESSION_TTL_MS,
|
|
172
|
+
} = {}) {
|
|
173
|
+
const configuredBridgeToken = normalizeBridgeToken(
|
|
174
|
+
bridgeToken ?? process.env.YUNTI_BROWSER_BRIDGE_TOKEN
|
|
175
|
+
)
|
|
176
|
+
const activeBridgeToken = configuredBridgeToken || randomUUID()
|
|
177
|
+
const hub = new BridgeHub({ sessionTtlMs })
|
|
178
|
+
const cleanupTimer = setInterval(() => {
|
|
179
|
+
hub.cleanupExpiredSessions()
|
|
180
|
+
}, Math.max(5_000, SESSION_CLEANUP_INTERVAL_MS))
|
|
181
|
+
cleanupTimer.unref?.()
|
|
182
|
+
const server = http.createServer(async (req, res) => {
|
|
183
|
+
try {
|
|
184
|
+
const url = new URL(req.url || "/", `http://${host}:${port}`)
|
|
185
|
+
const authorized = isAuthorizedBridgeRequest(req, activeBridgeToken)
|
|
186
|
+
|
|
187
|
+
if (req.method === "OPTIONS") {
|
|
188
|
+
const origin = String(req.headers.origin || "")
|
|
189
|
+
sendJson(req, res, isAllowedOrigin(origin, allowOrigins) ? 200 : 403, { ok: true }, { allowOrigins })
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
194
|
+
sendJson(
|
|
195
|
+
req,
|
|
196
|
+
res,
|
|
197
|
+
200,
|
|
198
|
+
authorized ? hub.health({ userId: url.searchParams.get("userId") }) : limitedHealth(),
|
|
199
|
+
{ allowOrigins }
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (PROTECTED_BRIDGE_PATHS.has(url.pathname) && !authorized) {
|
|
205
|
+
sendJson(
|
|
206
|
+
req,
|
|
207
|
+
res,
|
|
208
|
+
401,
|
|
209
|
+
{
|
|
210
|
+
ok: false,
|
|
211
|
+
error: `missing or invalid ${BRIDGE_TOKEN_HEADER}`,
|
|
212
|
+
auth: { required: true, header: BRIDGE_TOKEN_HEADER },
|
|
213
|
+
},
|
|
214
|
+
{ allowOrigins }
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (req.method === "GET" && url.pathname === "/sessions") {
|
|
220
|
+
const userId = url.searchParams.get("userId")
|
|
221
|
+
const sessions = hub.listSessions({ userId })
|
|
222
|
+
const activeSessionId = normalizeRouteUserId(userId)
|
|
223
|
+
? hub.activeSessionByUser.get(normalizeRouteUserId(userId)) || null
|
|
224
|
+
: null
|
|
225
|
+
sendJson(req, res, 200, { sessions, activeSessionId, sessionCount: hub.sessions.size }, { allowOrigins })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (req.method === "POST" && url.pathname === "/sessions/register") {
|
|
230
|
+
const body = await readJson(req)
|
|
231
|
+
const session = hub.registerSession(body)
|
|
232
|
+
sendJson(req, res, 200, { ok: true, session }, { allowOrigins })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (req.method === "POST" && url.pathname === "/sessions/activate") {
|
|
237
|
+
const body = await readJson(req)
|
|
238
|
+
const session = hub.activateSession(String(body.browserSessionId || ""), body)
|
|
239
|
+
sendJson(req, res, 200, { ok: true, session }, { allowOrigins })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (req.method === "GET" && url.pathname === "/extension/poll") {
|
|
244
|
+
const browserSessionId = String(url.searchParams.get("browserSessionId") || "").trim()
|
|
245
|
+
const timeoutMs = Number(url.searchParams.get("timeoutMs") || 25_000)
|
|
246
|
+
if (!browserSessionId) {
|
|
247
|
+
sendJson(req, res, 400, { error: "browserSessionId is required" }, { allowOrigins })
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
const event = await hub.poll(browserSessionId, timeoutMs)
|
|
251
|
+
sendJson(req, res, 200, event, { allowOrigins })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (req.method === "POST" && url.pathname === "/extension/result") {
|
|
256
|
+
const body = await readJson(req)
|
|
257
|
+
const outcome = hub.submitResult(body)
|
|
258
|
+
sendJson(req, res, outcome.accepted ? 200 : 404, outcome, { allowOrigins })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (req.method === "POST" && url.pathname === "/extension/network-event") {
|
|
263
|
+
const body = await readJson(req)
|
|
264
|
+
const outcome = hub.recordNetworkEvent(body)
|
|
265
|
+
sendJson(req, res, outcome.accepted ? 200 : 400, outcome, { allowOrigins })
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (req.method === "POST" && url.pathname === "/extension/cdp-event") {
|
|
270
|
+
const body = await readJson(req)
|
|
271
|
+
const outcome = hub.recordCdpEvent(body)
|
|
272
|
+
sendJson(req, res, outcome.accepted ? 200 : 400, outcome, { allowOrigins })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (req.method === "POST" && url.pathname === "/extension/console-event") {
|
|
277
|
+
const body = await readJson(req)
|
|
278
|
+
const outcome = hub.recordConsoleEvent(body)
|
|
279
|
+
sendJson(req, res, outcome.accepted ? 200 : 400, outcome, { allowOrigins })
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (req.method === "POST" && url.pathname === "/mcp/local-tool") {
|
|
284
|
+
const body = await readJson(req)
|
|
285
|
+
const result = await hub.callBridgeLocalTool(String(body.tool || ""), body.arguments ?? {})
|
|
286
|
+
sendJson(req, res, 200, { ok: true, result }, { allowOrigins })
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (req.method === "POST" && url.pathname === "/mcp/request") {
|
|
291
|
+
const body = await readJson(req)
|
|
292
|
+
const result = await hub.callTool(
|
|
293
|
+
String(body.tool || ""),
|
|
294
|
+
body.arguments ?? {},
|
|
295
|
+
Number(body.timeoutMs || DEFAULT_TOOL_TIMEOUT_MS)
|
|
296
|
+
)
|
|
297
|
+
sendJson(req, res, 200, { ok: true, result }, { allowOrigins })
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
sendJson(req, res, 404, { error: "not found" }, { allowOrigins })
|
|
302
|
+
} catch (error) {
|
|
303
|
+
sendJson(req, res, 500, {
|
|
304
|
+
error: error instanceof Error ? error.message : String(error),
|
|
305
|
+
}, { allowOrigins })
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const mode = await new Promise((resolve, reject) => {
|
|
310
|
+
server.once("error", (error) => {
|
|
311
|
+
if (error && error.code === "EADDRINUSE") {
|
|
312
|
+
resolve("proxy")
|
|
313
|
+
} else {
|
|
314
|
+
reject(error)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
server.listen(port, host, () => resolve("owner"))
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
if (mode === "proxy") {
|
|
321
|
+
clearInterval(cleanupTimer)
|
|
322
|
+
server.close()
|
|
323
|
+
return { mode: "proxy", host, port, bridgeToken: configuredBridgeToken, allowOrigins, hub: null, server: null }
|
|
324
|
+
}
|
|
325
|
+
return { mode: "owner", host, port, bridgeToken: activeBridgeToken, allowOrigins, hub, server }
|
|
326
|
+
}
|
package/mcp/json-rpc.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function jsonRpcOk(id, result) {
|
|
2
|
+
return { jsonrpc: "2.0", id, result }
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function jsonRpcErr(id, code, message) {
|
|
6
|
+
return { jsonrpc: "2.0", id, error: { code, message } }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function toolOk(value) {
|
|
10
|
+
if (value && typeof value === "object" && typeof value.dataUrl === "string") {
|
|
11
|
+
const match = value.dataUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i)
|
|
12
|
+
if (match) {
|
|
13
|
+
const { dataUrl: _dataUrl, ...summary } = value
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{ type: "text", text: JSON.stringify(summary, null, 2) },
|
|
17
|
+
{ type: "image", mimeType: match[1], data: match[2] },
|
|
18
|
+
],
|
|
19
|
+
structuredContent: summary,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
25
|
+
structuredContent: value,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toolError(message, detail = null) {
|
|
30
|
+
const payload = detail ? `${message}\n${detail}` : message
|
|
31
|
+
return {
|
|
32
|
+
isError: true,
|
|
33
|
+
content: [{ type: "text", text: payload }],
|
|
34
|
+
}
|
|
35
|
+
}
|
package/mcp/memory.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import { yuntiMemoryDir, yuntiMemoryFile } from "../lib/runtime-paths.js"
|
|
4
|
+
import { clampNumber, redactLikelySecrets, truncateText } from "./redaction.js"
|
|
5
|
+
|
|
6
|
+
const MAX_MEMORY_ITEMS = 500
|
|
7
|
+
const DEFAULT_ROUTE_USER_ID = process.env.YUNTI_BROWSER_USER_ID || "local"
|
|
8
|
+
|
|
9
|
+
function normalizeMemoryUserId(value) {
|
|
10
|
+
const userId = String(value || "").trim()
|
|
11
|
+
return userId && userId !== "anonymous" ? userId : ""
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function requireMemoryUserId(value = "") {
|
|
15
|
+
const userId = normalizeMemoryUserId(value) || normalizeMemoryUserId(DEFAULT_ROUTE_USER_ID)
|
|
16
|
+
if (!userId) {
|
|
17
|
+
throw new Error("userId is required for Yunti learning memory")
|
|
18
|
+
}
|
|
19
|
+
return userId
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function memoryFile(userId) {
|
|
23
|
+
return yuntiMemoryFile(requireMemoryUserId(userId))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function memoryDir(userId) {
|
|
27
|
+
return yuntiMemoryDir(requireMemoryUserId(userId))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readMemoryStore(userId) {
|
|
31
|
+
try {
|
|
32
|
+
const data = JSON.parse(await readFile(memoryFile(userId), "utf8"))
|
|
33
|
+
return { version: 1, memories: Array.isArray(data.memories) ? data.memories : [] }
|
|
34
|
+
} catch {
|
|
35
|
+
return { version: 1, memories: [] }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function writeMemoryStore(store, userId) {
|
|
40
|
+
await mkdir(memoryDir(userId), { recursive: true })
|
|
41
|
+
const memories = store.memories.slice(-MAX_MEMORY_ITEMS)
|
|
42
|
+
await writeFile(memoryFile(userId), `${JSON.stringify({ version: 1, memories }, null, 2)}\n`)
|
|
43
|
+
return { version: 1, memories }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizeMemoryInput(args = {}) {
|
|
47
|
+
return {
|
|
48
|
+
id: randomUUID(),
|
|
49
|
+
kind: truncateText(args.kind || "other", 80),
|
|
50
|
+
title: redactLikelySecrets(truncateText(args.title, 300)),
|
|
51
|
+
detail: redactLikelySecrets(truncateText(args.detail, 4000)),
|
|
52
|
+
tags: Array.isArray(args.tags)
|
|
53
|
+
? args.tags.map((x) => truncateText(x, 80)).filter(Boolean).slice(0, 20)
|
|
54
|
+
: [],
|
|
55
|
+
confidence: clampNumber(args.confidence ?? 0.5, 0, 1, 0.5),
|
|
56
|
+
source: redactLikelySecrets(truncateText(args.source || "", 500)),
|
|
57
|
+
relatedNetworkEventIds: Array.isArray(args.relatedNetworkEventIds)
|
|
58
|
+
? args.relatedNetworkEventIds.map((x) => Number(x)).filter(Number.isFinite).slice(0, 50)
|
|
59
|
+
: [],
|
|
60
|
+
createdAt: new Date().toISOString(),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function rememberLearning(args = {}) {
|
|
65
|
+
if (!String(args.title || "").trim()) throw new Error("title is required")
|
|
66
|
+
if (!String(args.detail || "").trim()) throw new Error("detail is required")
|
|
67
|
+
const userId = requireMemoryUserId(args.userId)
|
|
68
|
+
const store = await readMemoryStore(userId)
|
|
69
|
+
const memory = sanitizeMemoryInput({ ...args, userId })
|
|
70
|
+
store.memories.push(memory)
|
|
71
|
+
await writeMemoryStore(store, userId)
|
|
72
|
+
return { remembered: true, memory }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getLearningMemory(args = {}) {
|
|
76
|
+
const userId = requireMemoryUserId(args.userId)
|
|
77
|
+
const store = await readMemoryStore(userId)
|
|
78
|
+
const query = String(args.query || "").trim().toLowerCase()
|
|
79
|
+
const kind = String(args.kind || "").trim().toLowerCase()
|
|
80
|
+
const tags = Array.isArray(args.tags)
|
|
81
|
+
? args.tags.map((x) => String(x).trim().toLowerCase()).filter(Boolean)
|
|
82
|
+
: []
|
|
83
|
+
const limit = clampNumber(args.limit || 50, 1, 100, 50)
|
|
84
|
+
let memories = store.memories
|
|
85
|
+
if (kind) memories = memories.filter((item) => String(item.kind || "").toLowerCase() === kind)
|
|
86
|
+
if (query) {
|
|
87
|
+
memories = memories.filter((item) =>
|
|
88
|
+
[item.title, item.detail, item.source, ...(item.tags || [])].some((value) =>
|
|
89
|
+
String(value || "").toLowerCase().includes(query)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (tags.length > 0) {
|
|
94
|
+
memories = memories.filter((item) => {
|
|
95
|
+
const ownTags = new Set((item.tags || []).map((tag) => String(tag).toLowerCase()))
|
|
96
|
+
return tags.every((tag) => ownTags.has(tag))
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
memories = memories.slice(-limit).reverse()
|
|
100
|
+
return { memories, total: store.memories.length, returned: memories.length, storagePath: memoryFile(userId) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function forgetLearningMemory(args = {}) {
|
|
104
|
+
const userId = requireMemoryUserId(args.userId)
|
|
105
|
+
const store = await readMemoryStore(userId)
|
|
106
|
+
if (args.all) {
|
|
107
|
+
if (!args.confirmed) throw new Error("confirmed=true is required to delete all learning memories")
|
|
108
|
+
const deleted = store.memories.length
|
|
109
|
+
await writeMemoryStore({ version: 1, memories: [] }, userId)
|
|
110
|
+
return { deleted }
|
|
111
|
+
}
|
|
112
|
+
const id = String(args.id || "").trim()
|
|
113
|
+
if (!id) {
|
|
114
|
+
throw new Error("id is required unless all=true and confirmed=true. Call yunti_get_learning_memory first to find the memory id.")
|
|
115
|
+
}
|
|
116
|
+
const before = store.memories.length
|
|
117
|
+
const memories = store.memories.filter((item) => item.id !== id)
|
|
118
|
+
await writeMemoryStore({ version: 1, memories }, userId)
|
|
119
|
+
const deleted = before - memories.length
|
|
120
|
+
return {
|
|
121
|
+
deleted,
|
|
122
|
+
...(deleted
|
|
123
|
+
? {}
|
|
124
|
+
: { message: `No learning memory found for id: ${id}. Call yunti_get_learning_memory to list current ids.` }),
|
|
125
|
+
}
|
|
126
|
+
}
|
package/mcp/redaction.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const SENSITIVE_KEY_RE =
|
|
2
|
+
/cookie|authorization|token|secret|password|passwd|pwd|sid|session|ticket|tgc|captcha|验证码|密码/i
|
|
3
|
+
|
|
4
|
+
export function clampNumber(value, min, max, fallback = min) {
|
|
5
|
+
const number = Number(value)
|
|
6
|
+
if (!Number.isFinite(number)) return fallback
|
|
7
|
+
return Math.max(min, Math.min(max, number))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function truncateText(value, max = 1000) {
|
|
11
|
+
return String(value ?? "").slice(0, max)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function redactValue(key, value) {
|
|
15
|
+
if (SENSITIVE_KEY_RE.test(String(key))) return "[REDACTED]"
|
|
16
|
+
return truncateText(value, 500)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function redactLikelySecrets(text) {
|
|
20
|
+
return truncateText(text, 1500).replace(
|
|
21
|
+
/(cookie|authorization|token|secret|password|passwd|pwd|sid|session|ticket|tgc|captcha)(["'\s:=]+)([^"',\s}&]+)/gi,
|
|
22
|
+
"$1$2[REDACTED]"
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sanitizeUrl(rawUrl) {
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(String(rawUrl || ""))
|
|
29
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
30
|
+
const values = url.searchParams.getAll(key)
|
|
31
|
+
url.searchParams.delete(key)
|
|
32
|
+
for (const value of values) url.searchParams.append(key, redactValue(key, value))
|
|
33
|
+
}
|
|
34
|
+
return url.toString()
|
|
35
|
+
} catch {
|
|
36
|
+
return truncateText(rawUrl, 1000)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sanitizeRequestBody(body) {
|
|
41
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null
|
|
42
|
+
const out = {}
|
|
43
|
+
if (typeof body.kind === "string") out.kind = truncateText(body.kind, 80)
|
|
44
|
+
if (Array.isArray(body.fieldNames)) {
|
|
45
|
+
out.fieldNames = body.fieldNames.map((x) => truncateText(x, 120)).filter(Boolean)
|
|
46
|
+
}
|
|
47
|
+
if (body.formData && typeof body.formData === "object" && !Array.isArray(body.formData)) {
|
|
48
|
+
out.formData = {}
|
|
49
|
+
for (const [key, value] of Object.entries(body.formData)) {
|
|
50
|
+
out.formData[truncateText(key, 120)] = redactValue(key, value)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (Number.isFinite(Number(body.rawBytes))) out.rawBytes = Number(body.rawBytes)
|
|
54
|
+
if (typeof body.preview === "string") out.preview = redactLikelySecrets(body.preview)
|
|
55
|
+
return Object.keys(out).length ? out : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sanitizeNetworkEvent(input, id) {
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
browserSessionId: truncateText(input.browserSessionId, 160),
|
|
62
|
+
tabId: Number.isFinite(Number(input.tabId)) ? Number(input.tabId) : null,
|
|
63
|
+
requestId: truncateText(input.requestId, 160),
|
|
64
|
+
url: sanitizeUrl(input.url),
|
|
65
|
+
method: truncateText(input.method || "GET", 20).toUpperCase(),
|
|
66
|
+
type: truncateText(input.type, 80),
|
|
67
|
+
initiator: truncateText(input.initiator, 300),
|
|
68
|
+
documentUrl: input.documentUrl ? sanitizeUrl(input.documentUrl) : "",
|
|
69
|
+
statusCode: Number.isFinite(Number(input.statusCode)) ? Number(input.statusCode) : null,
|
|
70
|
+
ok: typeof input.ok === "boolean" ? input.ok : null,
|
|
71
|
+
fromCache: typeof input.fromCache === "boolean" ? input.fromCache : null,
|
|
72
|
+
error: input.error ? truncateText(input.error, 500) : "",
|
|
73
|
+
startedAt: truncateText(input.startedAt, 80),
|
|
74
|
+
completedAt: truncateText(input.completedAt, 80),
|
|
75
|
+
durationMs: Number.isFinite(Number(input.durationMs)) ? Math.round(Number(input.durationMs)) : null,
|
|
76
|
+
requestBody: sanitizeRequestBody(input.requestBody),
|
|
77
|
+
capturedBy: "extension.webRequest",
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function normalizeCdpEvent(input, id) {
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
browserSessionId: truncateText(input.browserSessionId, 160),
|
|
85
|
+
tabId: Number.isFinite(Number(input.tabId)) ? Number(input.tabId) : null,
|
|
86
|
+
method: truncateText(input.method, 200),
|
|
87
|
+
params:
|
|
88
|
+
input.params && typeof input.params === "object" && !Array.isArray(input.params)
|
|
89
|
+
? input.params
|
|
90
|
+
: {},
|
|
91
|
+
receivedAt: truncateText(input.receivedAt || new Date().toISOString(), 80),
|
|
92
|
+
capturedBy: "extension.chrome.debugger",
|
|
93
|
+
}
|
|
94
|
+
}
|