yunti-browser-runtime 0.1.2 → 0.2.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/mcp/bridge-hub.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  normalizeCdpEvent,
6
6
  sanitizeNetworkEvent,
7
7
  sanitizeUrl,
8
+ redactLikelySensitiveText,
8
9
  truncateText,
9
10
  } from "./redaction.js"
10
11
  import { toolUsageHints } from "./tools.js"
@@ -13,6 +14,7 @@ export const DEFAULT_TOOL_TIMEOUT_MS = 30_000
13
14
  const MAX_NETWORK_EVENTS = 1000
14
15
  const MAX_CDP_EVENTS = 2000
15
16
  const MAX_CONSOLE_MESSAGES = 1000
17
+ const MAX_ACTIVITY_EVENTS = 100
16
18
  const MAX_MEMORY_ITEMS = 500
17
19
  export const DEFAULT_SESSION_TTL_MS = Number(
18
20
  process.env.YUNTI_BROWSER_SESSION_TTL_MS || 90_000
@@ -41,6 +43,47 @@ function stripBrowserSessionId(args) {
41
43
  return next
42
44
  }
43
45
 
46
+ function summarizeObject(value) {
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
48
+ return { type: typeof value }
49
+ }
50
+ const summary = {
51
+ type: "object",
52
+ keys: Object.keys(value).slice(0, 12),
53
+ }
54
+ if (typeof value.ok === "boolean") summary.ok = value.ok
55
+ if (typeof value.code === "string") summary.code = redactLikelySensitiveText(value.code, 120)
56
+ if (typeof value.action === "string") summary.action = redactLikelySensitiveText(value.action, 120)
57
+ if (typeof value.nextStepHint === "string") {
58
+ summary.nextStepHint = redactLikelySensitiveText(value.nextStepHint, 240)
59
+ }
60
+ return summary
61
+ }
62
+
63
+ function summarizeSession(session, activeSessionId) {
64
+ const meta = session.meta || {}
65
+ const client = meta.client && typeof meta.client === "object" ? meta.client : {}
66
+ return {
67
+ browserSessionId: session.browserSessionId,
68
+ active: session.browserSessionId === activeSessionId,
69
+ userId: redactLikelySensitiveText(meta.userId || "", 120),
70
+ userName: redactLikelySensitiveText(meta.userName || "", 120),
71
+ displayName: redactLikelySensitiveText(meta.displayName || "", 160),
72
+ title: redactLikelySensitiveText(meta.title || "", 240),
73
+ url: meta.url ? sanitizeUrl(meta.url) : "",
74
+ tabId: meta.tabId ?? null,
75
+ windowId: meta.windowId ?? null,
76
+ clientFamily: redactLikelySensitiveText(client.family || "", 80),
77
+ extensionVersion: redactLikelySensitiveText(client.extensionVersion || meta.extensionVersion || "", 80),
78
+ queuedRequests: session.queue.length,
79
+ pollers: session.pollers.length,
80
+ updatedAt: meta.updatedAt || "",
81
+ lastSeenAt: meta.lastSeenAt || "",
82
+ expiresAt: meta.expiresAt || "",
83
+ staleReason: redactLikelySensitiveText(meta.staleReason || session.staleReason || "", 240),
84
+ }
85
+ }
86
+
44
87
  export function normalizeRouteUserId(value) {
45
88
  const userId = String(value || "").trim()
46
89
  return userId && userId !== "anonymous" ? userId : ""
@@ -60,14 +103,34 @@ export class BridgeHub {
60
103
  this.networkEvents = []
61
104
  this.cdpEvents = []
62
105
  this.consoleMessages = []
106
+ this.activityEvents = []
63
107
  this.nextNetworkEventId = 1
64
108
  this.nextCdpEventId = 1
65
109
  this.nextConsoleMsgId = 1
110
+ this.nextActivityEventId = 1
66
111
  this.activeSessionId = null
67
112
  this.activeSessionByUser = new Map()
68
113
  this.sessionTtlMs = Math.max(5_000, Number(sessionTtlMs) || DEFAULT_SESSION_TTL_MS)
69
114
  }
70
115
 
116
+ recordActivity(input = {}) {
117
+ const event = {
118
+ id: this.nextActivityEventId++,
119
+ timestamp: new Date().toISOString(),
120
+ type: redactLikelySensitiveText(input.type || "event", 80),
121
+ tool: input.tool ? redactLikelySensitiveText(input.tool, 120) : "",
122
+ browserSessionId: input.browserSessionId ? redactLikelySensitiveText(input.browserSessionId, 160) : "",
123
+ status: input.status ? redactLikelySensitiveText(input.status, 80) : "",
124
+ message: input.message ? redactLikelySensitiveText(input.message, 300) : "",
125
+ summary: input.summary && typeof input.summary === "object" ? input.summary : undefined,
126
+ }
127
+ this.activityEvents.push(event)
128
+ if (this.activityEvents.length > MAX_ACTIVITY_EVENTS) {
129
+ this.activityEvents.splice(0, this.activityEvents.length - MAX_ACTIVITY_EVENTS)
130
+ }
131
+ return event
132
+ }
133
+
71
134
  sessionExpiresAt(now = Date.now()) {
72
135
  return now + this.sessionTtlMs
73
136
  }
@@ -105,6 +168,12 @@ export class BridgeHub {
105
168
  for (const poller of session.pollers.splice(0)) {
106
169
  poller({ type: "noop", id: randomUUID(), stale: true, reason })
107
170
  }
171
+ this.recordActivity({
172
+ type: "stale-session",
173
+ browserSessionId,
174
+ status: "stale",
175
+ message: reason,
176
+ })
108
177
  return session.meta
109
178
  }
110
179
 
@@ -328,6 +397,13 @@ export class BridgeHub {
328
397
  const promise = new Promise((resolve, reject) => {
329
398
  const timer = setTimeout(() => {
330
399
  this.pendingRequests.delete(requestId)
400
+ this.recordActivity({
401
+ type: "tool-result",
402
+ tool,
403
+ browserSessionId,
404
+ status: "timeout",
405
+ message: `Timed out waiting for browser tool result: ${tool}`,
406
+ })
331
407
  reject(new Error(`Timed out waiting for browser tool result: ${tool}`))
332
408
  }, timeoutMs)
333
409
  this.pendingRequests.set(requestId, { resolve, reject, timer, browserSessionId, tool })
@@ -339,6 +415,12 @@ export class BridgeHub {
339
415
  } else {
340
416
  session.queue.push(payload)
341
417
  }
418
+ this.recordActivity({
419
+ type: "tool-request",
420
+ tool,
421
+ browserSessionId,
422
+ status: poller ? "sent" : "queued",
423
+ })
342
424
  return promise
343
425
  }
344
426
 
@@ -460,13 +542,13 @@ export class BridgeHub {
460
542
  browserSessionId: truncateText(input.browserSessionId || "", 160),
461
543
  tabId: Number.isFinite(Number(input.tabId)) ? Number(input.tabId) : null,
462
544
  level: ["error", "warning", "info", "debug", "log", "verbose"].includes(input.level) ? input.level : "log",
463
- text: truncateText(String(input.text || ""), 2000),
545
+ text: redactLikelySensitiveText(input.text || "", 2000),
464
546
  source: ["console-api", "javascript", "network", "other"].includes(input.source) ? input.source : "other",
465
547
  url: input.url ? sanitizeUrl(input.url) : "",
466
548
  lineNumber: Number.isFinite(Number(input.lineNumber)) ? Number(input.lineNumber) : null,
467
549
  columnNumber: Number.isFinite(Number(input.columnNumber)) ? Number(input.columnNumber) : null,
468
- stackTrace: input.stackTrace ? truncateText(String(input.stackTrace), 4000) : "",
469
- args: Array.isArray(input.args) ? input.args.slice(0, 20).map(a => truncateText(String(a), 500)) : [],
550
+ stackTrace: input.stackTrace ? redactLikelySensitiveText(input.stackTrace, 4000) : "",
551
+ args: Array.isArray(input.args) ? input.args.slice(0, 20).map(a => redactLikelySensitiveText(a, 500)) : [],
470
552
  timestamp: input.timestamp || new Date().toISOString(),
471
553
  }
472
554
  if (!event.browserSessionId) return { accepted: false, error: "browserSessionId is required" }
@@ -595,10 +677,182 @@ export class BridgeHub {
595
677
  clearTimeout(pending.timer)
596
678
  this.pendingRequests.delete(requestId)
597
679
  if (ok) {
680
+ this.recordActivity({
681
+ type: "tool-result",
682
+ tool: pending.tool,
683
+ browserSessionId: pending.browserSessionId,
684
+ status: "ok",
685
+ summary: summarizeObject(result),
686
+ })
598
687
  pending.resolve(result ?? null)
599
688
  } else {
689
+ this.recordActivity({
690
+ type: "tool-result",
691
+ tool: pending.tool,
692
+ browserSessionId: pending.browserSessionId,
693
+ status: "error",
694
+ message: error || "Browser tool failed",
695
+ })
600
696
  pending.reject(new Error(error || "Browser tool failed"))
601
697
  }
602
698
  return { accepted: true }
603
699
  }
700
+
701
+ cancelPendingRequests(args = {}) {
702
+ this.cleanupExpiredSessions()
703
+ const userId = normalizeRouteUserId(args.userId)
704
+ const explicitSessionId = String(args.browserSessionId || "").trim()
705
+ const browserSessionIds = explicitSessionId
706
+ ? new Set([explicitSessionId])
707
+ : userId
708
+ ? this.sessionIdsForUser(userId)
709
+ : new Set([...this.sessions.keys()])
710
+ const reason = redactLikelySensitiveText(
711
+ args.reason || "cancelled from local runtime console",
712
+ 240
713
+ )
714
+ let queuedCancelled = 0
715
+ const queuedRequestIds = new Set()
716
+ for (const session of this.sessions.values()) {
717
+ if (!browserSessionIds.has(session.browserSessionId)) continue
718
+ queuedCancelled += session.queue.length
719
+ for (const item of session.queue) {
720
+ queuedRequestIds.add(item.id)
721
+ const pending = this.pendingRequests.get(item.id)
722
+ if (pending) {
723
+ clearTimeout(pending.timer)
724
+ this.pendingRequests.delete(item.id)
725
+ pending.reject(new Error(reason))
726
+ }
727
+ this.recordActivity({
728
+ type: "tool-result",
729
+ tool: item.tool,
730
+ browserSessionId: session.browserSessionId,
731
+ status: "cancelled",
732
+ message: reason,
733
+ })
734
+ }
735
+ session.queue = []
736
+ }
737
+ let pendingCancelled = 0
738
+ for (const [requestId, pending] of [...this.pendingRequests.entries()]) {
739
+ if (queuedRequestIds.has(requestId)) continue
740
+ if (!browserSessionIds.has(pending.browserSessionId)) continue
741
+ clearTimeout(pending.timer)
742
+ this.pendingRequests.delete(requestId)
743
+ pending.reject(new Error(reason))
744
+ pendingCancelled += 1
745
+ this.recordActivity({
746
+ type: "tool-result",
747
+ tool: pending.tool,
748
+ browserSessionId: pending.browserSessionId,
749
+ status: "cancelled",
750
+ message: reason,
751
+ })
752
+ }
753
+ return {
754
+ ok: true,
755
+ pendingCancelled,
756
+ queuedCancelled,
757
+ note: "Only runtime pending or queued requests are cancelled; already completed browser-side effects cannot be undone.",
758
+ }
759
+ }
760
+
761
+ consoleState(args = {}) {
762
+ this.cleanupExpiredSessions()
763
+ const userId = normalizeRouteUserId(args.userId)
764
+ const sessions = [...this.sessions.values()]
765
+ .filter((session) => !userId || normalizeRouteUserId(session.meta?.userId) === userId)
766
+ .map((session) => summarizeSession(session, this.activeSessionId))
767
+ const browserSessionIds = new Set(sessions.map((session) => session.browserSessionId))
768
+ const pendingRequests = [...this.pendingRequests.entries()]
769
+ .filter(([, pending]) => browserSessionIds.has(pending.browserSessionId))
770
+ .map(([requestId, pending]) => ({
771
+ requestId,
772
+ browserSessionId: pending.browserSessionId,
773
+ tool: pending.tool,
774
+ }))
775
+ const queuedRequests = [...this.sessions.values()]
776
+ .filter((session) => browserSessionIds.has(session.browserSessionId))
777
+ .flatMap((session) =>
778
+ session.queue.map((item) => ({
779
+ requestId: item.id,
780
+ browserSessionId: session.browserSessionId,
781
+ tool: item.tool,
782
+ createdAt: item.createdAt || "",
783
+ }))
784
+ )
785
+ return {
786
+ ok: true,
787
+ name: "yunti-browser-runtime-console",
788
+ generatedAt: new Date().toISOString(),
789
+ runtime: {
790
+ version: redactLikelySensitiveText(args.runtimeVersion || "", 80),
791
+ expectedExtensionVersion: redactLikelySensitiveText(args.expectedExtensionVersion || "", 80),
792
+ },
793
+ sessionCount: sessions.length,
794
+ activeSessionId: userId ? this.activeSessionByUser.get(userId) || null : this.activeSessionId,
795
+ sessions,
796
+ pendingRequests,
797
+ queuedRequests,
798
+ diagnostics: {
799
+ networkEvents: this.networkEvents.filter((event) => browserSessionIds.has(event.browserSessionId)).length,
800
+ consoleMessages: this.consoleMessages.filter((event) => browserSessionIds.has(event.browserSessionId)).length,
801
+ cdpEvents: this.cdpEvents.filter((event) => browserSessionIds.has(event.browserSessionId)).length,
802
+ activityEvents: this.activityEvents.length,
803
+ },
804
+ recentActivity: this.activityEvents
805
+ .filter((event) => !event.browserSessionId || browserSessionIds.has(event.browserSessionId))
806
+ .slice(-30)
807
+ .reverse(),
808
+ warnings: consoleWarnings(sessions, {
809
+ expectedExtensionVersion: args.expectedExtensionVersion,
810
+ }),
811
+ guidance: {
812
+ noSessions:
813
+ "Keep this bridge running, load or reload the extension, open an http/https page, refresh that page, then run yunti-browser-runtime doctor.",
814
+ staleSession:
815
+ "Refresh the page or reload the extension, then call yunti_list_browser_targets before retrying browser tools.",
816
+ versionMismatch:
817
+ "Reload the unpacked extension from the current package directory, then refresh open http/https pages.",
818
+ cancellation:
819
+ "Cancel only clears runtime pending/queued requests; it does not undo browser-side effects that already happened.",
820
+ },
821
+ }
822
+ }
823
+ }
824
+
825
+ function consoleWarnings(sessions, { expectedExtensionVersion = "" } = {}) {
826
+ const warnings = []
827
+ if (sessions.length === 0) {
828
+ warnings.push({
829
+ code: "NO_CONNECTED_PAGES",
830
+ severity: "warning",
831
+ message:
832
+ "No browser pages are connected. Load or reload the extension, open an http/https page, and refresh the page.",
833
+ })
834
+ return warnings
835
+ }
836
+ const expected = String(expectedExtensionVersion || "").trim()
837
+ if (!expected) return warnings
838
+ const versions = new Set(sessions.map((session) => session.extensionVersion).filter(Boolean))
839
+ if (versions.size === 0) {
840
+ warnings.push({
841
+ code: "EXTENSION_VERSION_UNKNOWN",
842
+ severity: "info",
843
+ message:
844
+ "Connected extension did not report a version. Reload the extension if you recently upgraded the runtime.",
845
+ })
846
+ return warnings
847
+ }
848
+ for (const version of versions) {
849
+ if (version !== expected) {
850
+ warnings.push({
851
+ code: "EXTENSION_VERSION_MISMATCH",
852
+ severity: "warning",
853
+ message: `Connected extension version ${version} does not match runtime package version ${expected}. Reload the unpacked extension from the current package directory.`,
854
+ })
855
+ }
856
+ }
857
+ return warnings
604
858
  }
@@ -1,4 +1,7 @@
1
1
  import http from "node:http"
2
+ import { readFileSync } from "node:fs"
3
+ import { dirname, resolve } from "node:path"
4
+ import { fileURLToPath } from "node:url"
2
5
  import {
3
6
  BridgeHub,
4
7
  DEFAULT_SESSION_TTL_MS,
@@ -22,6 +25,9 @@ const RELAY_TOKEN = process.env.YUNTI_BROWSER_RELAY_TOKEN || ""
22
25
  const SERVER_NAME = process.env.YUNTI_BROWSER_SERVER_NAME || "Yunti Browser Runtime"
23
26
  const _ROUTE_USER_ID = process.env.YUNTI_BROWSER_USER_ID || "local"
24
27
  const _ROUTE_USER_NAME = process.env.YUNTI_BROWSER_USER_NAME || "local"
28
+ const PACKAGE_VERSION = readJsonFile(resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json")).version || ""
29
+ const EXTENSION_VERSION =
30
+ readJsonFile(resolve(dirname(fileURLToPath(import.meta.url)), "..", "extension", "manifest.json")).version || ""
25
31
  const MAX_JSON_BYTES = 2 * 1024 * 1024
26
32
  const SESSION_CLEANUP_INTERVAL_MS = Number(
27
33
  process.env.YUNTI_BROWSER_SESSION_CLEANUP_INTERVAL_MS || 15_000
@@ -35,6 +41,8 @@ const PROTECTED_BRIDGE_PATHS = new Set([
35
41
  "/extension/network-event",
36
42
  "/extension/cdp-event",
37
43
  "/extension/console-event",
44
+ "/console/state",
45
+ "/console/cancel-pending",
38
46
  "/mcp/request",
39
47
  "/mcp/local-tool",
40
48
  ])
@@ -45,6 +53,14 @@ function normalizeBaseUrl(value) {
45
53
  .replace(/\/+$/, "")
46
54
  }
47
55
 
56
+ function readJsonFile(path) {
57
+ try {
58
+ return JSON.parse(readFileSync(path, "utf8"))
59
+ } catch {
60
+ return {}
61
+ }
62
+ }
63
+
48
64
  function normalizeBridgeToken(value) {
49
65
  return String(value || "").trim()
50
66
  }
@@ -112,6 +128,14 @@ function sendJson(req, res, statusCode, value, { allowOrigins = DEFAULT_ALLOWED_
112
128
  res.end(body)
113
129
  }
114
130
 
131
+ function sendHtml(req, res, statusCode, html) {
132
+ res.writeHead(statusCode, {
133
+ "content-type": "text/html; charset=utf-8",
134
+ "cache-control": "no-store",
135
+ })
136
+ res.end(req.method === "HEAD" ? "" : html)
137
+ }
138
+
115
139
  function bridgeRequestToken(req) {
116
140
  const headerValue = req.headers[BRIDGE_TOKEN_HEADER]
117
141
  if (Array.isArray(headerValue)) return normalizeBridgeToken(headerValue[0])
@@ -226,6 +250,11 @@ export async function startBridgeServer({
226
250
  return
227
251
  }
228
252
 
253
+ if ((req.method === "GET" || req.method === "HEAD") && (url.pathname === "/console" || url.pathname === "/console/")) {
254
+ sendHtml(req, res, 200, localConsoleHtml({ authRequired, tokenHeader: BRIDGE_TOKEN_HEADER }))
255
+ return
256
+ }
257
+
229
258
  if (PROTECTED_BRIDGE_PATHS.has(url.pathname) && !authorized) {
230
259
  sendJson(
231
260
  req,
@@ -241,6 +270,32 @@ export async function startBridgeServer({
241
270
  return
242
271
  }
243
272
 
273
+ if (req.method === "GET" && url.pathname === "/console/state") {
274
+ const userId = url.searchParams.get("userId") || _ROUTE_USER_ID
275
+ sendJson(
276
+ req,
277
+ res,
278
+ 200,
279
+ hub.consoleState({
280
+ userId,
281
+ runtimeVersion: PACKAGE_VERSION,
282
+ expectedExtensionVersion: EXTENSION_VERSION || PACKAGE_VERSION,
283
+ }),
284
+ { allowOrigins }
285
+ )
286
+ return
287
+ }
288
+
289
+ if (req.method === "POST" && url.pathname === "/console/cancel-pending") {
290
+ const body = await readJson(req)
291
+ const result = hub.cancelPendingRequests({
292
+ ...body,
293
+ userId: body.userId || _ROUTE_USER_ID,
294
+ })
295
+ sendJson(req, res, 200, result, { allowOrigins })
296
+ return
297
+ }
298
+
244
299
  if (req.method === "GET" && url.pathname === "/sessions") {
245
300
  const userId = url.searchParams.get("userId")
246
301
  const sessions = hub.listSessions({ userId })
@@ -349,3 +404,161 @@ export async function startBridgeServer({
349
404
  }
350
405
  return { mode: "owner", host, port, bridgeToken: activeBridgeToken, authRequired, allowOrigins, hub, server }
351
406
  }
407
+
408
+ function localConsoleHtml({ authRequired, tokenHeader }) {
409
+ return `<!doctype html>
410
+ <html lang="en">
411
+ <head>
412
+ <meta charset="utf-8">
413
+ <meta name="viewport" content="width=device-width, initial-scale=1">
414
+ <title>Yunti Browser Runtime Console</title>
415
+ <style>
416
+ :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
417
+ body { margin: 0; background: Canvas; color: CanvasText; }
418
+ main { max-width: 1120px; margin: 0 auto; padding: 24px; }
419
+ header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
420
+ h1 { margin: 0; font-size: 24px; line-height: 1.2; }
421
+ h2 { margin: 0 0 10px; font-size: 16px; }
422
+ button { border: 1px solid ButtonBorder; background: ButtonFace; color: ButtonText; border-radius: 6px; padding: 8px 10px; cursor: pointer; }
423
+ button:disabled { opacity: .55; cursor: not-allowed; }
424
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
425
+ .panel { border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); border-radius: 8px; padding: 14px; }
426
+ .metric { font-size: 28px; font-weight: 700; }
427
+ .muted { color: color-mix(in srgb, CanvasText 62%, transparent); }
428
+ .sessions, .activity { display: grid; gap: 10px; }
429
+ .row { border-top: 1px solid color-mix(in srgb, CanvasText 14%, transparent); padding-top: 10px; }
430
+ .row:first-child { border-top: 0; padding-top: 0; }
431
+ code { overflow-wrap: anywhere; }
432
+ .status { display: inline-flex; border-radius: 999px; padding: 2px 8px; font-size: 12px; background: color-mix(in srgb, LinkText 16%, transparent); }
433
+ .warn { color: #9f4e00; }
434
+ .error { color: #b3261e; }
435
+ .warning-list { display: grid; gap: 8px; margin: 0 0 12px; }
436
+ .warning-item { border: 1px solid color-mix(in srgb, #9f4e00 50%, transparent); border-radius: 8px; padding: 10px; }
437
+ .toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
438
+ @media (max-width: 640px) { main { padding: 16px; } header { align-items: flex-start; flex-direction: column; } }
439
+ </style>
440
+ </head>
441
+ <body>
442
+ <main>
443
+ <header>
444
+ <div>
445
+ <h1>Yunti Browser Runtime</h1>
446
+ <div class="muted">Local console for bridge status, connected pages, diagnostics, and pending tasks.</div>
447
+ </div>
448
+ <div class="toolbar">
449
+ <button id="refresh" type="button">Refresh</button>
450
+ <button id="cancel" type="button">Cancel Pending</button>
451
+ </div>
452
+ </header>
453
+ <section id="auth" class="panel" hidden></section>
454
+ <section id="warnings" class="warning-list" aria-live="polite"></section>
455
+ <section class="grid" aria-live="polite">
456
+ <div class="panel"><h2>Sessions</h2><div id="sessionCount" class="metric">-</div><div class="muted">connected browser pages</div></div>
457
+ <div class="panel"><h2>Pending</h2><div id="pendingCount" class="metric">-</div><div class="muted">runtime requests waiting for results</div></div>
458
+ <div class="panel"><h2>Diagnostics</h2><div id="diagCount" class="metric">-</div><div class="muted">sanitized network, console, and CDP summaries</div></div>
459
+ <div class="panel"><h2>Runtime</h2><div id="runtimeVersion" class="metric">-</div><div class="muted">expected extension <span id="extensionVersion">-</span></div></div>
460
+ </section>
461
+ <section class="panel" style="margin-top: 12px;">
462
+ <h2>Connected Pages</h2>
463
+ <div id="sessions" class="sessions muted">Loading...</div>
464
+ </section>
465
+ <section class="panel" style="margin-top: 12px;">
466
+ <h2>Recent Activity</h2>
467
+ <div id="activity" class="activity muted">Loading...</div>
468
+ </section>
469
+ </main>
470
+ <script>
471
+ const stateUrl = "/console/state";
472
+ const authRequired = ${JSON.stringify(Boolean(authRequired))};
473
+ const tokenHeader = ${JSON.stringify(tokenHeader)};
474
+ const refreshButton = document.querySelector("#refresh");
475
+ const cancelButton = document.querySelector("#cancel");
476
+ const authPanel = document.querySelector("#auth");
477
+ const text = (value) => value == null || value === "" ? "-" : String(value);
478
+
479
+ if (authRequired) {
480
+ authPanel.hidden = false;
481
+ authPanel.innerHTML = '<strong>Bridge auth is enabled.</strong> This page shell is visible, but state requests require the <code>' + tokenHeader + '</code> header. Use <code>yunti-browser-runtime doctor</code> or curl with the header for protected diagnostics.';
482
+ }
483
+
484
+ async function loadState() {
485
+ refreshButton.disabled = true;
486
+ try {
487
+ const response = await fetch(stateUrl, { cache: "no-store" });
488
+ if (!response.ok) throw new Error("HTTP " + response.status);
489
+ const state = await response.json();
490
+ render(state);
491
+ } catch (error) {
492
+ document.querySelector("#sessions").innerHTML = '<span class="error">' + escapeHtml(error.message || String(error)) + '</span>';
493
+ document.querySelector("#activity").textContent = authRequired ? "State is protected by bridge auth." : "Unable to read console state.";
494
+ } finally {
495
+ refreshButton.disabled = false;
496
+ }
497
+ }
498
+
499
+ async function cancelPending() {
500
+ cancelButton.disabled = true;
501
+ try {
502
+ await fetch("/console/cancel-pending", {
503
+ method: "POST",
504
+ headers: { "content-type": "application/json" },
505
+ body: JSON.stringify({ reason: "cancelled from local runtime console" })
506
+ });
507
+ await loadState();
508
+ } finally {
509
+ cancelButton.disabled = false;
510
+ }
511
+ }
512
+
513
+ function render(state) {
514
+ const diagnostics = state.diagnostics || {};
515
+ const pending = (state.pendingRequests || []).length + (state.queuedRequests || []).length;
516
+ document.querySelector("#sessionCount").textContent = state.sessionCount || 0;
517
+ document.querySelector("#pendingCount").textContent = pending;
518
+ document.querySelector("#diagCount").textContent = (diagnostics.networkEvents || 0) + (diagnostics.consoleMessages || 0) + (diagnostics.cdpEvents || 0);
519
+ document.querySelector("#runtimeVersion").textContent = state.runtime?.version || "-";
520
+ document.querySelector("#extensionVersion").textContent = state.runtime?.expectedExtensionVersion || "-";
521
+ document.querySelector("#warnings").innerHTML = renderWarnings(state);
522
+ document.querySelector("#sessions").innerHTML = renderSessions(state);
523
+ document.querySelector("#activity").innerHTML = renderActivity(state);
524
+ }
525
+
526
+ function renderWarnings(state) {
527
+ if (!state.warnings || state.warnings.length === 0) return "";
528
+ return state.warnings.map((warning) => '<div class="warning-item"><strong>' + escapeHtml(text(warning.code)) + '</strong><div>' + escapeHtml(text(warning.message)) + '</div></div>').join("");
529
+ }
530
+
531
+ function renderSessions(state) {
532
+ if (!state.sessions || state.sessions.length === 0) {
533
+ return '<div>No connected pages. ' + escapeHtml(state.guidance?.noSessions || "") + '</div>';
534
+ }
535
+ return state.sessions.map((session) => '<div class="row"><div><span class="status">' + (session.active ? "active" : "connected") + '</span> <strong>' + escapeHtml(text(session.title || session.displayName)) + '</strong></div><div><code>' + escapeHtml(text(session.url)) + '</code></div><div class="muted">tab ' + escapeHtml(text(session.tabId)) + ' · extension ' + escapeHtml(text(session.extensionVersion)) + ' · queued ' + escapeHtml(text(session.queuedRequests)) + ' · last seen ' + escapeHtml(text(session.lastSeenAt)) + '</div></div>').join("");
536
+ }
537
+
538
+ function renderActivity(state) {
539
+ if (!state.recentActivity || state.recentActivity.length === 0) return '<div>No recent activity yet.</div>';
540
+ return state.recentActivity.map((event) => '<div class="row"><div><span class="status">' + escapeHtml(text(event.status || event.type)) + '</span> <strong>' + escapeHtml(text(event.tool || event.type)) + '</strong></div><div class="muted">' + escapeHtml(text(event.timestamp)) + ' · ' + escapeHtml(text(event.browserSessionId)) + '</div><div>' + escapeHtml(text(event.message || summarize(event.summary))) + '</div></div>').join("");
541
+ }
542
+
543
+ function summarize(summary) {
544
+ if (!summary) return "";
545
+ const parts = [];
546
+ if (summary.ok != null) parts.push("ok=" + summary.ok);
547
+ if (summary.code) parts.push("code=" + summary.code);
548
+ if (summary.action) parts.push("action=" + summary.action);
549
+ if (summary.keys) parts.push("keys=" + summary.keys.join(","));
550
+ return parts.join(" · ");
551
+ }
552
+
553
+ function escapeHtml(value) {
554
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
555
+ }
556
+
557
+ refreshButton.addEventListener("click", loadState);
558
+ cancelButton.addEventListener("click", cancelPending);
559
+ loadState();
560
+ setInterval(loadState, 5000);
561
+ </script>
562
+ </body>
563
+ </html>`
564
+ }
package/mcp/memory.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto"
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises"
3
3
  import { yuntiMemoryDir, yuntiMemoryFile } from "../lib/runtime-paths.js"
4
- import { clampNumber, redactLikelySecrets, truncateText } from "./redaction.js"
4
+ import { clampNumber, redactLikelySensitiveText, truncateText } from "./redaction.js"
5
5
 
6
6
  const MAX_MEMORY_ITEMS = 500
7
7
  const DEFAULT_ROUTE_USER_ID = process.env.YUNTI_BROWSER_USER_ID || "local"
@@ -47,13 +47,13 @@ function sanitizeMemoryInput(args = {}) {
47
47
  return {
48
48
  id: randomUUID(),
49
49
  kind: truncateText(args.kind || "other", 80),
50
- title: redactLikelySecrets(truncateText(args.title, 300)),
51
- detail: redactLikelySecrets(truncateText(args.detail, 4000)),
50
+ title: redactLikelySensitiveText(args.title, 300),
51
+ detail: redactLikelySensitiveText(args.detail, 4000),
52
52
  tags: Array.isArray(args.tags)
53
- ? args.tags.map((x) => truncateText(x, 80)).filter(Boolean).slice(0, 20)
53
+ ? args.tags.map((x) => redactLikelySensitiveText(x, 80)).filter(Boolean).slice(0, 20)
54
54
  : [],
55
55
  confidence: clampNumber(args.confidence ?? 0.5, 0, 1, 0.5),
56
- source: redactLikelySecrets(truncateText(args.source || "", 500)),
56
+ source: redactLikelySensitiveText(args.source || "", 500),
57
57
  relatedNetworkEventIds: Array.isArray(args.relatedNetworkEventIds)
58
58
  ? args.relatedNetworkEventIds.map((x) => Number(x)).filter(Number.isFinite).slice(0, 50)
59
59
  : [],