@geoly-ai/skills-hub 0.3.7 → 0.3.8

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/src/telemetry.mjs CHANGED
@@ -3,13 +3,14 @@
3
3
  // · 只收「哪个制品、什么结果」,不收路径、不收内容、不收用户名、不收目录清单
4
4
  // · 事件先落本地,上报是**独立**动作;关掉上报不影响本地统计
5
5
  // · install_id 是随机 UUID,与账号/机器名/用户名**无任何映射**
6
- import { randomUUID } from 'node:crypto';
7
- import { appendFileSync, existsSync, readFileSync, mkdirSync, openSync, closeSync, statSync, unlinkSync, renameSync, fstatSync, linkSync, fsyncSync } from 'node:fs';
8
- import { join } from 'node:path';
9
- import { homedir, platform, arch } from 'node:os';
6
+ import { randomUUID, generateKeyPairSync, createPublicKey } from 'node:crypto';
7
+ import { appendFileSync, existsSync, readFileSync, readdirSync, mkdirSync, openSync, closeSync, statSync, unlinkSync, renameSync, rmSync, fstatSync, linkSync, fsyncSync, chmodSync, fchmodSync } from 'node:fs';
8
+ import { join, basename } from 'node:path';
9
+ import { homedir, platform, arch, userInfo, hostname } from 'node:os';
10
10
  import { writeAtomic, fsyncParentAfter } from './atomic-fs.mjs';
11
11
  import { stringify, encodeString, parseStrict } from './canonical-json.mjs';
12
12
  import { acquire } from './lock.mjs';
13
+ import { ownVersion } from './version.mjs';
13
14
 
