@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/upload.mjs CHANGED
@@ -9,8 +9,13 @@ import {
9
9
  mkdirSync, renameSync, unlinkSync, linkSync, statSync,
10
10
  } from 'node:fs';
11
11
  import { join } from 'node:path';
12
+ import { createPrivateKey, sign as signEd } from 'node:crypto';
13
+ import {
14
+ CHALLENGE_SCHEMA, DELETE_SCHEMA, RE_PUBKEY, signedMessage,
15
+ } from './delete-proof.mjs';
12
16
  import {
13
17
  stateDir,
18
+ telemetryDir,
14
19
  readAll,
15
20
  uploadEnabled,
16
21
  offline,
@@ -21,6 +26,7 @@ import {
21
26
  appendDurable,
22
27
  noticeShown,
23
28
  claimAutoUploadSlot,
29
+ backoffAutoUploadSlot,
24
30
  } from './telemetry.mjs';
25
31
  import { acquire, LockBusyError } from './lock.mjs';
26
32
  // parseStrict 而非内建 parse:重复 key 会被静默取最后一个
@@ -107,13 +113,22 @@ export const isDefaultEndpoint = () => process.env.GEOLY_TELEMETRY_ENDPOINT ===
107
113
  const MAX_ACK_BYTES = 64 * 1024;
108
114
 
109
115
  async function ackOk(res, n) {
110
- if (typeof res.text !== 'function') return true;
111
- // ACK 只有几十个字节;声明得比这大得多的一律不读
112
- const declared = Number(res.headers?.get?.('content-length'));
113
- if (Number.isFinite(declared) && declared > MAX_ACK_BYTES) return false;
114
116
  let body;
115
- try { body = await res.text(); } catch { return false; }
116
- if (body.length > MAX_ACK_BYTES) return false;
117
+ if (res.body && typeof res.body.getReader === 'function') {
118
+ // 🔴 **按真实字节读,超限当场取消。** 早先是 `await res.text()` 之后再比长度 ——
119
+ // chunked 响应没有 Content-Length,一个恶意端点可以先把任意大小的 body
120
+ // 整个塞进内存(Codex 2026-09-13 P1)。永不结束的 body 由调用方的超时 abort 兜住。
121
+ body = await readBounded(res, MAX_ACK_BYTES);
122
+ if (body === null) return false;
123
+ } else if (typeof res.text === 'function') {
124
+ // 没有流、只有 text() 的只可能是测试替身(真实 Response 的 body 是 ReadableStream 或 null)
125
+ const declared = Number(res.headers?.get?.('content-length'));
126
+ if (Number.isFinite(declared) && declared > MAX_ACK_BYTES) return false;
127
+ try { body = await res.text(); } catch { return false; }
128
+ if (Buffer.byteLength(body, 'utf8') > MAX_ACK_BYTES) return false;
129
+ } else {
130
+ return true;
131
+ }
117
132
  let ack;
118
133
  try { ack = parseStrict(body); } catch { return false; }
119
134
  if (!ack || typeof ack !== 'object' || ack.schema !== 'geoly.skills.telemetry-ack/1') return false;
@@ -300,7 +315,10 @@ export async function flush({ fetchImpl = globalThis.fetch, timeoutMs = 3000 } =
300
315
 
301
316
  // 🔴 stage 与 retire 必须在同一把锁下,否则两个 flush 会各 stage 一半、重复上报。
302
317
  // 锁是内核释放的,进程猝死也不留死锁。
303
- mkdirSync(join(stateDir(), 'telemetry'), { recursive: true });
318
+ // 🔴 走 telemetryDir() 而不是裸 mkdirSync:目录必须是 0700。
319
+ // 裸 mkdirSync 会按 umask 建出 0755,于是「埋点目录 0700」这条保证
320
+ // 在**先跑 flush 的那台机器上**从来没生效过(Codex 2026-09-09 指出)。
321
+ telemetryDir();
304
322
  try {
305
323
  release = acquire(lockPath());
306
324
  } catch (e) {
@@ -369,18 +387,21 @@ export const pendingCount = () => readAll().length;
369
387
  // 「用户无感」,所以每一处都往最保守的一侧压 ——
370
388
  // · 只有 install,且**成功收尾**(失败的安装不该再替用户付一次网络代价)
371
389
  // · 24 小时最多一次(§5.1.1 说明为什么是 24)
372
- // · 超时 1 秒,不是 flush 的 3 秒
390
+ // · 超时 3 秒;失败后 1 小时可再试(2026-09-14 从「1 秒 + 失败压 24 小时」放宽)
373
391
  // · 完全静默:不打印、不改退出码、不抛错
374
392
  // · 两个否决(`--offline` / `GEOLY_TELEMETRY_UPLOAD=0`)照旧一票否决
375
393
  // · **首次告知没打过就不发**(noticeShown 这道门)
376
394
  // ─────────────────────────────────────────────────────────────────────────────
377
395
 
378
396
  /**
379
- * 自动上报的超时:**1 秒**,不是 flush 默认的 3 秒。
397
+ * 自动上报的超时:**3 秒**(2026-09-14 从 1 秒放宽,用户拍板「按推荐」)。
380
398
  *
381
- * 显式 flush 是用户主动等的,3 秒可以;自动上报挂在 install 的收尾上,
382
- * 那 3 秒是**用户没要求、也不知道自己在等**的 3 秒。1 秒是「慢网也够一个
383
- * 几 KB 的 POST 走完一趟」与「安装最坏被拖多久」之间的取舍。
399
+ * 🔴 **为什么放宽**:1 秒是按「慢网也够一个几 KB 的 POST」估的,实测是错的 ——
400
+ * 从国内经代理到端点,一次往返 1.3–1.7 秒(连不碰数据库的健康检查也要这么久)。
401
+ * 1 秒超时在 1003ms 处 abort,3 秒在 1238ms 发成功。于是自动上报几乎从不成功,
402
+ * 生产库一周只收到 3 条事件,dashboard 整页没有数据。
403
+ * ⚠️ **代价**:install 收尾最坏多等 3 秒(旧值是 1 秒),而这 3 秒是用户没要求、
404
+ * 也不知道自己在等的。节流(成功 24 小时一次、失败 1 小时后可再试)限制了它发生的频率。
384
405
  *
385
406
  * ⚠️ **它是「网络那一段」的上界,不是「install 多花多久」的上界**
386
407
  * (Codex 2026-09-01 指出,原先这里写成了后者 —— 那是夸大)。
@@ -391,15 +412,23 @@ export const pendingCount = () => readAll().length;
391
412
  * 要给 install 一个真正的时延上界,得给这串本地 I/O 也加超时 —— 那是另一件事,
392
413
  * 这里不假装已经做了。
393
414
  *
394
- * ⚠️ 1 秒**不产生新的丢事件面**,只是把结果从「发出去了」挪到「没发出去、下次再发」:
415
+ * ⚠️ 超时**不产生新的丢事件面**,只是把结果从「发出去了」挪到「没发出去、下次再发」:
395
416
  * 超时触发 abort → fetch 抛 / ackOk 返回 false → `sending` 原样留在盘上
396
417
  * → 下一轮接着发。哪怕服务端**已经 durable 了**只是 ACK 没赶回来,也只是
397
418
  * 重发一次、服务端按 `eid` 判成 duplicate(§5.2.2 的 at-least-once)。
398
419
  * 真正的代价见规格 §5.1.1「代价」第 4 条:端点**持续**慢到发不出去时,
399
420
  * 卡住的批次不动,而新事件会随换代被淘汰(§5.2.3)—— 那一格本来就存在,
400
- * 1 秒只是让它更容易被触发。
421
+ * 超时越短越容易触发它。
422
+ */
423
+ export const AUTO_UPLOAD_TIMEOUT_MS = 3000;
424
+
425
+ /**
426
+ * flush 的哪些结果算「这次自动上报失败了」—— 失败才退回名额(1 小时后可再试)。
427
+ * `empty`(没东西可发)与 `busy`(别的进程正在发)**不算**:前者没有要补发的,
428
+ * 后者那个进程会把事件发出去;为它们退回名额只会让 install 多跑几次空转。
401
429
  */
402
- export const AUTO_UPLOAD_TIMEOUT_MS = 1000;
430
+ const NOT_A_FAILURE = new Set(['empty', 'busy', 'offline', 'upload-disabled']);
431
+ const autoUploadFailed = (r) => Boolean(r?.skipped) && !NOT_A_FAILURE.has(r?.reason);
403
432
 
404
433
  /**
405
434
  * install 成功收尾后调用。**绝不抛错、绝不打印、绝不影响退出码。**
@@ -423,9 +452,13 @@ export async function maybeAutoUpload({ fetchImpl, timeoutMs = AUTO_UPLOAD_TIMEO
423
452
  // 不依赖谁在什么位置调我。
424
453
  if (!noticeShown()) return { ran: false, reason: 'notice-not-shown' };
425
454
 
426
- if (!claimAutoUploadSlot(now ?? Date.now())) return { ran: false, reason: 'throttled' };
455
+ const claimedAt = now ?? Date.now();
456
+ if (!claimAutoUploadSlot(claimedAt)) return { ran: false, reason: 'throttled' };
427
457
 
428
458
  const result = await flush({ fetchImpl: fetchImpl ?? globalThis.fetch, timeoutMs });
459
+ // 🔴 失败不压满 24 小时:退回成「1 小时后可再试」(telemetry.mjs backoffAutoUploadSlot)。
460
+ // flush 已在返回前释放上报锁,这里能再取到。
461
+ if (autoUploadFailed(result)) backoffAutoUploadSlot(claimedAt);
429
462
  return { ran: true, result };
430
463
  } catch (err) {
431
464
  // flush 自己已经吞掉一切异常,走到这里只可能是三道门自身出了岔子。
@@ -433,3 +466,151 @@ export async function maybeAutoUpload({ fetchImpl, timeoutMs = AUTO_UPLOAD_TIMEO
433
466
  return { ran: false, reason: `error:${err?.name ?? 'unknown'}` };
434
467
  }
435
468
  }
469
+
470
+ // ─────────────────────────────────────────────────────────────────────────────
471
+ // 远程删除(规格 §4.4;删除通道第四块)
472
+ //
473
+ // 🔴 与上报同一套出网纪律:只走 https 端点(endpoint() 校验)、禁止重定向、
474
+ // 整段有超时、响应当敌意输入读(有界)。
475
+ // 🔴 **三个开关一票否决**(Codex 2026-09-13 P1):`--offline`、`GEOLY_TELEMETRY=0`、
476
+ // `GEOLY_TELEMETRY_UPLOAD=0` 都承诺过「不发网络请求」,删除请求也是网络请求。
477
+ // 被否决时私钥保留,用户临时解除开关后可以重跑。
478
+ // ─────────────────────────────────────────────────────────────────────────────
479
+
480
+ /** 删除两个端点的响应都是几十字节的 JSON;8 KiB 已经很宽。 */
481
+ export const MAX_DELETE_RESPONSE_BYTES = 8 * 1024;
482
+ /** 两次往返(取挑战 + 删除)共用一个超时。用户主动在等,给得比自动上报宽。 */
483
+ export const DELETE_TIMEOUT_MS = 5000;
484
+
485
+ /**
486
+ * 删除端点的 URL,**相对上报端点解析**:默认 `…/v1/events` → `…/v1/delete`。
487
+ * `delete` 同时是签名里的 audience,客户端与服务端各自算、逐字比对。
488
+ */
489
+ export function deleteUrls(ep = endpoint()) {
490
+ return {
491
+ challenge: new URL('delete/challenge', ep).toString(),
492
+ delete: new URL('delete', ep).toString(),
493
+ };
494
+ }
495
+
496
+ /**
497
+ * 按**真实字节**读响应体,超限立刻取消。
498
+ *
499
+ * 🔴 不能先 `res.text()` 再比长度:chunked 响应没有 Content-Length,
500
+ * `text()` 会把一个恶意端点发来的任意大小的 body 整个读进内存之后才轮到你比
501
+ * (Codex 2026-09-13 P1,上报那边的 ackOk 有同样的问题,那是另一件事)。
502
+ * 🔴 字节数是 `Uint8Array.byteLength`,不是解码后的字符串长度 —— 多字节 UTF-8 下两者不等。
503
+ * 🔴 解码用 `fatal: true`:非法 UTF-8 不静默替换成 U+FFFD。
504
+ * ⚠️ 永不结束的 body 由调用方的 AbortController 兜:abort 让 read() 抛,这里返回 null。
505
+ * @returns {Promise<string|null>} 超限、读失败、非法 UTF-8、或者拿不到流时一律 null(fail-closed)
506
+ */
507
+ export async function readBounded(res, maxBytes) {
508
+ const declared = Number(res.headers?.get?.('content-length'));
509
+ if (Number.isFinite(declared) && declared > maxBytes) {
510
+ try { await res.body?.cancel(); } catch { /* 已经断了 */ }
511
+ return null;
512
+ }
513
+ const body = res.body;
514
+ if (!body || typeof body.getReader !== 'function') return null;
515
+ const reader = body.getReader();
516
+ const chunks = [];
517
+ let n = 0;
518
+ try {
519
+ for (;;) {
520
+ const { done, value } = await reader.read();
521
+ if (done) break;
522
+ n += value.byteLength;
523
+ if (n > maxBytes) {
524
+ try { await reader.cancel(); } catch { /* 已经断了 */ }
525
+ return null;
526
+ }
527
+ chunks.push(value);
528
+ }
529
+ } catch { return null; }
530
+ try {
531
+ return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
532
+ } catch { return null; }
533
+ }
534
+
535
+ /**
536
+ * 向服务端申请删除这台机器已发出的身份数据。**绝不抛错。**
537
+ *
538
+ * @param {object} opts
539
+ * @param {{pem: string, pubkey: string}|null} opts.key `deleteKey()` 的返回值
540
+ * @param {Function|null} [opts.fetchImpl] 缺省用 `globalThis.fetch`
541
+ * (用 `??` 而不是默认参数:默认参数只对 undefined 生效,null 会原样穿过去)
542
+ * @returns {Promise<{ ok: boolean, reason?: string, detail?: string }>}
543
+ */
544
+ export async function remoteDelete({ key, fetchImpl = null, timeoutMs = DELETE_TIMEOUT_MS } = {}) {
545
+ try {
546
+ if (offline()) return { ok: false, reason: 'offline' };
547
+ if (!uploadEnabled()) return { ok: false, reason: 'upload-disabled' };
548
+ if (!key || typeof key.pem !== 'string' || !RE_PUBKEY.test(key.pubkey ?? '')) {
549
+ return { ok: false, reason: 'no-key' };
550
+ }
551
+ const doFetch = fetchImpl ?? globalThis.fetch;
552
+ if (typeof doFetch !== 'function') return { ok: false, reason: 'no-fetch' };
553
+ let urls;
554
+ try {
555
+ urls = deleteUrls();
556
+ } catch (e) {
557
+ return { ok: false, reason: 'bad-endpoint', detail: e.message };
558
+ }
559
+
560
+ const ac = new AbortController();
561
+ const t = setTimeout(() => ac.abort(), timeoutMs);
562
+ try {
563
+ const post = async (url, body) => {
564
+ const res = await doFetch(url, {
565
+ method: 'POST',
566
+ signal: ac.signal,
567
+ redirect: 'error',
568
+ headers: { 'content-type': 'application/json' },
569
+ body,
570
+ });
571
+ if (res.redirected) return { err: 'redirect-refused' };
572
+ // 失败响应也按有界方式读完再丢:别让一个大错误页绕过上限
573
+ const text = await readBounded(res, MAX_DELETE_RESPONSE_BYTES);
574
+ if (!res.ok) return { err: `http-${res.status}` };
575
+ if (text === null) return { err: 'bad-response' };
576
+ let obj;
577
+ try { obj = parseStrict(text); } catch { return { err: 'bad-response' }; }
578
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return { err: 'bad-response' };
579
+ return { obj };
580
+ };
581
+
582
+ const c = await post(urls.challenge,
583
+ JSON.stringify({ schema: CHALLENGE_SCHEMA, pubkey: key.pubkey }));
584
+ if (c.err) return { ok: false, reason: c.err };
585
+ const ch = c.obj;
586
+ if (ch.schema !== CHALLENGE_SCHEMA || typeof ch.nonce !== 'string') {
587
+ return { ok: false, reason: 'bad-challenge' };
588
+ }
589
+ // 🔴 **audience 对不上就不签。** 服务端说「我是 X」,而我们要发去的是 Y ——
590
+ // 这正是中继的形状:Y 拿着从 X 那里取来的挑战让我们签,再把签名转给 X。
591
+ if (ch.audience !== urls.delete) return { ok: false, reason: 'audience-mismatch' };
592
+
593
+ let signature;
594
+ try {
595
+ const msg = signedMessage({ audience: urls.delete, pubkey: key.pubkey, nonce: ch.nonce });
596
+ signature = signEd(null, msg, createPrivateKey(key.pem)).toString('base64url');
597
+ } catch {
598
+ return { ok: false, reason: 'bad-challenge' };
599
+ }
600
+
601
+ const d = await post(urls.delete, JSON.stringify({
602
+ schema: DELETE_SCHEMA, pubkey: key.pubkey, nonce: ch.nonce, signature,
603
+ }));
604
+ if (d.err) return { ok: false, reason: d.err };
605
+ // 🔴 回执要**恰好**是 `{schema, deleted: true}`:一个回 200 + 随便什么的中间页不算删成功
606
+ const keys = Object.keys(d.obj).sort();
607
+ if (d.obj.schema !== DELETE_SCHEMA || d.obj.deleted !== true
608
+ || keys.length !== 2 || keys[0] !== 'deleted' || keys[1] !== 'schema') {
609
+ return { ok: false, reason: 'bad-ack' };
610
+ }
611
+ return { ok: true };
612
+ } finally { clearTimeout(t); }
613
+ } catch (err) {
614
+ return { ok: false, reason: `error:${err?.name ?? 'unknown'}` };
615
+ }
616
+ }
@@ -0,0 +1,27 @@
1
+ // 本 CLI 自己的版本号 —— **唯一的来源是 package.json**。
2
+ //
3
+ // 🔴 单独成文件,是为了让 `commands/context.mjs`(策略门 min_cli_version 用)和
4
+ // `telemetry.mjs`(事件里的 `cli` 字段)取的是**同一个值**。
5
+ // 2026-09-03 context 那一处修掉了硬编码的 '0.0.0-m1',埋点那一处却没人改:
6
+ // 从 npm 装下来的 CLI 一直上报 `cli: "0.0.0-m1"`,dashboard 的「CLI 版本」整栏是错的
7
+ // (2026-09-14 端到端实测发现)。两处各自取值,迟早又有一处被漏掉。
8
+ // 🔴 零依赖(只用 node:fs):telemetry.mjs 会 import 它,不能把命令层的依赖拖进来。
9
+ import { readFileSync } from 'node:fs';
10
+
11
+ let cachedVersion;
12
+
13
+ /**
14
+ * 读不出来就**抛**,绝不退回一个编出来的值:
15
+ * 假版本号会让 min_cli_version 这类版本门做出错误判定,也会让埋点把错的数当真。
16
+ */
17
+ export function ownVersion() {
18
+ if (cachedVersion === undefined) {
19
+ const p = new URL('../package.json', import.meta.url);
20
+ const v = JSON.parse(readFileSync(p, 'utf8')).version;
21
+ if (typeof v !== 'string' || v === '') {
22
+ throw new Error('读不出本 CLI 的版本号(package.json 的 version 不是非空字符串)');
23
+ }
24
+ cachedVersion = v;
25
+ }
26
+ return cachedVersion;
27
+ }