@eddyskywalker/dsh-chatgpt-subscription 0.24.2 → 0.24.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.24.3 - 2026-10-15
6
+ - **[Linux 凭据存储] 账号池撞上 secret-tool 的 8192 字节 stdin 上限:第 3 个账号添加后凭据写入成功、池写入失败,账号在 UI 闪现后消失(issue #54)**
7
+ - **现象**:Linux(gnome-keyring)下添加第 3 个 WorkBuddy 账号后账号区闪现即消失——凭据条目(7934B)惊险通过,但池条目(约 8960B)≥ 8192 被 `SecretServiceCredentialStore.save` 拒绝,`status.accounts`(池)永远少一个。该上限对齐的是 libsecret `secret-tool` 的 stdin 固定缓冲(超限静默截断仍 exit 0),且对**所有线路**(antigravity / claude / command-code / glm-coding-plan / kimi-code / minimax-code / ollama / step-code / workbuddy 的池与凭据共 25 处调用点)一视同仁,账号一多都会撞。
8
+ - **改法(`credential-store-secret-service.ts`,一处修复全线路受益)**:载荷 ≥ 8192 字节时改为**分片存储**——UTF-8 载荷 base64 后按 7000 字符切片写入独立 shard 条目(account 属性为 `<account>#<gen>#<i>`,Secret Service 属性精确匹配所以互不串扰),主条目最后写入作为**提交点**,内容为信封 `{ __dshChunked: { gen, chunks, bytes } }`;读取时按信封重组并校验解码字节数,损坏即报 `payload is too large` 同款 invalid 错误而非静默截断。小于上限的载荷保持单条目原格式,存量数据零迁移。shard 用随机代际(gen)标签:中断的 save 不触碰旧信封与旧分片,新代际要么整体提交要么回滚清理;提交成功后清理上一代分片(含分片→单条目的收缩场景)。`clear()` 改为先 lookup 探测:既拿到待清理的分片代际,也绕开 Ubuntu libsecret-tools 0.20.4 对不存在条目 `clear` 段错误的坑。分片数上限 128(约 650 KiB)防畸形信封。
9
+ - **验证**:`npm run typecheck` 0 错误;全量 **225 passed | 1 skipped(226 文件)、3739 passed | 7 skipped、0 failed**。新增/更新 12 条 store 用例:分片往返(断言每片 stdin < 8192、重组后逐字节等于原载荷、载荷不出现在命令行参数)、缺失/篡改分片报 invalid、畸形信封拒绝、换代清理、收缩清理、clear 连带清分片、shard 写失败时主条目零触碰。launch 计数测试同步更新(save 多一次探测启动,这是分代清理的信息来源)。
10
+
5
11
  ## 0.24.2 - 2026-10-14
6
12
  - **[三条登录线路] 回调端口探测窗口按「100 端口一块」的否证前提定宽,导致整段保留区把探测窗口吃穿:Windows 实测连续 277 个端口不可用,而窗口只有 128(command-code 只有 10)**
7
13
  - **现象**:本机全量 `npx vitest run` 稳定复现 **7 failed / 3711 passed**,全部集中在 `test/antigravity-callback-port.test.ts`(5 条)与 `test/login-open-browser-seams.test.ts`(2 条),报 `No usable local port for the Antigravity sign-in callback (tried 128 ports from 51121 on localhost)`,另有一条直接 `listen EACCES: permission denied ::1:51121`。
package/lib/index.js CHANGED
@@ -1321,6 +1321,7 @@ function runSecurity(args) {
1321
1321
  //#endregion
1322
1322
  //#region src/host/credential-store-secret-service.ts
1323
1323
  const UNAVAILABLE = "Linux encrypted credential storage requires secret-tool (libsecret) and an unlocked Secret Service keyring.";
1324
+ const INVALID = "Secret Service credential payload is invalid";
1324
1325
  /**
1325
1326
  * How long one Secret Service read may stand in for the stored value.
1326
1327
  *
@@ -1331,6 +1332,46 @@ const UNAVAILABLE = "Linux encrypted credential storage requires secret-tool (li
1331
1332
  * credentials.
1332
1333
  */
1333
1334
  const KEYRING_READ_CACHE_TTL_MS = 15e3;
1335
+ /**
1336
+ * secret-tool reads the secret from stdin into a fixed 8192-byte buffer
1337
+ * (`tool/secret-tool.c` `read_password_stdin()`) and, past the limit, prints
1338
+ * `password is too long` yet still stores the truncated bytes with exit 0 —
1339
+ * a silent corruption the caller cannot detect from the exit code. Payloads
1340
+ * that do not fit are therefore split into shard entries that each stay well
1341
+ * under the limit.
1342
+ */
1343
+ const SECRET_TOOL_STDIN_LIMIT = 8192;
1344
+ /**
1345
+ * Base64 characters per shard entry. Base64 is ASCII, so characters equal
1346
+ * bytes and any split point is safe; 7000 leaves the shard comfortably below
1347
+ * the stdin limit.
1348
+ */
1349
+ const SHARD_PAYLOAD_SIZE = 7e3;
1350
+ /**
1351
+ * Upper bound on the shard count one value may occupy. A corrupt or hostile
1352
+ * envelope cannot ask for unbounded lookups; 128 shards still hold ~650 KiB,
1353
+ * far past any plausible credential or account pool.
1354
+ */
1355
+ const MAX_SHARDS = 128;
1356
+ /** Property that marks a primary entry as the envelope of a chunked value. */
1357
+ const ENVELOPE_KEY = "__dshChunked";
1358
+ /**
1359
+ * Read the envelope of a stored primary entry, or null when the entry holds a
1360
+ * plain value. A malformed envelope is invalid stored data, not a plain value.
1361
+ */
1362
+ function envelopeFrom(parsed) {
1363
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
1364
+ const marker = parsed[ENVELOPE_KEY];
1365
+ if (marker === void 0) return null;
1366
+ if (typeof marker !== "object" || marker === null) throw new Error(INVALID);
1367
+ const { gen, chunks, bytes } = marker;
1368
+ if (typeof gen !== "string" || gen === "" || typeof chunks !== "number" || !Number.isInteger(chunks) || chunks < 1 || chunks > MAX_SHARDS || typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 0) throw new Error(INVALID);
1369
+ return {
1370
+ gen,
1371
+ chunks,
1372
+ bytes
1373
+ };
1374
+ }
1334
1375
  /** Secrets travel over stdin/stdout; command arguments contain only lookup attributes. */
1335
1376
  var SecretServiceCredentialStore = class {
1336
1377
  service;
@@ -1350,31 +1391,142 @@ var SecretServiceCredentialStore = class {
1350
1391
  this.account
1351
1392
  ];
1352
1393
  }
1394
+ /**
1395
+ * Lookup attributes of one shard entry. Secret Service matching is exact on
1396
+ * every given attribute pair, so the primary account value never matches a
1397
+ * shard and vice versa.
1398
+ */
1399
+ shardAttributes(gen, index) {
1400
+ return [
1401
+ "service",
1402
+ this.service,
1403
+ "account",
1404
+ `${this.account}#${gen}#${index}`
1405
+ ];
1406
+ }
1353
1407
  async load() {
1354
1408
  return this.cache.load(async () => {
1355
1409
  const result = await runSecretTool(["lookup", ...this.attributes()]);
1356
1410
  if (result.code === 1 && !result.hasStderr && result.stdout === "") return null;
1357
1411
  if (result.code !== 0) throw new Error(UNAVAILABLE);
1412
+ let parsed;
1358
1413
  try {
1359
- return this.parse(JSON.parse(result.stdout));
1414
+ parsed = JSON.parse(result.stdout);
1415
+ } catch {
1416
+ throw new Error(INVALID);
1417
+ }
1418
+ const envelope = envelopeFrom(parsed);
1419
+ if (envelope === null) try {
1420
+ return this.parse(parsed);
1421
+ } catch {
1422
+ throw new Error(INVALID);
1423
+ }
1424
+ const pieces = [];
1425
+ for (let index = 0; index < envelope.chunks; index += 1) {
1426
+ const shard = await runSecretTool(["lookup", ...this.shardAttributes(envelope.gen, index)]);
1427
+ if (shard.code !== 0) throw new Error(INVALID);
1428
+ pieces.push(shard.stdout);
1429
+ }
1430
+ try {
1431
+ const decoded = Buffer.from(pieces.join(""), "base64");
1432
+ if (decoded.length !== envelope.bytes) throw new Error("shard byte count mismatch");
1433
+ return this.parse(JSON.parse(decoded.toString("utf8")));
1360
1434
  } catch {
1361
- throw new Error("Secret Service credential payload is invalid");
1435
+ throw new Error(INVALID);
1362
1436
  }
1363
1437
  });
1364
1438
  }
1365
1439
  async save(value) {
1366
1440
  const payload = JSON.stringify(value);
1367
- if (Buffer.byteLength(payload, "utf8") >= 8192) throw new Error("Secret Service credential payload is too large");
1441
+ const bytes = Buffer.byteLength(payload, "utf8");
1442
+ const stale = await this.storedEnvelope();
1443
+ if (bytes < SECRET_TOOL_STDIN_LIMIT) {
1444
+ if ((await runSecretTool([
1445
+ "store",
1446
+ "--label=DSH Antigravity OAuth",
1447
+ ...this.attributes()
1448
+ ], payload)).code !== 0) throw new Error(UNAVAILABLE);
1449
+ } else await this.saveChunked(payload, bytes);
1450
+ if (stale !== null) await this.clearShards(stale.gen, stale.chunks);
1451
+ this.cache.invalidate();
1452
+ }
1453
+ /**
1454
+ * Store an oversized payload as shard entries plus an envelope. The shards
1455
+ * are written under a fresh generation tag first and the primary entry last:
1456
+ * that entry is the commit point, so an interrupted save leaves the previous
1457
+ * envelope and its shards fully intact and the new generation is either
1458
+ * committed whole or cleaned up.
1459
+ */
1460
+ async saveChunked(payload, bytes) {
1461
+ const encoded = Buffer.from(payload, "utf8").toString("base64");
1462
+ const chunks = Math.ceil(encoded.length / SHARD_PAYLOAD_SIZE);
1463
+ if (chunks > MAX_SHARDS) throw new Error("Secret Service credential payload is too large");
1464
+ const gen = randomBytes(6).toString("hex");
1465
+ for (let index = 0; index < chunks; index += 1) {
1466
+ const piece = encoded.slice(index * SHARD_PAYLOAD_SIZE, (index + 1) * SHARD_PAYLOAD_SIZE);
1467
+ if ((await runSecretTool([
1468
+ "store",
1469
+ "--label=DSH Antigravity OAuth",
1470
+ ...this.shardAttributes(gen, index)
1471
+ ], piece)).code !== 0) {
1472
+ await this.clearShards(gen, index);
1473
+ throw new Error(UNAVAILABLE);
1474
+ }
1475
+ }
1476
+ const envelope = {
1477
+ gen,
1478
+ chunks,
1479
+ bytes
1480
+ };
1368
1481
  if ((await runSecretTool([
1369
1482
  "store",
1370
1483
  "--label=DSH Antigravity OAuth",
1371
1484
  ...this.attributes()
1372
- ], payload)).code !== 0) throw new Error(UNAVAILABLE);
1373
- this.cache.invalidate();
1485
+ ], JSON.stringify({ [ENVELOPE_KEY]: envelope }))).code !== 0) {
1486
+ await this.clearShards(gen, chunks);
1487
+ throw new Error(UNAVAILABLE);
1488
+ }
1489
+ }
1490
+ /**
1491
+ * Envelope of the currently stored value, or null when the entry is missing,
1492
+ * holds a plain value, or cannot be read at all. A save that goes on to fail
1493
+ * surfaces its own error, so a probe failure must not fail the save first.
1494
+ */
1495
+ async storedEnvelope() {
1496
+ const probe = await runSecretTool(["lookup", ...this.attributes()]);
1497
+ if (probe.code !== 0) return null;
1498
+ try {
1499
+ return envelopeFrom(JSON.parse(probe.stdout));
1500
+ } catch {
1501
+ return null;
1502
+ }
1503
+ }
1504
+ /**
1505
+ * Remove shard entries, tolerating the ones that are already gone. A missing
1506
+ * entry exits 1 with empty stderr — the same shape a segfaulting secret-tool
1507
+ * takes through the close event (Ubuntu libsecret-tools 0.20.4 crashes on
1508
+ * clearing an entry that does not exist) — so that answer is never an error.
1509
+ */
1510
+ async clearShards(gen, chunks) {
1511
+ for (let index = 0; index < chunks; index += 1) {
1512
+ const result = await runSecretTool(["clear", ...this.shardAttributes(gen, index)]);
1513
+ if (result.code !== 0 && !(result.code === 1 && !result.hasStderr)) return;
1514
+ }
1374
1515
  }
