dsh-browser-playwright-codex 0.5.4 → 0.6.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/CHANGELOG.md +93 -2
- package/NOTICE.md +39 -0
- package/README.md +72 -11
- package/SECURITY.md +22 -0
- package/cordis.patch.yml +6 -0
- package/lib/compat.d.ts +9 -9
- package/lib/compat.js +0 -20
- package/lib/contract.d.ts +2 -2
- package/lib/contract.js +0 -4
- package/lib/login-sites.d.ts +136 -0
- package/lib/login-sites.js +303 -0
- package/lib/page-actions.d.ts +2 -2
- package/lib/page-actions.js +1 -1
- package/lib/provider.js +45 -1
- package/lib/runtime-state.d.ts +39 -0
- package/lib/runtime-state.js +20 -26
- package/lib/session.d.ts +40 -22
- package/lib/session.js +65 -1
- package/lib/site-aliases.d.ts +33 -0
- package/lib/site-aliases.js +132 -0
- package/lib/tool.js +95 -91
- package/lib/types.d.ts +13 -0
- package/package.json +5 -2
- package/scripts/preflight.mjs +192 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「已登录站点」的只读推断:把 cookie 按可注册域聚合、打分,供设置页展示。
|
|
3
|
+
*
|
|
4
|
+
* 两条设计来自成熟产品的做法,2026-10-16 重写时按它们改的(第一版按"谁设的域"聚合、用"名字像会话 +
|
|
5
|
+
* cookie 多"打分,结果真实名单里 `pubmatic.com`(广告交易所)和 `mmstat.com`(统计)都排在前面):
|
|
6
|
+
*
|
|
7
|
+
* 1. **归属看"你在哪个站",不看"谁设的"** —— Firefox 的 Total Cookie Protection 给每个访问过的
|
|
8
|
+
* 网站一个独立 cookie jar,第三方(统计/广告)的 cookie 属于**当时浏览的那个站**,不代表你登录了它。
|
|
9
|
+
* 所以这里只把「浏览器实际访问过的域」当候选第一方,其余只报个数、不进列表。
|
|
10
|
+
* 2. **判据用 OWASP 的会话 ID 特征** —— HttpOnly + Secure + 长且高熵 + 会话级;**名字是最不可靠的
|
|
11
|
+
* 信号**(框架名可被指纹、也可被改成任意名字),所以名字与"cookie 条数"都不再能单独把一条推成
|
|
12
|
+
* "推测登录"。
|
|
13
|
+
*
|
|
14
|
+
* 红线(写给下一个改这个文件的人):
|
|
15
|
+
* 1. **永不输出 cookie 的值,也不输出 cookie 的名字。** 出去的东西只有域名、条数和布尔信号;
|
|
16
|
+
* 名字只在本模块内部用于统计,值只被 `session.cookieJar()` 折算成一个布尔(像不像令牌)。
|
|
17
|
+
* 2. **不读 Chrome 的密码库**(Login Data)。这里只碰 cookie。
|
|
18
|
+
* 3. **这份数据不给模型看。** 它只经由设置页的路由出去;不注册工具、不写进 diagnostics。
|
|
19
|
+
* 4. 结论写"推测":cookie 存在 ≠ 登录着。
|
|
20
|
+
* @module dsh-browser-playwright-codex/login-sites
|
|
21
|
+
*/
|
|
22
|
+
import { siteAlias, siteGroup } from "./site-aliases.js";
|
|
23
|
+
/** How many sites the payload carries at most. */
|
|
24
|
+
const MAX_SITES = 60;
|
|
25
|
+
/**
|
|
26
|
+
* Compact public-suffix subset: exactly the multi-part endings where "last two labels"
|
|
27
|
+
* would be wrong. Not the full PSL — a curated list is enough here because a wrong group
|
|
28
|
+
* only mislabels a row, and the alternative (bundling the whole list) costs more than it buys.
|
|
29
|
+
*/
|
|
30
|
+
const MULTI_PART_SUFFIXES = new Set([
|
|
31
|
+
'com.cn',
|
|
32
|
+
'net.cn',
|
|
33
|
+
'org.cn',
|
|
34
|
+
'gov.cn',
|
|
35
|
+
'edu.cn',
|
|
36
|
+
'ac.cn',
|
|
37
|
+
'co.uk',
|
|
38
|
+
'org.uk',
|
|
39
|
+
'gov.uk',
|
|
40
|
+
'ac.uk',
|
|
41
|
+
'co.jp',
|
|
42
|
+
'ne.jp',
|
|
43
|
+
'or.jp',
|
|
44
|
+
'com.au',
|
|
45
|
+
'net.au',
|
|
46
|
+
'org.au',
|
|
47
|
+
'com.hk',
|
|
48
|
+
'com.tw',
|
|
49
|
+
'com.sg',
|
|
50
|
+
'com.br',
|
|
51
|
+
'com.mx',
|
|
52
|
+
'co.kr',
|
|
53
|
+
'co.in',
|
|
54
|
+
'co.nz',
|
|
55
|
+
'co.za',
|
|
56
|
+
]);
|
|
57
|
+
/**
|
|
58
|
+
* Cookies that every site sets whether or not you ever signed in. Counting them would make
|
|
59
|
+
* "visited" look like "logged in" — the exact false positive this feature must avoid.
|
|
60
|
+
*/
|
|
61
|
+
const TRACKER_PATTERNS = [
|
|
62
|
+
/^_ga/i,
|
|
63
|
+
/^_gid$/i,
|
|
64
|
+
/^_gat/i,
|
|
65
|
+
/^_gcl/i,
|
|
66
|
+
/^__utm/i,
|
|
67
|
+
/^_fbp$/i,
|
|
68
|
+
/^_fbc$/i,
|
|
69
|
+
/^__cf_bm$/i,
|
|
70
|
+
/^__cfduid$/i,
|
|
71
|
+
/^cf_clearance$/i,
|
|
72
|
+
/^Hm_lv/i,
|
|
73
|
+
/^HMACCOUNT/i,
|
|
74
|
+
/^BAIDUID/i,
|
|
75
|
+
/^BIDUPSID/i,
|
|
76
|
+
/^PSTM$/i,
|
|
77
|
+
/^BDUSS_BF/i,
|
|
78
|
+
/^_hj/i,
|
|
79
|
+
/^intercom-/i,
|
|
80
|
+
/^ajs_/i,
|
|
81
|
+
/^amplitude_/i,
|
|
82
|
+
/^mp_/i,
|
|
83
|
+
/^sensorsdata/i,
|
|
84
|
+
/^sajssdk/i,
|
|
85
|
+
/^_clck$/i,
|
|
86
|
+
/^_clsk$/i,
|
|
87
|
+
/^_uetsid$/i,
|
|
88
|
+
/^_uetvid$/i,
|
|
89
|
+
/^MUID$/i,
|
|
90
|
+
/^SRM_B$/i,
|
|
91
|
+
/^_dd_s$/i,
|
|
92
|
+
/^optimizely/i,
|
|
93
|
+
/^_pin_unauth$/i,
|
|
94
|
+
/^ab\./i,
|
|
95
|
+
/^cookie_?consent/i,
|
|
96
|
+
/^OptanonConsent$/i,
|
|
97
|
+
/^OptanonAlertBoxClosed$/i,
|
|
98
|
+
/^euconsent/i,
|
|
99
|
+
/^didomi/i,
|
|
100
|
+
/^_iub_cs/i,
|
|
101
|
+
/^CookieConsent$/i,
|
|
102
|
+
];
|
|
103
|
+
/**
|
|
104
|
+
* Whole hosts that are analytics/ad endpoints rather than sites you log into. The name list above
|
|
105
|
+
* cannot catch these: `mmstat.com` (Alibaba's stats host) sets cookies with innocent names, and a
|
|
106
|
+
* user who merely browsed Taobao would see it listed as a login. Found by looking at the first real
|
|
107
|
+
* payload this feature ever produced (231 domains; `mmstat.com` 30 cookies, `pubmatic.com` 27).
|
|
108
|
+
*/
|
|
109
|
+
const TRACKER_HOSTS = [
|
|
110
|
+
/(^|\.)mmstat\.com$/i,
|
|
111
|
+
/(^|\.)umeng\.com$/i,
|
|
112
|
+
/(^|\.)umengcloud\.com$/i,
|
|
113
|
+
/(^|\.)google-analytics\.com$/i,
|
|
114
|
+
/(^|\.)googletagmanager\.com$/i,
|
|
115
|
+
/(^|\.)doubleclick\.net$/i,
|
|
116
|
+
/(^|\.)hm\.baidu\.com$/i,
|
|
117
|
+
/(^|\.)cnzz\.com$/i,
|
|
118
|
+
/(^|\.)51\.la$/i,
|
|
119
|
+
/(^|\.)matomo\.cloud$/i,
|
|
120
|
+
/(^|\.)clarity\.ms$/i,
|
|
121
|
+
/(^|\.)hotjar\.com$/i,
|
|
122
|
+
/(^|\.)sentry\.io$/i,
|
|
123
|
+
/(^|\.)pubmatic\.com$/i,
|
|
124
|
+
/(^|\.)criteo\.com$/i,
|
|
125
|
+
/(^|\.)adnxs\.com$/i,
|
|
126
|
+
/(^|\.)rubiconproject\.com$/i,
|
|
127
|
+
/(^|\.)casalemedia\.com$/i,
|
|
128
|
+
/(^|\.)scorecardresearch\.com$/i,
|
|
129
|
+
/(^|\.)adsrvr\.org$/i,
|
|
130
|
+
/(^|\.)taboola\.com$/i,
|
|
131
|
+
/(^|\.)outbrain\.com$/i,
|
|
132
|
+
];
|
|
133
|
+
const WEEK_SECONDS = 7 * 24 * 60 * 60;
|
|
134
|
+
/**
|
|
135
|
+
* Collapse a cookie host to its registrable domain. `www.x.com`, `.x.com` and `a.b.x.com`
|
|
136
|
+
* all become `x.com`; `a.x.co.uk` becomes `x.co.uk`.
|
|
137
|
+
* @param host - the cookie's `domain` (may carry a leading dot).
|
|
138
|
+
* @returns the registrable domain, lowercased.
|
|
139
|
+
*/
|
|
140
|
+
export function registrableDomain(host) {
|
|
141
|
+
const clean = host.trim().replace(/^\./, '').toLowerCase();
|
|
142
|
+
if (clean === '' || clean === 'localhost' || /^\d+(\.\d+){3}$/.test(clean))
|
|
143
|
+
return clean;
|
|
144
|
+
const parts = clean.split('.');
|
|
145
|
+
if (parts.length <= 2)
|
|
146
|
+
return clean;
|
|
147
|
+
const lastTwo = parts.slice(-2).join('.');
|
|
148
|
+
if (MULTI_PART_SUFFIXES.has(lastTwo))
|
|
149
|
+
return parts.slice(-3).join('.');
|
|
150
|
+
return lastTwo;
|
|
151
|
+
}
|
|
152
|
+
/** Whether a cookie name belongs to the always-present analytics/consent family. */
|
|
153
|
+
export function isTracker(name) {
|
|
154
|
+
return TRACKER_PATTERNS.some((pattern) => pattern.test(name));
|
|
155
|
+
}
|
|
156
|
+
/** Whether a whole host is an analytics/ad endpoint rather than a site you log into. */
|
|
157
|
+
export function isTrackerHost(host) {
|
|
158
|
+
const clean = host.trim().replace(/^\./, '').toLowerCase();
|
|
159
|
+
return TRACKER_HOSTS.some((pattern) => pattern.test(clean));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether a cookie *value* looks like a session token rather than an analytics blob. OWASP's
|
|
163
|
+
* session-ID guidance is the basis: at least 64 bits of entropy, hex ids ≥16 chars, base64 ids
|
|
164
|
+
* longer still, opaque content. Analytics values are short, structured or JSON, so they fail here.
|
|
165
|
+
*
|
|
166
|
+
* The value is passed in and never returned: callers turn this into the `tokenLike` boolean.
|
|
167
|
+
* @param value - the raw cookie value.
|
|
168
|
+
* @returns whether it plausibly is a token.
|
|
169
|
+
*/
|
|
170
|
+
export function looksLikeToken(value) {
|
|
171
|
+
if (typeof value !== 'string')
|
|
172
|
+
return false;
|
|
173
|
+
const v = value.trim();
|
|
174
|
+
if (v.length < 20 || v.length > 4096)
|
|
175
|
+
return false;
|
|
176
|
+
if (/\s/.test(v))
|
|
177
|
+
return false;
|
|
178
|
+
if (v.startsWith('{') || v.startsWith('[') || v.startsWith('"'))
|
|
179
|
+
return false;
|
|
180
|
+
if (!/^[A-Za-z0-9._~+/=-]+$/.test(v))
|
|
181
|
+
return false;
|
|
182
|
+
if (new Set(v).size < 8)
|
|
183
|
+
return false;
|
|
184
|
+
const hasDigit = /[0-9]/.test(v);
|
|
185
|
+
const hasLetter = /[A-Za-z]/.test(v);
|
|
186
|
+
// Pure hex of session-id length, or a base64-ish mix, or simply long and varied.
|
|
187
|
+
if (/^[0-9a-f]{16,}$/i.test(v))
|
|
188
|
+
return true;
|
|
189
|
+
if (hasDigit && hasLetter)
|
|
190
|
+
return true;
|
|
191
|
+
return v.length >= 40;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Aggregate cookies into per-domain login inferences. Trackers are dropped, third-party domains
|
|
195
|
+
* (ones the browser never visited) are counted but not listed, and only counts and boolean signals
|
|
196
|
+
* leave this function.
|
|
197
|
+
* @param rows - cookies as read from the browser (or the last exported state file).
|
|
198
|
+
* @param source - where those cookies came from, for the card's label.
|
|
199
|
+
* @param now - epoch ms, injectable so tests are not time-dependent.
|
|
200
|
+
* @param options.visited - registrable domains the browser actually visited; the rest is third-party.
|
|
201
|
+
* @param options.titles - page titles by registrable domain, for display only.
|
|
202
|
+
* @param options.maxSites - display cap; defaults to {@link MAX_SITES}.
|
|
203
|
+
* @returns the display payload.
|
|
204
|
+
*/
|
|
205
|
+
export function summarizeLoginSites(rows, source, now, options = {}) {
|
|
206
|
+
const { visited = [], titles, maxSites = MAX_SITES } = options;
|
|
207
|
+
const visitedSet = visited instanceof Set ? visited : new Set(visited);
|
|
208
|
+
const perDomain = new Map();
|
|
209
|
+
let counted = 0;
|
|
210
|
+
let thirdParty = 0;
|
|
211
|
+
const seenThirdParty = new Set();
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
if (typeof row?.name !== 'string' || typeof row?.domain !== 'string')
|
|
214
|
+
continue;
|
|
215
|
+
if (isTracker(row.name))
|
|
216
|
+
continue;
|
|
217
|
+
if (isTrackerHost(row.domain))
|
|
218
|
+
continue;
|
|
219
|
+
const domain = registrableDomain(row.domain);
|
|
220
|
+
if (domain === '')
|
|
221
|
+
continue;
|
|
222
|
+
// First-party attribution: a cookie on a domain this browser never visited belongs to the site
|
|
223
|
+
// that was being browsed, not to that domain (Firefox calls this the per-site cookie jar).
|
|
224
|
+
if (visitedSet.size > 0 && !visitedSet.has(domain)) {
|
|
225
|
+
if (!seenThirdParty.has(domain)) {
|
|
226
|
+
seenThirdParty.add(domain);
|
|
227
|
+
thirdParty += 1;
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
counted += 1;
|
|
232
|
+
const entry = perDomain.get(domain) ?? {
|
|
233
|
+
cookies: 0,
|
|
234
|
+
httpOnly: 0,
|
|
235
|
+
longLived: 0,
|
|
236
|
+
sessionLike: 0,
|
|
237
|
+
tokenLike: 0,
|
|
238
|
+
};
|
|
239
|
+
entry.cookies += 1;
|
|
240
|
+
if (row.httpOnly === true)
|
|
241
|
+
entry.httpOnly += 1;
|
|
242
|
+
if (row.tokenLike === true)
|
|
243
|
+
entry.tokenLike += 1;
|
|
244
|
+
const expires = typeof row.expires === 'number' ? row.expires : -1;
|
|
245
|
+
if (expires === -1 || expires === 0)
|
|
246
|
+
entry.sessionLike += 1;
|
|
247
|
+
else if (expires - now / 1000 > WEEK_SECONDS)
|
|
248
|
+
entry.longLived += 1;
|
|
249
|
+
perDomain.set(domain, entry);
|
|
250
|
+
}
|
|
251
|
+
const sites = [];
|
|
252
|
+
for (const [domain, entry] of perDomain) {
|
|
253
|
+
// OWASP's criteria, not name-guessing: an httpOnly cookie AND a session-scoped or token-shaped
|
|
254
|
+
// one. Cookie count and session-like *names* deliberately do not contribute — the first version
|
|
255
|
+
// of this scored them and put an ad exchange at the top of a real list.
|
|
256
|
+
const likely = entry.httpOnly >= 1 && (entry.sessionLike >= 1 || entry.tokenLike >= 1);
|
|
257
|
+
const signals = [];
|
|
258
|
+
if (entry.httpOnly >= 1)
|
|
259
|
+
signals.push('httpOnly');
|
|
260
|
+
if (entry.sessionLike >= 1)
|
|
261
|
+
signals.push('会话 cookie');
|
|
262
|
+
if (entry.tokenLike >= 1)
|
|
263
|
+
signals.push('值像令牌');
|
|
264
|
+
if (entry.longLived >= 1)
|
|
265
|
+
signals.push('长效');
|
|
266
|
+
const title = titles?.get(domain)?.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
267
|
+
const alias = siteAlias(domain);
|
|
268
|
+
const group = siteGroup(domain);
|
|
269
|
+
sites.push({
|
|
270
|
+
domain,
|
|
271
|
+
cookies: entry.cookies,
|
|
272
|
+
httpOnly: entry.httpOnly,
|
|
273
|
+
longLived: entry.longLived,
|
|
274
|
+
sessionLike: entry.sessionLike,
|
|
275
|
+
tokenLike: entry.tokenLike,
|
|
276
|
+
likely,
|
|
277
|
+
signals,
|
|
278
|
+
...(title !== undefined && title !== '' ? { title } : {}),
|
|
279
|
+
...(alias !== undefined ? { alias } : {}),
|
|
280
|
+
...(group !== undefined ? { group } : {}),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const likelyTotal = sites.filter((site) => site.likely).length;
|
|
284
|
+
sites.sort((a, b) => {
|
|
285
|
+
if (a.likely !== b.likely)
|
|
286
|
+
return a.likely ? -1 : 1;
|
|
287
|
+
if (a.httpOnly !== b.httpOnly)
|
|
288
|
+
return b.httpOnly - a.httpOnly;
|
|
289
|
+
if (a.cookies !== b.cookies)
|
|
290
|
+
return b.cookies - a.cookies;
|
|
291
|
+
return a.domain.localeCompare(b.domain);
|
|
292
|
+
});
|
|
293
|
+
return {
|
|
294
|
+
source,
|
|
295
|
+
capturedAt: now,
|
|
296
|
+
cookies: counted,
|
|
297
|
+
total: sites.length,
|
|
298
|
+
likelyTotal,
|
|
299
|
+
thirdPartyTotal: thirdParty,
|
|
300
|
+
attribution: visitedSet.size > 0 ? 'first-party' : 'unfiltered',
|
|
301
|
+
sites: sites.slice(0, maxSites),
|
|
302
|
+
};
|
|
303
|
+
}
|
package/lib/page-actions.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export declare function resolveClickPoint(session: PlaywrightSession, locator: L
|
|
|
51
51
|
}>;
|
|
52
52
|
/**
|
|
53
53
|
* Click an element the snapshot gave no ref, addressed by its visible label
|
|
54
|
-
* (e.g. a menu item revealed by
|
|
54
|
+
* (e.g. a menu item revealed by browser_click with mode "hover" that is plain text). The
|
|
55
55
|
* label must equal an element's own direct text; the deepest match wins,
|
|
56
56
|
* and the landing note names what was actually clicked plus how many
|
|
57
57
|
* elements shared the label.
|
|
@@ -119,7 +119,7 @@ export declare function hoverText(session: PlaywrightSession, text: string, sign
|
|
|
119
119
|
* target came from a ref).
|
|
120
120
|
*/
|
|
121
121
|
export declare function hoverLocator(session: PlaywrightSession, locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<import("./types.ts").BrowserSnapshot>;
|
|
122
|
-
export declare function scroll(session: PlaywrightSession, direction: 'up' | 'down', amount: number, ref: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
122
|
+
export declare function scroll(session: PlaywrightSession, direction: 'up' | 'down', amount: number, ref: string | undefined, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
123
123
|
export declare function back(session: PlaywrightSession, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
124
124
|
export declare function forward(session: PlaywrightSession, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
125
125
|
export declare function switchTab(session: PlaywrightSession, index: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot | {
|
package/lib/page-actions.js
CHANGED
|
@@ -230,7 +230,7 @@ export async function resolveClickPoint(session, locator, signal) {
|
|
|
230
230
|
}
|
|
231
231
|
/**
|
|
232
232
|
* Click an element the snapshot gave no ref, addressed by its visible label
|
|
233
|
-
* (e.g. a menu item revealed by
|
|
233
|
+
* (e.g. a menu item revealed by browser_click with mode "hover" that is plain text). The
|
|
234
234
|
* label must equal an element's own direct text; the deepest match wins,
|
|
235
235
|
* and the landing note names what was actually clicked plus how many
|
|
236
236
|
* elements shared the label.
|
package/lib/provider.js
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
* @module dsh-browser-playwright-codex/provider
|
|
4
4
|
*/
|
|
5
5
|
import { persistState, restoreState } from "./login-state.js";
|
|
6
|
+
import { looksLikeToken, registrableDomain, summarizeLoginSites } from "./login-sites.js";
|
|
7
|
+
import fs from 'node:fs';
|
|
6
8
|
import { clearChromeLocks, ensureBrowser, ensureContext } from "./browser-lifecycle.js";
|
|
7
9
|
import {} from 'playwright-core';
|
|
8
10
|
import os from 'node:os';
|
|
9
11
|
import path from 'node:path';
|
|
10
12
|
import { PlaywrightSession } from "./session.js";
|
|
11
|
-
import { getEnabled, publishConfiguredEngine } from "./runtime-state.js";
|
|
13
|
+
import { getEnabled, publishConfiguredEngine, publishLoginSites } from "./runtime-state.js";
|
|
12
14
|
import { BrowserError } from "./errors.js";
|
|
13
15
|
import { SNAPSHOT_SCRIPT } from "./injected.js";
|
|
14
16
|
import { isWindowMinimized } from "./window-focus.js";
|
|
@@ -45,6 +47,48 @@ export class PlaywrightProvider {
|
|
|
45
47
|
: path.join(os.homedir(), '.dsh', 'browser-profiles', 'playwright');
|
|
46
48
|
this.profileDir = dir;
|
|
47
49
|
this.stateFile = path.join(dir, 'dsh-storage-state.json');
|
|
50
|
+
// Show the last exported state on the settings card before the browser is even open. The
|
|
51
|
+
// file is the browser's own cookie export; unreadable/unparsable simply means "nothing to
|
|
52
|
+
// show yet". Values are dropped by summarizeLoginSites — only counts and signals survive.
|
|
53
|
+
try {
|
|
54
|
+
const exported = JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
|
|
55
|
+
// With no live session, the exported localStorage origins are the honest stand-in for
|
|
56
|
+
// "sites this browser visited": a site can only have storage there if it was loaded.
|
|
57
|
+
const hosts = new Set();
|
|
58
|
+
if (Array.isArray(exported.origins)) {
|
|
59
|
+
for (const entry of exported.origins) {
|
|
60
|
+
const origin = entry?.origin;
|
|
61
|
+
if (typeof origin !== 'string')
|
|
62
|
+
continue;
|
|
63
|
+
if (!origin.startsWith('http://') && !origin.startsWith('https://'))
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
hosts.add(registrableDomain(new URL(origin).host));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* unparsable origin: skip it */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(exported.cookies)) {
|
|
74
|
+
// The export carries raw values, so the token-likeness signal has to be computed here —
|
|
75
|
+
// without it every "httpOnly but not session-scoped" cookie stops counting and the
|
|
76
|
+
// snapshot view reports far fewer logins than the live one (2026-10-16: 12 vs 86 on the
|
|
77
|
+
// same file). The value is reduced to a boolean and never leaves this mapping.
|
|
78
|
+
const rows = exported.cookies.map((cookie) => ({
|
|
79
|
+
name: typeof cookie.name === 'string' ? cookie.name : '',
|
|
80
|
+
domain: typeof cookie.domain === 'string' ? cookie.domain : '',
|
|
81
|
+
...(typeof cookie.expires === 'number' ? { expires: cookie.expires } : {}),
|
|
82
|
+
...(cookie.httpOnly === true ? { httpOnly: true } : {}),
|
|
83
|
+
...(cookie.secure === true ? { secure: true } : {}),
|
|
84
|
+
...(typeof cookie.value === 'string' && looksLikeToken(cookie.value) ? { tokenLike: true } : {}),
|
|
85
|
+
}));
|
|
86
|
+
publishLoginSites(summarizeLoginSites(rows, 'snapshot', Date.now(), { visited: hosts }));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* no state file yet, or it is not valid JSON: nothing to publish */
|
|
91
|
+
}
|
|
48
92
|
}
|
|
49
93
|
}
|
|
50
94
|
available() {
|
package/lib/runtime-state.d.ts
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared user settings for dsh-browser-playwright-codex, used by the three host
|
|
3
|
+
* entries (browser / browser-playwright / browser-tool) and by the settings-page
|
|
4
|
+
* switches in dsh-browser-toggle.
|
|
5
|
+
*
|
|
6
|
+
* Two settings, both user-facing:
|
|
7
|
+
* - `enabled` (default true) — are the browser tools available at all.
|
|
8
|
+
* - `engine` (default: unset) — how a page is read into the snapshot tree.
|
|
9
|
+
* 'legacy' = the plugin's own DOM walk (most tested).
|
|
10
|
+
* 'aria' = the browser's official accessibility tree (what a screen
|
|
11
|
+
* reader sees), usually more faithful on complex pages.
|
|
12
|
+
* Unset means "follow the configured default", which is 'legacy'.
|
|
13
|
+
*
|
|
14
|
+
* State persists to ~/.dsh/dsh-browser-playwright.state.json so both settings
|
|
15
|
+
* survive a DSH restart. The FILE name deliberately keeps the pre-rename
|
|
16
|
+
* spelling: it is persisted user state, and renaming it would silently reset the
|
|
17
|
+
* user's switches.
|
|
18
|
+
*
|
|
19
|
+
* The file is re-read on every call (no read-once caching), so a change made on
|
|
20
|
+
* one side (web or desktop) is visible to the other immediately, and flipping a
|
|
21
|
+
* switch needs no restart and no config edit.
|
|
22
|
+
*
|
|
23
|
+
* `DSH_BROWSER_STATE_FILE` redirects the file; tests use it so they never touch
|
|
24
|
+
* real user state.
|
|
25
|
+
* @module dsh-browser-playwright-codex/runtime-state
|
|
26
|
+
*/
|
|
27
|
+
import type { LoginSites } from './login-sites.ts';
|
|
1
28
|
/** How a page is read into a snapshot tree. */
|
|
2
29
|
export type SnapshotEngine = 'legacy' | 'aria';
|
|
3
30
|
/** Current enable state (loads from disk on first call). */
|
|
@@ -43,3 +70,15 @@ export declare function getGates(): {
|
|
|
43
70
|
allowEvaluate: boolean;
|
|
44
71
|
allowCdp: boolean;
|
|
45
72
|
} | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* The last computed "which sites look logged in" summary, or undefined before anything
|
|
75
|
+
* published one. In-memory only: it is derived from cookies, contains no cookie values or
|
|
76
|
+
* names, and must never be persisted — the moment it lands on disk it is a dossier.
|
|
77
|
+
* @param next - the summary to show on the settings card.
|
|
78
|
+
*/
|
|
79
|
+
export declare function publishLoginSites(next: LoginSites): void;
|
|
80
|
+
/**
|
|
81
|
+
* The published login summary. The settings card reads this; no tool and no model can.
|
|
82
|
+
* @returns a defensive copy, or undefined while nothing has been published.
|
|
83
|
+
*/
|
|
84
|
+
export declare function getLoginSites(): LoginSites | undefined;
|
package/lib/runtime-state.js
CHANGED
|
@@ -1,29 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared user settings for dsh-browser-playwright-codex, used by the three host
|
|
3
|
-
* entries (browser / browser-playwright / browser-tool) and by the settings-page
|
|
4
|
-
* switches in dsh-browser-toggle.
|
|
5
|
-
*
|
|
6
|
-
* Two settings, both user-facing:
|
|
7
|
-
* - `enabled` (default true) — are the browser tools available at all.
|
|
8
|
-
* - `engine` (default: unset) — how a page is read into the snapshot tree.
|
|
9
|
-
* 'legacy' = the plugin's own DOM walk (most tested).
|
|
10
|
-
* 'aria' = the browser's official accessibility tree (what a screen
|
|
11
|
-
* reader sees), usually more faithful on complex pages.
|
|
12
|
-
* Unset means "follow the configured default", which is 'legacy'.
|
|
13
|
-
*
|
|
14
|
-
* State persists to ~/.dsh/dsh-browser-playwright.state.json so both settings
|
|
15
|
-
* survive a DSH restart. The FILE name deliberately keeps the pre-rename
|
|
16
|
-
* spelling: it is persisted user state, and renaming it would silently reset the
|
|
17
|
-
* user's switches.
|
|
18
|
-
*
|
|
19
|
-
* The file is re-read on every call (no read-once caching), so a change made on
|
|
20
|
-
* one side (web or desktop) is visible to the other immediately, and flipping a
|
|
21
|
-
* switch needs no restart and no config edit.
|
|
22
|
-
*
|
|
23
|
-
* `DSH_BROWSER_STATE_FILE` redirects the file; tests use it so they never touch
|
|
24
|
-
* real user state.
|
|
25
|
-
* @module dsh-browser-playwright-codex/runtime-state
|
|
26
|
-
*/
|
|
27
1
|
import fs from 'node:fs';
|
|
28
2
|
import os from 'node:os';
|
|
29
3
|
import path from 'node:path';
|
|
@@ -43,6 +17,8 @@ let _configuredEngine = undefined;
|
|
|
43
17
|
/** Effective tool gates, pushed in by the tool family when it is applied (display only). */
|
|
44
18
|
let _gates = undefined;
|
|
45
19
|
const _subscribers = new Set();
|
|
20
|
+
/** Display-only login inference; see publishLoginSites. */
|
|
21
|
+
let _loginSites = undefined;
|
|
46
22
|
function load() {
|
|
47
23
|
// Re-read the file on every call: whichever side flips the switch, the
|
|
48
24
|
// other side follows immediately (no read-once caching).
|
|
@@ -179,3 +155,21 @@ export function publishGates(gates) {
|
|
|
179
155
|
export function getGates() {
|
|
180
156
|
return _gates === undefined ? undefined : { ..._gates };
|
|
181
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* The last computed "which sites look logged in" summary, or undefined before anything
|
|
160
|
+
* published one. In-memory only: it is derived from cookies, contains no cookie values or
|
|
161
|
+
* names, and must never be persisted — the moment it lands on disk it is a dossier.
|
|
162
|
+
* @param next - the summary to show on the settings card.
|
|
163
|
+
*/
|
|
164
|
+
export function publishLoginSites(next) {
|
|
165
|
+
_loginSites = { ...next, sites: next.sites.map((site) => ({ ...site, signals: [...site.signals] })) };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The published login summary. The settings card reads this; no tool and no model can.
|
|
169
|
+
* @returns a defensive copy, or undefined while nothing has been published.
|
|
170
|
+
*/
|
|
171
|
+
export function getLoginSites() {
|
|
172
|
+
if (_loginSites === undefined)
|
|
173
|
+
return undefined;
|
|
174
|
+
return { ..._loginSites, sites: _loginSites.sites.map((site) => ({ ...site, signals: [...site.signals] })) };
|
|
175
|
+
}
|