pi-harness-runtime 0.9.7 → 0.9.11

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 (134) hide show
  1. package/CHANGELOG.md +24 -12
  2. package/README.md +42 -5
  3. package/footer-status.ts +108 -11
  4. package/harness/e2e/minimax-quota-scraper.ts +46 -4
  5. package/index.ts +224 -16
  6. package/mirror.ts +194 -18
  7. package/package.json +1 -1
  8. package/packages/a2a-adapter/package.json +1 -1
  9. package/packages/auth/package.json +1 -1
  10. package/packages/autonomous-refactor/package.json +1 -1
  11. package/packages/cache-strategy/package.json +1 -1
  12. package/packages/capability-registry/package.json +1 -1
  13. package/packages/checkpoint/package.json +1 -1
  14. package/packages/cli-plugin-sdk/package.json +1 -1
  15. package/packages/code-generation/package.json +1 -1
  16. package/packages/code-review/package.json +1 -1
  17. package/packages/codex-adapter/package.json +1 -1
  18. package/packages/context-compiler/package.json +1 -1
  19. package/packages/context-manager/package.json +1 -1
  20. package/packages/cookie-sanitizer/README.md +141 -0
  21. package/packages/cookie-sanitizer/dist/atomic-write.d.ts +38 -0
  22. package/packages/cookie-sanitizer/dist/atomic-write.d.ts.map +1 -0
  23. package/packages/cookie-sanitizer/dist/atomic-write.js +67 -0
  24. package/packages/cookie-sanitizer/dist/atomic-write.js.map +1 -0
  25. package/packages/cookie-sanitizer/dist/detect-format.d.ts +36 -0
  26. package/packages/cookie-sanitizer/dist/detect-format.d.ts.map +1 -0
  27. package/packages/cookie-sanitizer/dist/detect-format.js +88 -0
  28. package/packages/cookie-sanitizer/dist/detect-format.js.map +1 -0
  29. package/packages/cookie-sanitizer/dist/index.d.ts +41 -0
  30. package/packages/cookie-sanitizer/dist/index.d.ts.map +1 -0
  31. package/packages/cookie-sanitizer/dist/index.js +39 -0
  32. package/packages/cookie-sanitizer/dist/index.js.map +1 -0
  33. package/packages/cookie-sanitizer/dist/infer-provider.d.ts +29 -0
  34. package/packages/cookie-sanitizer/dist/infer-provider.d.ts.map +1 -0
  35. package/packages/cookie-sanitizer/dist/infer-provider.js +96 -0
  36. package/packages/cookie-sanitizer/dist/infer-provider.js.map +1 -0
  37. package/packages/cookie-sanitizer/dist/normalize.d.ts +26 -0
  38. package/packages/cookie-sanitizer/dist/normalize.d.ts.map +1 -0
  39. package/packages/cookie-sanitizer/dist/normalize.js +75 -0
  40. package/packages/cookie-sanitizer/dist/normalize.js.map +1 -0
  41. package/packages/cookie-sanitizer/dist/parse-json.d.ts +45 -0
  42. package/packages/cookie-sanitizer/dist/parse-json.d.ts.map +1 -0
  43. package/packages/cookie-sanitizer/dist/parse-json.js +108 -0
  44. package/packages/cookie-sanitizer/dist/parse-json.js.map +1 -0
  45. package/packages/cookie-sanitizer/dist/parse-netscape.d.ts +43 -0
  46. package/packages/cookie-sanitizer/dist/parse-netscape.d.ts.map +1 -0
  47. package/packages/cookie-sanitizer/dist/parse-netscape.js +99 -0
  48. package/packages/cookie-sanitizer/dist/parse-netscape.js.map +1 -0
  49. package/packages/cookie-sanitizer/dist/sync.d.ts +62 -0
  50. package/packages/cookie-sanitizer/dist/sync.d.ts.map +1 -0
  51. package/packages/cookie-sanitizer/dist/sync.js +266 -0
  52. package/packages/cookie-sanitizer/dist/sync.js.map +1 -0
  53. package/packages/cookie-sanitizer/dist/types.d.ts +100 -0
  54. package/packages/cookie-sanitizer/dist/types.d.ts.map +1 -0
  55. package/packages/cookie-sanitizer/dist/types.js +13 -0
  56. package/packages/cookie-sanitizer/dist/types.js.map +1 -0
  57. package/packages/cookie-sanitizer/dist/watcher.d.ts +51 -0
  58. package/packages/cookie-sanitizer/dist/watcher.d.ts.map +1 -0
  59. package/packages/cookie-sanitizer/dist/watcher.js +148 -0
  60. package/packages/cookie-sanitizer/dist/watcher.js.map +1 -0
  61. package/packages/cookie-sanitizer/package.json +27 -0
  62. package/packages/cookie-sanitizer/src/atomic-write.ts +79 -0
  63. package/packages/cookie-sanitizer/src/detect-format.ts +102 -0
  64. package/packages/cookie-sanitizer/src/index.ts +89 -0
  65. package/packages/cookie-sanitizer/src/infer-provider.ts +103 -0
  66. package/packages/cookie-sanitizer/src/normalize.ts +92 -0
  67. package/packages/cookie-sanitizer/src/parse-json.ts +122 -0
  68. package/packages/cookie-sanitizer/src/parse-netscape.ts +118 -0
  69. package/packages/cookie-sanitizer/src/sync.ts +304 -0
  70. package/packages/cookie-sanitizer/src/types.ts +101 -0
  71. package/packages/cookie-sanitizer/src/watcher.ts +180 -0
  72. package/packages/cookie-sanitizer/test/infer.test.ts +110 -0
  73. package/packages/cookie-sanitizer/test/normalize.test.ts +113 -0
  74. package/packages/cookie-sanitizer/test/parse.test.ts +204 -0
  75. package/packages/cookie-sanitizer/test/sync.test.ts +231 -0
  76. package/packages/cookie-sanitizer/test/watcher.test.ts +27 -0
  77. package/packages/cookie-sanitizer/tsconfig.json +8 -0
  78. package/packages/cost-optimizer/package.json +1 -1
  79. package/packages/django-plugin/package.json +1 -1
  80. package/packages/doc-generator/package.json +1 -1
  81. package/packages/evaluation-engine/package.json +1 -1
  82. package/packages/evaluation-runner/package.json +1 -1
  83. package/packages/experience-replay/package.json +1 -1
  84. package/packages/feedback-collector/package.json +1 -1
  85. package/packages/framework-detector/package.json +1 -1
  86. package/packages/framework-plugin-sdk/package.json +1 -1
  87. package/packages/frappe-plugin/package.json +1 -1
  88. package/packages/generic-web-plugin/package.json +1 -1
  89. package/packages/health-monitor/package.json +1 -1
  90. package/packages/laravel-plugin/package.json +1 -1
  91. package/packages/learning-engine/package.json +1 -1
  92. package/packages/mcp-adapter/package.json +1 -1
  93. package/packages/memory-engine/package.json +1 -1
  94. package/packages/model-registry/package.json +1 -1
  95. package/packages/nextjs-plugin/package.json +1 -1
  96. package/packages/notification/package.json +1 -1
  97. package/packages/observability/package.json +1 -1
  98. package/packages/performance-optimizer/package.json +1 -1
  99. package/packages/project-analyzer/package.json +1 -1
  100. package/packages/prompt-compiler/package.json +1 -1
  101. package/packages/prompt-versioning/package.json +1 -1
  102. package/packages/provider-adapter-sdk/package.json +1 -1
  103. package/packages/provider-router/package.json +1 -1
  104. package/packages/provider-selector/package.json +1 -1
  105. package/packages/providers/dist/index.d.ts +1 -0
  106. package/packages/providers/dist/index.d.ts.map +1 -1
  107. package/packages/providers/dist/index.js +1 -0
  108. package/packages/providers/dist/index.js.map +1 -1
  109. package/packages/providers/dist/provider-id.d.ts +39 -0
  110. package/packages/providers/dist/provider-id.d.ts.map +1 -0
  111. package/packages/providers/dist/provider-id.js +96 -0
  112. package/packages/providers/dist/provider-id.js.map +1 -0
  113. package/packages/providers/package.json +1 -1
  114. package/packages/providers/src/index.ts +1 -0
  115. package/packages/providers/src/provider-id.ts +110 -0
  116. package/packages/quota-manager/package.json +1 -1
  117. package/packages/rate-limiter/package.json +1 -1
  118. package/packages/react-vite-plugin/package.json +1 -1
  119. package/packages/release-manager/package.json +1 -1
  120. package/packages/requirement-compiler/package.json +1 -1
  121. package/packages/runtime/package.json +1 -1
  122. package/packages/scheduler/package.json +1 -1
  123. package/packages/session/package.json +1 -1
  124. package/packages/shared-context/package.json +1 -1
  125. package/packages/skill-registry/package.json +1 -1
  126. package/packages/sprint-planner/package.json +1 -1
  127. package/packages/task-compiler/package.json +1 -1
  128. package/packages/test-data-generator/package.json +1 -1
  129. package/packages/test-generator/package.json +1 -1
  130. package/packages/token-estimation/package.json +1 -1
  131. package/packages/token-optimizer/package.json +1 -1
  132. package/packages/tui/package.json +1 -1
  133. package/packages/types/package.json +1 -1
  134. package/packages/worktree/package.json +1 -1
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Sync orchestrator — the heart of the sanitizer.
3
+ *
4
+ * Pipeline (per run):
5
+ * 1. Enumerate files in the drop folder (one level + one sublevel).
6
+ * 2. Read each file. Detect format. Parse.
7
+ * 3. Concatenate cookies from all parsed files.
8
+ * 4. Infer provider from filename + cookies.
9
+ * 5. Normalize (dedupe, expire, domain-match).
10
+ * 6. Serialize to Netscape and write to the canonical cache (atomic).
11
+ *
12
+ * Returns a SyncResult. Never throws — returns structured errors in `result.errors`.
13
+ *
14
+ * Sec-001: Cookie VALUES never appear in any log line. Diagnostics
15
+ * use file basenames and 64-char previews with values redacted.
16
+ */
17
+
18
+ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import type { CanonicalCookie, SyncOptions, SyncResult } from "./types.js";
21
+ import { buildDetectedFile, safePreview } from "./detect-format.js";
22
+ import { parseNetscape, serializeNetscape } from "./parse-netscape.js";
23
+ import { parseEditThisCookieJson } from "./parse-json.js";
24
+ import { normalize } from "./normalize.js";
25
+ import { inferProvider } from "./infer-provider.js";
26
+ import { atomicWriteFileSync, defaultCachePathFor } from "./atomic-write.js";
27
+
28
+ /** Max depth for the drop-folder walk (one-level + one subfolder). */
29
+ const MAX_DEPTH = 2;
30
+ /** Max size of a single input file we'll parse (5 MB). */
31
+ const MAX_INPUT_BYTES = 5 * 1024 * 1024;
32
+ /** Default drop-folder location. */
33
+ export const DEFAULT_DROP_DIR = join(
34
+ process.env.HOME ?? "/tmp",
35
+ ".pi-harness-runtime",
36
+ "cookies",
37
+ );
38
+
39
+ /**
40
+ * Enumerate readable files inside `dir`, up to MAX_DEPTH.
41
+ * Returns absolute paths. Skips dotfiles, editor temp files.
42
+ *
43
+ * `excludePath` (optional) — absolute path of a file to skip, e.g.
44
+ * the canonical cache file (so we never recursively read it).
45
+ */
46
+ export function enumerateDropFiles(
47
+ dir: string,
48
+ excludePath?: string,
49
+ ): string[] {
50
+ if (!existsSync(dir)) return [];
51
+ let stat;
52
+ try {
53
+ stat = statSync(dir);
54
+ } catch {
55
+ return [];
56
+ }
57
+ if (!stat.isDirectory()) return [];
58
+
59
+ const result: string[] = [];
60
+
61
+ function walk(current: string, depth: number) {
62
+ if (depth > MAX_DEPTH) return;
63
+ let entries: string[];
64
+ try {
65
+ entries = readdirSync(current);
66
+ } catch {
67
+ return;
68
+ }
69
+ for (const name of entries) {
70
+ // Skip dotfiles, editor temp/swap.
71
+ if (name.startsWith(".")) continue;
72
+ if (name.endsWith("~") || name.endsWith(".swp")) continue;
73
+
74
+ const full = join(current, name);
75
+ if (excludePath && full === excludePath) continue;
76
+
77
+ let st;
78
+ try {
79
+ st = statSync(full);
80
+ } catch {
81
+ continue;
82
+ }
83
+
84
+ if (st.isFile()) {
85
+ result.push(full);
86
+ } else if (st.isDirectory() && depth < MAX_DEPTH) {
87
+ walk(full, depth + 1);
88
+ }
89
+ }
90
+ }
91
+
92
+ walk(dir, 0);
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Read a single input file as text, capped at MAX_INPUT_BYTES.
98
+ * Returns null + a `reason` if the file can't be read.
99
+ */
100
+ export function readInputFile(filePath: string): {
101
+ text: string;
102
+ reason?: string;
103
+ } {
104
+ let stat;
105
+ try {
106
+ stat = statSync(filePath);
107
+ } catch {
108
+ return { text: "", reason: "file not accessible" };
109
+ }
110
+ if (!stat.isFile()) return { text: "", reason: "not a regular file" };
111
+ if (stat.size > MAX_INPUT_BYTES) {
112
+ return { text: "", reason: `file too large (${stat.size} bytes)` };
113
+ }
114
+ try {
115
+ return { text: readFileSync(filePath, "utf8") };
116
+ } catch (e) {
117
+ return {
118
+ text: "",
119
+ reason: `read failed: ${e instanceof Error ? e.message : String(e)}`,
120
+ };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Run the full sync pipeline.
126
+ *
127
+ * Options:
128
+ * - dropDir default: ~/.pi-harness-runtime/cookies/
129
+ * - cachePath default: ~/.config/<provider>-cookies.txt
130
+ * - providerHint override inference (skips filename/domain checks)
131
+ * - dryRun skip the actual file write (still returns would-be result)
132
+ *
133
+ * Returns a SyncResult. Never throws.
134
+ */
135
+ export function sync(opts: SyncOptions = {}): SyncResult {
136
+ const dropDir = opts.dropDir ?? DEFAULT_DROP_DIR;
137
+
138
+ const tmpResult: SyncResult = {
139
+ provider: null,
140
+ processedFiles: [],
141
+ skippedFiles: [],
142
+ totalCookies: 0,
143
+ cachePath: "",
144
+ sourceDir: dropDir,
145
+ wrote: false,
146
+ errors: [],
147
+ };
148
+
149
+ const files = enumerateDropFiles(dropDir, opts.cachePath);
150
+ if (files.length === 0) {
151
+ // Nothing to do — no error, just nothing. Caller decides whether
152
+ // to surface this to the user.
153
+ return tmpResult;
154
+ }
155
+
156
+ const allCookies: CanonicalCookie[] = [];
157
+ let firstBasename: string | undefined;
158
+
159
+ for (const filePath of files) {
160
+ const { text, reason } = readInputFile(filePath);
161
+ if (reason) {
162
+ const det = buildDetectedFile(filePath, "");
163
+ tmpResult.skippedFiles.push({ basename: det.basename, reason });
164
+ tmpResult.errors.push({ basename: det.basename, message: reason });
165
+ continue;
166
+ }
167
+
168
+ const det = buildDetectedFile(filePath, text);
169
+ if (det.format == null) {
170
+ const preview = safePreview(text);
171
+ tmpResult.skippedFiles.push({
172
+ basename: det.basename,
173
+ reason: `unrecognised format (preview: ${preview})`,
174
+ });
175
+ tmpResult.errors.push({
176
+ basename: det.basename,
177
+ message: "unrecognised format",
178
+ });
179
+ continue;
180
+ }
181
+
182
+ let cookies: CanonicalCookie[] = [];
183
+ if (det.format === "netscape") {
184
+ cookies = parseNetscape(text);
185
+ } else if (det.format === "edit-this-cookie-json") {
186
+ cookies = parseEditThisCookieJson(text);
187
+ }
188
+
189
+ if (cookies.length === 0) {
190
+ tmpResult.skippedFiles.push({
191
+ basename: det.basename,
192
+ reason: `${det.format} parsed but yielded 0 cookies`,
193
+ });
194
+ continue;
195
+ }
196
+
197
+ allCookies.push(...cookies);
198
+ tmpResult.processedFiles.push(det.basename);
199
+ if (!firstBasename) firstBasename = det.basename;
200
+ }
201
+
202
+ if (allCookies.length === 0) {
203
+ return tmpResult;
204
+ }
205
+
206
+ const provider = inferProvider(firstBasename, allCookies, opts.providerHint);
207
+ if (!provider) {
208
+ tmpResult.errors.push({
209
+ basename: firstBasename ?? "<unknown>",
210
+ message: "could not infer provider (no filename/domain match)",
211
+ });
212
+ return tmpResult;
213
+ }
214
+ tmpResult.provider = provider;
215
+
216
+ const cachePath = opts.cachePath ?? defaultCachePathFor(provider);
217
+ tmpResult.cachePath = cachePath;
218
+
219
+ const normalized = normalize(allCookies, provider);
220
+ tmpResult.totalCookies = normalized.length;
221
+
222
+ if (normalized.length === 0) {
223
+ // All cookies were filtered (expired / wrong domain).
224
+ tmpResult.errors.push({
225
+ basename: firstBasename ?? "<unknown>",
226
+ message: "all cookies filtered (expired or wrong-provider domain)",
227
+ });
228
+ return tmpResult;
229
+ }
230
+
231
+ if (opts.dryRun) {
232
+ tmpResult.wrote = false;
233
+ return tmpResult;
234
+ }
235
+
236
+ const serialized = serializeNetscape(normalized);
237
+ try {
238
+ atomicWriteFileSync(cachePath, serialized);
239
+ tmpResult.wrote = true;
240
+ } catch (e) {
241
+ tmpResult.errors.push({
242
+ basename: firstBasename ?? "<unknown>",
243
+ message: `atomic write failed: ${e instanceof Error ? e.message : String(e)}`,
244
+ });
245
+ }
246
+
247
+ return tmpResult;
248
+ }
249
+
250
+ /**
251
+ * Has-any-input check: true if the drop folder has anything readable.
252
+ * Cheap — does a single `readdirSync`; no parsing.
253
+ */
254
+ export function hasAnyCookieSource(
255
+ dropDir: string = DEFAULT_DROP_DIR,
256
+ ): boolean {
257
+ return enumerateDropFiles(dropDir).length > 0;
258
+ }
259
+
260
+ /**
261
+ * Convenience: cache path for the dominant provider in the drop folder,
262
+ * or null if nothing's there. Used by `footer-status.ts` to ask "should
263
+ * the footer hint point at this provider?"
264
+ */
265
+ export function inferCachePath(
266
+ dropDir: string = DEFAULT_DROP_DIR,
267
+ ): string | null {
268
+ const files = enumerateDropFiles(dropDir);
269
+ if (files.length === 0) return null;
270
+
271
+ let firstBasename: string | undefined;
272
+ const cookies: CanonicalCookie[] = [];
273
+
274
+ for (const fp of files) {
275
+ const { text } = readInputFile(fp);
276
+ if (!text) continue;
277
+ const det = buildDetectedFile(fp, text);
278
+ if (!det.format) continue;
279
+ if (det.format === "netscape") cookies.push(...parseNetscape(text));
280
+ else if (det.format === "edit-this-cookie-json")
281
+ cookies.push(...parseEditThisCookieJson(text));
282
+ if (!firstBasename) firstBasename = det.basename;
283
+ }
284
+
285
+ const provider = inferProvider(firstBasename, cookies);
286
+ if (!provider) return null;
287
+ return defaultCachePathFor(provider);
288
+ }
289
+
290
+ export {
291
+ inferProvider,
292
+ inferProviderFromFilename,
293
+ inferProviderFromCookies,
294
+ } from "./infer-provider.js";
295
+ export { defaultCachePathFor } from "./atomic-write.js";
296
+ export type {
297
+ SyncOptions,
298
+ SyncResult,
299
+ CanonicalCookie,
300
+ ProviderId,
301
+ DetectedFile,
302
+ CookieFileFormat,
303
+ WatcherEvent,
304
+ } from "./types.js";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Cookie sanitizer types.
3
+ *
4
+ * The data model is intentionally simple and explicit. Cookies flow in
5
+ * from one of two input formats (Netscape, EditThisCookie JSON), are
6
+ * normalised here into a canonical record, and written out as a single
7
+ * standard Netscape file by the writer.
8
+ *
9
+ * Cookie values are NEVER logged anywhere. They live in memory only
10
+ * for the duration of one parse → normalize → write cycle.
11
+ */
12
+
13
+ /** Provider identifier — inferred from filename or dominant cookie domain. */
14
+ export type ProviderId =
15
+ | "minimax"
16
+ | "anthropic"
17
+ | "openai"
18
+ | "glm"
19
+ | "openrouter"
20
+ | string;
21
+
22
+ /** Input file format. */
23
+ export type CookieFileFormat = "netscape" | "edit-this-cookie-json";
24
+
25
+ /** A single cookie in canonical (post-normalize) form. */
26
+ export interface CanonicalCookie {
27
+ /** Provider this cookie belongs to. Set during infer-provider pass. */
28
+ provider?: ProviderId;
29
+ /** Cookie name (e.g. "_token", "_ga"). Must be non-empty after normalization. */
30
+ name: string;
31
+ /** Cookie value. NEVER logged. */
32
+ value: string;
33
+ /** Cookie domain (e.g. ".minimax.io", "platform.minimax.io"). */
34
+ domain: string;
35
+ /** URL path this cookie applies to (default "/"). */
36
+ path: string;
37
+ /** Expires at — Unix seconds since epoch. NaN or undefined = session cookie. */
38
+ expires?: number;
39
+ /** Whether this is a Secure-only cookie (HTTPS-only). */
40
+ secure: boolean;
41
+ /** Whether this cookie is HTTP-only (no JS access). */
42
+ httpOnly: boolean;
43
+ }
44
+
45
+ /** Result of detecting a single file's format. */
46
+ export interface DetectedFile {
47
+ /** Absolute path (or relative, in tests). */
48
+ path: string;
49
+ /** Filename only (no directory). Used for log lines — never the full path contents. */
50
+ basename: string;
51
+ /** Detected format. */
52
+ format: CookieFileFormat | null;
53
+ /** Raw text content (truncated to first 4096 chars for safety in debug logs). */
54
+ text: string;
55
+ /** Parse error if format is null. Null error means format is unknown. */
56
+ error?: string;
57
+ }
58
+
59
+ /** Sync result for the whole drop folder. */
60
+ export interface SyncResult {
61
+ /** Provider that was inferred. Null if inference failed. */
62
+ provider: ProviderId | null;
63
+ /** Files considered (readable, recognized format). */
64
+ processedFiles: string[];
65
+ /** Files skipped (unreadable, unknown format). */
66
+ skippedFiles: Array<{ basename: string; reason: string }>;
67
+ /** Total cookies after dedupe across all files. */
68
+ totalCookies: number;
69
+ /** Path the canonical cache was written to (relative or absolute). */
70
+ cachePath: string;
71
+ /** Source path the cookies came from. */
72
+ sourceDir: string;
73
+ /** True if the cache was actually written (no-op if no new data). */
74
+ wrote: boolean;
75
+ /** Errors during sync (per-file, never value-bearing). */
76
+ errors: Array<{ basename: string; message: string }>;
77
+ }
78
+
79
+ /** Sync options. */
80
+ export interface SyncOptions {
81
+ /** Override the default drop-folder location. */
82
+ dropDir?: string;
83
+ /** Override the default canonical cache location. */
84
+ cachePath?: string;
85
+ /** Provider hint (skips filename/domain inference). */
86
+ providerHint?: ProviderId;
87
+ /** Skip writing the cache file even if normalization produced cookies. */
88
+ dryRun?: boolean;
89
+ }
90
+
91
+ /** Watcher events emitted by the live watcher. */
92
+ export type WatcherEvent =
93
+ | {
94
+ kind: "sync-ok";
95
+ provider: ProviderId;
96
+ cachePath: string;
97
+ cookies: number;
98
+ }
99
+ | { kind: "sync-skip"; reason: string }
100
+ | { kind: "sync-error"; message: string }
101
+ | { kind: "watcher-error"; message: string };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Live cookie-folder watcher.
3
+ *
4
+ * Wraps chokidar v5 with the project-specific options:
5
+ * - `atomic: 100` — EditThisCookie / browser exporters use atomic write
6
+ * - `awaitWriteFinish` — wait for size to settle before triggering
7
+ * - `ignoreInitial: true` — startup is handled by sync.ts separately
8
+ * - `depth: 2` — one level + one subfolder
9
+ * - editor temp ignored
10
+ *
11
+ * Sec-001: Cookie values are NEVER logged. Diagnostics use only
12
+ * basenames, file paths, and event kinds.
13
+ */
14
+
15
+ import chokidar, { type FSWatcher } from "chokidar";
16
+ import { join } from "node:path";
17
+ import { sync, type SyncOptions } from "./sync.js";
18
+ import type { WatcherEvent } from "./types.js";
19
+
20
+ /** Public watcher config. */
21
+ export interface CookieWatcherConfig {
22
+ /** Folder to watch. Default: ~/.pi-harness-runtime/cookies/ */
23
+ dropDir: string;
24
+ /** Sync options passed through to the sync orchestrator. */
25
+ syncOptions?: SyncOptions;
26
+ /** Event sink. Required. */
27
+ onEvent: (event: WatcherEvent) => void;
28
+ /** Override the debounce window (ms). Default 250. */
29
+ debounceMs?: number;
30
+ /** Optional logger (defaults to console). */
31
+ logger?: (msg: string) => void;
32
+ }
33
+
34
+ const DEFAULT_DEBOUNCE_MS = 250;
35
+
36
+ /** Wraps a chokidar watcher for the drop folder. */
37
+ export class CookieWatcher {
38
+ private watcher: FSWatcher | null = null;
39
+ private debounceMs: number;
40
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
41
+ private log: (msg: string) => void;
42
+ private cfg: CookieWatcherConfig;
43
+ private running = false;
44
+
45
+ constructor(config: CookieWatcherConfig) {
46
+ this.cfg = config;
47
+ this.debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
48
+ this.log = config.logger ?? ((m) => console.log(`[cookie-sanitizer] ${m}`));
49
+ }
50
+
51
+ /** Start watching. Idempotent — calling twice is a no-op. */
52
+ start(): void {
53
+ if (this.running) return;
54
+ this.running = true;
55
+
56
+ this.watcher = chokidar.watch(this.cfg.dropDir, {
57
+ ignored: (targetPath: string) => {
58
+ const base = targetPath.split("/").pop() ?? "";
59
+ if (base.startsWith(".")) return true;
60
+ if (base.endsWith("~") || base.endsWith(".swp")) return true;
61
+ if (base.endsWith(".tmp")) return true;
62
+ // Exclude the canonical cache file (if specified) so the
63
+ // sanitizer's own write doesn't trigger a re-sync.
64
+ if (
65
+ this.cfg.syncOptions?.cachePath &&
66
+ targetPath === this.cfg.syncOptions.cachePath
67
+ ) {
68
+ return true;
69
+ }
70
+ return false;
71
+ },
72
+ ignoreInitial: true,
73
+ persistent: true,
74
+ depth: 2,
75
+ atomic: 100,
76
+ awaitWriteFinish: {
77
+ stabilityThreshold: 200,
78
+ pollInterval: 50,
79
+ },
80
+ });
81
+
82
+ this.watcher.on("add", () => this.schedule());
83
+ this.watcher.on("change", () => this.schedule());
84
+ this.watcher.on("unlink", () => this.schedule());
85
+ this.watcher.on("error", (err: unknown) => {
86
+ this.log(
87
+ `watcher error: ${err instanceof Error ? err.message : String(err)}`,
88
+ );
89
+ this.cfg.onEvent({
90
+ kind: "watcher-error",
91
+ message: err instanceof Error ? err.message : String(err),
92
+ });
93
+ });
94
+
95
+ this.log(`watching ${this.cfg.dropDir}`);
96
+ }
97
+
98
+ /** Stop watching. Idempotent. */
99
+ async stop(): Promise<void> {
100
+ if (!this.running) return;
101
+ this.running = false;
102
+ if (this.debounceTimer) {
103
+ clearTimeout(this.debounceTimer);
104
+ this.debounceTimer = null;
105
+ }
106
+ if (this.watcher) {
107
+ await this.watcher.close();
108
+ this.watcher = null;
109
+ }
110
+ this.log("stopped");
111
+ }
112
+
113
+ /** Trigger a sync immediately (bypass debounce). */
114
+ triggerNow(): void {
115
+ if (this.debounceTimer) {
116
+ clearTimeout(this.debounceTimer);
117
+ this.debounceTimer = null;
118
+ }
119
+ this.runSync();
120
+ }
121
+
122
+ private schedule(): void {
123
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
124
+ this.debounceTimer = setTimeout(() => {
125
+ this.debounceTimer = null;
126
+ this.runSync();
127
+ }, this.debounceMs);
128
+ }
129
+
130
+ private runSync(): void {
131
+ try {
132
+ const result = sync(this.cfg.syncOptions ?? {});
133
+ if (result.errors.length > 0) {
134
+ for (const err of result.errors) {
135
+ this.cfg.onEvent({
136
+ kind: "sync-error",
137
+ message: `${err.basename}: ${err.message}`,
138
+ });
139
+ }
140
+ }
141
+ if (result.provider && result.wrote) {
142
+ this.cfg.onEvent({
143
+ kind: "sync-ok",
144
+ provider: result.provider,
145
+ cachePath: result.cachePath,
146
+ cookies: result.totalCookies,
147
+ });
148
+ } else if (
149
+ result.processedFiles.length === 0 &&
150
+ result.provider === null
151
+ ) {
152
+ this.cfg.onEvent({
153
+ kind: "sync-skip",
154
+ reason: "no usable cookies found",
155
+ });
156
+ }
157
+ } catch (e) {
158
+ this.cfg.onEvent({
159
+ kind: "sync-error",
160
+ message: e instanceof Error ? e.message : String(e),
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ /** Factory: a watcher that writes to a default no-op logger. */
167
+ export function createWatcher(
168
+ dropDir: string,
169
+ syncOptions: SyncOptions,
170
+ onEvent: (event: WatcherEvent) => void,
171
+ ): CookieWatcher {
172
+ return new CookieWatcher({ dropDir, syncOptions, onEvent });
173
+ }
174
+
175
+ /** Compute a sensible dropDir for a given HOME override (test seam). */
176
+ export function defaultDropDir(
177
+ home: string = process.env.HOME ?? "/tmp",
178
+ ): string {
179
+ return join(home, ".pi-harness-runtime", "cookies");
180
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Provider inference tests.
3
+ *
4
+ * Three inference passes:
5
+ * 1. filename hint
6
+ * 2. dominant domain (>= 80%)
7
+ * 3. null
8
+ *
9
+ * The `hint` parameter overrides all of the above.
10
+ */
11
+
12
+ import { describe, it, expect } from "bun:test";
13
+ import {
14
+ inferProvider,
15
+ inferProviderFromFilename,
16
+ inferProviderFromCookies,
17
+ knownProviders,
18
+ } from "../src/infer-provider.js";
19
+ import type { CanonicalCookie } from "../src/types.js";
20
+
21
+ function cookie(domain: string, name = "n"): CanonicalCookie {
22
+ return {
23
+ name,
24
+ value: "v",
25
+ domain,
26
+ path: "/",
27
+ secure: false,
28
+ httpOnly: false,
29
+ };
30
+ }
31
+
32
+ describe("inferProviderFromFilename", () => {
33
+ it("matches known providers by name fragment", () => {
34
+ expect(inferProviderFromFilename("minimax-cookies.txt")).toBe("minimax");
35
+ expect(inferProviderFromFilename("anthropic-session.json")).toBe(
36
+ "anthropic",
37
+ );
38
+ expect(inferProviderFromFilename("openai-tokens.json")).toBe("openai");
39
+ expect(inferProviderFromFilename("glm-export.json")).toBe("glm");
40
+ });
41
+
42
+ it("returns null for unknown names", () => {
43
+ expect(inferProviderFromFilename("random-export.json")).toBe(null);
44
+ expect(inferProviderFromFilename("")).toBe(null);
45
+ });
46
+ });
47
+
48
+ describe("inferProviderFromCookies", () => {
49
+ it("returns provider when ≥ 80% of cookies match one domain", () => {
50
+ const cookies = [
51
+ cookie(".minimax.io"),
52
+ cookie("platform.minimax.io"),
53
+ cookie(".minimax.io"),
54
+ cookie(".minimax.io"),
55
+ cookie(".other.com", "x"),
56
+ ];
57
+ // 4/5 = 80% → matches minimax
58
+ expect(inferProviderFromCookies(cookies)).toBe("minimax");
59
+ });
60
+
61
+ it("returns null when no single provider dominates", () => {
62
+ const cookies = [
63
+ cookie(".minimax.io", "a"),
64
+ cookie(".anthropic.com", "b"),
65
+ cookie(".openai.com", "c"),
66
+ ];
67
+ expect(inferProviderFromCookies(cookies)).toBe(null);
68
+ });
69
+
70
+ it("returns null for empty input", () => {
71
+ expect(inferProviderFromCookies([])).toBe(null);
72
+ });
73
+ });
74
+
75
+ describe("inferProvider (combined)", () => {
76
+ it("explicit hint wins over everything", () => {
77
+ const cookies = [cookie(".minimax.io")];
78
+ expect(inferProvider("anthropic.json", cookies, "openai")).toBe("openai");
79
+ });
80
+
81
+ it("filename hint wins over domain inference", () => {
82
+ const cookies = [cookie(".openai.com")];
83
+ expect(inferProvider("minimax-2026.json", cookies)).toBe("minimax");
84
+ });
85
+
86
+ it("falls through to domain inference when no filename match", () => {
87
+ const cookies = [
88
+ cookie(".anthropic.com"),
89
+ cookie(".anthropic.com"),
90
+ cookie(".anthropic.com"),
91
+ cookie(".anthropic.com"),
92
+ ];
93
+ expect(inferProvider("random.txt", cookies)).toBe("anthropic");
94
+ });
95
+
96
+ it("returns null when nothing matches", () => {
97
+ expect(inferProvider("random.txt", [cookie(".other.com")])).toBe(null);
98
+ });
99
+ });
100
+
101
+ describe("knownProviders", () => {
102
+ it("returns at least the bundled providers", () => {
103
+ const providers = knownProviders();
104
+ expect(providers).toContain("minimax");
105
+ expect(providers).toContain("anthropic");
106
+ expect(providers).toContain("openai");
107
+ expect(providers).toContain("glm");
108
+ expect(providers).toContain("openrouter");
109
+ });
110
+ });