harnery 0.45.2 → 0.46.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.
Files changed (227) hide show
  1. package/dist/commander.d.ts +13 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commands/admission.d.ts.map +1 -1
  4. package/dist/commands/admission.js +214 -27
  5. package/dist/commands/agents.d.ts +4 -0
  6. package/dist/commands/agents.d.ts.map +1 -1
  7. package/dist/commands/agents.js +102 -25
  8. package/dist/commands/browse.d.ts +3 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +172 -8
  11. package/dist/commands/fetch.js +1 -0
  12. package/dist/commands/ledger-v3.d.ts.map +1 -1
  13. package/dist/commands/ledger-v3.js +8 -2
  14. package/dist/commands/presence.d.ts.map +1 -1
  15. package/dist/commands/presence.js +40 -0
  16. package/dist/commands/qa-run.d.ts.map +1 -1
  17. package/dist/commands/qa-run.js +4 -3
  18. package/dist/commands/review-pack.d.ts.map +1 -1
  19. package/dist/commands/review-pack.js +33 -6
  20. package/dist/core/agents/index.d.ts +1 -0
  21. package/dist/core/agents/index.d.ts.map +1 -1
  22. package/dist/core/agents/index.js +1 -0
  23. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  24. package/dist/core/agents/live-authority-v3.js +11 -4
  25. package/dist/core/agents/render/session-context.d.ts +0 -6
  26. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  27. package/dist/core/agents/render/session-context.js +1 -6
  28. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  29. package/dist/core/agents/rules/claim-conflict.js +36 -16
  30. package/dist/core/agents/rules/stop-hook.d.ts +6 -0
  31. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  32. package/dist/core/agents/rules/stop-hook.js +20 -1
  33. package/dist/core/agents/session-events.d.ts.map +1 -1
  34. package/dist/core/agents/session-events.js +12 -4
  35. package/dist/core/agents/state/heartbeat-reader.d.ts +3 -0
  36. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  37. package/dist/core/agents/state/live-coordination-view.d.ts +3 -1
  38. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  39. package/dist/core/agents/state/live-coordination-view.js +6 -2
  40. package/dist/core/agents/state/parent-reference-contract.d.ts +16 -0
  41. package/dist/core/agents/state/parent-reference-contract.d.ts.map +1 -0
  42. package/dist/core/agents/state/parent-reference-contract.js +1 -0
  43. package/dist/core/agents/state/parent-reference.d.ts +18 -0
  44. package/dist/core/agents/state/parent-reference.d.ts.map +1 -0
  45. package/dist/core/agents/state/parent-reference.js +119 -0
  46. package/dist/core/config.d.ts +39 -0
  47. package/dist/core/config.d.ts.map +1 -1
  48. package/dist/core/config.js +63 -0
  49. package/dist/core/diagnostics/path-identity.d.ts +1 -1
  50. package/dist/core/diagnostics/path-identity.d.ts.map +1 -1
  51. package/dist/core/diagnostics/path-identity.js +51 -6
  52. package/dist/core/events/v3/canonical.js +43 -14
  53. package/dist/core/events/v3/index.d.ts +1 -1
  54. package/dist/core/events/v3/index.d.ts.map +1 -1
  55. package/dist/core/events/v3/index.js +1 -1
  56. package/dist/core/events/v3/producers/recorder.d.ts +34 -0
  57. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  58. package/dist/core/events/v3/producers/recorder.js +117 -13
  59. package/dist/core/events/v3/reader.d.ts.map +1 -1
  60. package/dist/core/events/v3/reader.js +53 -13
  61. package/dist/core/events/v3/validate.d.ts.map +1 -1
  62. package/dist/core/events/v3/validate.js +36 -1
  63. package/dist/core/hooks/adapter/native-parent.d.ts +23 -0
  64. package/dist/core/hooks/adapter/native-parent.d.ts.map +1 -0
  65. package/dist/core/hooks/adapter/native-parent.js +91 -0
  66. package/dist/core/hooks/cli.js +103 -5
  67. package/dist/core/hooks/resolve/owner.d.ts +0 -11
  68. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  69. package/dist/core/hooks/resolve/owner.js +0 -23
  70. package/dist/core/hooks/resolve/transcript.d.ts +12 -0
  71. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  72. package/dist/core/hooks/resolve/transcript.js +54 -0
  73. package/dist/core/hooks/shell-admission.d.ts +84 -0
  74. package/dist/core/hooks/shell-admission.d.ts.map +1 -0
  75. package/dist/core/hooks/shell-admission.js +622 -0
  76. package/dist/core/hooks/shell-commit-tree.d.ts +2 -0
  77. package/dist/core/hooks/shell-commit-tree.d.ts.map +1 -0
  78. package/dist/core/hooks/shell-commit-tree.js +104 -0
  79. package/dist/core/inbox/contract.d.ts +12 -0
  80. package/dist/core/inbox/contract.d.ts.map +1 -1
  81. package/dist/core/inbox/contract.js +16 -0
  82. package/dist/core/inbox/peer-messages.d.ts +41 -16
  83. package/dist/core/inbox/peer-messages.d.ts.map +1 -1
  84. package/dist/core/inbox/peer-messages.js +372 -15
  85. package/dist/core/inbox/service.d.ts +8 -1
  86. package/dist/core/inbox/service.d.ts.map +1 -1
  87. package/dist/core/inbox/service.js +83 -3
  88. package/dist/core/inbox/targets.d.ts +17 -3
  89. package/dist/core/inbox/targets.d.ts.map +1 -1
  90. package/dist/core/inbox/targets.js +77 -2
  91. package/dist/core/presence/git.d.ts +18 -12
  92. package/dist/core/presence/git.d.ts.map +1 -1
  93. package/dist/core/presence/git.js +32 -38
  94. package/dist/core/presence/index.d.ts +2 -2
  95. package/dist/core/presence/index.d.ts.map +1 -1
  96. package/dist/core/presence/index.js +8 -6
  97. package/dist/core/presence/mail.d.ts +133 -0
  98. package/dist/core/presence/mail.d.ts.map +1 -0
  99. package/dist/core/presence/mail.js +464 -0
  100. package/dist/core/semantic/contract.d.ts +5 -5
  101. package/dist/core/storage/modes.d.ts +4 -0
  102. package/dist/core/storage/modes.d.ts.map +1 -1
  103. package/dist/core/storage/modes.js +4 -0
  104. package/dist/core/storage/segments.d.ts.map +1 -1
  105. package/dist/core/storage/segments.js +5 -4
  106. package/dist/lib/admission-history.d.ts +96 -0
  107. package/dist/lib/admission-history.d.ts.map +1 -0
  108. package/dist/lib/admission-history.js +196 -0
  109. package/dist/lib/admission.d.ts +56 -7
  110. package/dist/lib/admission.d.ts.map +1 -1
  111. package/dist/lib/admission.js +161 -19
  112. package/dist/lib/browser/client.d.ts +18 -0
  113. package/dist/lib/browser/client.d.ts.map +1 -1
  114. package/dist/lib/browser/client.js +100 -12
  115. package/dist/lib/browser/content-checks.d.ts +3 -1
  116. package/dist/lib/browser/content-checks.d.ts.map +1 -1
  117. package/dist/lib/browser/content-checks.js +10 -2
  118. package/dist/lib/browser/critique.d.ts +32 -0
  119. package/dist/lib/browser/critique.d.ts.map +1 -1
  120. package/dist/lib/browser/critique.js +72 -3
  121. package/dist/lib/browser/geometry.d.ts +2 -0
  122. package/dist/lib/browser/geometry.d.ts.map +1 -1
  123. package/dist/lib/browser/geometry.js +61 -15
  124. package/dist/lib/browser/index.d.ts +1 -1
  125. package/dist/lib/browser/index.d.ts.map +1 -1
  126. package/dist/lib/browser/index.js +1 -1
  127. package/dist/lib/browser/page-review-contracts.d.ts +6 -1
  128. package/dist/lib/browser/page-review-contracts.d.ts.map +1 -1
  129. package/dist/lib/browser/page-review-contracts.js +6 -1
  130. package/dist/lib/browser/page-review-judge.d.ts +3 -1
  131. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  132. package/dist/lib/browser/page-review-judge.js +41 -11
  133. package/dist/lib/browser/page-review-pack.d.ts +3 -0
  134. package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
  135. package/dist/lib/browser/page-review-pack.js +5 -2
  136. package/dist/lib/browser/qa-run-contracts.d.ts +11 -0
  137. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  138. package/dist/lib/browser/qa-run-contracts.js +11 -4
  139. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  140. package/dist/lib/browser/qa-run.js +73 -16
  141. package/dist/lib/browser/runts.d.ts +4 -2
  142. package/dist/lib/browser/runts.d.ts.map +1 -1
  143. package/dist/lib/browser/runts.js +84 -33
  144. package/dist/lib/browser/windows-chrome.d.ts +14 -0
  145. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  146. package/dist/lib/browser/windows-chrome.js +42 -0
  147. package/dist/lib/browser/windows-login-sync.d.ts +110 -0
  148. package/dist/lib/browser/windows-login-sync.d.ts.map +1 -0
  149. package/dist/lib/browser/windows-login-sync.js +171 -0
  150. package/dist/lib/cookies/client.d.ts +30 -5
  151. package/dist/lib/cookies/client.d.ts.map +1 -1
  152. package/dist/lib/cookies/client.js +55 -10
  153. package/dist/lib/cookies/index.d.ts +1 -1
  154. package/dist/lib/cookies/index.d.ts.map +1 -1
  155. package/dist/lib/cookies/index.js +1 -1
  156. package/dist/lib/docs-metadata-sync.d.ts.map +1 -1
  157. package/dist/lib/docs-metadata-sync.js +41 -2
  158. package/dist/lib/durable-job.d.ts +8 -0
  159. package/dist/lib/durable-job.d.ts.map +1 -1
  160. package/dist/lib/durable-job.js +10 -0
  161. package/dist/lib/http/client.d.ts +9 -8
  162. package/dist/lib/http/client.d.ts.map +1 -1
  163. package/dist/lib/http/client.js +44 -2
  164. package/dist/lib/instructions/templates.js +4 -4
  165. package/package.json +2 -2
  166. package/src/commander.ts +13 -0
  167. package/src/commands/admission.ts +272 -29
  168. package/src/commands/agents.ts +132 -27
  169. package/src/commands/browse.ts +208 -6
  170. package/src/commands/fetch.ts +1 -0
  171. package/src/commands/ledger-v3.ts +8 -2
  172. package/src/commands/presence.ts +49 -0
  173. package/src/commands/qa-run.ts +4 -3
  174. package/src/commands/review-pack.ts +41 -5
  175. package/src/core/agents/index.ts +6 -0
  176. package/src/core/agents/live-authority-v3.ts +11 -4
  177. package/src/core/agents/render/session-context.ts +1 -7
  178. package/src/core/agents/rules/claim-conflict.ts +50 -18
  179. package/src/core/agents/rules/stop-hook.ts +28 -1
  180. package/src/core/agents/session-events.ts +18 -3
  181. package/src/core/agents/state/heartbeat-reader.ts +3 -0
  182. package/src/core/agents/state/live-coordination-view.ts +13 -6
  183. package/src/core/agents/state/parent-reference-contract.ts +14 -0
  184. package/src/core/agents/state/parent-reference.ts +179 -0
  185. package/src/core/config.ts +85 -0
  186. package/src/core/diagnostics/path-identity.ts +52 -6
  187. package/src/core/events/v3/canonical.ts +46 -13
  188. package/src/core/events/v3/index.ts +3 -0
  189. package/src/core/events/v3/producers/recorder.ts +151 -13
  190. package/src/core/events/v3/reader.ts +55 -11
  191. package/src/core/events/v3/validate.ts +42 -1
  192. package/src/core/hooks/adapter/native-parent.ts +109 -0
  193. package/src/core/hooks/cli.ts +110 -4
  194. package/src/core/hooks/resolve/owner.ts +0 -23
  195. package/src/core/hooks/resolve/transcript.ts +61 -0
  196. package/src/core/hooks/shell-admission.ts +634 -0
  197. package/src/core/hooks/shell-commit-tree.ts +102 -0
  198. package/src/core/inbox/contract.ts +18 -0
  199. package/src/core/inbox/peer-messages.ts +501 -15
  200. package/src/core/inbox/service.ts +108 -3
  201. package/src/core/inbox/targets.ts +94 -3
  202. package/src/core/presence/git.ts +45 -37
  203. package/src/core/presence/index.ts +16 -8
  204. package/src/core/presence/mail.ts +620 -0
  205. package/src/core/storage/modes.ts +6 -0
  206. package/src/core/storage/segments.ts +5 -4
  207. package/src/lib/admission-history.ts +267 -0
  208. package/src/lib/admission.ts +217 -19
  209. package/src/lib/browser/client.ts +147 -35
  210. package/src/lib/browser/content-checks.ts +14 -3
  211. package/src/lib/browser/critique.ts +113 -3
  212. package/src/lib/browser/geometry.ts +63 -14
  213. package/src/lib/browser/index.ts +5 -0
  214. package/src/lib/browser/page-review-contracts.ts +6 -1
  215. package/src/lib/browser/page-review-judge.ts +58 -13
  216. package/src/lib/browser/page-review-pack.ts +13 -2
  217. package/src/lib/browser/qa-run-contracts.ts +30 -4
  218. package/src/lib/browser/qa-run.ts +78 -17
  219. package/src/lib/browser/runts.ts +85 -37
  220. package/src/lib/browser/windows-chrome.ts +50 -0
  221. package/src/lib/browser/windows-login-sync.ts +275 -0
  222. package/src/lib/cookies/client.ts +62 -10
  223. package/src/lib/cookies/index.ts +3 -0
  224. package/src/lib/docs-metadata-sync.ts +44 -2
  225. package/src/lib/durable-job.ts +15 -0
  226. package/src/lib/http/client.ts +63 -1
  227. package/src/lib/instructions/templates.ts +4 -4