1375
1516
  async clear() {
1376
- const result = await runSecretTool(["clear", ...this.attributes()]);
1377
- if (result.code !== 0 && !(result.code === 1 && !result.hasStderr)) throw new Error(UNAVAILABLE);
1517
+ const probe = await runSecretTool(["lookup", ...this.attributes()]);
1518
+ if (probe.code !== 0 && !(probe.code === 1 && !probe.hasStderr)) throw new Error(UNAVAILABLE);
1519
+ if (probe.code === 0) {
1520
+ let envelope = null;
1521
+ try {
1522
+ envelope = envelopeFrom(JSON.parse(probe.stdout));
1523
+ } catch {
1524
+ envelope = null;
1525
+ }
1526
+ if (envelope !== null) await this.clearShards(envelope.gen, envelope.chunks);
1527
+ const result = await runSecretTool(["clear", ...this.attributes()]);
1528
+ if (result.code !== 0 && !(result.code === 1 && !result.hasStderr)) throw new Error(UNAVAILABLE);
1529
+ }
1378
1530
  this.cache.invalidate();
1379
1531
  }
1380
1532
  };
@@ -7,8 +7,35 @@ export declare class SecretServiceCredentialStore<T> implements CredentialStore<
7
7
  private readonly cache;
8
8
  constructor(service: string, account: string, parse: (value: unknown) => T);
