@foxden-app/foxclaw 0.5.11 → 0.5.12
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 +12 -0
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/cross_node_sync.d.ts +1 -0
- package/dist/auth/cross_node_sync.js +24 -0
- package/dist/auth/mirror.d.ts +3 -0
- package/dist/auth/mirror.js +73 -8
- package/dist/controller/controller.d.ts +2 -2
- package/dist/controller/controller.js +53 -35
- package/dist/main.js +2 -0
- package/dist/store/database.d.ts +3 -2
- package/dist/store/database.js +24 -9
- package/docs/cross-node-auth-sync.md +3 -3
- package/docs/user-manual.md +3 -3
- package/docs/zh/cross-node-auth-sync.md +3 -3
- package/docs/zh/user-manual.md +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.12 - 2026-06-08
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- `/auth` 额度快照现在按 ChatGPT 额度身份合并,优先区分 `chatgpt_user_id`,其次区分 email,避免同一个 Team account 下不同 seat 显示成同一份额度。
|
|
9
|
+
- 本机 auth mirror、跨节点 auth sync 和 `/auth refresh all` 会拒绝可识别为不同 ChatGPT 用户/邮箱的同名候选互相覆盖,即使它们共享同一个 account id。
|
|
10
|
+
- 额度快照数据库新增 `quota_identity_id` 并自动兼容旧数据;文档同步说明 account id 与额度身份的区别。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- `/auth` quota snapshots now merge by ChatGPT quota identity, preferring `chatgpt_user_id` and then email, so different seats under the same Team account do not display as one shared quota.
|
|
14
|
+
- Same-node auth mirroring, cross-node auth sync, and `/auth refresh all` now refuse to overwrite same-name candidates when they are identifiable as different ChatGPT users/emails, even if they share the same account id.
|
|
15
|
+
- Added a `quota_identity_id` quota-snapshot migration with backward compatibility for old data, and updated docs to distinguish account id from quota identity.
|
|
16
|
+
|
|
5
17
|
## 0.5.11 - 2026-06-08
|
|
6
18
|
|
|
7
19
|
### 中文
|
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
|
|
|
105
105
|
**多账号管理:**
|
|
106
106
|
- Codex 账户管理:`/account`、`/quota`、`/login_device`、`/auth add <name>`
|
|
107
107
|
- 触发用量限制时自动在本地 `auth.json_*` 之间切换认证
|
|
108
|
-
- `/auth` 面板分页查看、筛选、启用、禁用、搜索和切换候选账号;额度按真实窗口展示,多 bot
|
|
108
|
+
- `/auth` 面板分页查看、筛选、启用、禁用、搜索和切换候选账号;额度按真实窗口展示,多 bot 模式下会按 ChatGPT 额度身份汇总各 runtime 最近掌握的额度快照
|
|
109
109
|
|
|
110
110
|
**线程与会话:**
|
|
111
111
|
- `/threads`、`/open`、`/new`、`/where`、`/interrupt`——稳定的聊天-线程绑定
|
package/README_EN.md
CHANGED
|
@@ -105,7 +105,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
|
|
|
105
105
|
**Multi-account management:**
|
|
106
106
|
- Codex account controls: `/account`, `/quota`, `/login_device`, `/auth add <name>`
|
|
107
107
|
- Automatic auth rotation across local `auth.json_*` files when a usage limit is hit — seamless account switching
|
|
108
|
-
- Paginated `/auth` panel to filter, enable, disable, search, and switch candidate accounts; quota uses observed window lengths, and multi-bot mode merges recent snapshots by
|
|
108
|
+
- Paginated `/auth` panel to filter, enable, disable, search, and switch candidate accounts; quota uses observed window lengths, and multi-bot mode merges recent snapshots by ChatGPT quota identity across runtimes
|
|
109
109
|
|
|
110
110
|
**Threads and sessions:**
|
|
111
111
|
- `/threads`, `/open`, `/new`, `/where`, `/interrupt` — sticky chat-to-thread binding
|
|
@@ -226,6 +226,7 @@ export declare class CrossNodeAuthSync {
|
|
|
226
226
|
publishDigest(): Promise<void>;
|
|
227
227
|
requestRecovery(candidateName: string, current?: {
|
|
228
228
|
accountId: string | null;
|
|
229
|
+
quotaIdentityId?: string | null;
|
|
229
230
|
lastRefreshMs: number | null;
|
|
230
231
|
}): Promise<boolean>;
|
|
231
232
|
acquireRefreshLease(reason: string): Promise<AuthSyncLeaseResult>;
|
|
@@ -275,6 +275,7 @@ export class CrossNodeAuthSync {
|
|
|
275
275
|
requestId,
|
|
276
276
|
candidateName,
|
|
277
277
|
accountId: current?.accountId ?? null,
|
|
278
|
+
quotaIdentityId: current?.quotaIdentityId ?? null,
|
|
278
279
|
lastRefreshMs: current?.lastRefreshMs ?? null,
|
|
279
280
|
}).catch((error) => {
|
|
280
281
|
clearTimeout(timer);
|
|
@@ -519,6 +520,18 @@ export class CrossNodeAuthSync {
|
|
|
519
520
|
});
|
|
520
521
|
return;
|
|
521
522
|
}
|
|
523
|
+
if (!quotaIdentitiesCompatible(record.accountId, record.quotaIdentityId, message.quotaIdentityId ?? null)) {
|
|
524
|
+
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'quota identity mismatch' });
|
|
525
|
+
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'account_mismatch', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'quota identity mismatch' });
|
|
526
|
+
this.notify({
|
|
527
|
+
kind: 'pull_response_sent',
|
|
528
|
+
candidateName: message.candidateName,
|
|
529
|
+
peer,
|
|
530
|
+
result: 'account_mismatch',
|
|
531
|
+
reason: 'quota identity mismatch',
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
522
535
|
if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
|
|
523
536
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
|
|
524
537
|
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'not_newer', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'not newer' });
|
|
@@ -728,6 +741,9 @@ export class CrossNodeAuthSync {
|
|
|
728
741
|
if (!metadata || metadata.accountId !== bundle.accountId || metadata.lastRefreshMs !== bundle.lastRefreshMs) {
|
|
729
742
|
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle metadata mismatch for ${bundle.candidateName}`);
|
|
730
743
|
}
|
|
744
|
+
if (bundle.quotaIdentityId && metadata.quotaIdentityId !== bundle.quotaIdentityId) {
|
|
745
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle quota identity mismatch for ${bundle.candidateName}`);
|
|
746
|
+
}
|
|
731
747
|
if (sha256(bundle.rawAuth) !== bundle.authSha256) {
|
|
732
748
|
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle hash mismatch for ${bundle.candidateName}`);
|
|
733
749
|
}
|
|
@@ -1078,6 +1094,7 @@ function bundleFromRecord(record) {
|
|
|
1078
1094
|
return {
|
|
1079
1095
|
candidateName: record.candidateName,
|
|
1080
1096
|
accountId: record.accountId,
|
|
1097
|
+
quotaIdentityId: record.quotaIdentityId,
|
|
1081
1098
|
lastRefreshMs: record.lastRefreshMs,
|
|
1082
1099
|
rawAuth: record.raw,
|
|
1083
1100
|
authSha256: sha256(record.raw),
|
|
@@ -1087,11 +1104,18 @@ function isValidBundle(value) {
|
|
|
1087
1104
|
return typeof value.candidateName === 'string'
|
|
1088
1105
|
&& isAuthCandidateName(value.candidateName)
|
|
1089
1106
|
&& typeof value.accountId === 'string'
|
|
1107
|
+
&& (value.quotaIdentityId === undefined || value.quotaIdentityId === null || typeof value.quotaIdentityId === 'string')
|
|
1090
1108
|
&& typeof value.lastRefreshMs === 'number'
|
|
1091
1109
|
&& Number.isFinite(value.lastRefreshMs)
|
|
1092
1110
|
&& typeof value.rawAuth === 'string'
|
|
1093
1111
|
&& typeof value.authSha256 === 'string';
|
|
1094
1112
|
}
|
|
1113
|
+
function quotaIdentitiesCompatible(accountId, left, right) {
|
|
1114
|
+
if (!left || !right || left === accountId || right === accountId) {
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
return left === right;
|
|
1118
|
+
}
|
|
1095
1119
|
function decodeSharedKey(raw) {
|
|
1096
1120
|
const value = raw.trim();
|
|
1097
1121
|
const candidates = [];
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -32,6 +32,9 @@ export interface AuthMirrorValidationResult {
|
|
|
32
32
|
}
|
|
33
33
|
export interface ChatGptAuthMetadata {
|
|
34
34
|
accountId: string;
|
|
35
|
+
quotaIdentityId: string;
|
|
36
|
+
userId: string | null;
|
|
37
|
+
email: string | null;
|
|
35
38
|
lastRefreshMs: number;
|
|
36
39
|
}
|
|
37
40
|
export interface ChatGptAuthRecord extends ChatGptAuthMetadata {
|
package/dist/auth/mirror.js
CHANGED
|
@@ -161,7 +161,7 @@ export class AuthCandidateMirror {
|
|
|
161
161
|
return null;
|
|
162
162
|
const sources = await this.collectAuthRecords();
|
|
163
163
|
const newest = sources
|
|
164
|
-
.filter(entry => entry.record
|
|
164
|
+
.filter(entry => authRecordsCompatible(entry.record, destination))
|
|
165
165
|
.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
|
|
166
166
|
if (!newest || newest.record.lastRefreshMs <= destination.lastRefreshMs) {
|
|
167
167
|
return null;
|
|
@@ -203,7 +203,7 @@ export class AuthCandidateMirror {
|
|
|
203
203
|
for (const runtime of this.runtimes) {
|
|
204
204
|
const destinationPath = path.join(runtime.authDir, name);
|
|
205
205
|
const destination = await readChatGptAuthRecord(destinationPath);
|
|
206
|
-
if (destination && destination
|
|
206
|
+
if (destination && !authRecordsCompatible(destination, canonical)) {
|
|
207
207
|
skipped += 1;
|
|
208
208
|
this.logger.warn('auth.mirror.distribution_conflict', { runtimeId: runtime.id, name });
|
|
209
209
|
continue;
|
|
@@ -234,9 +234,9 @@ export class AuthCandidateMirror {
|
|
|
234
234
|
}
|
|
235
235
|
const existing = (await this.collectAuthRecords())
|
|
236
236
|
.filter(entry => entry.candidateName === candidateName);
|
|
237
|
-
const conflicting = existing.find(entry => entry.record
|
|
237
|
+
const conflicting = existing.find(entry => !authRecordsCompatible(entry.record, metadata));
|
|
238
238
|
if (conflicting) {
|
|
239
|
-
return { ok: false, imported: false, reason: 'same candidate belongs to a different account' };
|
|
239
|
+
return { ok: false, imported: false, reason: 'same candidate belongs to a different account or ChatGPT user' };
|
|
240
240
|
}
|
|
241
241
|
const newest = existing.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
|
|
242
242
|
const previousRefresh = Math.max(newest?.record.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(candidateName) ?? 0);
|
|
@@ -288,7 +288,7 @@ export class AuthCandidateMirror {
|
|
|
288
288
|
return false;
|
|
289
289
|
const canonicalPath = path.join(this.canonicalDir, name);
|
|
290
290
|
const canonical = await readChatGptAuthRecord(canonicalPath);
|
|
291
|
-
if (canonical && canonical
|
|
291
|
+
if (canonical && !authRecordsCompatible(canonical, record)) {
|
|
292
292
|
this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
|
|
293
293
|
return false;
|
|
294
294
|
}
|
|
@@ -422,8 +422,8 @@ export class AuthCandidateMirror {
|
|
|
422
422
|
})))).filter((entry) => entry.record !== null);
|
|
423
423
|
if (records.length === 0)
|
|
424
424
|
return;
|
|
425
|
-
const
|
|
426
|
-
if (
|
|
425
|
+
const reference = records[0].record;
|
|
426
|
+
if (records.some(entry => !authRecordsCompatible(reference, entry.record))) {
|
|
427
427
|
this.logger.warn('auth.mirror.startup_conflict', { name });
|
|
428
428
|
return;
|
|
429
429
|
}
|
|
@@ -544,7 +544,72 @@ export function parseChatGptAuthMetadata(raw) {
|
|
|
544
544
|
const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
|
|
545
545
|
if (!accountId || !Number.isFinite(lastRefreshMs))
|
|
546
546
|
return null;
|
|
547
|
-
|
|
547
|
+
const accessClaims = decodeJwtPayload(parsed.tokens?.access_token);
|
|
548
|
+
const idClaims = decodeJwtPayload(parsed.tokens?.id_token);
|
|
549
|
+
const userId = firstStringClaim(accessClaims, idClaims, 'https://api.openai.com/auth.chatgpt_user_id', 'https://api.openai.com/auth.user_id');
|
|
550
|
+
const email = firstStringClaim(accessClaims, idClaims, 'https://api.openai.com/profile.email', 'email');
|
|
551
|
+
return {
|
|
552
|
+
accountId,
|
|
553
|
+
quotaIdentityId: chatGptQuotaIdentityId(accountId, userId, email),
|
|
554
|
+
userId,
|
|
555
|
+
email,
|
|
556
|
+
lastRefreshMs,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function chatGptQuotaIdentityId(accountId, userId, email) {
|
|
564
|
+
if (userId) {
|
|
565
|
+
return `${accountId}:user:${userId}`;
|
|
566
|
+
}
|
|
567
|
+
if (email) {
|
|
568
|
+
return `${accountId}:email:${email.toLowerCase()}`;
|
|
569
|
+
}
|
|
570
|
+
return accountId;
|
|
571
|
+
}
|
|
572
|
+
function authRecordsCompatible(left, right) {
|
|
573
|
+
if (left.accountId !== right.accountId) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
if (!isSpecificQuotaIdentity(left) || !isSpecificQuotaIdentity(right)) {
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
return left.quotaIdentityId === right.quotaIdentityId;
|
|
580
|
+
}
|
|
581
|
+
function isSpecificQuotaIdentity(metadata) {
|
|
582
|
+
return metadata.quotaIdentityId !== metadata.accountId;
|
|
583
|
+
}
|
|
584
|
+
function firstStringClaim(primary, secondary, ...keys) {
|
|
585
|
+
for (const claims of [primary, secondary]) {
|
|
586
|
+
if (!claims) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
for (const key of keys) {
|
|
590
|
+
const value = claims[key];
|
|
591
|
+
if (typeof value === 'string' && value.trim()) {
|
|
592
|
+
return value;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
function decodeJwtPayload(token) {
|
|
599
|
+
if (typeof token !== 'string') {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
const parts = token.split('.');
|
|
603
|
+
if (parts.length < 2 || !parts[1]) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const base64 = parts[1].replaceAll('-', '+').replaceAll('_', '/');
|
|
608
|
+
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
|
|
609
|
+
const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
610
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
611
|
+
? parsed
|
|
612
|
+
: null;
|
|
548
613
|
}
|
|
549
614
|
catch {
|
|
550
615
|
return null;
|
|
@@ -357,8 +357,8 @@ export declare class BridgeSessionCore {
|
|
|
357
357
|
private refreshCurrentCodexAuthQuota;
|
|
358
358
|
private applySharedCodexAuthQuotaSnapshots;
|
|
359
359
|
private recordCodexAuthQuotaSnapshot;
|
|
360
|
-
private
|
|
361
|
-
private
|
|
360
|
+
private readCodexAuthCandidateQuotaIdentities;
|
|
361
|
+
private codexAuthQuotaSnapshotMatchesIdentity;
|
|
362
362
|
private readCodexAuthQuotaSnapshots;
|
|
363
363
|
private writeCodexAuthQuotaSnapshots;
|
|
364
364
|
private codexAuthQuotaSnapshotPath;
|
|
@@ -5310,15 +5310,15 @@ export class BridgeSessionCore {
|
|
|
5310
5310
|
async listCodexAuthState() {
|
|
5311
5311
|
const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
|
|
5312
5312
|
const snapshots = await this.readCodexAuthQuotaSnapshots();
|
|
5313
|
-
const
|
|
5313
|
+
const candidateQuotaIdentities = await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
|
|
5314
5314
|
state.candidates.forEach((candidate) => {
|
|
5315
|
-
const
|
|
5315
|
+
const candidateQuotaIdentity = candidateQuotaIdentities.get(candidate.name) ?? null;
|
|
5316
5316
|
const snapshot = snapshots[candidate.name] ?? null;
|
|
5317
|
-
candidate.quota = this.
|
|
5317
|
+
candidate.quota = this.codexAuthQuotaSnapshotMatchesIdentity(snapshot, candidateQuotaIdentity)
|
|
5318
5318
|
? snapshot
|
|
5319
5319
|
: null;
|
|
5320
5320
|
});
|
|
5321
|
-
await this.applySharedCodexAuthQuotaSnapshots(state,
|
|
5321
|
+
await this.applySharedCodexAuthQuotaSnapshots(state, candidateQuotaIdentities);
|
|
5322
5322
|
return state;
|
|
5323
5323
|
}
|
|
5324
5324
|
resolveAuthDir() {
|
|
@@ -5427,13 +5427,13 @@ export class BridgeSessionCore {
|
|
|
5427
5427
|
throw new Error('Codex did not return ChatGPT rate limits after refresh');
|
|
5428
5428
|
}
|
|
5429
5429
|
const after = await readChatGptAuthMetadata(candidate.path);
|
|
5430
|
-
if (!after || after
|
|
5431
|
-
throw new Error('refreshed auth
|
|
5430
|
+
if (!after || !chatGptAuthMetadataCompatible(before, after)) {
|
|
5431
|
+
throw new Error('refreshed auth identity did not match the original candidate');
|
|
5432
5432
|
}
|
|
5433
5433
|
if (after.lastRefreshMs <= before.lastRefreshMs) {
|
|
5434
5434
|
throw new Error('Codex did not advance last_refresh');
|
|
5435
5435
|
}
|
|
5436
|
-
await this.recordCodexAuthQuotaSnapshot(candidate.name, after
|
|
5436
|
+
await this.recordCodexAuthQuotaSnapshot(candidate.name, after, snapshot);
|
|
5437
5437
|
await this.syncCodexAuthCandidate(candidate.name);
|
|
5438
5438
|
result.refreshed.push(candidate.name);
|
|
5439
5439
|
}
|
|
@@ -5659,9 +5659,9 @@ export class BridgeSessionCore {
|
|
|
5659
5659
|
if (!snapshot) {
|
|
5660
5660
|
return;
|
|
5661
5661
|
}
|
|
5662
|
-
const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null);
|
|
5662
|
+
const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null, metadata?.quotaIdentityId ?? null);
|
|
5663
5663
|
candidate.quota = quota;
|
|
5664
|
-
await this.recordCodexAuthQuotaSnapshot(candidate.name, metadata
|
|
5664
|
+
await this.recordCodexAuthQuotaSnapshot(candidate.name, metadata, snapshot);
|
|
5665
5665
|
await this.applySharedCodexAuthQuotaSnapshots(state);
|
|
5666
5666
|
await this.syncCodexAuthCandidate(candidate.name);
|
|
5667
5667
|
}
|
|
@@ -5669,36 +5669,36 @@ export class BridgeSessionCore {
|
|
|
5669
5669
|
this.logger.warn('codex.auth_quota_refresh_failed', { error: formatUserError(error) });
|
|
5670
5670
|
}
|
|
5671
5671
|
}
|
|
5672
|
-
async applySharedCodexAuthQuotaSnapshots(state,
|
|
5673
|
-
const
|
|
5674
|
-
const
|
|
5675
|
-
if (
|
|
5672
|
+
async applySharedCodexAuthQuotaSnapshots(state, candidateQuotaIdentities) {
|
|
5673
|
+
const quotaIdentities = candidateQuotaIdentities ?? await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
|
|
5674
|
+
const uniqueQuotaIdentityIds = [...new Set([...quotaIdentities.values()].map(identity => identity.quotaIdentityId))];
|
|
5675
|
+
if (uniqueQuotaIdentityIds.length === 0) {
|
|
5676
5676
|
return;
|
|
5677
5677
|
}
|
|
5678
|
-
const
|
|
5679
|
-
for (const record of this.store.listCodexAuthQuotaSnapshots(
|
|
5678
|
+
const snapshotsByIdentity = new Map();
|
|
5679
|
+
for (const record of this.store.listCodexAuthQuotaSnapshots(uniqueQuotaIdentityIds)) {
|
|
5680
5680
|
if (!isFiniteCodexAuthQuotaSnapshotRecord(record)) {
|
|
5681
5681
|
continue;
|
|
5682
5682
|
}
|
|
5683
|
-
|
|
5683
|
+
snapshotsByIdentity.set(record.quotaIdentityId, mergeCodexAuthQuotaSnapshots(snapshotsByIdentity.get(record.quotaIdentityId) ?? null, codexAuthQuotaSnapshotFromRecord(record)));
|
|
5684
5684
|
}
|
|
5685
5685
|
for (const candidate of state.candidates) {
|
|
5686
|
-
const
|
|
5687
|
-
if (!
|
|
5686
|
+
const quotaIdentity = quotaIdentities.get(candidate.name);
|
|
5687
|
+
if (!quotaIdentity) {
|
|
5688
5688
|
continue;
|
|
5689
5689
|
}
|
|
5690
|
-
candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota,
|
|
5690
|
+
candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota, snapshotsByIdentity.get(quotaIdentity.quotaIdentityId) ?? null);
|
|
5691
5691
|
}
|
|
5692
5692
|
}
|
|
5693
|
-
async recordCodexAuthQuotaSnapshot(candidateName,
|
|
5694
|
-
const quota = authQuotaSnapshotFromRateLimit(snapshot, accountId);
|
|
5693
|
+
async recordCodexAuthQuotaSnapshot(candidateName, metadata, snapshot) {
|
|
5694
|
+
const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null, metadata?.quotaIdentityId ?? null);
|
|
5695
5695
|
this.authQuotaSnapshots[candidateName] = quota;
|
|
5696
|
-
if (
|
|
5697
|
-
this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidateName, accountId, quota);
|
|
5696
|
+
if (metadata) {
|
|
5697
|
+
this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidateName, metadata.accountId, metadata.quotaIdentityId, quota);
|
|
5698
5698
|
}
|
|
5699
5699
|
await this.writeCodexAuthQuotaSnapshots();
|
|
5700
5700
|
}
|
|
5701
|
-
async
|
|
5701
|
+
async readCodexAuthCandidateQuotaIdentities(candidates) {
|
|
5702
5702
|
const entries = await Promise.all(candidates.map(async (candidate) => {
|
|
5703
5703
|
const metadata = await readChatGptAuthMetadata(candidate.path);
|
|
5704
5704
|
candidate.credentialKind = metadata
|
|
@@ -5707,24 +5707,28 @@ export class BridgeSessionCore {
|
|
|
5707
5707
|
? 'api-key'
|
|
5708
5708
|
: 'invalid';
|
|
5709
5709
|
candidate.credentialLastRefreshMs = metadata?.lastRefreshMs ?? null;
|
|
5710
|
-
return [candidate.name, metadata
|
|
5710
|
+
return [candidate.name, metadata ? {
|
|
5711
|
+
accountId: metadata.accountId,
|
|
5712
|
+
quotaIdentityId: metadata.quotaIdentityId,
|
|
5713
|
+
} : null];
|
|
5711
5714
|
}));
|
|
5712
|
-
const
|
|
5713
|
-
for (const [name,
|
|
5714
|
-
if (
|
|
5715
|
-
|
|
5715
|
+
const quotaIdentities = new Map();
|
|
5716
|
+
for (const [name, quotaIdentity] of entries) {
|
|
5717
|
+
if (quotaIdentity) {
|
|
5718
|
+
quotaIdentities.set(name, quotaIdentity);
|
|
5716
5719
|
}
|
|
5717
5720
|
}
|
|
5718
|
-
return
|
|
5721
|
+
return quotaIdentities;
|
|
5719
5722
|
}
|
|
5720
|
-
|
|
5723
|
+
codexAuthQuotaSnapshotMatchesIdentity(snapshot, identity) {
|
|
5721
5724
|
if (!snapshot) {
|
|
5722
5725
|
return false;
|
|
5723
5726
|
}
|
|
5724
|
-
|
|
5725
|
-
|
|
5727
|
+
const snapshotIdentityId = snapshot.quotaIdentityId ?? snapshot.accountId ?? null;
|
|
5728
|
+
if (!snapshotIdentityId) {
|
|
5729
|
+
return identity === null;
|
|
5726
5730
|
}
|
|
5727
|
-
return
|
|
5731
|
+
return identity !== null && snapshotIdentityId === identity.quotaIdentityId;
|
|
5728
5732
|
}
|
|
5729
5733
|
async readCodexAuthQuotaSnapshots() {
|
|
5730
5734
|
if (this.authQuotaSnapshotsLoaded) {
|
|
@@ -9368,10 +9372,11 @@ function formatRemainingUsagePercent(usedPercent) {
|
|
|
9368
9372
|
const remainingPercent = remainingUsagePercent(usedPercent);
|
|
9369
9373
|
return remainingPercent === null ? '?' : formatUsagePercent(remainingPercent);
|
|
9370
9374
|
}
|
|
9371
|
-
function authQuotaSnapshotFromRateLimit(snapshot, accountId = null) {
|
|
9375
|
+
function authQuotaSnapshotFromRateLimit(snapshot, accountId = null, quotaIdentityId = null) {
|
|
9372
9376
|
return {
|
|
9373
9377
|
capturedAtMs: Date.now(),
|
|
9374
9378
|
accountId,
|
|
9379
|
+
quotaIdentityId,
|
|
9375
9380
|
planType: snapshot.planType,
|
|
9376
9381
|
primaryWindowDurationMins: snapshot.primary?.windowDurationMins ?? null,
|
|
9377
9382
|
primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
|
|
@@ -9383,6 +9388,7 @@ function codexAuthQuotaSnapshotFromRecord(record) {
|
|
|
9383
9388
|
return {
|
|
9384
9389
|
capturedAtMs: record.capturedAtMs,
|
|
9385
9390
|
accountId: record.accountId,
|
|
9391
|
+
quotaIdentityId: record.quotaIdentityId,
|
|
9386
9392
|
planType: record.planType,
|
|
9387
9393
|
primaryWindowDurationMins: record.primaryWindowDurationMins,
|
|
9388
9394
|
primaryRemainingPercent: record.primaryRemainingPercent,
|
|
@@ -9402,6 +9408,7 @@ function mergeCodexAuthQuotaSnapshots(current, incoming) {
|
|
|
9402
9408
|
return {
|
|
9403
9409
|
...freshest,
|
|
9404
9410
|
accountId: freshest.accountId ?? older.accountId ?? null,
|
|
9411
|
+
quotaIdentityId: freshest.quotaIdentityId ?? older.quotaIdentityId ?? null,
|
|
9405
9412
|
};
|
|
9406
9413
|
}
|
|
9407
9414
|
function isFiniteCodexAuthQuotaSnapshotRecord(record) {
|
|
@@ -9450,6 +9457,7 @@ function isCodexAuthQuotaSnapshot(value) {
|
|
|
9450
9457
|
return typeof snapshot.capturedAtMs === 'number'
|
|
9451
9458
|
&& Number.isFinite(snapshot.capturedAtMs)
|
|
9452
9459
|
&& (snapshot.accountId === undefined || snapshot.accountId === null || typeof snapshot.accountId === 'string')
|
|
9460
|
+
&& (snapshot.quotaIdentityId === undefined || snapshot.quotaIdentityId === null || typeof snapshot.quotaIdentityId === 'string')
|
|
9453
9461
|
&& (snapshot.planType === undefined || isNullableString(snapshot.planType))
|
|
9454
9462
|
&& (snapshot.primaryWindowDurationMins === undefined || isNullableFiniteNumber(snapshot.primaryWindowDurationMins))
|
|
9455
9463
|
&& isNullableFiniteNumber(snapshot.primaryRemainingPercent)
|
|
@@ -9460,6 +9468,7 @@ function normalizeCodexAuthQuotaSnapshot(snapshot) {
|
|
|
9460
9468
|
return {
|
|
9461
9469
|
capturedAtMs: snapshot.capturedAtMs,
|
|
9462
9470
|
accountId: snapshot.accountId ?? null,
|
|
9471
|
+
quotaIdentityId: snapshot.quotaIdentityId ?? snapshot.accountId ?? null,
|
|
9463
9472
|
planType: snapshot.planType ?? null,
|
|
9464
9473
|
primaryWindowDurationMins: snapshot.primaryWindowDurationMins ?? null,
|
|
9465
9474
|
primaryRemainingPercent: snapshot.primaryRemainingPercent,
|
|
@@ -9467,6 +9476,15 @@ function normalizeCodexAuthQuotaSnapshot(snapshot) {
|
|
|
9467
9476
|
secondaryRemainingPercent: snapshot.secondaryRemainingPercent,
|
|
9468
9477
|
};
|
|
9469
9478
|
}
|
|
9479
|
+
function chatGptAuthMetadataCompatible(left, right) {
|
|
9480
|
+
if (left.accountId !== right.accountId) {
|
|
9481
|
+
return false;
|
|
9482
|
+
}
|
|
9483
|
+
if (left.quotaIdentityId === left.accountId || right.quotaIdentityId === right.accountId) {
|
|
9484
|
+
return true;
|
|
9485
|
+
}
|
|
9486
|
+
return left.quotaIdentityId === right.quotaIdentityId;
|
|
9487
|
+
}
|
|
9470
9488
|
function isNullableFiniteNumber(value) {
|
|
9471
9489
|
return value === null || (typeof value === 'number' && Number.isFinite(value));
|
|
9472
9490
|
}
|
package/dist/main.js
CHANGED
|
@@ -334,6 +334,7 @@ async function runServeCli() {
|
|
|
334
334
|
?? await mirror.readNewestCandidate(candidateName);
|
|
335
335
|
return await authSync?.requestRecovery(candidateName, {
|
|
336
336
|
accountId: current?.accountId ?? null,
|
|
337
|
+
quotaIdentityId: current?.quotaIdentityId ?? null,
|
|
337
338
|
lastRefreshMs: current?.lastRefreshMs ?? null,
|
|
338
339
|
}) ?? false;
|
|
339
340
|
},
|
|
@@ -502,6 +503,7 @@ async function runServeCli() {
|
|
|
502
503
|
?? null;
|
|
503
504
|
return await singleAuthSync?.requestRecovery(candidateName, {
|
|
504
505
|
accountId: current?.accountId ?? null,
|
|
506
|
+
quotaIdentityId: current?.quotaIdentityId ?? null,
|
|
505
507
|
lastRefreshMs: current?.lastRefreshMs ?? null,
|
|
506
508
|
}) ?? false;
|
|
507
509
|
},
|
package/dist/store/database.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface CodexAuthQuotaSnapshotRecord {
|
|
|
28
28
|
runtimeId: string;
|
|
29
29
|
candidateName: string;
|
|
30
30
|
accountId: string;
|
|
31
|
+
quotaIdentityId: string;
|
|
31
32
|
capturedAtMs: number;
|
|
32
33
|
planType: string | null;
|
|
33
34
|
primaryWindowDurationMins: number | null;
|
|
@@ -117,7 +118,7 @@ export declare class BridgeStore {
|
|
|
117
118
|
setWeixinContextToken(scopeId: string, contextToken: string): void;
|
|
118
119
|
listDisabledCodexAuthCandidateNames(runtimeId?: string): Set<string>;
|
|
119
120
|
setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
|
|
120
|
-
setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
|
|
121
|
-
listCodexAuthQuotaSnapshots(
|
|
121
|
+
setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, quotaIdentityId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
|
|
122
|
+
listCodexAuthQuotaSnapshots(quotaIdentityIds: string[]): CodexAuthQuotaSnapshotRecord[];
|
|
122
123
|
private ensureColumn;
|
|
123
124
|
}
|
package/dist/store/database.js
CHANGED
|
@@ -182,6 +182,7 @@ export class BridgeStore {
|
|
|
182
182
|
runtime_id TEXT NOT NULL,
|
|
183
183
|
candidate_name TEXT NOT NULL,
|
|
184
184
|
account_id TEXT NOT NULL,
|
|
185
|
+
quota_identity_id TEXT NOT NULL DEFAULT '',
|
|
185
186
|
captured_at_ms INTEGER NOT NULL,
|
|
186
187
|
plan_type TEXT,
|
|
187
188
|
primary_window_duration_mins REAL,
|
|
@@ -209,6 +210,16 @@ export class BridgeStore {
|
|
|
209
210
|
this.ensureColumn('codex_auth_quota_snapshots', 'plan_type', 'TEXT');
|
|
210
211
|
this.ensureColumn('codex_auth_quota_snapshots', 'primary_window_duration_mins', 'REAL');
|
|
211
212
|
this.ensureColumn('codex_auth_quota_snapshots', 'secondary_window_duration_mins', 'REAL');
|
|
213
|
+
this.ensureColumn('codex_auth_quota_snapshots', 'quota_identity_id', "TEXT NOT NULL DEFAULT ''");
|
|
214
|
+
this.db.prepare(`
|
|
215
|
+
UPDATE codex_auth_quota_snapshots
|
|
216
|
+
SET quota_identity_id = account_id
|
|
217
|
+
WHERE quota_identity_id = ''
|
|
218
|
+
`).run();
|
|
219
|
+
this.db.exec(`
|
|
220
|
+
CREATE INDEX IF NOT EXISTS codex_auth_quota_snapshots_quota_identity_idx
|
|
221
|
+
ON codex_auth_quota_snapshots(quota_identity_id);
|
|
222
|
+
`);
|
|
212
223
|
migrateLegacyBridgeScopeIds(this.db);
|
|
213
224
|
}
|
|
214
225
|
getTelegramOffset(botKey) {
|
|
@@ -839,12 +850,13 @@ export class BridgeStore {
|
|
|
839
850
|
ON CONFLICT(name) DO UPDATE SET disabled = excluded.disabled, updated_at = excluded.updated_at
|
|
840
851
|
`).run(name, disabled ? 1 : 0, Date.now());
|
|
841
852
|
}
|
|
842
|
-
setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, snapshot) {
|
|
853
|
+
setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, quotaIdentityId, snapshot) {
|
|
843
854
|
this.db.prepare(`
|
|
844
855
|
INSERT INTO codex_auth_quota_snapshots (
|
|
845
856
|
runtime_id,
|
|
846
857
|
candidate_name,
|
|
847
858
|
account_id,
|
|
859
|
+
quota_identity_id,
|
|
848
860
|
captured_at_ms,
|
|
849
861
|
plan_type,
|
|
850
862
|
primary_window_duration_mins,
|
|
@@ -853,9 +865,10 @@ export class BridgeStore {
|
|
|
853
865
|
secondary_remaining_percent,
|
|
854
866
|
updated_at
|
|
855
867
|
)
|
|
856
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
868
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
857
869
|
ON CONFLICT(runtime_id, candidate_name) DO UPDATE SET
|
|
858
870
|
account_id = excluded.account_id,
|
|
871
|
+
quota_identity_id = excluded.quota_identity_id,
|
|
859
872
|
captured_at_ms = excluded.captured_at_ms,
|
|
860
873
|
plan_type = excluded.plan_type,
|
|
861
874
|
primary_window_duration_mins = excluded.primary_window_duration_mins,
|
|
@@ -863,19 +876,20 @@ export class BridgeStore {
|
|
|
863
876
|
secondary_window_duration_mins = excluded.secondary_window_duration_mins,
|
|
864
877
|
secondary_remaining_percent = excluded.secondary_remaining_percent,
|
|
865
878
|
updated_at = excluded.updated_at
|
|
866
|
-
`).run(runtimeId, candidateName, accountId, snapshot.capturedAtMs, snapshot.planType, snapshot.primaryWindowDurationMins, snapshot.primaryRemainingPercent, snapshot.secondaryWindowDurationMins, snapshot.secondaryRemainingPercent, Date.now());
|
|
879
|
+
`).run(runtimeId, candidateName, accountId, quotaIdentityId, snapshot.capturedAtMs, snapshot.planType, snapshot.primaryWindowDurationMins, snapshot.primaryRemainingPercent, snapshot.secondaryWindowDurationMins, snapshot.secondaryRemainingPercent, Date.now());
|
|
867
880
|
}
|
|
868
|
-
listCodexAuthQuotaSnapshots(
|
|
869
|
-
const
|
|
870
|
-
if (
|
|
881
|
+
listCodexAuthQuotaSnapshots(quotaIdentityIds) {
|
|
882
|
+
const uniqueQuotaIdentityIds = [...new Set(quotaIdentityIds.filter(Boolean))];
|
|
883
|
+
if (uniqueQuotaIdentityIds.length === 0) {
|
|
871
884
|
return [];
|
|
872
885
|
}
|
|
873
|
-
const placeholders =
|
|
886
|
+
const placeholders = uniqueQuotaIdentityIds.map(() => '?').join(', ');
|
|
874
887
|
const rows = this.db.prepare(`
|
|
875
888
|
SELECT
|
|
876
889
|
runtime_id,
|
|
877
890
|
candidate_name,
|
|
878
891
|
account_id,
|
|
892
|
+
quota_identity_id,
|
|
879
893
|
captured_at_ms,
|
|
880
894
|
plan_type,
|
|
881
895
|
primary_window_duration_mins,
|
|
@@ -884,12 +898,13 @@ export class BridgeStore {
|
|
|
884
898
|
secondary_remaining_percent,
|
|
885
899
|
updated_at
|
|
886
900
|
FROM codex_auth_quota_snapshots
|
|
887
|
-
WHERE
|
|
888
|
-
`).all(...
|
|
901
|
+
WHERE quota_identity_id IN (${placeholders})
|
|
902
|
+
`).all(...uniqueQuotaIdentityIds);
|
|
889
903
|
return rows.map(row => ({
|
|
890
904
|
runtimeId: String(row.runtime_id),
|
|
891
905
|
candidateName: String(row.candidate_name),
|
|
892
906
|
accountId: String(row.account_id),
|
|
907
|
+
quotaIdentityId: String(row.quota_identity_id),
|
|
893
908
|
capturedAtMs: Number(row.captured_at_ms),
|
|
894
909
|
planType: nullableString(row.plan_type),
|
|
895
910
|
primaryWindowDurationMins: nullableNumber(row.primary_window_duration_mins),
|
|
@@ -33,7 +33,7 @@ Safety boundaries:
|
|
|
33
33
|
- FoxClaw only accepts sync files from bots listed in `AUTH_SYNC_PEERS`.
|
|
34
34
|
- Wrong `AUTH_SYNC_KEY`, cluster, nonce, or payload validation never writes files.
|
|
35
35
|
- Remote imports wait for global local idleness, then run temporary usage validation before writing a candidate.
|
|
36
|
-
- A same-name candidate known to belong to a different account id is never overwritten.
|
|
36
|
+
- A same-name candidate known to belong to a different account id, or to a different identifiable ChatGPT user/email under the same account, is never overwritten.
|
|
37
37
|
- Sync packets do not create reply chains. FoxClaw filters by packet type, nonce, and peer allowlist to avoid bot-to-bot loops.
|
|
38
38
|
|
|
39
39
|
Telegram's official Bot Features documentation says private bot-to-bot messaging requires Bot-to-Bot Communication Mode on both sender and recipient, and it calls out loop-prevention requirements. See https://core.telegram.org/bots/features#bot-to-bot-communication
|
|
@@ -164,7 +164,7 @@ If it shows `Missing replies: @peer_bot`, Telegram delivery may have succeeded,
|
|
|
164
164
|
|
|
165
165
|
Confirm that pending imports were processed, or that the candidate exists or has a newer timestamp.
|
|
166
166
|
|
|
167
|
-
Note: `/auth sync push all` saying “sent” only means this node successfully handed encrypted packages to Telegram. It does not prove the peer wrote files. The peer imports only when it is globally idle, usage validation succeeds, same-name candidates belong to the same account id, and the remote `last_refresh` is newer than the local copy. If the local file is already equal or newer, it will not change and `Last import` may remain empty.
|
|
167
|
+
Note: `/auth sync push all` saying “sent” only means this node successfully handed encrypted packages to Telegram. It does not prove the peer wrote files. The peer imports only when it is globally idle, usage validation succeeds, same-name candidates belong to the same account id and compatible ChatGPT user/email identity, and the remote `last_refresh` is newer than the local copy. If the local file is already equal or newer, it will not change and `Last import` may remain empty.
|
|
168
168
|
|
|
169
169
|
When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Notifications never include auth contents, tokens, or encrypted bundle payloads.
|
|
170
170
|
|
|
@@ -198,7 +198,7 @@ With cross-node sync enabled, this command first requests a cross-node refresh l
|
|
|
198
198
|
|
|
199
199
|
- The local node may not be globally idle. Active turns, approvals, inputs, login flows, and mirror writes make imports wait.
|
|
200
200
|
- Usage validation failure rejects the write.
|
|
201
|
-
- Same-name candidates from different account ids are refused.
|
|
201
|
+
- Same-name candidates from different account ids, or from different identifiable ChatGPT users/emails under the same account, are refused.
|
|
202
202
|
- Run `/auth sync events <candidate>` or `/auth sync trace <requestId>` to see the receive, validation, skip, or failure records kept by FoxClaw.
|
|
203
203
|
|
|
204
204
|
**Should I periodically run `/auth refresh all confirm` as keepalive?**
|
package/docs/user-manual.md
CHANGED
|
@@ -359,7 +359,7 @@ FoxClaw recognizes candidate names in these forms:
|
|
|
359
359
|
- `auth.json.<name>`
|
|
360
360
|
- `auth.json-<name>`
|
|
361
361
|
|
|
362
|
-
`auth.json` is what Codex currently uses. When switching accounts, FoxClaw points `auth.json` at one candidate. Candidate contents are Codex-generated JSON and should not be hand-written. In multi-bot mode, FoxClaw mirrors a candidate only when its account identity
|
|
362
|
+
`auth.json` is what Codex currently uses. When switching accounts, FoxClaw points `auth.json` at one candidate. Candidate contents are Codex-generated JSON and should not be hand-written. In multi-bot mode, FoxClaw mirrors a candidate only when its account identity and identifiable ChatGPT user/email identity are compatible, its refresh timestamp is newer, and the active app-server verifies it against the ChatGPT usage endpoint, preventing a same-name candidate from overwriting a different account or Team seat. Before an auth switch or reload, FoxClaw also searches the other Codex homes for a newer compatible credential, restores it into the requesting runtime, then verifies and mirrors it after restart.
|
|
363
363
|
|
|
364
364
|
If you already have a working `auth.json`, you can save it as a candidate:
|
|
365
365
|
|
|
@@ -395,7 +395,7 @@ If the login is cancelled or fails, FoxClaw tries to restore the previous auth t
|
|
|
395
395
|
|
|
396
396
|
`/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload. In multi-bot mode the panel names the `@botname` runtime being managed, because private chats, groups, and topics on one bot share that bot's current auth. The panel shows 8 candidates per page and supports paging, `All / Enabled / Attention` filters, and `/auth list <keyword>` filename search for large local inventories. Panel text and buttons omit the repeated `auth.json_` prefix from standard candidate filenames, so `auth.json_personal` on disk renders as `personal`; files are not renamed, and search and commands still operate on the original candidates. `/auth use <n>` always uses the full-list candidate number, independent of panel paging.
|
|
397
397
|
|
|
398
|
-
Candidate rows are prefixed with observed `window:remaining-percent` values. For example, a Plus account may show `5h:20|7d:25`, while an account with one monthly window may show `30d:97`. Buttons use a compact two-number `primary|secondary` form such as `20|25`; unknown values render as `—`. The current auth quota is refreshed when the panel opens; other candidates are not switched merely to query quota. When multiple bot runtimes have recently used the same ChatGPT
|
|
398
|
+
Candidate rows are prefixed with observed `window:remaining-percent` values. For example, a Plus account may show `5h:20|7d:25`, while an account with one monthly window may show `30d:97`. Buttons use a compact two-number `primary|secondary` form such as `20|25`; unknown values render as `—`. The current auth quota is refreshed when the panel opens; other candidates are not switched merely to query quota. When multiple bot runtimes have recently used the same ChatGPT quota identity, FoxClaw combines their cached quota snapshots by verified user/email identity under the account, so one bot's `/auth` panel can show quota information learned by another bot without mixing different seats on the same Team account.
|
|
399
399
|
|
|
400
400
|
Approximation:
|
|
401
401
|
|
|
@@ -453,7 +453,7 @@ Safety boundaries:
|
|
|
453
453
|
- Telegram only carries ciphertext. Auth contents, candidate names, account ids, and `last_refresh` live inside the AES-256-GCM encrypted payload.
|
|
454
454
|
- FoxClaw only accepts sync files from bots listed in `AUTH_SYNC_PEERS`; wrong key, cluster, nonce, or payload validation never writes files.
|
|
455
455
|
- Remote imports wait for global local idleness, temporarily switch to the candidate for app-server usage validation, and only then write the candidate.
|
|
456
|
-
- A same-name candidate known to belong to a different account id is never overwritten.
|
|
456
|
+
- A same-name candidate known to belong to a different account id, or to a different identifiable ChatGPT user/email under the same account, is never overwritten.
|
|
457
457
|
- Cross-node recovery only pulls an already-held valid peer copy and does not rotate refresh tokens during recovery. If no peer has a usable copy, it stops and asks you to maintain auth manually. The background 9-day proactive refresh separately requests the cross-node refresh lease and skips that cycle if the lease is not granted.
|
|
458
458
|
|
|
459
459
|
Dual-active behavior:
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
- 只接收 `AUTH_SYNC_PEERS` 中列出的 peer bot 发来的同步文件。
|
|
34
34
|
- `AUTH_SYNC_KEY`、cluster、nonce 或 payload 校验失败时不会写盘。
|
|
35
35
|
- 远端导入必须等本机全局空闲,再临时验证 usage;验证成功后才写入候选。
|
|
36
|
-
- 同名候选如果已知属于不同 account id
|
|
36
|
+
- 同名候选如果已知属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱,永远拒绝覆盖。
|
|
37
37
|
- 同步包不会触发自动回复链路;FoxClaw 对包类型、nonce 和 peer allowlist 做过滤,避免 bot-to-bot 循环。
|
|
38
38
|
|
|
39
39
|
Telegram 官方 Bot Features 文档说明:私聊 bot-to-bot 需要发送方和接收方都启用 Bot-to-Bot Communication Mode,并提醒开发者处理 loop prevention。参考:https://core.telegram.org/bots/features#bot-to-bot-communication
|
|
@@ -164,7 +164,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
164
164
|
|
|
165
165
|
确认待导入清单被处理,或候选已经出现/更新时间变新。
|
|
166
166
|
|
|
167
|
-
注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id
|
|
167
|
+
注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致且 ChatGPT 用户/邮箱身份兼容,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
|
|
168
168
|
|
|
169
169
|
启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
|
|
170
170
|
|
|
@@ -198,7 +198,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
198
198
|
|
|
199
199
|
- 本机可能不是全局空闲;有 turn、审批、待输入、登录流程或镜像写入时会排队。
|
|
200
200
|
- usage 验证失败会拒绝写盘。
|
|
201
|
-
- 同名候选属于不同 account id
|
|
201
|
+
- 同名候选属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱时会拒绝覆盖。
|
|
202
202
|
- 执行 `/auth sync events <候选名>` 或 `/auth sync trace <requestId>`,查看 FoxClaw 记录的接收、验证、跳过或失败流水。
|
|
203
203
|
|
|
204
204
|
**要不要定期 `/auth refresh all confirm` 保活**
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -359,7 +359,7 @@ FoxClaw 识别这些候选文件名:
|
|
|
359
359
|
- `auth.json.<name>`
|
|
360
360
|
- `auth.json-<name>`
|
|
361
361
|
|
|
362
|
-
`auth.json` 是 Codex 当前使用的文件。切换账号时,FoxClaw 会把 `auth.json` 指向某个候选文件。候选文件内容是 Codex CLI 生成的 JSON,不建议手写这些字段。多 bot
|
|
362
|
+
`auth.json` 是 Codex 当前使用的文件。切换账号时,FoxClaw 会把 `auth.json` 指向某个候选文件。候选文件内容是 Codex CLI 生成的 JSON,不建议手写这些字段。多 bot 镜像只在账号标识和可识别的 ChatGPT 用户/邮箱身份兼容、刷新时间更新,并且由当前 app-server 通过 ChatGPT 用量接口在线验证时复制候选,避免同名候选意外覆盖成另一个账号或同一 Team 下的另一个 seat。切换或重载 auth 之前,FoxClaw 还会在其他 Codex home 中查找兼容的较新凭据,先恢复到发起操作的 runtime,再在重启后验证并镜像。
|
|
363
363
|
|
|
364
364
|
如果你已经有一个可用的 `auth.json`,可以先备份成候选:
|
|
365
365
|
|
|
@@ -395,7 +395,7 @@ cp -L ~/.codex/auth.json ~/.codex/auth.json_personal
|
|
|
395
395
|
|
|
396
396
|
`/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。面板每页显示 8 个候选,支持翻页、`全部 / 已启用 / 需关注` 筛选和 `/auth list <关键词>` 文件名搜索,适合管理较大的本地候选清单。面板文本和按钮会省略标准候选文件名中重复的 `auth.json_` 前缀,例如磁盘上的 `auth.json_personal` 显示为 `personal`;文件本身不会重命名,搜索和命令仍按原候选工作。命令 `/auth use <n>` 的编号始终对应完整候选列表,不会因为分页变化。
|
|
397
397
|
|
|
398
|
-
文本列表中每个候选名前的 `窗口:剩余百分比` 来自最近一次观察到的真实额度窗口,例如 Plus 账号可能显示 `5h:20|7d:25`,只有一个月度窗口的账号可能显示 `30d:97`。按钮为了适配窄屏,只显示两个剩余百分比数字,例如 `20|25`;未知值显示为 `—`。当前 auth 会在打开面板时刷新额度;其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT
|
|
398
|
+
文本列表中每个候选名前的 `窗口:剩余百分比` 来自最近一次观察到的真实额度窗口,例如 Plus 账号可能显示 `5h:20|7d:25`,只有一个月度窗口的账号可能显示 `30d:97`。按钮为了适配窄屏,只显示两个剩余百分比数字,例如 `20|25`;未知值显示为 `—`。当前 auth 会在打开面板时刷新额度;其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 额度身份,FoxClaw 会按账号下已验证的用户/邮箱身份合并缓存到的额度快照,因此一个 bot 的 `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把同一个 Team account 里的不同 seat 混在一起。
|
|
399
399
|
|
|
400
400
|
示意:
|
|
401
401
|
|
|
@@ -453,7 +453,7 @@ AUTH_SYNC_NODE_ID=workstation-a
|
|
|
453
453
|
- Telegram 只承载密文;auth 文件内容、候选名、account id、`last_refresh` 都在 AES-256-GCM 加密 payload 内。
|
|
454
454
|
- 只接收 `AUTH_SYNC_PEERS` 中 peer bot 发来的同步文件;密钥、cluster、nonce 或 payload 校验失败时不会写盘。
|
|
455
455
|
- 远端导入必须等本机全局空闲,再临时切换到待验证 auth、重启 app-server、读取 usage 验证成功后才写入候选。
|
|
456
|
-
- 同名候选如果已知属于不同 account id
|
|
456
|
+
- 同名候选如果已知属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱,永远拒绝覆盖。
|
|
457
457
|
- 跨节点恢复只拉取 peer 已持有的有效副本,不会在恢复过程中直接轮换 refresh token;找不到有效副本时会停止,提示你人工维护授权。后台 9 天主动刷新会单独申请跨节点刷新锁,拿不到锁就跳过本轮。
|
|
458
458
|
|
|
459
459
|
双主动流程:
|