dsh-browser-playwright-codex 0.5.5 → 0.6.1

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.
@@ -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
+ }
@@ -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 browser_hover that is plain text). The
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 | {
@@ -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 browser_hover that is plain text). The
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() {
@@ -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;
@@ -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
+ }