@tiny-fish/cli 0.29.2-next.243 → 0.29.2-next.246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.js +33 -1
- package/dist/commands/profile.d.ts +3 -2
- package/dist/commands/profile.js +50 -78
- package/dist/lib/chrome-cookies.d.ts +15 -0
- package/dist/lib/chrome-cookies.js +164 -0
- package/dist/lib/setup-telemetry.d.ts +36 -0
- package/dist/lib/setup-telemetry.js +44 -0
- package/package.json +2 -2
package/dist/commands/doctor.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
|
|
|
6
6
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
7
7
|
import { err, errLine, out, outLine } from "../lib/output.js";
|
|
8
8
|
import { detectRegistrations } from "../lib/registration-detect.js";
|
|
9
|
+
import { sendDoctorCompleted } from "../lib/setup-telemetry.js";
|
|
9
10
|
import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
|
|
10
11
|
import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
|
|
11
12
|
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
@@ -351,6 +352,30 @@ export async function applyRepairs(repairs, options) {
|
|
|
351
352
|
}
|
|
352
353
|
return outcomes;
|
|
353
354
|
}
|
|
355
|
+
/** Aggregate counts only; check `detail` text and per-harness rows never leave the machine. */
|
|
356
|
+
function telemetryPayload(report, offered, outcomes, options) {
|
|
357
|
+
const count = (status) => report.checks.filter((check) => check.status === status).length;
|
|
358
|
+
return {
|
|
359
|
+
schema_version: report.schema_version,
|
|
360
|
+
cli_version: report.cli_version,
|
|
361
|
+
ok_harnesses: report.ok_harnesses,
|
|
362
|
+
ok_cli: report.ok_cli,
|
|
363
|
+
checks_pass: count("pass"),
|
|
364
|
+
checks_fail: count("fail"),
|
|
365
|
+
checks_warn: count("warn"),
|
|
366
|
+
checks_skip: count("skip"),
|
|
367
|
+
// From the pre-repair report; the verdicts above describe the machine after it.
|
|
368
|
+
repairs_offered: offered.length,
|
|
369
|
+
repair_actions: [...new Set(offered.map((repair) => repair.action))],
|
|
370
|
+
repairs_attempted: outcomes.filter((outcome) => outcome.status !== "skipped").length,
|
|
371
|
+
repairs_succeeded: outcomes.filter((outcome) => outcome.status === "repaired").length,
|
|
372
|
+
fix_mode: !options.fix ? "none" : options.yes ? "fix_yes" : "fix",
|
|
373
|
+
// --harness scopes detection, so the counts above cover that harness alone.
|
|
374
|
+
harness_scope: options.harness ?? "all",
|
|
375
|
+
// Same signal cli_connect_stage carries, so agent and human doctor runs split.
|
|
376
|
+
is_human_initiated: detectHumanInitiated(),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
354
379
|
export function registerDoctor(program) {
|
|
355
380
|
program
|
|
356
381
|
.command("doctor")
|
|
@@ -373,10 +398,14 @@ export function registerDoctor(program) {
|
|
|
373
398
|
...(options.harness ? { harness: options.harness } : {}),
|
|
374
399
|
};
|
|
375
400
|
let report;
|
|
401
|
+
let offered;
|
|
402
|
+
let outcomes = [];
|
|
376
403
|
try {
|
|
377
404
|
report = await runDoctor(doctorOptions);
|
|
405
|
+
offered = report.repairs;
|
|
378
406
|
if (doctorOptions.fix && report.repairs.length > 0) {
|
|
379
|
-
|
|
407
|
+
outcomes = await applyRepairs(report.repairs, doctorOptions);
|
|
408
|
+
for (const outcome of outcomes) {
|
|
380
409
|
const reason = outcome.reason ? ` (${outcome.reason})` : "";
|
|
381
410
|
errLine(`${outcome.status}: ${outcome.repair.command}${reason}`);
|
|
382
411
|
}
|
|
@@ -399,5 +428,8 @@ export function registerDoctor(program) {
|
|
|
399
428
|
else
|
|
400
429
|
out(report);
|
|
401
430
|
process.exitCode = exitCodeFor(report.checks);
|
|
431
|
+
// After the exit code is set: telemetry never decides how doctor exits.
|
|
432
|
+
const resolved = apiKeyStatus();
|
|
433
|
+
await sendDoctorCompleted(doctorOptions.mcpUrl, telemetryPayload(report, offered, outcomes, doctorOptions), "key" in resolved ? resolved.key : undefined);
|
|
402
434
|
});
|
|
403
435
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { type DecryptStats } from "../lib/chrome-cookies.js";
|
|
2
3
|
import type { CookieRecord } from "../lib/types.js";
|
|
3
|
-
export declare function extractChromeCookies(domain: string):
|
|
4
|
-
export declare function extractAllChromeCookies():
|
|
4
|
+
export declare function extractChromeCookies(domain: string, stats?: DecryptStats): CookieRecord[];
|
|
5
|
+
export declare function extractAllChromeCookies(stats?: DecryptStats): CookieRecord[];
|
|
5
6
|
type TldtsParse = (host: string, opts: {
|
|
6
7
|
allowPrivateDomains: boolean;
|
|
7
8
|
}) => {
|
package/dist/commands/profile.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as os from "os";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { createRequire } from "module";
|
|
5
5
|
import { getApiKey } from "../lib/auth.js";
|
|
6
|
+
import { deriveChromeKey, listAuthRelevantHostKeys, openCookiesDb, readCookiesForHost, } from "../lib/chrome-cookies.js";
|
|
6
7
|
import { profileCreate, profileUpload } from "../lib/client.js";
|
|
7
8
|
import { err, errLine, handleApiError, out, outLine } from "../lib/output.js";
|
|
8
9
|
// ── UserFacingError ───────────────────────────────────────────────────────────
|
|
@@ -32,13 +33,8 @@ function validateDomain(domain) {
|
|
|
32
33
|
process.exit(1);
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
// Chrome
|
|
36
|
-
|
|
37
|
-
function chromeTimeToUnix(chromeMicros) {
|
|
38
|
-
return Math.floor(chromeMicros / 1e6) - CHROME_EPOCH_DELTA_SECONDS;
|
|
39
|
-
}
|
|
40
|
-
// Sets up chrome-cookies-secure in a temp dir and calls fn; cleans up after.
|
|
41
|
-
async function withCookiesDb(fn) {
|
|
36
|
+
// Opens a private copy of Chrome's cookie DB, derives the decryption key, and calls fn.
|
|
37
|
+
function withCookiesDb(fn) {
|
|
42
38
|
if (process.platform === "win32") {
|
|
43
39
|
throw new UserFacingError("Cookie extraction is not supported on Windows yet");
|
|
44
40
|
}
|
|
@@ -46,17 +42,32 @@ async function withCookiesDb(fn) {
|
|
|
46
42
|
if (!fs.existsSync(cookiesPath)) {
|
|
47
43
|
throw new UserFacingError(`Chrome cookies file not found at ${cookiesPath}. Is Google Chrome installed?`);
|
|
48
44
|
}
|
|
49
|
-
//
|
|
50
|
-
// Copy the file into a temp dir so we avoid the Chrome file lock.
|
|
45
|
+
// Copy the DB out of the profile directory so we avoid the Chrome file lock.
|
|
51
46
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinyfish-chrome-cookies-"));
|
|
47
|
+
let db;
|
|
52
48
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
49
|
+
const dbPath = path.join(tmpDir, "Cookies"); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
50
|
+
fs.copyFileSync(cookiesPath, dbPath);
|
|
51
|
+
// Committed rows can sit in Cookies-wal until Chrome checkpoints.
|
|
52
|
+
for (const ext of ["-wal", "-shm"]) {
|
|
53
|
+
if (fs.existsSync(cookiesPath + ext))
|
|
54
|
+
fs.copyFileSync(cookiesPath + ext, dbPath + ext);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
db = openCookiesDb(dbPath);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
throw new UserFacingError(`Failed to read Chrome cookies database: ${e instanceof Error ? e.message : String(e)}`);
|
|
61
|
+
}
|
|
62
|
+
return fn(db, deriveChromeKey());
|
|
58
63
|
}
|
|
59
64
|
finally {
|
|
65
|
+
try {
|
|
66
|
+
db?.close();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Ignore
|
|
70
|
+
}
|
|
60
71
|
try {
|
|
61
72
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
62
73
|
}
|
|
@@ -65,61 +76,21 @@ async function withCookiesDb(fn) {
|
|
|
65
76
|
}
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
|
-
// Extract cookies for a single domain using an already-open cookies DB.
|
|
69
|
-
async function extractDomain(tmpDir, chromeCookies, domain) {
|
|
70
|
-
// "puppeteer" format returns an array with capitalized boolean fields and
|
|
71
|
-
// Chrome-internal microsecond timestamps — convert to our CookieRecord shape.
|
|
72
|
-
const raw = await chromeCookies.getCookiesPromised(`https://${domain}`, "puppeteer", tmpDir);
|
|
73
|
-
return raw.map((c) => ({
|
|
74
|
-
name: c.name,
|
|
75
|
-
value: c.value,
|
|
76
|
-
domain: c.domain,
|
|
77
|
-
path: c.path,
|
|
78
|
-
secure: c.Secure ?? false,
|
|
79
|
-
httpOnly: c.HttpOnly ?? false,
|
|
80
|
-
...(c.expires && c.expires > 0 ? { expires: chromeTimeToUnix(c.expires) } : {}),
|
|
81
|
-
}));
|
|
82
|
-
}
|
|
83
79
|
// Exported for testing
|
|
84
|
-
export
|
|
85
|
-
return withCookiesDb((
|
|
80
|
+
export function extractChromeCookies(domain, stats) {
|
|
81
|
+
return withCookiesDb((db, key) => readCookiesForHost(db, key, domain, stats));
|
|
86
82
|
}
|
|
87
83
|
// Exported for testing
|
|
88
|
-
export
|
|
89
|
-
return withCookiesDb(
|
|
90
|
-
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
91
|
-
let sqliteDb = null;
|
|
84
|
+
export function extractAllChromeCookies(stats) {
|
|
85
|
+
return withCookiesDb((db, key) => {
|
|
92
86
|
let hostKeys;
|
|
93
87
|
try {
|
|
94
|
-
|
|
95
|
-
// Only import domains with auth-relevant cookies — skips tracking/analytics junk.
|
|
96
|
-
// A domain qualifies if it has any HttpOnly cookie (session/auth indicator) or
|
|
97
|
-
// a cookie whose name suggests authentication.
|
|
98
|
-
const rows = sqliteDb
|
|
99
|
-
.prepare(`SELECT DISTINCT host_key FROM cookies
|
|
100
|
-
WHERE is_httponly = 1
|
|
101
|
-
OR name LIKE '%session%'
|
|
102
|
-
OR name LIKE '%token%'
|
|
103
|
-
OR name LIKE '%auth%'
|
|
104
|
-
OR name LIKE '%sid%'
|
|
105
|
-
OR name LIKE '%login%'
|
|
106
|
-
OR name LIKE '%user%'
|
|
107
|
-
OR name LIKE '%account%'`)
|
|
108
|
-
.all();
|
|
109
|
-
hostKeys = rows.map((r) => r.host_key);
|
|
88
|
+
hostKeys = listAuthRelevantHostKeys(db);
|
|
110
89
|
}
|
|
111
90
|
catch (e) {
|
|
112
91
|
throw new UserFacingError(`Failed to read Chrome cookies database: ${e instanceof Error ? e.message : String(e)}`);
|
|
113
92
|
}
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
sqliteDb?.close();
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// Ignore
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
// 2. Canonicalize host_keys → registrable domains (mirrors frontend/app/lib/services/domain-utils.ts)
|
|
93
|
+
// Canonicalize host_keys → registrable domains (mirrors frontend/app/lib/services/domain-utils.ts)
|
|
123
94
|
const require = createRequire(import.meta.url);
|
|
124
95
|
const { parse } = require("tldts");
|
|
125
96
|
const registrableDomains = new Set();
|
|
@@ -130,26 +101,19 @@ export async function extractAllChromeCookies() {
|
|
|
130
101
|
registrableDomains.add(result.domain.toLowerCase());
|
|
131
102
|
}
|
|
132
103
|
errLine(`Scanning ${registrableDomains.size} domains from Chrome...`);
|
|
133
|
-
//
|
|
104
|
+
// hostKeyCandidates drops public-suffix parents, so registrable domains
|
|
105
|
+
// have disjoint host_key sets and need no cross-domain dedup.
|
|
134
106
|
const all = [];
|
|
135
107
|
for (const domain of registrableDomains) {
|
|
108
|
+
// One bad domain must not discard the whole import.
|
|
136
109
|
try {
|
|
137
|
-
|
|
138
|
-
all.push(...cookies);
|
|
110
|
+
all.push(...readCookiesForHost(db, key, domain, stats));
|
|
139
111
|
}
|
|
140
112
|
catch {
|
|
141
|
-
|
|
113
|
+
errLine(`Skipping ${domain}: could not read its cookies`);
|
|
142
114
|
}
|
|
143
115
|
}
|
|
144
|
-
|
|
145
|
-
const seen = new Set();
|
|
146
|
-
return all.filter((c) => {
|
|
147
|
-
const key = `${c.name}\t${c.domain}\t${c.path}`;
|
|
148
|
-
if (seen.has(key))
|
|
149
|
-
return false;
|
|
150
|
-
seen.add(key);
|
|
151
|
-
return true;
|
|
152
|
-
});
|
|
116
|
+
return all;
|
|
153
117
|
});
|
|
154
118
|
}
|
|
155
119
|
// Exported for testing
|
|
@@ -254,13 +218,21 @@ export function registerProfile(program) {
|
|
|
254
218
|
try {
|
|
255
219
|
// Extract cookies before creating profile to avoid orphan profiles
|
|
256
220
|
// if extraction fails (Windows, no Chrome, no cookies).
|
|
221
|
+
const stats = { keyringSkipped: 0 };
|
|
257
222
|
const cookies = opts.allDomains
|
|
258
|
-
?
|
|
259
|
-
:
|
|
223
|
+
? extractAllChromeCookies(stats)
|
|
224
|
+
: extractChromeCookies(opts.domain, stats);
|
|
225
|
+
// Warn even on partial success — surviving plaintext rows can mask
|
|
226
|
+
// that every auth cookie was keyring-encrypted and skipped.
|
|
227
|
+
if (stats.keyringSkipped > 0) {
|
|
228
|
+
errLine(`Skipped ${stats.keyringSkipped} keyring-encrypted cookies (not supported on Linux yet).`);
|
|
229
|
+
}
|
|
260
230
|
if (cookies.length === 0) {
|
|
261
|
-
throw new UserFacingError(
|
|
262
|
-
? "
|
|
263
|
-
:
|
|
231
|
+
throw new UserFacingError(stats.keyringSkipped > 0
|
|
232
|
+
? "Chrome on Linux encrypts cookies with the system keyring, which is not supported yet."
|
|
233
|
+
: opts.allDomains
|
|
234
|
+
? "No Chrome cookies found. Is Google Chrome installed and have you logged in to any sites?"
|
|
235
|
+
: `No Chrome cookies found for ${opts.domain}. Are you logged in to this site in Chrome?`);
|
|
264
236
|
}
|
|
265
237
|
if (profileId === undefined) {
|
|
266
238
|
const name = opts.allDomains ? "chrome-import" : opts.domain;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CookieRecord } from "./types.js";
|
|
2
|
+
export interface CookiesDb {
|
|
3
|
+
prepare(sql: string): {
|
|
4
|
+
all(...params: string[]): unknown[];
|
|
5
|
+
};
|
|
6
|
+
close(): void;
|
|
7
|
+
}
|
|
8
|
+
export declare function openCookiesDb(dbPath: string): CookiesDb;
|
|
9
|
+
export declare function deriveChromeKey(): Buffer;
|
|
10
|
+
export interface DecryptStats {
|
|
11
|
+
keyringSkipped: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stats?: DecryptStats): string | null;
|
|
14
|
+
export declare function listAuthRelevantHostKeys(db: CookiesDb): string[];
|
|
15
|
+
export declare function readCookiesForHost(db: CookiesDb, key: Buffer, host: string, stats?: DecryptStats): CookieRecord[];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createDecipheriv, pbkdf2Sync } from "node:crypto";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
// Reads Chrome's `Cookies` SQLite DB and decrypts values with the Safe Storage key.
|
|
6
|
+
// Scheme per platform (Windows is unsupported and guarded by the caller):
|
|
7
|
+
// macOS — password from the login keychain, pbkdf2(pw, "saltysalt", 1003, 16, sha1)
|
|
8
|
+
// Linux — hardcoded "peanuts" password, 1 pbkdf2 iteration (Chrome's v10 fallback)
|
|
9
|
+
// Values are AES-128-CBC with a 16-space IV, prefixed "v10"/"v11", and modern Chrome
|
|
10
|
+
// prepends a 32-byte domain hash to the plaintext.
|
|
11
|
+
const KEY_LENGTH = 16;
|
|
12
|
+
const SALT = "saltysalt";
|
|
13
|
+
const CBC_IV = Buffer.alloc(KEY_LENGTH, " ");
|
|
14
|
+
const DOMAIN_HASH_LENGTH = 32;
|
|
15
|
+
const SUPPORTED_PREFIXES = ["v10", "v11"];
|
|
16
|
+
// Chrome-internal timestamp (microseconds since 1601-01-01) → Unix seconds
|
|
17
|
+
const CHROME_EPOCH_DELTA_SECONDS = 11644473600;
|
|
18
|
+
export function openCookiesDb(dbPath) {
|
|
19
|
+
// node:sqlite is stable on the Node >=24 floor in `engines`; createRequire avoids
|
|
20
|
+
// needing @types/node >=22 for the import type.
|
|
21
|
+
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
22
|
+
return new DatabaseSync(dbPath);
|
|
23
|
+
}
|
|
24
|
+
export function deriveChromeKey() {
|
|
25
|
+
if (process.platform !== "darwin") {
|
|
26
|
+
return pbkdf2Sync("peanuts", SALT, 1, KEY_LENGTH, "sha1");
|
|
27
|
+
}
|
|
28
|
+
let password;
|
|
29
|
+
try {
|
|
30
|
+
password = execFileSync("/usr/bin/security", ["find-generic-password", "-w", "-s", "Chrome Safe Storage", "-a", "Chrome"],
|
|
31
|
+
// stderr is dropped so `security` diagnostics never pollute our JSON output.
|
|
32
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Deliberately not chaining the cause — it can carry keychain material.
|
|
36
|
+
throw new Error('Could not read Chrome\'s encryption key from the macOS keychain. Approve the Keychain prompt ("Always Allow") and retry.');
|
|
37
|
+
}
|
|
38
|
+
return pbkdf2Sync(password, SALT, 1003, KEY_LENGTH, "sha1");
|
|
39
|
+
}
|
|
40
|
+
// Returns null when the row cannot be decrypted — a wrong-key decrypt yields garbage,
|
|
41
|
+
// which would silently upload broken cookies.
|
|
42
|
+
export function decryptCookieValue(encrypted, key, stats) {
|
|
43
|
+
const prefix = Buffer.from(encrypted.subarray(0, 3)).toString("latin1");
|
|
44
|
+
if (!SUPPORTED_PREFIXES.includes(prefix))
|
|
45
|
+
return null;
|
|
46
|
+
// Off macOS the key is Chrome's v10 "peanuts" fallback; v11 rows are
|
|
47
|
+
// keyring-encrypted and would decrypt to garbage with it.
|
|
48
|
+
if (prefix === "v11" && process.platform !== "darwin") {
|
|
49
|
+
if (stats)
|
|
50
|
+
stats.keyringSkipped++;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
let decoded;
|
|
54
|
+
try {
|
|
55
|
+
const decipher = createDecipheriv("aes-128-cbc", key, CBC_IV);
|
|
56
|
+
decipher.setAutoPadding(false);
|
|
57
|
+
decoded = Buffer.concat([decipher.update(encrypted.subarray(3)), decipher.final()]);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (decoded.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
const padding = decoded[decoded.length - 1];
|
|
65
|
+
// A zero final byte means a legacy row with no padding and no domain-hash prefix.
|
|
66
|
+
if (!padding)
|
|
67
|
+
return decoded.toString("utf8");
|
|
68
|
+
// Wrong-key decrypts yield random bytes; require valid PKCS#7 padding or skip the row.
|
|
69
|
+
if (padding > KEY_LENGTH || decoded.length - padding < DOMAIN_HASH_LENGTH)
|
|
70
|
+
return null;
|
|
71
|
+
for (let i = decoded.length - padding; i < decoded.length; i++) {
|
|
72
|
+
if (decoded[i] !== padding)
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return decoded.subarray(DOMAIN_HASH_LENGTH, decoded.length - padding).toString("utf8");
|
|
76
|
+
}
|
|
77
|
+
// A cookie applies to a host when its host_key is the host itself or one of its
|
|
78
|
+
// dot-delimited parents (RFC 6265 §5.1.3), with Chrome's optional leading dot.
|
|
79
|
+
// Public-suffix parents (github.io, co.uk) are dropped — a row there would leak
|
|
80
|
+
// into every registrable domain under the suffix.
|
|
81
|
+
function hostKeyCandidates(host) {
|
|
82
|
+
const { parse } = createRequire(import.meta.url)("tldts");
|
|
83
|
+
const labels = host.split(".");
|
|
84
|
+
const parents = labels
|
|
85
|
+
.slice(1, -1)
|
|
86
|
+
.map((_, i) => labels.slice(i + 1).join("."))
|
|
87
|
+
.filter((p) => parse(p, { allowPrivateDomains: true }).domain);
|
|
88
|
+
return [host, ...parents].flatMap((h) => [h, `.${h}`]);
|
|
89
|
+
}
|
|
90
|
+
const hostKeyRowSchema = z.object({ host_key: z.string() });
|
|
91
|
+
const cookieRowSchema = z.object({
|
|
92
|
+
host_key: z.string(),
|
|
93
|
+
name: z.string(),
|
|
94
|
+
value: z.string(),
|
|
95
|
+
encrypted_value: z.instanceof(Uint8Array),
|
|
96
|
+
is_secure: z.number(),
|
|
97
|
+
is_httponly: z.number(),
|
|
98
|
+
expires_utc: z.number(),
|
|
99
|
+
});
|
|
100
|
+
// Only hosts with auth-relevant cookies — skips tracking/analytics junk. A host
|
|
101
|
+
// qualifies via any HttpOnly cookie or an auth-suggesting cookie name.
|
|
102
|
+
export function listAuthRelevantHostKeys(db) {
|
|
103
|
+
const rows = db
|
|
104
|
+
.prepare(`SELECT DISTINCT host_key FROM cookies
|
|
105
|
+
WHERE is_httponly = 1
|
|
106
|
+
OR name LIKE '%session%'
|
|
107
|
+
OR name LIKE '%token%'
|
|
108
|
+
OR name LIKE '%auth%'
|
|
109
|
+
OR name LIKE '%sid%'
|
|
110
|
+
OR name LIKE '%login%'
|
|
111
|
+
OR name LIKE '%user%'
|
|
112
|
+
OR name LIKE '%account%'`)
|
|
113
|
+
.all();
|
|
114
|
+
return rows.flatMap((row) => {
|
|
115
|
+
const parsed = hostKeyRowSchema.safeParse(row);
|
|
116
|
+
return parsed.success ? [parsed.data.host_key] : [];
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export function readCookiesForHost(db, key, host, stats) {
|
|
120
|
+
const candidates = hostKeyCandidates(host);
|
|
121
|
+
const rawRows = db
|
|
122
|
+
.prepare(
|
|
123
|
+
// Only root-path cookies are requested, matching the "/" request path this has
|
|
124
|
+
// always used. expires_utc is cast to REAL because Chrome's microsecond epoch
|
|
125
|
+
// exceeds Number.MAX_SAFE_INTEGER, which node:sqlite rejects as an exact integer.
|
|
126
|
+
`SELECT host_key, name, value, encrypted_value, is_secure, is_httponly,
|
|
127
|
+
CAST(expires_utc AS REAL) AS expires_utc
|
|
128
|
+
FROM cookies
|
|
129
|
+
WHERE path = '/' AND host_key IN (${candidates.map(() => "?").join(", ")})
|
|
130
|
+
ORDER BY creation_utc DESC`)
|
|
131
|
+
.all(...candidates);
|
|
132
|
+
// Malformed rows are skipped, matching the decrypt-failure policy.
|
|
133
|
+
const rows = rawRows.flatMap((raw) => {
|
|
134
|
+
const parsed = cookieRowSchema.safeParse(raw);
|
|
135
|
+
return parsed.success ? [parsed.data] : [];
|
|
136
|
+
});
|
|
137
|
+
const cookies = [];
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
// Newest row wins for a repeated name, matching chrome-cookies-secure.
|
|
141
|
+
if (seen.has(row.name))
|
|
142
|
+
continue;
|
|
143
|
+
let value = row.value;
|
|
144
|
+
if (value === "" && row.encrypted_value.length > 0) {
|
|
145
|
+
const decrypted = decryptCookieValue(row.encrypted_value, key, stats);
|
|
146
|
+
if (decrypted === null)
|
|
147
|
+
continue;
|
|
148
|
+
value = decrypted;
|
|
149
|
+
}
|
|
150
|
+
seen.add(row.name);
|
|
151
|
+
cookies.push({
|
|
152
|
+
name: row.name,
|
|
153
|
+
value,
|
|
154
|
+
domain: row.host_key,
|
|
155
|
+
path: "/",
|
|
156
|
+
secure: row.is_secure === 1,
|
|
157
|
+
httpOnly: row.is_httponly === 1,
|
|
158
|
+
...(row.expires_utc > 0
|
|
159
|
+
? { expires: Math.floor(row.expires_utc / 1e6) - CHROME_EPOCH_DELTA_SECONDS }
|
|
160
|
+
: {}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return cookies.reverse();
|
|
164
|
+
}
|
|
@@ -75,4 +75,40 @@ export type UpgradeScope = "both" | "cli" | "skill";
|
|
|
75
75
|
/** `toVersion` is null when the run was interrupted or the installed version could not be read. */
|
|
76
76
|
export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
|
|
77
77
|
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode): Promise<void>;
|
|
78
|
+
export declare const DOCTOR_HARNESS_SCOPES: readonly ["all", "claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
|
|
79
|
+
export declare const doctorCompletedPayloadSchema: z.ZodObject<{
|
|
80
|
+
schema_version: z.ZodInt;
|
|
81
|
+
cli_version: z.ZodString;
|
|
82
|
+
ok_harnesses: z.ZodBoolean;
|
|
83
|
+
ok_cli: z.ZodBoolean;
|
|
84
|
+
checks_pass: z.ZodInt;
|
|
85
|
+
checks_fail: z.ZodInt;
|
|
86
|
+
checks_warn: z.ZodInt;
|
|
87
|
+
checks_skip: z.ZodInt;
|
|
88
|
+
repairs_offered: z.ZodInt;
|
|
89
|
+
repair_actions: z.ZodArray<z.ZodEnum<{
|
|
90
|
+
connect: "connect";
|
|
91
|
+
"auth-login": "auth-login";
|
|
92
|
+
}>>;
|
|
93
|
+
repairs_attempted: z.ZodInt;
|
|
94
|
+
repairs_succeeded: z.ZodInt;
|
|
95
|
+
fix_mode: z.ZodEnum<{
|
|
96
|
+
none: "none";
|
|
97
|
+
fix: "fix";
|
|
98
|
+
fix_yes: "fix_yes";
|
|
99
|
+
}>;
|
|
100
|
+
harness_scope: z.ZodEnum<{
|
|
101
|
+
openclaw: "openclaw";
|
|
102
|
+
grok: "grok";
|
|
103
|
+
cursor: "cursor";
|
|
104
|
+
codex: "codex";
|
|
105
|
+
hermes: "hermes";
|
|
106
|
+
opencode: "opencode";
|
|
107
|
+
"claude-code": "claude-code";
|
|
108
|
+
all: "all";
|
|
109
|
+
}>;
|
|
110
|
+
is_human_initiated: z.ZodBoolean;
|
|
111
|
+
}, z.core.$strip>;
|
|
112
|
+
export type DoctorCompletedPayload = z.infer<typeof doctorCompletedPayloadSchema>;
|
|
113
|
+
export declare function sendDoctorCompleted(mcpUrl: string, payload: DoctorCompletedPayload, apiKey?: string): Promise<void>;
|
|
78
114
|
export {};
|
|
@@ -152,3 +152,47 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode) {
|
|
|
152
152
|
}
|
|
153
153
|
await postConnectEvent({ endpoint, body, attempts: 2, label: "setup", apiKey });
|
|
154
154
|
}
|
|
155
|
+
export const DOCTOR_HARNESS_SCOPES = ["all", ...ALL_HARNESSES];
|
|
156
|
+
// Aggregate only: per-harness rows and check `detail` strings stay local, so nothing
|
|
157
|
+
// user-authored or credential-shaped can ride a doctor run to PostHog.
|
|
158
|
+
// Caps mirror the route's schema: a payload the server would 400 is dropped here instead,
|
|
159
|
+
// because postConnectEvent never retries a 4xx and the event would vanish silently.
|
|
160
|
+
export const doctorCompletedPayloadSchema = z.object({
|
|
161
|
+
schema_version: z.int().positive(),
|
|
162
|
+
cli_version: z.string().max(32),
|
|
163
|
+
ok_harnesses: z.boolean(),
|
|
164
|
+
ok_cli: z.boolean(),
|
|
165
|
+
checks_pass: z.int().nonnegative().max(256),
|
|
166
|
+
checks_fail: z.int().nonnegative().max(256),
|
|
167
|
+
checks_warn: z.int().nonnegative().max(256),
|
|
168
|
+
checks_skip: z.int().nonnegative().max(256),
|
|
169
|
+
/** Offered by the pre-repair report; the verdicts describe the machine after it. */
|
|
170
|
+
repairs_offered: z.int().nonnegative().max(256),
|
|
171
|
+
repair_actions: z.array(z.enum(["connect", "auth-login"])).max(8),
|
|
172
|
+
repairs_attempted: z.int().nonnegative().max(256),
|
|
173
|
+
repairs_succeeded: z.int().nonnegative().max(256),
|
|
174
|
+
fix_mode: z.enum(["none", "fix", "fix_yes"]),
|
|
175
|
+
/** "all" unless --harness scoped the run; the counts above cover only that scope. */
|
|
176
|
+
harness_scope: z.enum(DOCTOR_HARNESS_SCOPES),
|
|
177
|
+
is_human_initiated: z.boolean(),
|
|
178
|
+
});
|
|
179
|
+
export async function sendDoctorCompleted(mcpUrl, payload, apiKey) {
|
|
180
|
+
if (telemetryDisabled())
|
|
181
|
+
return;
|
|
182
|
+
let endpoint;
|
|
183
|
+
let body;
|
|
184
|
+
try {
|
|
185
|
+
// Parse inside try — a schema violation must not fail a diagnosis that already ran.
|
|
186
|
+
const parsed = doctorCompletedPayloadSchema.parse(payload);
|
|
187
|
+
endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
188
|
+
body = JSON.stringify({ event: "doctor_completed", ...parsed });
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
192
|
+
errLine(`doctor telemetry skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// One attempt: doctor just probed this origin, so a stale keep-alive socket is not the risk.
|
|
197
|
+
await postConnectEvent({ endpoint, body, attempts: 1, label: "doctor", apiKey });
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiny-fish/cli",
|
|
3
|
-
"version": "0.29.2-next.
|
|
3
|
+
"version": "0.29.2-next.246",
|
|
4
4
|
"description": "TinyFish CLI — run web automations from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@tiny-fish/sdk": "^0.5.0",
|
|
36
|
-
"chrome-cookies-secure": "^3.0.2",
|
|
37
36
|
"commander": "^12.0.0",
|
|
38
37
|
"cross-spawn": "^7.0.6",
|
|
39
38
|
"globals": "^17.4.0",
|
|
39
|
+
"tldts": "^6.1.86",
|
|
40
40
|
"which": "^2.0.2",
|
|
41
41
|
"zod": "^4.3.6"
|
|
42
42
|
},
|