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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -0
  3. package/bin/yunti-browser-runtime.js +86 -0
  4. package/docs/EXECUTION_PLAN.md +1278 -0
  5. package/docs/INSTALL.md +205 -0
  6. package/docs/PROJECT_INTENT.md +44 -0
  7. package/docs/PROJECT_STATUS.md +263 -0
  8. package/docs/PUBLISHING_BLOCKERS.md +110 -0
  9. package/docs/RELEASE.md +148 -0
  10. package/docs/ROADMAP.md +42 -0
  11. package/docs/SECURITY.md +56 -0
  12. package/docs/TOOL_GUIDE.md +69 -0
  13. package/extension/background.js +55 -0
  14. package/extension/cdp.js +582 -0
  15. package/extension/content.css +9 -0
  16. package/extension/content.js +946 -0
  17. package/extension/manifest.json +35 -0
  18. package/extension/network-monitor.js +140 -0
  19. package/extension/popup.css +66 -0
  20. package/extension/popup.html +30 -0
  21. package/extension/popup.js +55 -0
  22. package/extension/session-manager.js +332 -0
  23. package/extension/settings.js +94 -0
  24. package/extension/tool-handlers.js +1158 -0
  25. package/lib/runtime-paths.js +39 -0
  26. package/mcp/bridge-hub.js +604 -0
  27. package/mcp/http-server.js +326 -0
  28. package/mcp/json-rpc.js +35 -0
  29. package/mcp/memory.js +126 -0
  30. package/mcp/redaction.js +94 -0
  31. package/mcp/server.js +269 -0
  32. package/mcp/tools.js +1092 -0
  33. package/package.json +60 -0
  34. package/scripts/check-package-metadata.js +131 -0
  35. package/scripts/check-published-package.js +113 -0
  36. package/scripts/doctor.js +163 -0
  37. package/scripts/package-extension.js +137 -0
  38. package/scripts/print-config.js +116 -0
  39. package/scripts/release-check.js +472 -0
  40. 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
+ }
@@ -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
+ }
@@ -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
+ }