9
9
  private attributes;
10
+ /**
11
+ * Lookup attributes of one shard entry. Secret Service matching is exact on
12
+ * every given attribute pair, so the primary account value never matches a
13
+ * shard and vice versa.
14
+ */
15
+ private shardAttributes;
10
16
  load(): Promise<T | null>;
11
17
  save(value: T): Promise<void>;
18
+ /**
19
+ * Store an oversized payload as shard entries plus an envelope. The shards
20
+ * are written under a fresh generation tag first and the primary entry last:
21
+ * that entry is the commit point, so an interrupted save leaves the previous
22
+ * envelope and its shards fully intact and the new generation is either
23
+ * committed whole or cleaned up.
24
+ */
25
+ private saveChunked;
26
+ /**
27
+ * Envelope of the currently stored value, or null when the entry is missing,
28
+ * holds a plain value, or cannot be read at all. A save that goes on to fail
29
+ * surfaces its own error, so a probe failure must not fail the save first.
30
+ */
31
+ private storedEnvelope;
32
+ /**
33
+ * Remove shard entries, tolerating the ones that are already gone. A missing
34
+ * entry exits 1 with empty stderr — the same shape a segfaulting secret-tool
35
+ * takes through the close event (Ubuntu libsecret-tools 0.20.4 crashes on
36
+ * clearing an entry that does not exist) — so that answer is never an error.
37
+ */
38
+ private clearShards;
12
39
  clear(): Promise<void>;