@@ -39,7 +39,7 @@ export interface RuntsResult {
39
39
  outcome: "pass" | "fail" | "unknown";
40
40
  /** Text blocks scanned (post length/inline-children filters). */
41
41
  scannedBlocks: number;
42
- /** True when the block sweep hit the internal cap (very large page). */
42
+ /** True when the sweep reached its findings limit or the internal block cap. */
43
43
  truncated: boolean;
44
44
  runts: RuntHit[];
45
45
  }
@@ -47,13 +47,20 @@ export interface RuntsResult {
47
47
  /**
48
48
  * Build the JS function passed to `page.evaluate`. `scope` narrows the sweep
49
49
  * to one container (null = whole body); `minChars` filters out tiny labels
50
- * that can't meaningfully wrap (default 40).
50
+ * that can't meaningfully wrap (default 40). `maxFindings` bounds the retained
51
+ * failure list (default 50; integer 1..5000), not the separate 5000-block cap.
51
52
  */
52
53
  export function buildRuntsCheck(): (args: {
53
54
  scope: string | null;
54
55
  minChars: number;
56
+ maxFindings?: number;
55
57
  }) => RuntsResult {
56
- return ({ scope, minChars }) => {
58
+ return ({ scope, minChars, maxFindings = 50 }) => {
59
+ // page.evaluate has no module closure: validate this boundary too, even
60
+ // when a caller evaluates the built function without the Browser client.
61
+ if (!Number.isInteger(maxFindings) || maxFindings < 1 || maxFindings > 5000) {
62
+ throw new Error("maxFindings must be an integer from 1 to 5000.");
63
+ }
57
64
  const INLINE = new Set([
58
65
  "B",
59
66
  "I",
@@ -74,7 +81,7 @@ export function buildRuntsCheck(): (args: {
74
81
  ]);
75
82
  const ATOMIC = /@|https?:|\.(com|org|net|io|co)\b|\d-\d{3}/;
76
83
  const BLOCK_CAP = 5000;
77
- const RUNT_CAP = 50;
84
+ const RUNT_CAP = maxFindings;
78
85
 
79
86
  const root = scope ? document.querySelector(scope) : document.body;
80
87
  if (!root) {
@@ -128,34 +135,78 @@ export function buildRuntsCheck(): (args: {
128
135
  }
129
136
  scanned++;
130
137
 
131
- // Map every word to its visual line by rounding the Range rect top.
132
- const lines = new Map<number, { words: string[]; last: DOMRect; node: Node }>();
133
- const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
138
+ // Inline markup does not create word boundaries. Keep offsets into the
139
+ // original text nodes so one Range can measure a word split by emphasis
140
+ // or followed by punctuation in a separate node. BR still separates words.
141
+ const segments: Array<{ node: Text; start: number; end: number }> = [];
142
+ let content = "";
143
+ const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
134
144
  for (let n = tw.nextNode(); n; n = tw.nextNode()) {
145
+ if (n instanceof Element && n.tagName === "BR") content += "\n";
146
+ if (n.nodeType !== Node.TEXT_NODE) continue;
135
147
  const value = n.nodeValue ?? "";
136
- for (const m of value.matchAll(/\S+/g)) {
137
- const r = document.createRange();
138
- r.setStart(n, m.index);
139
- r.setEnd(n, m.index + m[0].length);
140
- const rect = r.getBoundingClientRect();
141
- if (!rect.width) continue;
142
- const top = Math.round(rect.top);
143
- const entry = lines.get(top);
144
- if (entry) {
145
- entry.words.push(m[0]);
146
- entry.last = rect;
147
- entry.node = n;
148
- } else {
149
- lines.set(top, { words: [m[0]], last: rect, node: n });
148
+ segments.push({
149
+ node: n as Text,
150
+ start: content.length,
151
+ end: content.length + value.length,
152
+ });
153
+ content += value;
154
+ }
155
+ type Word = { value: string; rect: DOMRect; node: Node };
156
+ const lines: Array<{ top: number; bottom: number; words: Map<number, Word> }> = [];
157
+ let segmentIndex = 0;
158
+ for (const m of content.matchAll(/\S+/g)) {
159
+ const lexical = /[\p{L}\p{N}]/u.exec(m[0]);
160
+ if (!lexical) continue; // punctuation alone is not an orphan word
161
+ const end = m.index + m[0].length;
162
+ while (segments[segmentIndex] && segments[segmentIndex]!.end <= m.index) segmentIndex++;
163
+ const first = segments[segmentIndex];
164
+ if (!first) continue;
165
+ let endIndex = segmentIndex;
166
+ while (segments[endIndex] && segments[endIndex]!.end < end) endIndex++;
167
+ const final = segments[endIndex];
168
+ if (!final) continue;
169
+ let wordIndex = segmentIndex;
170
+ while (segments[wordIndex]!.end <= m.index + lexical.index) wordIndex++;
171
+ const wordNode = segments[wordIndex]!.node;
172
+ const range = document.createRange();
173
+ range.setStart(first.node, m.index - first.start);
174
+ range.setEnd(final.node, end - final.start);
175
+ for (const rect of range.getClientRects()) {
176
+ if (!rect.width || !rect.height) continue;
177
+ // Different inline fonts have different glyph tops on the same line.
178
+ // Require substantial vertical overlap rather than exact rounded tops.
179
+ let line = lines.find(
180
+ (candidate) =>
181
+ Math.min(candidate.bottom, rect.bottom) - Math.max(candidate.top, rect.top) >
182
+ Math.min(candidate.bottom - candidate.top, rect.height) / 2,
183
+ );
184
+ if (!line) {
185
+ line = { top: rect.top, bottom: rect.bottom, words: new Map() };
186
+ lines.push(line);
150
187
  }
188
+ line.top = Math.min(line.top, rect.top);
189
+ line.bottom = Math.max(line.bottom, rect.bottom);
190
+ const previous = line.words.get(m.index)?.rect;
191
+ const box = previous
192
+ ? new DOMRect(
193
+ Math.min(previous.left, rect.left),
194
+ Math.min(previous.top, rect.top),
195
+ Math.max(previous.right, rect.right) - Math.min(previous.left, rect.left),
196
+ Math.max(previous.bottom, rect.bottom) - Math.min(previous.top, rect.top),
197
+ )
198
+ : rect;
199
+ line.words.set(m.index, { value: m[0], rect: box, node: wordNode });
151
200
  }
152
201
  }
153
- const tops = [...lines.keys()].sort((a, b) => a - b);
154
- if (tops.length < 2) continue; // single line, no runt risk
155
- const lastTop = tops[tops.length - 1];
156
- const last = lastTop === undefined ? undefined : lines.get(lastTop);
157
- if (last?.words.length !== 1) continue;
158
- const word = last.words[0] ?? "";
202
+ lines.sort((a, b) => a.top - b.top);
203
+ if (lines.length < 2) continue; // single line, no runt risk
204
+ const lastLine = lines[lines.length - 1];
205
+ if (lastLine?.words.size !== 1) continue;
206
+ const [wordOffset, last] = [...lastLine.words.entries()][0]!;
207
+ // A word split across visual lines is not a separate final orphan word.
208
+ if (lines.slice(0, -1).some((line) => line.words.has(wordOffset))) continue;
209
+ const word = last.value;
159
210
  if (ATOMIC.test(word)) continue;
160
211
  // A chip is not prose. A lone token whose glyphs live inside a padded,
161
212
  // boxed inline element (a <code> chip, pill, or badge) is a UI control:
@@ -165,11 +216,7 @@ export function buildRuntsCheck(): (args: {
165
216
  // inline ancestry (tag-based, since flex/grid parents blockify computed
166
217
  // display) and skip the finding when a chip hosts it.
167
218
  const chipHosted = (() => {
168
- for (
169
- let e = last.node.parentElement;
170
- e && e !== el && INLINE.has(e.tagName);
171
- e = e.parentElement
172
- ) {
219
+ for (let e = last.node.parentElement; e && INLINE.has(e.tagName); e = e.parentElement) {
173
220
  const cs = getComputedStyle(e);
174
221
  const padX =
175
222
  (Number.parseFloat(cs.paddingLeft) || 0) + (Number.parseFloat(cs.paddingRight) || 0);
@@ -177,6 +224,7 @@ export function buildRuntsCheck(): (args: {
177
224
  (cs.backgroundColor !== "rgba(0, 0, 0, 0)" && cs.backgroundColor !== "transparent") ||
178
225
  (Number.parseFloat(cs.borderTopWidth) || 0) > 0;
179
226
  if (padX > 0 && boxed) return true;
227
+ if (e === el) break;
180
228
  }
181
229
  return false;
182
230
  })();
@@ -187,12 +235,12 @@ export function buildRuntsCheck(): (args: {
187
235
  word,
188
236
  snippet: text.replace(/\s+/g, " ").trim().slice(0, 80),
189
237
  rect: {
190
- x: Math.round(last.last.x + sx),
191
- y: Math.round(last.last.y + sy),
192
- width: Math.round(last.last.width),
193
- height: Math.round(last.last.height),
238
+ x: Math.round(last.rect.x + sx),
239
+ y: Math.round(last.rect.y + sy),
240
+ width: Math.round(last.rect.width),
241
+ height: Math.round(last.rect.height),
194
242
  },
195
- lines: tops.length,
243
+ lines: lines.length,
196
244
  });
197
245
  if (runts.length >= RUNT_CAP) {
198
246
  truncated = true;
@@ -479,6 +479,56 @@ export function stopWindowsChromeProfile(
479
479
  return parseStopOutput(result.stdout, profileWindows);
480
480
  }
481
481
 
482
+ /**
483
+ * PowerShell that lists the Chrome processes running on the profile matched
484
+ * by `$Pattern` and prints their ids as a JSON array. It never closes them.
485
+ */
486
+ export function runningScript(): string {
487
+ return [
488
+ "param([string]$Pattern)",
489
+ "$ErrorActionPreference = 'Stop'",
490
+ PROFILE_PROCESS_FUNCTION,
491
+ "$ids = @(Get-ProfileChrome | ForEach-Object { [int]$_.ProcessId })",
492
+ "[Console]::Out.Write((ConvertTo-Json -Compress -InputObject $ids))",
493
+ ].join("\r\n");
494
+ }
495
+
496
+ /** Parse the running script's stdout into process ids. */
497
+ export function parseRunningOutput(stdout: string): number[] {
498
+ const trimmed = stdout.trim();
499
+ if (!trimmed) return [];
500
+ const parsed = JSON.parse(trimmed) as number | number[] | null;
501
+ if (parsed === null) return [];
502
+ return (Array.isArray(parsed) ? parsed : [parsed]).map(Number);
503
+ }
504
+
505
+ /**
506
+ * Ids of the Chrome processes running on `profileWindows`, for callers that
507
+ * must leave an open profile alone. Throws when the check cannot run.
508
+ */
509
+ export function windowsChromeProfileProcesses(profileWindows: string): number[] {
510
+ const result = runPowerShellScript(
511
+ "profile-running",
512
+ runningScript(),
513
+ ["-Pattern", profileCommandLinePattern(profileWindows)],
514
+ 30_000,
515
+ );
516
+ if (result.status !== 0) {
517
+ throw new Error(
518
+ `Checking for Chrome on ${profileWindows} failed: ${(result.stderr || result.stdout || result.error?.message || "no output").trim().slice(0, 400)}`,
519
+ );
520
+ }
521
+ return parseRunningOutput(result.stdout);
522
+ }
523
+
524
+ /** Linux form of a drive-letter Windows path (`C:\x` becomes `/mnt/c/x`). */
525
+ export function windowsPathToWsl(windowsPath: string): string {
526
+ const match = /^([A-Za-z]):\\(.*)$/.exec(windowsPath);
527
+ if (!match) throw new Error(`Not a drive-letter path: ${windowsPath}`);
528
+ const rest = (match[2] ?? "").replace(/\\+/g, "/").replace(/\/+$/, "");
529
+ return `/mnt/${(match[1] as string).toLowerCase()}${rest ? `/${rest}` : ""}`;
530
+ }
531
+
482
532
  /** Windows form of a `/mnt/<drive>/...` path. */
483
533
  export function wslPathToWindows(linuxPath: string): string {
484
534
  const match = /^\/mnt\/([a-z])\/(.*)$/.exec(linuxPath);
@@ -0,0 +1,275 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { basename, dirname, join } from "node:path";
14
+ import type { Cookie } from "../cookies/client.ts";
15
+ import { CookieJar, mergeCookies } from "../cookies/index.ts";
16
+ import type { WindowsChromeHarvest } from "./windows-chrome.ts";
17
+
18
+ /**
19
+ * Keep the shared cookie store current with the WSL sign-in profile.
20
+ *
21
+ * Under WSL, `browse --login` signs in through the Windows host's Chrome on a
22
+ * dedicated profile and copies that site's cookies into the cookie store when
23
+ * the window closes. People also keep that profile open as an ordinary
24
+ * browser and sign in to other sites there later. Nothing copies those
25
+ * sign-ins when the window is closed outside a waiting `--login` run, so a
26
+ * later headless run would show the site signed out.
27
+ *
28
+ * Before a headless run, this module compares the profile's cookie database
29
+ * modification time with the time recorded at the last copy for the run's
30
+ * domain. When the profile changed and no Chrome is running on it, the
31
+ * domain's cookies are read (the same headless read `--login` uses) and
32
+ * merged into the store. A profile that is open is never touched. A small
33
+ * owner-only state file next to the store records, per domain, the database
34
+ * time and a fingerprint of the cookies last read. When a read returns the
35
+ * same cookies as last time, nothing is merged, so cookies a headless run
36
+ * refreshed in the store are not replaced with the profile's older copies.
37
+ */
38
+
39
+ export interface DomainSyncRecord {
40
+ /** When the profile's cookies for the domain were last read. */
41
+ syncedAt: string;
42
+ /** Cookie database modification time right after that read. */
43
+ cookieDbMtimeMs: number;
44
+ /** SHA-256 over the cookies read, so an unchanged read merges nothing. */
45
+ fingerprint: string;
46
+ /** Number of cookies read. */
47
+ cookies: number;
48
+ }
49
+
50
+ export interface LoginSyncState {
51
+ version: 1;
52
+ domains: Record<string, DomainSyncRecord>;
53
+ }
54
+
55
+ /** The sync-state file for a cookie store: `<store>.login-sync.json`, beside it. */
56
+ export function loginSyncStatePath(storePath: string): string {
57
+ const name = basename(storePath).replace(/\.json$/i, "");
58
+ return join(dirname(storePath), `${name}.login-sync.json`);
59
+ }
60
+
61
+ export function readLoginSyncState(statePath: string): LoginSyncState {
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(statePath, "utf8")) as Partial<LoginSyncState>;
64
+ if (parsed && parsed.version === 1 && parsed.domains && typeof parsed.domains === "object") {
65
+ return { version: 1, domains: parsed.domains };
66
+ }
67
+ } catch {
68
+ // Missing or unreadable state means nothing has been copied yet.
69
+ }
70
+ return { version: 1, domains: {} };
71
+ }
72
+
73
+ /** Write the state file owner-only, replacing it atomically. */
74
+ export function writeLoginSyncState(statePath: string, state: LoginSyncState): void {
75
+ mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
76
+ const tmp = `${statePath}.${process.pid}.tmp`;
77
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
78
+ renameSync(tmp, statePath);
79
+ }
80
+
81
+ /** Order-independent fingerprint of a cookie set. Values are hashed, never stored. */
82
+ export function cookieFingerprint(cookies: Cookie[]): string {
83
+ const rows = cookies
84
+ .map((c) => JSON.stringify([c.name, c.domain, c.path, c.value, c.expires]))
85
+ .sort();
86
+ return createHash("sha256").update(rows.join("\n")).digest("hex");
87
+ }
88
+
89
+ export type LoginSyncDecision =
90
+ /** No cookie database: the profile has never been used. */
91
+ | "no-profile"
92
+ /** The database has not changed since the last read for this domain. */
93
+ | "current"
94
+ /** The database is newer, but Chrome is open on the profile. */
95
+ | "open"
96
+ /** The database is newer (or the read is forced) and the profile is closed. */
97
+ | "sync";
98
+
99
+ /**
100
+ * Decide what to do before reading. `running` is consulted only when the
101
+ * database is newer, since checking for Chrome costs a PowerShell call.
102
+ */
103
+ export function decideLoginSync(input: {
104
+ cookieDbMtimeMs: number | undefined;
105
+ record: DomainSyncRecord | undefined;
106
+ running: () => boolean;
107
+ force?: boolean;
108
+ }): LoginSyncDecision {
109
+ if (input.cookieDbMtimeMs === undefined) return "no-profile";
110
+ if (!input.force && input.record && input.cookieDbMtimeMs <= input.record.cookieDbMtimeMs)
111
+ return "current";
112
+ return input.running() ? "open" : "sync";
113
+ }
114
+
115
+ export interface LoginSyncDeps {
116
+ /** Newest modification time of the profile's cookie database, or `undefined`. */
117
+ cookieDbMtimeMs: () => number | undefined;
118
+ /** Ids of Chrome processes running on the profile. */
119
+ profileProcesses: () => number[];
120
+ /** Read the profile's cookies for `domain`; the profile must be closed. */
121
+ harvest: (domain: string) => WindowsChromeHarvest;
122
+ now?: () => Date;
123
+ }
124
+
125
+ export interface LoginSyncResult {
126
+ status: "no-profile" | "current" | "open" | "busy" | "synced" | "unchanged";
127
+ domain: string;
128
+ /** Cookies read from the profile (0 when nothing was read). */
129
+ cookies: number;
130
+ /** Cookies merged into the store (0 unless `synced`). */
131
+ merged: number;
132
+ sessionCookies: number;
133
+ /** Cookie names merged, sorted. Never values. */
134
+ names: string[];
135
+ /** Chrome process ids on the profile when `open`. */
136
+ processes: number[];
137
+ }
138
+
139
+ /**
140
+ * Copy the profile's cookies for `domain` into the store when the profile
141
+ * changed since the last copy and is closed. `force` skips the time check
142
+ * and merges whatever is read. Throws only on read failures; the caller
143
+ * decides whether that is a warning.
144
+ */
145
+ export function syncLoginCookies(input: {
146
+ domain: string;
147
+ storePath: string;
148
+ deps: LoginSyncDeps;
149
+ force?: boolean;
150
+ }): LoginSyncResult {
151
+ const { domain, storePath, deps } = input;
152
+ const base: LoginSyncResult = {
153
+ status: "current",
154
+ domain,
155
+ cookies: 0,
156
+ merged: 0,
157
+ sessionCookies: 0,
158
+ names: [],
159
+ processes: [],
160
+ };
161
+ const statePath = loginSyncStatePath(storePath);
162
+ const state = readLoginSyncState(statePath);
163
+ const record = state.domains[domain];
164
+ let processes: number[] = [];
165
+ const decision = decideLoginSync({
166
+ cookieDbMtimeMs: deps.cookieDbMtimeMs(),
167
+ record,
168
+ force: input.force,
169
+ running: () => {
170
+ processes = deps.profileProcesses();
171
+ return processes.length > 0;
172
+ },
173
+ });
174
+ if (decision === "no-profile") return { ...base, status: "no-profile" };
175
+ if (decision === "current") return base;
176
+ if (decision === "open") return { ...base, status: "open", processes };
177
+
178
+ const release = acquireSyncLock(statePath);
179
+ if (!release) return { ...base, status: "busy" };
180
+ try {
181
+ const { cookies } = deps.harvest(domain);
182
+ const fingerprint = cookieFingerprint(cookies);
183
+ const changed = input.force || !record || record.fingerprint !== fingerprint;
184
+ let merged: Cookie[] = [];
185
+ if (changed && cookies.length > 0) {
186
+ const jar = new CookieJar({ path: storePath, source: "harn-browse" });
187
+ jar.save(mergeCookies(jar.load(), cookies));
188
+ merged = cookies;
189
+ }
190
+ const after = readLoginSyncState(statePath);
191
+ after.domains[domain] = {
192
+ syncedAt: (deps.now?.() ?? new Date()).toISOString(),
193
+ cookieDbMtimeMs: deps.cookieDbMtimeMs() ?? 0,
194
+ fingerprint,
195
+ cookies: cookies.length,
196
+ };
197
+ writeLoginSyncState(statePath, after);
198
+ return {
199
+ ...base,
200
+ status: merged.length > 0 ? "synced" : "unchanged",
201
+ cookies: cookies.length,
202
+ merged: merged.length,
203
+ sessionCookies: merged.filter((c) => c.expires <= 0).length,
204
+ names: [...new Set(merged.map((c) => c.name))].sort(),
205
+ };
206
+ } finally {
207
+ release();
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Record a read that happened elsewhere (the `--login` flow), so the next
213
+ * headless run does not read the same cookies again.
214
+ */
215
+ export function recordLoginSync(input: {
216
+ domain: string;
217
+ storePath: string;
218
+ cookies: Cookie[];
219
+ cookieDbMtimeMs: number | undefined;
220
+ now?: Date;
221
+ }): void {
222
+ const statePath = loginSyncStatePath(input.storePath);
223
+ const state = readLoginSyncState(statePath);
224
+ state.domains[input.domain] = {
225
+ syncedAt: (input.now ?? new Date()).toISOString(),
226
+ cookieDbMtimeMs: input.cookieDbMtimeMs ?? 0,
227
+ fingerprint: cookieFingerprint(input.cookies),
228
+ cookies: input.cookies.length,
229
+ };
230
+ writeLoginSyncState(statePath, state);
231
+ }
232
+
233
+ /** Newest mtime among a profile's cookie database locations, or `undefined`. */
234
+ export function profileCookieDbMtimeMs(
235
+ profileLinux: string,
236
+ stat: (path: string) => number | undefined = fileMtimeMs,
237
+ ): number | undefined {
238
+ const times = [
239
+ join(profileLinux, "Default", "Network", "Cookies"),
240
+ join(profileLinux, "Default", "Cookies"),
241
+ ]
242
+ .map((path) => stat(path))
243
+ .filter((t): t is number => t !== undefined);
244
+ return times.length > 0 ? Math.max(...times) : undefined;
245
+ }
246
+
247
+ function fileMtimeMs(path: string): number | undefined {
248
+ try {
249
+ return statSync(path).mtimeMs;
250
+ } catch {
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ const LOCK_STALE_MS = 3 * 60_000;
256
+
257
+ /**
258
+ * One read at a time: a second headless Chrome on the profile would refuse
259
+ * to start. A lock older than three minutes is treated as abandoned.
260
+ */
261
+ function acquireSyncLock(statePath: string): (() => void) | null {
262
+ const lockPath = `${statePath}.lock`;
263
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
264
+ for (let attempt = 0; attempt < 2; attempt++) {
265
+ try {
266
+ closeSync(openSync(lockPath, "wx", 0o600));
267
+ return () => rmSync(lockPath, { force: true });
268
+ } catch {
269
+ const age = existsSync(lockPath) ? Date.now() - (fileMtimeMs(lockPath) ?? 0) : 0;
270
+ if (age < LOCK_STALE_MS) return null;
271
+ rmSync(lockPath, { force: true });
272
+ }
273
+ }
274
+ return null;
275
+ }
@@ -284,9 +284,9 @@ export class CookieJar {
284
284
  }
285
285
 
286
286
  /**
287
- * Read + filter helper. Drops expired cookies. Optional domain filter
288
- * uses the same `domainMatches` logic Playwright/CDP do (leading dot
289
- * means subdomain match).
287
+ * Read + filter helper. Drops expired cookies. The optional domain filter
288
+ * uses `domainMatches` (equal host or any subdomain); it is a query, not a
289
+ * delivery rule. `header(url)` uses the stricter `cookieSendsToHost`.
290
290
  */
291
291
  list(filter: { domain?: string } = {}): Cookie[] {
292
292
  const store = this.load();
@@ -451,14 +451,62 @@ export function mergeCookies(store: CookieStore, incoming: Cookie[]): CookieStor
451
451
  return { ...store, cookies: Array.from(map.values()) };
452
452
  }
453
453
 
454
- /** RFC 6265 domain match. Leading dot in cookie domain means subdomain match. */
454
+ /**
455
+ * Filter-style domain match used by `list({ domain })` and `clear({ domain })`:
456
+ * a cookie domain "matches" a hostname when they are equal or the hostname is
457
+ * a subdomain of it, whether or not the cookie is host-only. Never use this to
458
+ * decide whether a cookie may be SENT to a host; use `cookieSendsToHost`.
459
+ */
455
460
  export function domainMatches(cookieDomain: string, hostname: string): boolean {
456
- const cd = cookieDomain.startsWith(".") ? cookieDomain.slice(1) : cookieDomain;
457
- if (hostname === cd) return true;
458
- if (hostname.endsWith(`.${cd}`)) return true;
461
+ const cd = (cookieDomain.startsWith(".") ? cookieDomain.slice(1) : cookieDomain).toLowerCase();
462
+ const host = hostname.toLowerCase();
463
+ if (host === cd) return true;
464
+ if (host.endsWith(`.${cd}`)) return true;
459
465
  return false;
460
466
  }
461
467
 
468
+ /**
469
+ * RFC 6265 delivery rule. A cookie domain with a leading dot is a domain
470
+ * cookie and goes to the domain and every subdomain. A cookie domain without
471
+ * one is host-only (how CDP, Playwright and `Set-Cookie` without `Domain`
472
+ * record it) and goes to that exact host and nowhere else.
473
+ */
474
+ export function cookieSendsToHost(cookieDomain: string, hostname: string): boolean {
475
+ const host = hostname.toLowerCase();
476
+ if (cookieDomain.startsWith(".")) return domainMatches(cookieDomain, host);
477
+ return cookieDomain.toLowerCase() === host;
478
+ }
479
+
480
+ /**
481
+ * Cookie-prefix rules (RFC 6265bis). `__Secure-` needs Secure; `__Host-` also
482
+ * needs Path=/ and no Domain attribute, which a jar records as a host-only
483
+ * domain (no leading dot). Browsers refuse a violating cookie, so the jar
484
+ * never stores one and the browser injector never hands one to Chromium.
485
+ */
486
+ export function violatesCookiePrefix(
487
+ cookie: Pick<Cookie, "name" | "domain" | "path" | "secure">,
488
+ ): boolean {
489
+ if (cookie.name.startsWith("__Secure-") && !cookie.secure) return true;
490
+ if (
491
+ cookie.name.startsWith("__Host-") &&
492
+ (!cookie.secure || cookie.domain.startsWith(".") || cookie.path !== "/")
493
+ ) {
494
+ return true;
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /** True for hosts a browser treats as a secure context without TLS. */
500
+ export function isLoopbackHost(hostname: string): boolean {
501
+ const host = hostname.toLowerCase();
502
+ return (
503
+ host === "localhost" ||
504
+ host.endsWith(".localhost") ||
505
+ host === "[::1]" ||
506
+ /^127(?:\.\d{1,3}){3}$/.test(host)
507
+ );
508
+ }
509
+
462
510
  /** RFC 6265 path match. */
463
511
  export function pathMatches(cookiePath: string, urlPath: string): boolean {
464
512
  if (urlPath === cookiePath) return true;
@@ -473,13 +521,17 @@ export function isExpired(cookie: Cookie): boolean {
473
521
  return cookie.expires < Date.now() / 1000;
474
522
  }
475
523
 
476
- /** Cookies that should be sent with a request to `url`. */
524
+ /**
525
+ * Cookies that should be sent with a request to `url`: exact-host delivery for
526
+ * host-only cookies, domain delivery for dotted ones, then path and Secure.
527
+ * Secure cookies go over HTTPS and to loopback hosts, as in browsers.
528
+ */
477
529
  export function matchingCookies(store: CookieStore, url: string): Cookie[] {
478
530
  const parsed = new URL(url);
479
- const isSecure = parsed.protocol === "https:";
531
+ const isSecure = parsed.protocol === "https:" || isLoopbackHost(parsed.hostname);
480
532
  return store.cookies.filter((c) => {
481
533
  if (isExpired(c)) return false;
482
- if (!domainMatches(c.domain, parsed.hostname)) return false;
534
+ if (!cookieSendsToHost(c.domain, parsed.hostname)) return false;
483
535
  if (!pathMatches(c.path, parsed.pathname)) return false;
484
536
  if (c.secure && !isSecure) return false;
485
537
  return true;
@@ -4,12 +4,15 @@ export {
4
4
  type CookieJarOptions,
5
5
  type CookieStore,
6
6
  CookieStoreParseError,
7
+ cookieSendsToHost,
7
8
  domainMatches,
8
9
  type InfoResult,
9
10
  isExpired,
11
+ isLoopbackHost,
10
12
  matchingCookies,
11
13
  mergeCookies,
12
14
  type OriginEntry,
13
15
  pathMatches,
16
+ violatesCookiePrefix,
14
17
  } from "./client.js";
15
18
  export { applyExtraCookies, type ExtraCookies } from "./extra.js";