14
15
  export const KINDS = new Set([
15
16
  'install', 'update', 'remove', 'check', 'rollback', 'recover', 'sync-lock',
@@ -20,9 +21,50 @@ export const KINDS = new Set([
20
21
  ]);
21
22
  export const RESULTS = new Set(['ok', 'skipped', 'failed', 'corrupt']);
22
23
 
24
+ let _lastError = null;
25
+ export const lastError = () => _lastError;
26
+
27
+ /**
28
+ * 权限收紧失败的告警。**与 `_lastError` 分开存**。
29
+ *
30
+ * 🔴 `record()` 成功一次就把 `_lastError` 清成 null(那是对的:它说的是
31
+ * 「上一次记录出没出错」)。把 chmod 失败塞进同一个变量,等于**下一条事件
32
+ * 就把它擦掉了** —— 于是「记进 lastError 供诊断」这句话在实际运行里从来不成立
33
+ * (Codex 2026-09-09 指出)。权限是一个**持续状态**,不是一次操作的结果,
34
+ * 所以它要自己的变量,而且只增不清。
35
+ */
36
+ let _permWarn = null;
37
+ export const permWarning = () => _permWarn;
38
+
23
39
  export function stateDir() {
24
40
  return process.env.GEOLY_STATE_DIR ?? join(homedir(), '.local', 'state', 'geoly-skills');
25
41
  }
42
+ /**
43
+ * 埋点状态目录 —— **0700,并且每次都把已存在的目录纠正回 0700**。
44
+ *
45
+ * 🔴 判据不能是「建的时候给了 mode」:`mkdirSync(…, { mode })` 只对**这次真的创建**
46
+ * 生效,而且还要减去 umask;目录早就存在(老版本按默认 0755 建的)时它一声不吭。
47
+ * 所以这里无条件 chmod 一次 —— 幂等、便宜,且能把老机器上的目录迁移过来。
48
+ *
49
+ * 🔴 为什么是 0700 而不是 0755:这里面是 queue / history / sending / install-id。
50
+ * 同机的**其他账户**本来就能读它们;一旦事件里出现自报的用户名与主机名,
51
+ * 那就是把身份信息摊在一个所有人可读的目录里。目录一收紧,里面所有文件
52
+ * (包括 writeAtomic 的临时文件)一起被保护,不用逐个文件去追。
53
+ */
54
+ export function telemetryDir() {
55
+ const d = join(stateDir(), 'telemetry');
56
+ mkdirSync(d, { recursive: true, mode: 0o700 });
57
+ try {
58
+ chmodSync(d, 0o700);
59
+ } catch (err) {
60
+ // 🔴 **吞掉但要留痕。** 收不紧权限时继续写是有意的(T-5:埋点不得让主命令挂),
61
+ // 但「悄悄地继续写」意味着没人知道这台机器上的埋点目录是所有人可读的。
62
+ // `telemetry status` 会把它显示出来。
63
+ _permWarn = `目录 ${d} 收不到 0700:${err?.message ?? err}`;
64
+ }
65
+ return d;
66
+ }
67
+
26
68
  const queuePath = () => join(stateDir(), 'telemetry', 'queue.ndjson');
27
69
  const idPath = () => join(stateDir(), 'telemetry', 'install-id');
28
70
 
@@ -34,6 +76,58 @@ export function enabled() {
34
76
  /** M0 的全局 `--offline`:置位后本 CLI 不得有任何网络出口,埋点也不例外 */
35
77
  export const offline = () => process.env.GEOLY_OFFLINE === '1';
36
78
 
79
+ /**
80
+ * 是否采集**身份三项**(`os_user` / `host`,以及服务端观测的 `ip`)。
81
+ *
82
+ * 🔴 **默认关,而且必须显式打开。** 2026-09-09 用户拍板要采身份字段,但 Codex 在
83
+ * 方案评审里把三件事列为阻断项:服务端还没把身份与事件分表(现在会直接写进
84
+ * `telemetry_events.ev`)、dashboard 还是共享口令没有按人审计、删除通道还没有。
85
+ * 在那三件事落地之前把默认打开,等于先把身份数据灌进一个管不住它的库。
86
+ * ⚠️ **翻这个默认值是一次独立的、要过评审的动作**,不要顺手改掉。
87
+ *
88
+ * 🔴 **先告知、后采集,由代码强制,不只是文档里的一句话。** 即使显式打开,
89
+ * 没展示过身份告知(`identity-notice.v2` 标记不在)也一律不采 —— 与 §4.3
90
+ * 「先打印、后落标记」是同一个取向:漏掉一次告知比多看一次严重得多。
91
+ *
92
+ * 关的两条路:`GEOLY_TELEMETRY_IDENTITY=off`(环境变量),或 `telemetry off`
93
+ * 落下的本地标记。**关掉身份不影响匿名计数**(用户 2026-09-09 拍板)——
94
+ * 想连计数一起停是 `GEOLY_TELEMETRY=0`(`enabled()`)。
95
+ */
96
+ const OFFISH = new Set(['0', 'off', 'false']);
97
+ const ONISH = new Set(['1', 'on', 'true']);
98
+ const identityOffPath = () => join(stateDir(), 'telemetry', 'identity-off');
99
+ const identityNoticePath = () => join(stateDir(), 'telemetry', 'identity-notice.v2');
100
+
101
+ /** 当前身份告知的版本号。改采集面就要发新版本并重新告知(规格 §4.3)。 */
102
+ export const IDENTITY_NOTICE = 'v2';
103
+
104
+ export function identityEnabled() {
105
+ if (!enabled()) return false;
106
+ const v = process.env.GEOLY_TELEMETRY_IDENTITY;
107
+ if (typeof v === 'string' && OFFISH.has(v)) return false;
108
+ try { if (existsSync(identityOffPath())) return false; } catch { return false; }
109
+ if (!(typeof v === 'string' && ONISH.has(v))) return false; // 默认关,见上
110
+ return identityNoticeShown();
111
+ }
112
+
113
+ /**
114
+ * 身份告知是不是**真的展示过**。
115
+ *
116
+ * 🔴 判据不能是「这个名字存在」(Codex 2026-09-09 指出):预先建一个同名**目录**
117
+ * 或者一个指向别处的 symlink,就能在没看过告知的情况下把身份采集打开。
118
+ * 这正是 §5.2.4 那条 —— 「文件在不在」永远不是判据 —— 的又一个实例,
119
+ * 而且这次它守的是「先告知后采集」,比队列那次更贵。
120
+ * ⚠️ 上报告知那个标记是另一回事:它的内容确实没人读,存在性就是全部语义。
121
+ * 这里不同,这里的存在性要用来**放行一件事**,所以必须验到内容。
122
+ */
123
+ export function identityNoticeShown() {
124
+ try {
125
+ const st = statSync(identityNoticePath()); // 不跟随不存在的目标,坏 symlink 直接抛
126
+ if (!st.isFile()) return false;
127
+ return /^shown-at=\d{4}-/m.test(readFileSync(identityNoticePath(), 'utf8'));
128
+ } catch { return false; }
129
+ }
130
+
37
131
  /** 是否上报:默认开;`GEOLY_TELEMETRY_UPLOAD=0` 或 `--offline` 只留本地 */
38
132
  export function uploadEnabled() {
39
133
  const v = process.env.GEOLY_TELEMETRY_UPLOAD;
@@ -51,9 +145,21 @@ export function installId() {
51
145
  };
52
146
 
53
147
  const existing = readValid();
54
- if (existing) return existing;
148
+ if (existing) {
149
+ // 🔴 **提前返回这条路径也要迁移权限。** 老机器上 install-id 早就存在、
150
+ // 而且是 0644 建的;只在「新建」那条路径上给 0600,等于**永远迁移不到**
151
+ // 那些真正需要迁移的机器(Codex 2026-09-09 指出)。
152
+ // ⚠️ 目录本身由 telemetryDir() 收到 0700,这里是第二道。
153
+ try {
154
+ telemetryDir();
155
+ if ((statSync(p).mode & 0o777) !== 0o600) chmodSync(p, 0o600);
156
+ } catch (err) {
157
+ _permWarn = `install-id 收不到 0600:${err?.message ?? err}`;
158
+ }
159
+ return existing;
160
+ }
55
161
 
56
- mkdirSync(join(stateDir(), 'telemetry'), { recursive: true });
162
+ telemetryDir();
57
163
 
58
164
  // 🔴 **先写满,再让名字出现。**
59
165
  //
@@ -70,7 +176,7 @@ export function installId() {
70
176
  const id = randomUUID();
71
177
  const tmp = `${p}.${process.pid}.${attempt}.tmp`;
72
178
  try {
73
- const fd = openSync(tmp, 'w', 0o644);
179
+ const fd = openSync(tmp, 'w', 0o600);
74
180
  try { appendFileSync(fd, id + '\n'); fsyncSync(fd); } finally { closeSync(fd); }
75
181
  try {
76
182
  linkSync(tmp, p); // 抢到了
@@ -90,6 +196,69 @@ export function installId() {
90
196
  return randomUUID();
91
197
  }
92
198
 
199
+ /**
200
+ * 删除所有权密钥 —— Ed25519 私钥,只存本机。
201
+ *
202
+ * 🔴 **与 `install_id` 同一套「先写满、再让名字出现」**(见上面那段长注释):
203
+ * `wx` 抢占看着原子,其实文件一建就存在而内容还没写,抢输的进程读到空串。
204
+ * 这里的代价比 install_id 更大 —— 并发首采若生成两把密钥、只落盘一把,
205
+ * 另一批已经带着公钥发出去的身份数据就**永久删不掉**
206
+ * (Codex 2026-09-10 指出)。
207
+ *
208
+ * 🔴 **不在这里做「没有就生成」以外的任何事。** 特别是:读不出来时**不重新生成** ——
209
+ * 重新生成等于把旧公钥对应的那批数据永久孤立。读不出来就返回 null,
210
+ * 让调用方如实说「这台机器没有可用的密钥,删不了」。
211
+ */
212
+ const deleteKeyPath = () => join(stateDir(), 'telemetry', 'delete-key');
213
+
214
+ export function deleteKey({ create = false } = {}) {
215
+ const p = deleteKeyPath();
216
+ const readValid = () => {
217
+ try {
218
+ const pem = readFileSync(p, 'utf8');
219
+ if (!pem.includes('BEGIN PRIVATE KEY')) return null;
220
+ const pub = createPublicKey(pem);
221
+ const { x } = pub.export({ format: 'jwk' });
222
+ return RE_PUBKEY.test(x) ? { pem, pubkey: x } : null;
223
+ } catch { return null; }
224
+ };
225
+
226
+ const existing = readValid();
227
+ if (existing) {
228
+ // 权限迁移与 install-id 同理:只在新建时给 0600 等于永远迁不到老机器
229
+ try {
230
+ telemetryDir();
231
+ if ((statSync(p).mode & 0o777) !== 0o600) chmodSync(p, 0o600);
232
+ } catch (err) { _permWarn = `delete-key 收不到 0600:${err?.message ?? err}`; }
233
+ return existing;
234
+ }
235
+ if (!create) return null;
236
+
237
+ telemetryDir();
238
+ for (let attempt = 0; attempt < 3; attempt++) {
239
+ const { privateKey } = generateKeyPairSync('ed25519');
240
+ const pem = privateKey.export({ format: 'pem', type: 'pkcs8' });
241
+ const tmp = `${p}.${process.pid}.${attempt}.tmp`;
242
+ try {
243
+ const fd = openSync(tmp, 'w', 0o600);
244
+ try { appendFileSync(fd, pem); fsyncSync(fd); } finally { closeSync(fd); }
245
+ try {
246
+ linkSync(tmp, p); // 原子 no-replace:抢到了
247
+ return readValid();
248
+ } catch {
249
+ const winner = readValid(); // 别人抢先,此刻内容必然完整
250
+ if (winner) return winner;
251
+ }
252
+ } catch {
253
+ const winner = readValid();
254
+ if (winner) return winner;
255
+ } finally {
256
+ try { unlinkSync(tmp); } catch { /* 没建成 */ }
257
+ }
258
+ }
259
+ return null; // 抢不到又读不出:如实返回 null,绝不「再生成一把」
260
+ }
261
+
93
262
  // ─────────────────────────────────────────────────────────────────────────────
94
263
  // 严格 schema:这是隐私契约的**唯一**执行点
95
264
  //
@@ -104,6 +273,9 @@ export function installId() {
104
273
  const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
105
274
  const RE_AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
106
275
  const RE_SEMVERISH = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,31}$/;
276
+ // Ed25519 公钥的 JWK `x`:32 字节 → base64url 恰好 43 字符,无 padding。
277
+ // 🔴 **定长**,不是「长度不超过」:可变长度会让一个塞了别的东西的字段混进来。
278
+ const RE_PUBKEY = /^[A-Za-z0-9_-]{43}$/;
107
279
  const RE_ARTIFACT = /^(skill|pack):[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9._-]*@[0-9A-Za-z.+-]{1,32}$/;
108
280
 
109
281
  export const CLIENTS = new Set(['claude', 'cursor', 'codex', 'agents']);
@@ -125,6 +297,39 @@ export const REASONS = new Set([
125
297
  'unknown',
126
298
  ]);
127
299
 
300
+ /**
301
+ * 身份字段的清洗。**它同时是校验器和构造器** —— 两边用同一个函数,
302
+ * 「能构造出来的」与「能通过校验的」按定义相等,不会有一边宽一边窄。
303
+ *
304
+ * 🔴 不用一条宽正则。用户名与主机名是**操作系统给的**,不是我们能约束的输入:
305
+ * Windows 的用户名可以带空格与中文,容器里的主机名可以是 64 位十六进制,
306
+ * 被改过的环境里它可以是任意字节。所以这里逐条收:
307
+ * · NFC 归一 —— 同一个名字的两种 Unicode 写法必须折成同一个值,
308
+ * 否则「同一个人」会在聚合里被数成两个
309
+ * · 拒绝所有控制字符与行分隔符(`\p{C}`)—— 换行会把一条 NDJSON 撕成两条,
310
+ * 那是注入,不是脏数据
311
+ * · 长度上限 —— 一个超长的名字既是存储放大,也是指纹
312
+ * 清洗不过就**整个字段不发**(返回 null),绝不发一个截断过的名字:
313
+ * 截断后的名字看起来仍然像一个真名,但它谁都不是。
314
+ */
315
+ const MAX_OS_USER = 64;
316
+ const MAX_HOST = 128;
317
+ const RE_UNSAFE_IDENTITY = /[\p{C}\p{Zl}\p{Zp}]/u;
318
+
319
+ export function sanitizeIdentity(raw, max) {
320
+ if (typeof raw !== 'string') return null;
321
+ let v;
322
+ try { v = raw.normalize('NFC'); } catch { return null; }
323
+ v = v.trim();
324
+ if (v === '' || v.length > max) return null;
325
+ if (RE_UNSAFE_IDENTITY.test(v)) return null;
326
+ return v;
327
+ }
328
+ const identityOk = (max) => (v) => typeof v === 'string' && sanitizeIdentity(v, max) === v;
329
+
330
+ /** 告知版本是**有限代码表**,与 `reason` 同理:一个自由字符串迟早会被拿来塞信息。 */
331
+ export const NOTICES = new Set(['v2']);
332
+
128
333
  const str = (re) => (v) => typeof v === 'string' && re.test(v);
129
334
  const oneOf = (set) => (v) => typeof v === 'string' && set.has(v);
130
335
 
@@ -150,6 +355,28 @@ const FIELDS = {
150
355
  scope: { required: false, ok: oneOf(SCOPES) },
151
356
  ms: { required: false, ok: (v) => Number.isInteger(v) && v >= 0 && v <= 86_400_000 },
152
357
  reason: { required: false, ok: oneOf(REASONS) },
358
+
359
+ // ── 身份三项中的两项(第三项 `ip` 由服务端观测,客户端不采:见规格 §5.3)──
360
+ //
361
+ // 🔴 三个都是 `required: false`,而且**必须**是可选的:队列里躺着的老事件
362
+ // (身份开关打开之前记的、以及关掉身份之后记的)没有这些键,
363
+ // 设成必填会让它们在下一次 flush 时**整批**校验不过,永久卡在队列里。
364
+ //
365
+ // 🔴 `os_user` / `host` 是**客户端自报**的,服务端无从核实。任何把它们叫做
366
+ // 「真实归属」的措辞都是错的 —— 页面上只能说「自报归属」。
367
+ // 🔴 `identity: true` 是**唯一**的身份标记来源。早先身份字段名是另写的一个
368
+ // 手写数组,加字段时忘了同步那边,正向 pick 就会把新字段当匿名字段放出去
369
+ // —— 而且不报错(Codex 2026-09-09 的硬化建议)。现在两张名单都从这里派生。
370
+ // 🔴 `pubkey` 也是身份类字段,理由和 install_id 一样:**它是一个高熵且稳定的
371
+ // 标识符**。为了让「删除」能证明所有权,我们不得不多收一个这样的东西 ——
372
+ // 「能删」与「少收」在这件事上是对立的(Codex 2026-09-10 复核过这个取舍)。
373
+ // 能接受的原因只有两条:它跟身份三项同期 90 天到期,
374
+ // 且只在身份**已经开启**时才存在(身份没开就没有可删的东西)。
375
+ // 定长 43 字符 base64url = 32 字节 Ed25519 公钥的 JWK `x`。
376
+ pubkey: { required: false, identity: true, ok: str(RE_PUBKEY) },
377
+ os_user: { required: false, identity: true, ok: identityOk(MAX_OS_USER) },
378
+ host: { required: false, identity: true, ok: identityOk(MAX_HOST) },
379
+ notice: { required: false, identity: true, ok: oneOf(NOTICES) },
153
380
  };
154
381
 
155
382
  /**
@@ -159,6 +386,26 @@ const FIELDS = {
159
386
  */
160
387
  export const FIELD_NAMES = Object.freeze(Object.keys(FIELDS));
161
388
 
389
+ /**
390
+ * 身份字段的键名。**这是唯一的定义处**,服务端摄入层按它把一条事件拆成
391
+ * 「匿名事件」与「身份行」两半(`server/validate.mjs` 的 splitIdentity)。
392
+ *
393
+ * 🔴 为什么不让服务端自己列一份:那就成了第二张表,两张表迟早分叉,
394
+ * 而分叉的方向一定是**服务端那份漏掉新字段**,于是新身份字段直接落进
395
+ * 匿名事件的 jsonb 里 —— 不报错、没迹象,只有在某天导出的时候才看见。
396
+ * 与「事件校验器只有 assertValidEvent 一个」是同一条纪律。
397
+ *
398
+ * ⚠️ `ip` 不在这里:它由服务端观测,从来不是客户端事件的一个键。
399
+ */
400
+ export const IDENTITY_FIELD_NAMES = Object.freeze(
401
+ FIELD_NAMES.filter((k) => FIELDS[k].identity === true),
402
+ );
403
+
404
+ /** 匿名事件的键名 = 全部键减去身份键。存进 `telemetry_events.ev` 的只能是这些。 */
405
+ export const ANONYMOUS_FIELD_NAMES = Object.freeze(
406
+ FIELD_NAMES.filter((k) => FIELDS[k].identity !== true),
407
+ );
408
+
162
409
  /**
163
410
  * 🔴 隐私契约的执行点。落盘、读队列、上报、导出 —— 四个边界都走这一个函数。
164
411
  * 任何不合规的事件都**不得**离开本机。
@@ -199,10 +446,31 @@ export function buildEvent({ kind, artifact, version, client, scope, result, ms,
199
446
  at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
200
447
  install_id: installId(),
201
448
  // 环境变量可被任意注入,所以它也要过 RE_SEMVERISH
202
- cli: process.env.GEOLY_CLI_VERSION ?? '0.0.0-m1',
449
+ // 🔴 缺省取 package.json 的真实版本,不是字面量:早先这里回落 '0.0.0-m1',
450
+ // 而从 npm 装下来运行时没人设 GEOLY_CLI_VERSION —— 发布出去的 CLI 一直上报
451
+ // `cli: "0.0.0-m1"`(2026-09-14 端到端实测)。与 context.mjs 的版本门同一个来源。
452
+ cli: process.env.GEOLY_CLI_VERSION ?? ownVersion(),
203
453
  os: platform(), arch: arch(), node: process.versions.node,
204
454
  kind, result,
205
455
  };
456
+ // 🔴 身份三项只在 identityEnabled() 为真时才**存在**,不是「填一个空值」。
457
+ // 缺席就是缺席 —— 一个 `os_user: ''` 会让服务端以为「这台机器开了身份、
458
+ // 只是名字为空」,那是两件不同的事。
459
+ // ⚠️ `userInfo()` 在没有 passwd 条目的容器里会抛(uid 不在 /etc/passwd);
460
+ // hostname() 在极端环境里也可能失败。**取不到就不发那一项,绝不让主命令挂**。
461
+ if (identityEnabled()) {
462
+ let u = null, h = null;
463
+ try { u = sanitizeIdentity(userInfo().username, MAX_OS_USER); } catch { /* 无 passwd 条目 */ }
464
+ try { h = sanitizeIdentity(hostname(), MAX_HOST); } catch { /* 拿不到主机名 */ }
465
+ if (u !== null) ev.os_user = u;
466
+ if (h !== null) ev.host = h;
467
+ ev.notice = IDENTITY_NOTICE;
468
+ // 🔴 公钥在**第一条带身份的事件**上就要带上,不能等到删除时才发:
469
+ // 删除要能指认「这批数据是我的」,而指认靠的正是每条数据上都有这把公钥。
470
+ // 生成失败时不发 —— 那台机器的这批数据将删不掉,CLI 会如实告诉用户。
471
+ const k = deleteKey({ create: true });
472
+ if (k) ev.pubkey = k.pubkey;
473
+ }
206
474
  if (artifact !== undefined) ev.artifact = artifact;
207
475
  if (version !== undefined) ev.version = version;
208
476
  if (client !== undefined) ev.client = client;
@@ -261,9 +529,9 @@ export const queueFiles = () => [sendingPath(), prevQueuePath(), queuePath()];
261
529
  * 能把**已经通过校验的对象**换成别的东西再写盘/上报。
262
530
  * 事件的值只有受限字符集的字符串和整数,手写既安全又不复杂。
263
531
  */
264
- export function serializeEvent(ev) {
532
+ function serializeKeys(ev, keys) {
265
533
  const parts = [];
266
- for (const k of Object.keys(FIELDS)) {
534
+ for (const k of keys) {
267
535
  if (!Object.hasOwn(ev, k)) continue;
268
536
  const v = ev[k];
269
537
  parts.push(`${encodeString(k)}:${typeof v === 'number' ? String(v) : encodeString(v)}`);
@@ -271,6 +539,23 @@ export function serializeEvent(ev) {
271
539
  return `{${parts.join(',')}}`;
272
540
  }
273
541
 
542
+ /**
543
+ * 🔴 **保持一元。** 早先这里图省事写成 `serializeEvent(ev, keys = …)`,
544
+ * 结果 `pending.map(serializeEvent)` 把**数组下标**当成 keys 传了进来
545
+ * (`Array.prototype.map` 给回调三个参数),`for…of` 一个数字直接 TypeError,
546
+ * 上报整个静默失败 —— flush 只回一个 `error:TypeError`,没人看得出发生了什么。
547
+ * ⚠️ 一个「有默认值的可选第二参数」在 map / forEach 回调里从来不是可选的。
548
+ * 要另一种键集就另开一个函数名,不要加位置参数。
549
+ */
550
+ export function serializeEvent(ev) {
551
+ return serializeKeys(ev, Object.keys(FIELDS));
552
+ }
553
+
554
+ /** 只输出匿名字段 —— 服务端摄入层用它,保证身份字段进不了事件存储。 */
555
+ export function serializeAnonymousEvent(ev) {
556
+ return serializeKeys(ev, ANONYMOUS_FIELD_NAMES);
557
+ }
558
+
274
559
  /**
275
560
  * 追加到本地队列(NDJSON,一行一事件)。
276
561
  *
@@ -281,14 +566,11 @@ export function serializeEvent(ev) {
281
566
  * 不 fsync:丢掉最后几条埋点无所谓,让主命令等一次 fsync 才是真的有害。
282
567
  * 追加走 O_APPEND,单行远小于 PIPE_BUF,并发追加不会交错。
283
568
  */
284
- let _lastError = null;
285
- export const lastError = () => _lastError;
286
-
287
569
  export function record(input) {
288
570
  if (!enabled()) return null;
289
571
  try {
290
572
  const ev = buildEvent(input);
291
- mkdirSync(join(stateDir(), 'telemetry'), { recursive: true });
573
+ telemetryDir();
292
574
  const line = serializeEvent(ev) + '\n';
293
575
  // 🔴 open 与 append 之间,别的进程可能把这个文件 unlink 掉(换代删上一代、
294
576
  // retire 删 sending)。那样这一行就写进了一个没有目录项的 inode —— 谁都读不到,
@@ -318,11 +600,21 @@ export function record(input) {
318
600
  */
319
601
  export function appendDurable(path, line) {
320
602
  for (let attempt = 0; attempt < 4; attempt++) {
321
- const fd = openSync(path, 'a', 0o644);
603
+ const fd = openSync(path, 'a', 0o600);
322
604
  let orphaned;
323
605
  try {
324
606
  appendFileSync(fd, line);
325
- orphaned = fstatSync(fd).nlink === 0;
607
+ // 这一次 fstat 本来就是为了查 nlink(见上面那段注释),顺带把老版本用 0644
608
+ // 建出来的队列/历史迁移成 0600 —— 走 fd 而不是路径,避免 TOCTOU 换靶。
609
+ const st = fstatSync(fd);
610
+ if ((st.mode & 0o777) !== 0o600) {
611
+ try {
612
+ fchmodSync(fd, 0o600);
613
+ } catch (err) {
614
+ _permWarn = `${path} 收不到 0600:${err?.message ?? err}`;
615
+ }
616
+ }
617
+ orphaned = st.nlink === 0;
326
618
  } finally { closeSync(fd); }
327
619
  if (!orphaned) return;
328
620
  }
@@ -417,11 +709,13 @@ skills-hub 会上报匿名使用埋点(首次运行提示,只显示这一次
417
709
  收什么 装了哪个制品、哪个 client、成功还是失败、耗时,
418
710
  以及 CLI / OS / arch / Node 版本和一个「本机随机 ID」
419
711
  (随机 UUID,与账号、机器名、用户名、MAC 无任何映射,删了就换一个)
420
- 不收什么 路径、目录清单、文件内容、用户名、命令行原文、异常栈
712
+ 不收什么 路径、目录清单、文件内容、命令行原文、异常栈
421
713
  —— 采集面是穷举白名单,整张表见 docs/telemetry/00-spec.md §2
714
+ 身份三项 登录名 / 主机名 / 来源 IP —— **默认不采**。
715
+ 要开的话会**单独再告知一次**,并且可以只关它、匿名计数照发
422
716
  发到哪 ${url}
423
717
  什么时候发 一次 install 成功收尾之后,最多每 24 小时静默发一次
424
- (超时 1 秒;发不出去就算了,不会影响安装结果);
718
+ (超时 3 秒;发不出去就留在本地稍后再试,不会影响安装结果);
425
719
  别的命令(check / list / stats…)只写本地,不出网。
426
720
  也可以随时手动 \`skills-hub telemetry flush\` 立刻发
427
721
  怎么关 GEOLY_TELEMETRY_UPLOAD=0 只留本地统计,不上报
@@ -433,6 +727,147 @@ ${bar}
433
727
  `;
434
728
  }
435
729
 
730
+ /**
731
+ * 身份采集的告知文案(notice v2)。
732
+ *
733
+ * 🔴 **与上报告知是两段,不是一段。** 上报告知回答「会不会出网」,
734
+ * 这一段回答「出网的东西里有没有你是谁」。把它们合成一段的话,
735
+ * 早就看过上报告知的老用户在身份开启时**一个字都不会再看到**。
736
+ */
737
+ export function identityNoticeText(url) {
738
+ const bar = '─'.repeat(74);
739
+ return `${bar}
740
+ skills-hub 从这次起会上报**身份信息**(只显示这一次)
741
+
742
+ 新增采集 你的登录名、主机名,以及服务端看到的来源 IP
743
+ 仍然不收 路径、目录清单、文件内容、命令行原文、异常栈
744
+ 发到哪 ${url}
745
+ 留多久 身份三项 90 天后清除;匿名计数保留 180 天
746
+ 怎么删 skills-hub telemetry delete 申请服务端删除已发出的身份,并清本机
747
+ 删除凭证只存在这台机器上;丢了就无法证明那些记录是你的,只能等 90 天到期
748
+ 怎么只关它 skills-hub telemetry off 身份不发了,匿名计数照发
749
+ GEOLY_TELEMETRY_IDENTITY=off 同上,环境变量写法
750
+ 怎么全关 GEOLY_TELEMETRY=0 什么都不发,本地也不写
751
+
752
+ 关掉之后功能完全不受影响。当前状态:skills-hub telemetry status
753
+ ${bar}
754
+ `;
755
+ }
756
+
757
+ /**
758
+ * 身份采集的首次告知。与上报告知同一套纪律:**先打印、后落标记**。
759
+ *
760
+ * 🔴 这段告知是 `identityEnabled()` 的**硬前置**:标记不在就一项都不采
761
+ * (见那个函数的注释)。所以这里不是「顺手提示一下」,
762
+ * 它是那条开关能打开的唯一途径。
763
+ * 🔴 只有在**有人显式打开身份采集**时才打 —— 默认关的时候打这段,
764
+ * 等于吓唬一个我们根本没在采的用户。
765
+ */
766
+ export function maybeNoticeIdentity(write, url) {
767
+ try {
768
+ if (!enabled() || !uploadEnabled() || !url) return false;
769
+ const v = process.env.GEOLY_TELEMETRY_IDENTITY;
770
+ if (!(typeof v === 'string' && ONISH.has(v))) return false;
771
+ if (existsSync(identityOffPath())) return false; // 用户关过就是关过
772
+ const p = identityNoticePath();
773
+ if (existsSync(p)) return false;
774
+ telemetryDir();
775
+ write(identityNoticeText(url));
776
+ try {
777
+ const fd = openSync(p, 'wx', 0o600);
778
+ try { appendFileSync(fd, `shown-at=${new Date().toISOString()}\nendpoint=${url}\n`); } finally { closeSync(fd); }
779
+ } catch { /* 别人抢先建了 —— 告知已经打过,不影响主命令 */ }
780
+ return true;
781
+ } catch (err) {
782
+ _lastError = err;
783
+ return false;
784
+ }
785
+ }
786
+
787
+ /**
788
+ * 只关身份(第一档退出)。匿名计数照发。
789
+ *
790
+ * 🔴 落的是一个**标记文件**而不是改环境变量:环境变量只对当前进程有效,
791
+ * 而用户说的「关掉」是一个持久的决定。标记优先级高于 `GEOLY_TELEMETRY_IDENTITY=on`
792
+ * (见 identityEnabled)—— 用户关过就是关过,配置不该把它掀回来。
793
+ */
794
+ export function identityOff() {
795
+ telemetryDir();
796
+ try {
797
+ const fd = openSync(identityOffPath(), 'wx', 0o600);
798
+ try { appendFileSync(fd, `off-at=${new Date().toISOString()}\n`); } finally { closeSync(fd); }
799
+ } catch { /* 已经关过了 */ }
800
+ return true;
801
+ }
802
+
803
+ /** 撤销上面那个决定。**不会**自动打开身份采集 —— 还要过环境变量与告知两道门。 */
804
+ export function identityOn() {
805
+ try { unlinkSync(identityOffPath()); } catch { /* 本来就没关过 */ }
806
+ return true;
807
+ }
808
+
809
+ /**
810
+ * 清空本机所有埋点数据。
811
+ *
812
+ * 🔴 **在上报锁下做**,否则正在 flush 的那个进程会把 sending 里的事件发出去,
813
+ * 「删掉了」变成「删掉了但还是发出去了」。
814
+ * 🔴 `install-id` 也一起删:留着它,下一条事件仍然接得回同一条时间线,
815
+ * 那样「删除」只是删了一半(Codex 2026-09-09 指出)。
816
+ * 🔴 **只管本机。** 服务端那一半由 `upload.mjs` 的 `remoteDelete` 负责,
817
+ * 而且**必须先于这里**:这里会删掉私钥,私钥是远程删除唯一的所有权证明。
818
+ *
819
+ * @param {object} [opts]
820
+ * @param {boolean} [opts.keepDeleteKey] 远程删除没成功时传 true —— 私钥一删,
821
+ * 服务端那批数据就再也证明不了是谁的,只能等 90 天到期。
822
+ * @returns {{ removed: number, remaining: string[], kept: string[], deleteKeyRemoved: boolean }}
823
+ * `deleteKeyRemoved` 看的是**删完之后目录里还有没有它**,不是「我们试过删」:
824
+ * 远程已写墓碑、本机私钥却没删掉时,下一条身份事件会复用旧钥、被墓碑静默丢掉,
825
+ * CLI 必须知道这件事才能说实话(Codex 2026-09-13 P1)。
826
+ */
827
+ export function purgeLocal({ keepDeleteKey = false } = {}) {
828
+ const dir = join(stateDir(), 'telemetry');
829
+ // 目录都不在 = 本来就没有数据。**不为了删而先建目录**(那会在
830
+ // `GEOLY_TELEMETRY=0` 的机器上凭空写出东西来)。
831
+ if (!existsSync(dir)) return { removed: 0, remaining: [], kept: [], deleteKeyRemoved: true };
832
+
833
+ const release = acquire(lockPath());
834
+ try {
835
+ // 🔴 **删的是目录里除保留项以外的全部文件,不是一张手写清单。**
836
+ // 手写清单漏过 `sending.tomb.ndjson` 与 `sending.tomb.mark`
837
+ // (Codex 2026-09-09 指出)—— 墓碑里没被 mark 覆盖的尾部,
838
+ // 下一次 flush 会**扫回队列并发出去**:用户以为删干净了,
839
+ // 结果删完还发了一批。告知里还写着「删除」,那就是一句假话。
840
+ // 清单式删除的问题不是这次漏了哪个,是**它会一直漏**:
841
+ // 每加一个新的状态文件都要有人记得回来改这里。
842
+ // 所以清单只列**保留**的,且每一项都要有理由:
843
+ // · 锁 —— 它此刻正被我们持有,且里面没有任何埋点数据
844
+ // · `identity-off` —— **用户的偏好,不是埋点数据**。删了它等于替用户把
845
+ // 身份采集重新打开:先 `telemetry off` 再 `telemetry delete` 的人,
846
+ // 删完反而又开始被采(Codex 2026-09-13 P1)
847
+ // · `delete-key`(仅当调用方要求)—— 见 keepDeleteKey
848
+ const lock = lockPath();
849
+ const keep = new Set(['identity-off']);
850
+ if (keepDeleteKey) keep.add(basename(deleteKeyPath()));
851
+ let removed = 0;
852
+ const remaining = [];
853
+ const kept = [];
854
+ for (const name of readdirSync(dir)) {
855
+ const p = join(dir, name);
856
+ if (p === lock || name.startsWith(basename(lock))) { remaining.push(name); continue; }
857
+ if (keep.has(name)) { kept.push(name); continue; }
858
+ try {
859
+ rmSync(p, { recursive: true, force: true });
860
+ removed++;
861
+ } catch {
862
+ remaining.push(name); // 删不掉要说出来,不能算「已清空」
863
+ }
864
+ }
865
+ let deleteKeyRemoved;
866
+ try { deleteKeyRemoved = !readdirSync(dir).includes(basename(deleteKeyPath())); } catch { deleteKeyRemoved = false; }
867
+ return { removed, remaining, kept, deleteKeyRemoved };
868
+ } finally { release(); }
869
+ }
870
+
436
871
  /**
437
872
  * 首次运行时把告知打出来,并落一个标记,之后不再打。
438
873
  *
@@ -454,14 +889,14 @@ export function maybeNoticeUpload(write, url) {
454
889
  if (!enabled() || !uploadEnabled() || !url) return false;
455
890
  const p = noticeMarkPath();
456
891
  if (existsSync(p)) return false;
457
- mkdirSync(join(stateDir(), 'telemetry'), { recursive: true });
892
+ telemetryDir();
458
893
  write(uploadNoticeText(url));
459
894
  // 'wx' = 原子 no-replace:并发首跑只有一个能建成,别的走 catch,
460
895
  // 但那时告知已经打过了,重复的只是打印,不是漏打。
461
896
  // 不 fsync:丢了标记的后果只是多打一次告知,为它在**每个用户的第一条命令**上
462
897
  // 加一次同步 fsync 不划算(T-5:埋点不得让主命令变慢)。
463
898
  try {
464
- const fd = openSync(p, 'wx', 0o644);
899
+ const fd = openSync(p, 'wx', 0o600);
465
900
  try { appendFileSync(fd, `shown-at=${new Date().toISOString()}\nendpoint=${url}\n`); } finally { closeSync(fd); }
466
901
  } catch { /* 别人抢先建了,或建不了 —— 都不影响主命令 */ }
467
902
  return true;
@@ -515,10 +950,11 @@ const autoUploadStampPath = () => join(stateDir(), 'telemetry', 'auto-upload.las
515
950
  *
516
951
  * 🔴 **戳是在尝试之前写的,不是发成功之后写的。** 节流的判据是「距上次**尝试**」
517
952
  * 而不是「距上次**成功**」:端点挂了的时候,按「上次成功」算会让**每一次**
518
- * install 都去撞一遍那个挂掉的端点、每次多付最多 1 秒 —— 恰恰是端点最不该
519
- * 被继续敲的时候敲得最凶。按「上次尝试」算,无论成败,24 小时内最多一次。
520
- * 代价写在明处:一次失败的尝试会把这批事件压后 24 小时(它们留在本地不丢,
521
- * §5.2.2),用户想立刻发有 `telemetry flush` 这条明路。
953
+ * install 都去撞一遍那个挂掉的端点、每次多付最多 3 秒 —— 恰恰是端点最不该
954
+ * 被继续敲的时候敲得最凶。按「上次尝试」算,成功时 24 小时内最多一次。
955
+ * ⚠️ 失败时不压满 24 小时:maybeAutoUpload 会调 `backoffAutoUploadSlot`,
956
+ * 把名额退回成「1 小时后可再试」(2026-09-14 用户拍板,见那个函数的注释)。
957
+ * 事件留在本地不丢(§5.2.2),用户想立刻发有 `telemetry flush` 这条明路。
522
958
  *
523
959
  * ⚠️ 崩在「写戳」与「真的发」之间 = 这一天不发了。事件留在队列里,无害。
524
960
  * 反过来(先发后写戳)在同样的崩溃下会让下一次 install 再发一遍 ——
@@ -546,7 +982,7 @@ export function claimAutoUploadSlot(now = Date.now(), intervalMs = AUTO_UPLOAD_I
546
982
  const p = autoUploadStampPath();
547
983
  let release;
548
984
  try {
549
- mkdirSync(join(stateDir(), 'telemetry'), { recursive: true });
985
+ telemetryDir();
550
986
  release = acquire(lockPath());
551
987
  } catch (err) {
552
988
  // busy = 别人正在发;别的错(盘满、db 坏)也一样 —— 都按「这一轮不发」处理。
@@ -583,8 +1019,70 @@ export function claimAutoUploadSlot(now = Date.now(), intervalMs = AUTO_UPLOAD_I
583
1019
  }
584
1020
  }
585
1021
 
1022
+ /** 自动上报失败后,多久可以再试一次。 */
1023
+ export const AUTO_UPLOAD_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
1024
+
1025
+ /**
1026
+ * 自动上报**失败**后退回一部分名额:1 小时后允许再试,而不是等满 24 小时。
1027
+ *
1028
+ * 🔴 **为什么要有它**(2026-09-14 用户拍板「按推荐」):实测从国内到端点一次往返 1.3–1.7 秒,
1029
+ * 旧的 1 秒超时下自动上报几乎从不成功,而每次失败还要把事件压后 24 小时 ——
1030
+ * 生产库一周只收到 3 条事件。超时同日放宽到 3 秒(upload.mjs AUTO_UPLOAD_TIMEOUT_MS)。
1031
+ * 🔴 **不是「失败就完全不占名额」**:端点挂着时那等于每一次 install 都多等 3 秒去撞它,
1032
+ * 正是 claimAutoUploadSlot 注释里要防的形状。1 小时是折中:同一天还能再试几次,
1033
+ * 但不会每条 install 都敲。
1034
+ * 🔴 **只改本次写下的那个戳**(在上报锁下比对):期间别的进程已经认领过新名额,就不动它。
1035
+ *
1036
+ * 做法:把戳改成 `claimedAt - interval + retry`,于是下一次认领在 `claimedAt + retry` 放行。
1037
+ * 戳仍是「过去的一个时刻」,claimAutoUploadSlot 的时钟回拨判据不受影响。
1038
+ *
1039
+ * @returns {boolean} 是否改了戳
1040
+ */
1041
+ export function backoffAutoUploadSlot(
1042
+ claimedAt,
1043
+ intervalMs = AUTO_UPLOAD_INTERVAL_MS,
1044
+ retryMs = AUTO_UPLOAD_RETRY_AFTER_FAILURE_MS,
1045
+ ) {
1046
+ if (!Number.isFinite(claimedAt)) return false;
1047
+ const p = autoUploadStampPath();
1048
+ let release;
1049
+ try {
1050
+ release = acquire(lockPath());
1051
+ } catch (err) {
1052
+ _lastError = err;
1053
+ return false; // 拿不到锁就不动:多压一会儿比写坏别人的戳安全
1054
+ }
1055
+ try {
1056
+ let cur;
1057
+ try { cur = Number(readFileSync(p, 'utf8').trim()); } catch { return false; }
1058
+ if (cur !== claimedAt) return false;
1059
+ writeAtomic(p, String(claimedAt - intervalMs + retryMs) + '\n');
1060
+ return true;
1061
+ } catch (err) {
1062
+ _lastError = err;
1063
+ return false;
1064
+ } finally {
1065
+ try { release(); } catch { /* 释放失败不该盖掉返回值 */ }
1066
+ }
1067
+ }
1068
+
586
1069
  /** 导出 canonical JSON(给静态页读)。导出也是一个出口,同样过校验。 */
587
1070
  export function exportJson(events = readHistory()) {
588
- const clean = events.filter(isValidEvent);
1071
+ // 🔴 **导出一律剥掉身份三项。**(Codex 2026-09-09 在 diff 复查里揪出来的 P0)
1072
+ // `stats --export data.json` 出来的文件正是拖进 `docs/dashboard/index.html`
1073
+ // 那个匿名页面的东西,而那个页面把整个 events 数组交给浏览器。
1074
+ // 「页面不渲染这几个字段」挡不住任何事:文件里有、devtools 里就有。
1075
+ // ⚠️ 摄入端有 serializeAnonymousEvent,**那是另一条路径**,
1076
+ // 覆盖不到本地导出 —— 出口不止一个,每一个都要自己剥。
1077
+ const clean = events.filter(isValidEvent).map(anonymize);
589
1078
  return stringify({ schema: 'geoly.skills.telemetry-export/1', count: clean.length, events: clean });
590
1079
  }
1080
+
1081
+ /** 只保留匿名字段的一份拷贝。正向 pick,不是「删掉那三个」—— 加字段时不会漏。 */
1082
+ function anonymize(ev) {
1083
+ const out = {};
1084
+ for (const k of ANONYMOUS_FIELD_NAMES) {
1085
+ if (Object.hasOwn(ev, k)) out[k] = ev[k];
1086
+ }
1087
+ return out;
1088
+ }