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/README.md +52 -15
- package/bin/yunti-browser-runtime.js +6 -0
- package/docs/ACTION_RESULT_COVERAGE.md +41 -0
- package/docs/AGENT_WORKFLOW_CONTRACT.md +97 -0
- package/docs/EXECUTION_PLAN.md +1433 -4
- package/docs/EXTENSION_DISTRIBUTION.md +145 -0
- package/docs/EXTENSION_PERMISSION_STRATEGY.md +164 -0
- package/docs/EXTENSION_STORE_COPY.md +210 -0
- package/docs/INSTALL.md +18 -8
- package/docs/NEXT_MAJOR_PLAN.md +808 -0
- package/docs/PROJECT_STATUS.md +1019 -8
- package/docs/PUBLISHING_BLOCKERS.md +2 -2
- package/docs/RELEASE.md +6 -2
- package/docs/RELEASE_0_2_0_CHECKLIST.md +820 -0
- package/docs/ROADMAP.md +44 -0
- package/docs/SECURITY.md +34 -0
- package/docs/TOOL_GUIDE.md +282 -5
- package/extension/content.js +76 -7
- package/extension/dom-observer.js +588 -0
- package/extension/manifest.json +2 -2
- package/extension/popup.css +6 -1
- package/extension/popup.html +10 -10
- package/extension/popup.js +21 -10
- package/extension/session-manager.js +2 -0
- package/extension/tool-handlers.js +1120 -94
- package/mcp/bridge-hub.js +257 -3
- package/mcp/http-server.js +213 -0
- package/mcp/memory.js +5 -5
- package/mcp/redaction.js +52 -3
- package/mcp/server.js +2 -0
- package/mcp/tools.js +417 -38
- package/package.json +4 -2
- package/scripts/check-action-result-coverage.js +145 -0
- package/scripts/doctor.js +4 -0
- package/scripts/package-extension.js +1 -0
- package/scripts/print-config.js +1 -1
- package/scripts/release-check.js +3 -0
- package/skills/yunti-browser-runtime/SKILL.md +125 -2
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:
|
|
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 ?
|
|
469
|
-
args: Array.isArray(input.args) ? input.args.slice(0, 20).map(a =>
|
|
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
|
}
|
package/mcp/http-server.js
CHANGED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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,
|
|
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:
|
|
51
|
-
detail:
|
|
50
|
+
title: redactLikelySensitiveText(args.title, 300),
|
|
51
|
+
detail: redactLikelySensitiveText(args.detail, 4000),
|
|
52
52
|
tags: Array.isArray(args.tags)
|
|
53
|
-
? args.tags.map((x) =>
|
|
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:
|
|
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
|
: [],
|