13
40
  }
14
41
  //# sourceMappingURL=credential-store-secret-service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"credential-store-secret-service.d.ts","sourceRoot":"","sources":["../../../src/host/credential-store-secret-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAevD,0FAA0F;AAC1F,qBAAa,4BAA4B,CAAC,CAAC,CAAE,YAAW,eAAe,CAAC,CAAC,CAAC;IAMtE,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,KAAK;IAPxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAErB;gBAGkB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,CAAC;IAG/C,OAAO,CAAC,UAAU;IAIZ,IAAI,IAAI,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAazB,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAS7B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAK7B"}
1
+ {"version":3,"file":"credential-store-secret-service.d.ts","sourceRoot":"","sources":["../../../src/host/credential-store-secret-service.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAyEvD,0FAA0F;AAC1F,qBAAa,4BAA4B,CAAC,CAAC,CAAE,YAAW,eAAe,CAAC,CAAC,CAAC;IAMtE,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,KAAK;IAPxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAErB;gBAGkB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,CAAC;IAG/C,OAAO,CAAC,UAAU;IAIlB;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAIjB,IAAI,IAAI,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAqCzB,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBnC;;;;;;OAMG;YACW,WAAW;IA2BzB;;;;OAIG;YACW,cAAc;IAU5B;;;;;OAKG;YACW,WAAW;IAWnB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAmB7B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eddyskywalker/dsh-chatgpt-subscription",
3
- "version": "0.24.2",
3
+ "version": "0.24.3",
4
4
  "description": "DSH provider plugin for Codex access through a ChatGPT subscription.",
5
5
  "icon": "./icon.svg",
6
6
  "author": {