@foxden-app/foxclaw 0.4.17 → 0.5.0
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 +3 -1
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/cross_node_sync.d.ts +101 -0
- package/dist/auth/cross_node_sync.js +208 -35
- package/dist/main.js +130 -0
- package/dist/store/database.d.ts +4 -0
- package/dist/store/database.js +4 -0
- package/docs/cross-node-auth-sync.md +2 -0
- package/docs/user-manual.md +1 -1
- package/docs/zh/cross-node-auth-sync.md +2 -0
- package/docs/zh/user-manual.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
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
|
+
## 0.5.0 - 2026-06-04
|
|
6
6
|
|
|
7
7
|
### 中文
|
|
8
8
|
- 明确跨节点 auth 同步推荐架构:每节点一个联系人 bot;多 bot 模式默认使用 `TG_BOT_TOKENS` 的第一个 token 作为联系人,同节点其他 bot 继续走本机 auth 镜像。
|
|
9
9
|
- `/auth sync status` 和 `/status` 现在显示联系人 bot,避免在非联系人 bot 上执行命令时误判实际发包身份。
|
|
10
10
|
- `/auth sync test` 升级为等待 peer 加密 pong 的真实握手,并显示未回应 peer;同时文档补充 BotFather MiniApp 入口、不要误进 Configure Mini App,以及 `push all` 只代表发送成功、不代表对端已导入。
|
|
11
|
+
- 跨节点 auth 同步现在会通过联系人 bot 私聊通知发送、接收、排队、导入、跳过、失败和恢复查询进度;所有 peer 都无法提供可用副本时会明确提示人工介入。
|
|
11
12
|
|
|
12
13
|
### English
|
|
13
14
|
- Clarified the recommended cross-node auth sync topology: one contact bot per node. In multi-bot mode, the first `TG_BOT_TOKENS` entry is the default contact while other same-node bots keep using local auth mirroring.
|
|
14
15
|
- `/auth sync status` and `/status` now show the contact bot so commands run from a non-contact bot no longer obscure the actual sender identity.
|
|
15
16
|
- `/auth sync test` now waits for encrypted peer pong replies and reports missing peers. Documentation now covers the BotFather MiniApp entry point, avoids the Configure Mini App confusion, and explains that `push all` only proves send success, not peer import.
|
|
17
|
+
- Cross-node auth sync now sends contact-bot private notifications for send, receive, queue, import, skip, failure, and recovery query progress; it explicitly asks for manual intervention when every peer lacks an importable copy.
|
|
16
18
|
|
|
17
19
|
## 0.4.16 - 2026-06-04
|
|
18
20
|
|
package/README.md
CHANGED
|
@@ -212,7 +212,7 @@ TG_BOT_TOKEN=123456:token_a
|
|
|
212
212
|
|
|
213
213
|
FoxClaw 仍然只运行一个系统服务。默认情况下,它会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新在线验证后镜像同步;切换或重载前还会从其他 Codex home 恢复同账号较新凭据。各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、runtime 类型、当前 auth 和活动 turn 摘要。
|
|
214
214
|
|
|
215
|
-
多台机器共享同一合法账号池时,可以启用可选跨节点 auth 同步:`AUTH_SYNC_ENABLED=true`、`AUTH_SYNC_KEY` 和 `AUTH_SYNC_PEERS=@peer_contact_bot`。推荐每台机器只选一个联系人 bot;同一节点内的其他 bot 继续走本机 auth 镜像。多 bot 模式下,默认用 `TG_BOT_TOKENS` 的第一个 token 作为联系人 bot。FoxClaw 会通过 Telegram Bot-to-Bot 私聊传输加密 auth 包;本机验证刷新后主动 push,发现本机候选失效时主动 pull peer
|
|
215
|
+
多台机器共享同一合法账号池时,可以启用可选跨节点 auth 同步:`AUTH_SYNC_ENABLED=true`、`AUTH_SYNC_KEY` 和 `AUTH_SYNC_PEERS=@peer_contact_bot`。推荐每台机器只选一个联系人 bot;同一节点内的其他 bot 继续走本机 auth 镜像。多 bot 模式下,默认用 `TG_BOT_TOKENS` 的第一个 token 作为联系人 bot。FoxClaw 会通过 Telegram Bot-to-Bot 私聊传输加密 auth 包;本机验证刷新后主动 push,发现本机候选失效时主动 pull peer 已持有的有效副本,并在联系人 bot 私聊里报告发送、接收、排队、导入、失败和人工介入提示。跨节点恢复不会自动刷新 token,`/auth refresh all confirm` 会先申请跨节点刷新锁。完整配置、`@BotFather` 操作和验证步骤见 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
|
|
216
216
|
|
|
217
217
|
如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
|
|
218
218
|
|
package/README_EN.md
CHANGED
|
@@ -212,7 +212,7 @@ TG_BOT_TOKEN=123456:token_a
|
|
|
212
212
|
|
|
213
213
|
FoxClaw remains one system service. By default, it starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after online-validated login or refresh; before switching or reloading, FoxClaw also restores a newer same-account credential from another Codex home when available. Current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, runtime type, selected auth, and active turns.
|
|
214
214
|
|
|
215
|
-
When multiple machines share the same legally owned account pool, optional cross-node auth sync can be enabled with `AUTH_SYNC_ENABLED=true`, `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS=@peer_contact_bot`. The recommended topology is one contact bot per node; other bots on the same node continue to use local auth mirroring. In multi-bot mode, the first token in `TG_BOT_TOKENS` is the default contact bot. FoxClaw sends encrypted auth bundles through Telegram Bot-to-Bot private messages. A locally verified refresh is pushed to peers, and a node with a bad local candidate can pull an already-held valid peer copy. Cross-node recovery never auto-refreshes tokens; `/auth refresh all confirm` first requests a cross-node refresh lease. For full config, `@BotFather` operations, and verification steps, read the [Cross-Node Auth Sync Setup Guide](./docs/cross-node-auth-sync.md).
|
|
215
|
+
When multiple machines share the same legally owned account pool, optional cross-node auth sync can be enabled with `AUTH_SYNC_ENABLED=true`, `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS=@peer_contact_bot`. The recommended topology is one contact bot per node; other bots on the same node continue to use local auth mirroring. In multi-bot mode, the first token in `TG_BOT_TOKENS` is the default contact bot. FoxClaw sends encrypted auth bundles through Telegram Bot-to-Bot private messages. A locally verified refresh is pushed to peers, and a node with a bad local candidate can pull an already-held valid peer copy. The contact bot private chat reports send, receive, queue, import, failure, and manual-intervention states. Cross-node recovery never auto-refreshes tokens; `/auth refresh all confirm` first requests a cross-node refresh lease. For full config, `@BotFather` operations, and verification steps, read the [Cross-Node Auth Sync Setup Guide](./docs/cross-node-auth-sync.md).
|
|
216
216
|
|
|
217
217
|
To keep one Telegram bot interoperable with terminal Codex sessions, put the same token in both `TG_BOT_TOKENS` and `TG_BOT_TOKEN`. That bot uses the default `CODEX_HOME` (usually `~/.codex` when unset) and default auth, so it can see local terminal sessions. It no longer has the isolated runtime guarantee: `/auth` switches also affect the terminal and other default runtimes.
|
|
218
218
|
|
|
@@ -35,6 +35,101 @@ export interface AuthSyncValidationResult {
|
|
|
35
35
|
ok: boolean;
|
|
36
36
|
reason?: string | null;
|
|
37
37
|
}
|
|
38
|
+
export type AuthSyncRemoteImportMode = 'push' | 'pull';
|
|
39
|
+
export type AuthSyncPullResponseResult = 'sent' | 'candidate_not_found' | 'account_mismatch' | 'not_newer';
|
|
40
|
+
export type AuthSyncNotification = {
|
|
41
|
+
kind: 'candidate_publish_started';
|
|
42
|
+
candidateName: string;
|
|
43
|
+
peers: string[];
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'candidate_publish_completed';
|
|
46
|
+
candidateName: string;
|
|
47
|
+
peers: string[];
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'candidate_publish_failed';
|
|
50
|
+
candidateName: string;
|
|
51
|
+
peers: string[];
|
|
52
|
+
reason: string;
|
|
53
|
+
} | {
|
|
54
|
+
kind: 'push_all_started';
|
|
55
|
+
candidateCount: number;
|
|
56
|
+
peers: string[];
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'push_all_completed';
|
|
59
|
+
sent: number;
|
|
60
|
+
skipped: number;
|
|
61
|
+
peers: string[];
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'push_all_failed';
|
|
64
|
+
sent: number;
|
|
65
|
+
skipped: number;
|
|
66
|
+
peers: string[];
|
|
67
|
+
reason: string;
|
|
68
|
+
} | {
|
|
69
|
+
kind: 'remote_bundle_received';
|
|
70
|
+
candidateName: string;
|
|
71
|
+
sourceNodeId: string;
|
|
72
|
+
sourceLabel: string;
|
|
73
|
+
peer: string;
|
|
74
|
+
queued: boolean;
|
|
75
|
+
queueLength: number;
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'remote_import_imported';
|
|
78
|
+
candidateName: string;
|
|
79
|
+
sourceNodeId: string;
|
|
80
|
+
sourceLabel: string;
|
|
81
|
+
peer: string;
|
|
82
|
+
mode: AuthSyncRemoteImportMode;
|
|
83
|
+
} | {
|
|
84
|
+
kind: 'remote_import_skipped';
|
|
85
|
+
candidateName: string;
|
|
86
|
+
sourceNodeId: string;
|
|
87
|
+
sourceLabel: string;
|
|
88
|
+
peer: string;
|
|
89
|
+
mode: AuthSyncRemoteImportMode;
|
|
90
|
+
reason: string;
|
|
91
|
+
} | {
|
|
92
|
+
kind: 'remote_import_failed';
|
|
93
|
+
candidateName: string;
|
|
94
|
+
sourceNodeId: string;
|
|
95
|
+
sourceLabel: string;
|
|
96
|
+
peer: string;
|
|
97
|
+
mode: AuthSyncRemoteImportMode;
|
|
98
|
+
reason: string;
|
|
99
|
+
} | {
|
|
100
|
+
kind: 'recovery_started';
|
|
101
|
+
candidateName: string;
|
|
102
|
+
peers: string[];
|
|
103
|
+
} | {
|
|
104
|
+
kind: 'recovery_peer_empty';
|
|
105
|
+
candidateName: string;
|
|
106
|
+
peer: string;
|
|
107
|
+
reason: string;
|
|
108
|
+
} | {
|
|
109
|
+
kind: 'recovery_peer_bundle_received';
|
|
110
|
+
candidateName: string;
|
|
111
|
+
peer: string;
|
|
112
|
+
sourceNodeId: string;
|
|
113
|
+
} | {
|
|
114
|
+
kind: 'recovery_failed';
|
|
115
|
+
candidateName: string;
|
|
116
|
+
peers: string[];
|
|
117
|
+
reason: string;
|
|
118
|
+
} | {
|
|
119
|
+
kind: 'pull_request_received';
|
|
120
|
+
candidateName: string;
|
|
121
|
+
peer: string;
|
|
122
|
+
requesterNodeId: string;
|
|
123
|
+
} | {
|
|
124
|
+
kind: 'pull_response_sent';
|
|
125
|
+
candidateName: string;
|
|
126
|
+
peer: string;
|
|
127
|
+
result: AuthSyncPullResponseResult;
|
|
128
|
+
reason: string | null;
|
|
129
|
+
} | {
|
|
130
|
+
kind: 'sync_error';
|
|
131
|
+
reason: string;
|
|
132
|
+
};
|
|
38
133
|
export interface AuthSyncImportCallbacks {
|
|
39
134
|
readLocalCandidate: (candidateName: string) => Promise<AuthMirrorCandidateRecord | null>;
|
|
40
135
|
listLocalCandidates: () => Promise<AuthMirrorCandidateRecord[]>;
|
|
@@ -44,6 +139,7 @@ export interface AuthSyncImportCallbacks {
|
|
|
44
139
|
label?: string | null;
|
|
45
140
|
}) => Promise<AuthMirrorImportResult>;
|
|
46
141
|
isIdle: () => boolean;
|
|
142
|
+
notify?: (event: AuthSyncNotification) => Promise<void>;
|
|
47
143
|
}
|
|
48
144
|
export interface AuthSyncTransport {
|
|
49
145
|
send: (peer: string, envelope: string) => Promise<void>;
|
|
@@ -75,6 +171,7 @@ export declare class CrossNodeAuthSync {
|
|
|
75
171
|
private timer;
|
|
76
172
|
private activeRemoteLease;
|
|
77
173
|
private activeLocalLease;
|
|
174
|
+
private lastNotifiedError;
|
|
78
175
|
private state;
|
|
79
176
|
constructor(config: AuthSyncConfig, logger: Logger, transport: AuthSyncTransport, callbacks: AuthSyncImportCallbacks);
|
|
80
177
|
initialize(): Promise<void>;
|
|
@@ -99,6 +196,7 @@ export declare class CrossNodeAuthSync {
|
|
|
99
196
|
private handleMessage;
|
|
100
197
|
private handlePullRequest;
|
|
101
198
|
private handlePullResponse;
|
|
199
|
+
private markPullPeerUnavailable;
|
|
102
200
|
private handleDigest;
|
|
103
201
|
private handleLeaseRequest;
|
|
104
202
|
private handleLeaseReply;
|
|
@@ -116,6 +214,9 @@ export declare class CrossNodeAuthSync {
|
|
|
116
214
|
private matchConfiguredPeer;
|
|
117
215
|
private expireLeases;
|
|
118
216
|
private recordError;
|
|
217
|
+
private rejectImport;
|
|
218
|
+
private notify;
|
|
219
|
+
private notifyError;
|
|
119
220
|
private writeState;
|
|
120
221
|
}
|
|
121
222
|
export declare function readAccessTokenExpiresAtMs(raw: string): number | null;
|
|
@@ -27,6 +27,7 @@ export class CrossNodeAuthSync {
|
|
|
27
27
|
timer = null;
|
|
28
28
|
activeRemoteLease = null;
|
|
29
29
|
activeLocalLease = null;
|
|
30
|
+
lastNotifiedError = null;
|
|
30
31
|
state = {
|
|
31
32
|
lastSentAt: null,
|
|
32
33
|
lastReceivedAt: null,
|
|
@@ -116,10 +117,24 @@ export class CrossNodeAuthSync {
|
|
|
116
117
|
const record = await this.callbacks.readLocalCandidate(candidateName);
|
|
117
118
|
if (!record)
|
|
118
119
|
return false;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
this.notify({ kind: 'candidate_publish_started', candidateName, peers: [...this.peers] });
|
|
121
|
+
try {
|
|
122
|
+
await this.sendToAll({
|
|
123
|
+
kind: 'push.bundle',
|
|
124
|
+
...bundleFromRecord(record),
|
|
125
|
+
});
|
|
126
|
+
this.notify({ kind: 'candidate_publish_completed', candidateName, peers: [...this.peers] });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.recordError(`candidate publish failed for ${candidateName}: ${formatError(error)}`, false);
|
|
130
|
+
this.notify({
|
|
131
|
+
kind: 'candidate_publish_failed',
|
|
132
|
+
candidateName,
|
|
133
|
+
peers: [...this.peers],
|
|
134
|
+
reason: formatError(error),
|
|
135
|
+
});
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
123
138
|
return true;
|
|
124
139
|
}
|
|
125
140
|
async pushAll() {
|
|
@@ -127,18 +142,34 @@ export class CrossNodeAuthSync {
|
|
|
127
142
|
return { sent: 0, skipped: 0 };
|
|
128
143
|
let sent = 0;
|
|
129
144
|
let skipped = 0;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
145
|
+
const records = await this.callbacks.listLocalCandidates();
|
|
146
|
+
this.notify({ kind: 'push_all_started', candidateCount: records.length, peers: [...this.peers] });
|
|
147
|
+
try {
|
|
148
|
+
for (const record of records) {
|
|
149
|
+
if (!isAuthCandidateName(record.candidateName)) {
|
|
150
|
+
skipped += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
await this.sendToAll({
|
|
154
|
+
kind: 'push.bundle',
|
|
155
|
+
...bundleFromRecord(record),
|
|
156
|
+
});
|
|
157
|
+
sent += 1;
|
|
134
158
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
this.notify({ kind: 'push_all_completed', sent, skipped, peers: [...this.peers] });
|
|
160
|
+
return { sent, skipped };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.recordError(`push all failed: ${formatError(error)}`, false);
|
|
164
|
+
this.notify({
|
|
165
|
+
kind: 'push_all_failed',
|
|
166
|
+
sent,
|
|
167
|
+
skipped,
|
|
168
|
+
peers: [...this.peers],
|
|
169
|
+
reason: formatError(error),
|
|
138
170
|
});
|
|
139
|
-
|
|
171
|
+
throw error;
|
|
140
172
|
}
|
|
141
|
-
return { sent, skipped };
|
|
142
173
|
}
|
|
143
174
|
async publishDigest() {
|
|
144
175
|
if (!this.isReady())
|
|
@@ -154,18 +185,28 @@ export class CrossNodeAuthSync {
|
|
|
154
185
|
if (!this.isReady() || !isAuthCandidateName(candidateName))
|
|
155
186
|
return false;
|
|
156
187
|
const requestId = crypto.randomUUID();
|
|
188
|
+
const peers = [...this.peers];
|
|
189
|
+
this.notify({ kind: 'recovery_started', candidateName, peers });
|
|
157
190
|
const result = await new Promise((resolve) => {
|
|
158
191
|
const timer = setTimeout(() => {
|
|
159
192
|
const pending = this.pendingPulls.get(requestId);
|
|
160
193
|
if (pending) {
|
|
161
194
|
pending.finished = true;
|
|
162
195
|
this.pendingPulls.delete(requestId);
|
|
196
|
+
this.notify({
|
|
197
|
+
kind: 'recovery_failed',
|
|
198
|
+
candidateName,
|
|
199
|
+
peers,
|
|
200
|
+
reason: `timed out waiting for ${this.peers.length} auth sync peer response(s)`,
|
|
201
|
+
});
|
|
163
202
|
resolve(false);
|
|
164
203
|
}
|
|
165
204
|
}, PULL_TIMEOUT_MS);
|
|
166
205
|
timer.unref();
|
|
167
206
|
this.pendingPulls.set(requestId, {
|
|
168
207
|
candidateName,
|
|
208
|
+
peers,
|
|
209
|
+
emptyReplies: new Map(),
|
|
169
210
|
resolve,
|
|
170
211
|
timer,
|
|
171
212
|
finished: false,
|
|
@@ -179,7 +220,13 @@ export class CrossNodeAuthSync {
|
|
|
179
220
|
}).catch((error) => {
|
|
180
221
|
clearTimeout(timer);
|
|
181
222
|
this.pendingPulls.delete(requestId);
|
|
182
|
-
this.recordError(`pull request failed: ${formatError(error)}
|
|
223
|
+
this.recordError(`pull request failed: ${formatError(error)}`, false);
|
|
224
|
+
this.notify({
|
|
225
|
+
kind: 'recovery_failed',
|
|
226
|
+
candidateName,
|
|
227
|
+
peers: [...this.peers],
|
|
228
|
+
reason: formatError(error),
|
|
229
|
+
});
|
|
183
230
|
resolve(false);
|
|
184
231
|
});
|
|
185
232
|
});
|
|
@@ -301,7 +348,7 @@ export class CrossNodeAuthSync {
|
|
|
301
348
|
this.enqueueImport(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
302
349
|
return;
|
|
303
350
|
case 'pull.request':
|
|
304
|
-
await this.handlePullRequest(message, normalizePeerIdentity(peer));
|
|
351
|
+
await this.handlePullRequest(message, senderNodeId, normalizePeerIdentity(peer));
|
|
305
352
|
return;
|
|
306
353
|
case 'pull.response':
|
|
307
354
|
await this.handlePullResponse(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
@@ -336,20 +383,47 @@ export class CrossNodeAuthSync {
|
|
|
336
383
|
return;
|
|
337
384
|
}
|
|
338
385
|
}
|
|
339
|
-
async handlePullRequest(message, peer) {
|
|
386
|
+
async handlePullRequest(message, requesterNodeId, peer) {
|
|
340
387
|
if (!isAuthCandidateName(message.candidateName))
|
|
341
388
|
return;
|
|
389
|
+
this.notify({
|
|
390
|
+
kind: 'pull_request_received',
|
|
391
|
+
candidateName: message.candidateName,
|
|
392
|
+
peer,
|
|
393
|
+
requesterNodeId,
|
|
394
|
+
});
|
|
342
395
|
const record = await this.callbacks.readLocalCandidate(message.candidateName);
|
|
343
396
|
if (!record) {
|
|
344
397
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'candidate not found' });
|
|
398
|
+
this.notify({
|
|
399
|
+
kind: 'pull_response_sent',
|
|
400
|
+
candidateName: message.candidateName,
|
|
401
|
+
peer,
|
|
402
|
+
result: 'candidate_not_found',
|
|
403
|
+
reason: 'candidate not found',
|
|
404
|
+
});
|
|
345
405
|
return;
|
|
346
406
|
}
|
|
347
407
|
if (message.accountId && record.accountId !== message.accountId) {
|
|
348
408
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'account mismatch' });
|
|
409
|
+
this.notify({
|
|
410
|
+
kind: 'pull_response_sent',
|
|
411
|
+
candidateName: message.candidateName,
|
|
412
|
+
peer,
|
|
413
|
+
result: 'account_mismatch',
|
|
414
|
+
reason: 'account mismatch',
|
|
415
|
+
});
|
|
349
416
|
return;
|
|
350
417
|
}
|
|
351
418
|
if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
|
|
352
419
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
|
|
420
|
+
this.notify({
|
|
421
|
+
kind: 'pull_response_sent',
|
|
422
|
+
candidateName: message.candidateName,
|
|
423
|
+
peer,
|
|
424
|
+
result: 'not_newer',
|
|
425
|
+
reason: 'not newer',
|
|
426
|
+
});
|
|
353
427
|
return;
|
|
354
428
|
}
|
|
355
429
|
await this.sendToPeer(peer, {
|
|
@@ -357,22 +431,68 @@ export class CrossNodeAuthSync {
|
|
|
357
431
|
requestId: message.requestId,
|
|
358
432
|
bundle: bundleFromRecord(record),
|
|
359
433
|
});
|
|
434
|
+
this.notify({
|
|
435
|
+
kind: 'pull_response_sent',
|
|
436
|
+
candidateName: message.candidateName,
|
|
437
|
+
peer,
|
|
438
|
+
result: 'sent',
|
|
439
|
+
reason: null,
|
|
440
|
+
});
|
|
360
441
|
}
|
|
361
442
|
async handlePullResponse(message, senderNodeId, sourceLabel, peer) {
|
|
362
443
|
const pending = this.pendingPulls.get(message.requestId);
|
|
363
444
|
if (!pending || pending.finished)
|
|
364
445
|
return;
|
|
446
|
+
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
365
447
|
if (!message.bundle || message.bundle.candidateName !== pending.candidateName) {
|
|
448
|
+
const reason = message.reason ?? 'peer did not return a matching candidate';
|
|
449
|
+
this.notify({
|
|
450
|
+
kind: 'recovery_peer_empty',
|
|
451
|
+
candidateName: pending.candidateName,
|
|
452
|
+
peer,
|
|
453
|
+
reason,
|
|
454
|
+
});
|
|
455
|
+
this.markPullPeerUnavailable(message.requestId, matchedPeer, reason);
|
|
366
456
|
return;
|
|
367
457
|
}
|
|
368
|
-
|
|
369
|
-
|
|
458
|
+
this.notify({
|
|
459
|
+
kind: 'recovery_peer_bundle_received',
|
|
460
|
+
candidateName: pending.candidateName,
|
|
461
|
+
peer,
|
|
462
|
+
sourceNodeId: senderNodeId,
|
|
463
|
+
});
|
|
464
|
+
const outcome = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
|
|
465
|
+
if (!outcome.imported) {
|
|
466
|
+
this.markPullPeerUnavailable(message.requestId, matchedPeer, outcome.reason ?? 'peer candidate was not imported');
|
|
370
467
|
return;
|
|
468
|
+
}
|
|
371
469
|
pending.finished = true;
|
|
372
470
|
clearTimeout(pending.timer);
|
|
373
471
|
this.pendingPulls.delete(message.requestId);
|
|
374
472
|
pending.resolve(true);
|
|
375
473
|
}
|
|
474
|
+
markPullPeerUnavailable(requestId, peer, reason) {
|
|
475
|
+
const pending = this.pendingPulls.get(requestId);
|
|
476
|
+
if (!pending || pending.finished)
|
|
477
|
+
return;
|
|
478
|
+
pending.emptyReplies.set(peer, reason);
|
|
479
|
+
if (!pending.peers.every(peerName => pending.emptyReplies.has(peerName))) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
pending.finished = true;
|
|
483
|
+
clearTimeout(pending.timer);
|
|
484
|
+
this.pendingPulls.delete(requestId);
|
|
485
|
+
const details = pending.peers
|
|
486
|
+
.map(peerName => `${peerName}: ${pending.emptyReplies.get(peerName) ?? 'no usable candidate'}`)
|
|
487
|
+
.join('; ');
|
|
488
|
+
this.notify({
|
|
489
|
+
kind: 'recovery_failed',
|
|
490
|
+
candidateName: pending.candidateName,
|
|
491
|
+
peers: pending.peers,
|
|
492
|
+
reason: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
|
|
493
|
+
});
|
|
494
|
+
pending.resolve(false);
|
|
495
|
+
}
|
|
376
496
|
async handleDigest(message, peer) {
|
|
377
497
|
const remote = new Map(message.records.map(record => [record.candidateName, record]));
|
|
378
498
|
for (const local of await this.callbacks.listLocalCandidates()) {
|
|
@@ -428,6 +548,7 @@ export class CrossNodeAuthSync {
|
|
|
428
548
|
}
|
|
429
549
|
}
|
|
430
550
|
enqueueImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
|
|
551
|
+
const queued = !this.callbacks.isIdle() || this.pendingImports.length > 0;
|
|
431
552
|
this.pendingImports.push({
|
|
432
553
|
bundle,
|
|
433
554
|
sourceNodeId,
|
|
@@ -435,6 +556,15 @@ export class CrossNodeAuthSync {
|
|
|
435
556
|
receivedAt: Date.now(),
|
|
436
557
|
fromPeer,
|
|
437
558
|
});
|
|
559
|
+
this.notify({
|
|
560
|
+
kind: 'remote_bundle_received',
|
|
561
|
+
candidateName: bundle.candidateName,
|
|
562
|
+
sourceNodeId,
|
|
563
|
+
sourceLabel: sourceLabel ?? fromPeer,
|
|
564
|
+
peer: fromPeer,
|
|
565
|
+
queued,
|
|
566
|
+
queueLength: this.pendingImports.length,
|
|
567
|
+
});
|
|
438
568
|
void this.processPendingImports().catch((error) => {
|
|
439
569
|
this.recordError(`remote import failed: ${formatError(error)}`);
|
|
440
570
|
});
|
|
@@ -444,40 +574,35 @@ export class CrossNodeAuthSync {
|
|
|
444
574
|
return;
|
|
445
575
|
while (this.pendingImports.length > 0 && this.callbacks.isIdle()) {
|
|
446
576
|
const pending = this.pendingImports.shift();
|
|
447
|
-
await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer);
|
|
577
|
+
await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer, 'push');
|
|
448
578
|
}
|
|
449
579
|
}
|
|
450
|
-
async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
|
|
580
|
+
async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode) {
|
|
581
|
+
const source = sourceLabel ?? fromPeer;
|
|
451
582
|
if (!isValidBundle(bundle)) {
|
|
452
|
-
this.
|
|
453
|
-
return false;
|
|
583
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, 'remote bundle shape is invalid');
|
|
454
584
|
}
|
|
455
585
|
const metadata = parseChatGptAuthMetadata(bundle.rawAuth);
|
|
456
586
|
if (!metadata || metadata.accountId !== bundle.accountId || metadata.lastRefreshMs !== bundle.lastRefreshMs) {
|
|
457
|
-
this.
|
|
458
|
-
return false;
|
|
587
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle metadata mismatch for ${bundle.candidateName}`);
|
|
459
588
|
}
|
|
460
589
|
if (sha256(bundle.rawAuth) !== bundle.authSha256) {
|
|
461
|
-
this.
|
|
462
|
-
return false;
|
|
590
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle hash mismatch for ${bundle.candidateName}`);
|
|
463
591
|
}
|
|
464
592
|
const expiresAt = readAccessTokenExpiresAtMs(bundle.rawAuth);
|
|
465
593
|
if (expiresAt === null || expiresAt <= Date.now() + REMOTE_ACCESS_TOKEN_MIN_TTL_MS) {
|
|
466
|
-
this.
|
|
467
|
-
return false;
|
|
594
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote access token is expired or missing exp for ${bundle.candidateName}`);
|
|
468
595
|
}
|
|
469
596
|
const validation = await this.callbacks.validateCandidate(bundle.candidateName, bundle.rawAuth, bundle.accountId);
|
|
470
597
|
if (!validation.ok) {
|
|
471
|
-
this.
|
|
472
|
-
return false;
|
|
598
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate validation failed for ${bundle.candidateName}: ${validation.reason ?? 'unknown'}`);
|
|
473
599
|
}
|
|
474
600
|
const result = await this.callbacks.importCandidate(bundle.candidateName, bundle.rawAuth, {
|
|
475
601
|
nodeId: sourceNodeId,
|
|
476
|
-
label:
|
|
602
|
+
label: source,
|
|
477
603
|
});
|
|
478
604
|
if (!result.ok) {
|
|
479
|
-
this.
|
|
480
|
-
return false;
|
|
605
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
|
|
481
606
|
}
|
|
482
607
|
if (result.imported) {
|
|
483
608
|
this.state.lastImportedAt = new Date().toISOString();
|
|
@@ -485,8 +610,27 @@ export class CrossNodeAuthSync {
|
|
|
485
610
|
this.state.lastError = null;
|
|
486
611
|
await this.writeState();
|
|
487
612
|
this.logger.info('auth.sync.imported', { candidateName: bundle.candidateName, sourceNodeId });
|
|
613
|
+
this.notify({
|
|
614
|
+
kind: 'remote_import_imported',
|
|
615
|
+
candidateName: bundle.candidateName,
|
|
616
|
+
sourceNodeId,
|
|
617
|
+
sourceLabel: source,
|
|
618
|
+
peer: fromPeer,
|
|
619
|
+
mode,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
this.notify({
|
|
624
|
+
kind: 'remote_import_skipped',
|
|
625
|
+
candidateName: bundle.candidateName,
|
|
626
|
+
sourceNodeId,
|
|
627
|
+
sourceLabel: source,
|
|
628
|
+
peer: fromPeer,
|
|
629
|
+
mode,
|
|
630
|
+
reason: result.reason ?? 'local candidate did not need an update',
|
|
631
|
+
});
|
|
488
632
|
}
|
|
489
|
-
return result.imported;
|
|
633
|
+
return { ok: true, imported: result.imported, reason: result.reason ?? null };
|
|
490
634
|
}
|
|
491
635
|
async sendToAll(message) {
|
|
492
636
|
await Promise.all(this.peers.map(peer => this.sendToPeer(peer, message)));
|
|
@@ -601,12 +745,41 @@ export class CrossNodeAuthSync {
|
|
|
601
745
|
this.activeLocalLease = null;
|
|
602
746
|
}
|
|
603
747
|
}
|
|
604
|
-
recordError(message) {
|
|
748
|
+
recordError(message, notify = true) {
|
|
605
749
|
this.state.lastError = message;
|
|
606
750
|
this.logger.warn('auth.sync.error', { error: message });
|
|
607
751
|
void this.writeState().catch((error) => {
|
|
608
752
|
this.logger.warn('auth.sync.state_write_failed', { error: formatError(error) });
|
|
609
753
|
});
|
|
754
|
+
if (notify) {
|
|
755
|
+
this.notifyError(message);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
|
|
759
|
+
this.recordError(reason, false);
|
|
760
|
+
this.notify({
|
|
761
|
+
kind: 'remote_import_failed',
|
|
762
|
+
candidateName: bundle.candidateName,
|
|
763
|
+
sourceNodeId,
|
|
764
|
+
sourceLabel,
|
|
765
|
+
peer: fromPeer,
|
|
766
|
+
mode,
|
|
767
|
+
reason,
|
|
768
|
+
});
|
|
769
|
+
return { ok: false, imported: false, reason };
|
|
770
|
+
}
|
|
771
|
+
notify(event) {
|
|
772
|
+
if (!this.callbacks.notify)
|
|
773
|
+
return;
|
|
774
|
+
void this.callbacks.notify(event).catch((error) => {
|
|
775
|
+
this.logger.warn('auth.sync.notify_failed', { error: formatError(error) });
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
notifyError(reason) {
|
|
779
|
+
if (this.lastNotifiedError === reason)
|
|
780
|
+
return;
|
|
781
|
+
this.lastNotifiedError = reason;
|
|
782
|
+
this.notify({ kind: 'sync_error', reason });
|
|
610
783
|
}
|
|
611
784
|
async writeState() {
|
|
612
785
|
if (!this.config.enabled)
|
package/dist/main.js
CHANGED
|
@@ -375,6 +375,7 @@ async function runServeCli() {
|
|
|
375
375
|
},
|
|
376
376
|
importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
|
|
377
377
|
isIdle: authSyncLocalIdle,
|
|
378
|
+
notify: createAuthSyncNotifier(store, authSyncTransportBot.bot),
|
|
378
379
|
});
|
|
379
380
|
await authSync.initialize();
|
|
380
381
|
activeAuthSync = authSync;
|
|
@@ -488,6 +489,7 @@ async function runServeCli() {
|
|
|
488
489
|
}
|
|
489
490
|
core = new BridgeSessionCore(config, store, logger, bot, app, outbound, selfUpdater, singleCoordinator);
|
|
490
491
|
if (config.authSyncEnabled && singleMirror) {
|
|
492
|
+
await bot.initializeIdentity();
|
|
491
493
|
singleAuthSync = new CrossNodeAuthSync(buildAuthSyncConfig(config, bot.username ? `@${bot.username}` : 'default'), logger, {
|
|
492
494
|
send: async (peer, envelope) => {
|
|
493
495
|
await bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
|
|
@@ -503,6 +505,7 @@ async function runServeCli() {
|
|
|
503
505
|
},
|
|
504
506
|
importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
|
|
505
507
|
isIdle: singleAuthSyncLocalIdle,
|
|
508
|
+
notify: createAuthSyncNotifier(store, bot),
|
|
506
509
|
});
|
|
507
510
|
await singleAuthSync.initialize();
|
|
508
511
|
activeAuthSync = singleAuthSync;
|
|
@@ -569,6 +572,133 @@ async function runServeCli() {
|
|
|
569
572
|
throw error;
|
|
570
573
|
}
|
|
571
574
|
}
|
|
575
|
+
function createAuthSyncNotifier(store, bot) {
|
|
576
|
+
return async (event) => {
|
|
577
|
+
if (!bot.identity)
|
|
578
|
+
return;
|
|
579
|
+
const privateScope = store.getTelegramPrivateScope(bot.identity);
|
|
580
|
+
if (!privateScope)
|
|
581
|
+
return;
|
|
582
|
+
const locale = store.getChatSettings(privateScope.scopeId)?.locale ?? 'en';
|
|
583
|
+
await bot.sendMessage(privateScope.chatId, formatAuthSyncNotification(locale, event));
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function formatAuthSyncNotification(locale, event) {
|
|
587
|
+
const peers = 'peers' in event ? formatPeerList(event.peers, locale) : '';
|
|
588
|
+
if (locale === 'zh') {
|
|
589
|
+
switch (event.kind) {
|
|
590
|
+
case 'candidate_publish_started':
|
|
591
|
+
return `本机 auth 已更新:${event.candidateName}\n处理:正在同步到跨节点 peer:${peers}`;
|
|
592
|
+
case 'candidate_publish_completed':
|
|
593
|
+
return `跨节点 auth 已发出:${event.candidateName}\nPeer:${peers}\n注意:这只代表发送成功,对端导入结果会在对端通知或 /auth sync status 中显示。`;
|
|
594
|
+
case 'candidate_publish_failed':
|
|
595
|
+
return `跨节点 auth 发送失败:${event.candidateName}\nPeer:${peers}\n原因:${event.reason}`;
|
|
596
|
+
case 'push_all_started':
|
|
597
|
+
return `开始手动推送全部 auth:候选 ${event.candidateCount} 个\nPeer:${peers}`;
|
|
598
|
+
case 'push_all_completed':
|
|
599
|
+
return `手动 auth 同步推送完成:已发送 ${event.sent},已跳过 ${event.skipped}\nPeer:${peers}`;
|
|
600
|
+
case 'push_all_failed':
|
|
601
|
+
return `手动 auth 同步推送中断:已发送 ${event.sent},已跳过 ${event.skipped}\nPeer:${peers}\n原因:${event.reason}`;
|
|
602
|
+
case 'remote_bundle_received':
|
|
603
|
+
return [
|
|
604
|
+
`收到跨节点 auth:${event.candidateName}`,
|
|
605
|
+
`来源:${formatSource(event.sourceLabel, event.sourceNodeId)},peer ${event.peer}`,
|
|
606
|
+
`处理:${event.queued ? `本机忙,已排队等待空闲后验证导入;当前待导入 ${event.queueLength}` : '本机空闲,正在验证 usage 后导入'}`,
|
|
607
|
+
].join('\n');
|
|
608
|
+
case 'remote_import_imported':
|
|
609
|
+
return `${event.mode === 'pull' ? '已拉取并导入跨节点 auth' : '已导入跨节点 auth'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n处理:已写入本机 auth 镜像,并同步到同节点 bot home。`;
|
|
610
|
+
case 'remote_import_skipped':
|
|
611
|
+
return `${event.mode === 'pull' ? '跨节点拉取未改动本机文件' : '收到跨节点 auth 但未写盘'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
|
|
612
|
+
case 'remote_import_failed':
|
|
613
|
+
return `${event.mode === 'pull' ? '跨节点拉取导入失败' : '跨节点 auth 导入失败'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}\n需要注意:如果其他候选也无法恢复,请人工介入重新登录或刷新这个 auth。`;
|
|
614
|
+
case 'recovery_started':
|
|
615
|
+
return `auth 恢复开始:${event.candidateName}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers}`;
|
|
616
|
+
case 'recovery_peer_empty':
|
|
617
|
+
return `auth 恢复收到 peer 回应但没有可用副本:${event.candidateName}\nPeer:${event.peer}\n原因:${event.reason}`;
|
|
618
|
+
case 'recovery_peer_bundle_received':
|
|
619
|
+
return `auth 恢复收到 peer 候选:${event.candidateName}\nPeer:${event.peer},来源节点:${event.sourceNodeId}\n处理:正在验证 usage 后导入。`;
|
|
620
|
+
case 'recovery_failed':
|
|
621
|
+
return `auth 恢复已穷尽:${event.candidateName}\n已查询 peer:${peers}\n原因:${event.reason}\n请人工介入:使用 /auth add <name> 或设备登录重新生成可用 auth。`;
|
|
622
|
+
case 'pull_request_received':
|
|
623
|
+
return `收到 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer},请求节点:${event.requesterNodeId}`;
|
|
624
|
+
case 'pull_response_sent':
|
|
625
|
+
return `已回应 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer}\n结果:${formatPullResponseResult(event.result, event.reason, locale)}`;
|
|
626
|
+
case 'sync_error':
|
|
627
|
+
return `auth sync 需要注意:\n${event.reason}`;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
switch (event.kind) {
|
|
631
|
+
case 'candidate_publish_started':
|
|
632
|
+
return `Local auth updated: ${event.candidateName}\nAction: syncing to cross-node peers: ${peers}`;
|
|
633
|
+
case 'candidate_publish_completed':
|
|
634
|
+
return `Cross-node auth sent: ${event.candidateName}\nPeers: ${peers}\nNote: this confirms send success only; peer import is reported on the receiving node.`;
|
|
635
|
+
case 'candidate_publish_failed':
|
|
636
|
+
return `Cross-node auth send failed: ${event.candidateName}\nPeers: ${peers}\nReason: ${event.reason}`;
|
|
637
|
+
case 'push_all_started':
|
|
638
|
+
return `Manual auth sync push started: ${event.candidateCount} candidates\nPeers: ${peers}`;
|
|
639
|
+
case 'push_all_completed':
|
|
640
|
+
return `Manual auth sync push complete: sent ${event.sent}, skipped ${event.skipped}\nPeers: ${peers}`;
|
|
641
|
+
case 'push_all_failed':
|
|
642
|
+
return `Manual auth sync push stopped: sent ${event.sent}, skipped ${event.skipped}\nPeers: ${peers}\nReason: ${event.reason}`;
|
|
643
|
+
case 'remote_bundle_received':
|
|
644
|
+
return [
|
|
645
|
+
`Received cross-node auth: ${event.candidateName}`,
|
|
646
|
+
`Source: ${formatSource(event.sourceLabel, event.sourceNodeId)}, peer ${event.peer}`,
|
|
647
|
+
`Action: ${event.queued ? `queued until this node is idle; pending imports ${event.queueLength}` : 'validating usage before import'}`,
|
|
648
|
+
].join('\n');
|
|
649
|
+
case 'remote_import_imported':
|
|
650
|
+
return `${event.mode === 'pull' ? 'Pulled and imported cross-node auth' : 'Imported cross-node auth'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nAction: written to the local auth mirror and same-node bot homes.`;
|
|
651
|
+
case 'remote_import_skipped':
|
|
652
|
+
return `${event.mode === 'pull' ? 'Cross-node pull did not change local files' : 'Received cross-node auth but did not write it'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
|
|
653
|
+
case 'remote_import_failed':
|
|
654
|
+
return `${event.mode === 'pull' ? 'Cross-node pull import failed' : 'Cross-node auth import failed'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}\nAttention: if no other candidate can recover this account, run device login or refresh this auth manually.`;
|
|
655
|
+
case 'recovery_started':
|
|
656
|
+
return `Auth recovery started: ${event.candidateName}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}`;
|
|
657
|
+
case 'recovery_peer_empty':
|
|
658
|
+
return `Auth recovery peer replied without usable auth: ${event.candidateName}\nPeer: ${event.peer}\nReason: ${event.reason}`;
|
|
659
|
+
case 'recovery_peer_bundle_received':
|
|
660
|
+
return `Auth recovery peer returned a candidate: ${event.candidateName}\nPeer: ${event.peer}, source node: ${event.sourceNodeId}\nAction: validating usage before import.`;
|
|
661
|
+
case 'recovery_failed':
|
|
662
|
+
return `Auth recovery exhausted: ${event.candidateName}\nPeers checked: ${peers}\nReason: ${event.reason}\nManual action: use /auth add <name> or device login to create a usable auth.`;
|
|
663
|
+
case 'pull_request_received':
|
|
664
|
+
return `Received peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}, requester node: ${event.requesterNodeId}`;
|
|
665
|
+
case 'pull_response_sent':
|
|
666
|
+
return `Replied to peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}\nResult: ${formatPullResponseResult(event.result, event.reason, locale)}`;
|
|
667
|
+
case 'sync_error':
|
|
668
|
+
return `Auth sync needs attention:\n${event.reason}`;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function formatPeerList(peers, locale) {
|
|
672
|
+
return peers.length > 0 ? peers.join(', ') : (locale === 'zh' ? '无' : 'none');
|
|
673
|
+
}
|
|
674
|
+
function formatSource(sourceLabel, sourceNodeId) {
|
|
675
|
+
return sourceLabel === sourceNodeId ? sourceNodeId : `${sourceLabel} / ${sourceNodeId}`;
|
|
676
|
+
}
|
|
677
|
+
function formatPullResponseResult(result, reason, locale) {
|
|
678
|
+
const suffix = reason ? ` (${reason})` : '';
|
|
679
|
+
if (locale === 'zh') {
|
|
680
|
+
switch (result) {
|
|
681
|
+
case 'sent':
|
|
682
|
+
return `已返回较新候选${suffix}`;
|
|
683
|
+
case 'candidate_not_found':
|
|
684
|
+
return `本机没有这个候选${suffix}`;
|
|
685
|
+
case 'account_mismatch':
|
|
686
|
+
return `账号不匹配,未返回${suffix}`;
|
|
687
|
+
case 'not_newer':
|
|
688
|
+
return `本机副本不更新,未返回${suffix}`;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
switch (result) {
|
|
692
|
+
case 'sent':
|
|
693
|
+
return `sent newer candidate${suffix}`;
|
|
694
|
+
case 'candidate_not_found':
|
|
695
|
+
return `candidate not found${suffix}`;
|
|
696
|
+
case 'account_mismatch':
|
|
697
|
+
return `account mismatch; nothing sent${suffix}`;
|
|
698
|
+
case 'not_newer':
|
|
699
|
+
return `local copy is not newer; nothing sent${suffix}`;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
572
702
|
const AUTH_SYNC_TELEGRAM_CAPTION = 'FOXCLAW_AUTH_SYNC_V1';
|
|
573
703
|
function buildAuthSyncConfig(config, transportLabel = null) {
|
|
574
704
|
return {
|
package/dist/store/database.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ export declare class BridgeStore {
|
|
|
43
43
|
setTelegramOffset(botKey: string, updateId: number): void;
|
|
44
44
|
rememberTelegramPrivateScope(botId: string, scopeId: string, chatId: string): void;
|
|
45
45
|
getTelegramPrivateChatId(botId: string): string | null;
|
|
46
|
+
getTelegramPrivateScope(botId: string): {
|
|
47
|
+
scopeId: string;
|
|
48
|
+
chatId: string;
|
|
49
|
+
} | null;
|
|
46
50
|
getBinding(chatId: string): ThreadBinding | null;
|
|
47
51
|
setBinding(chatId: string, threadId: string, cwd: string | null): void;
|
|
48
52
|
clearBinding(chatId: string): void;
|
package/dist/store/database.js
CHANGED
|
@@ -178,6 +178,10 @@ export class BridgeStore {
|
|
|
178
178
|
const row = this.db.prepare('SELECT chat_id FROM telegram_private_scopes WHERE bot_id = ?').get(botId);
|
|
179
179
|
return row ? String(row.chat_id) : null;
|
|
180
180
|
}
|
|
181
|
+
getTelegramPrivateScope(botId) {
|
|
182
|
+
const row = this.db.prepare('SELECT scope_id, chat_id FROM telegram_private_scopes WHERE bot_id = ?').get(botId);
|
|
183
|
+
return row ? { scopeId: String(row.scope_id), chatId: String(row.chat_id) } : null;
|
|
184
|
+
}
|
|
181
185
|
getBinding(chatId) {
|
|
182
186
|
const row = this.db.prepare('SELECT chat_id, thread_id, cwd, updated_at FROM chat_bindings WHERE chat_id = ?').get(chatId);
|
|
183
187
|
if (!row)
|
|
@@ -166,6 +166,8 @@ Confirm that pending imports were processed, or that the candidate exists or has
|
|
|
166
166
|
|
|
167
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.
|
|
168
168
|
|
|
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
|
+
|
|
169
171
|
5. Only test refresh-token rotation after you understand the risk:
|
|
170
172
|
|
|
171
173
|
```text
|
package/docs/user-manual.md
CHANGED
|
@@ -419,7 +419,7 @@ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token r
|
|
|
419
419
|
|
|
420
420
|
### 6.4 Cross-Node Auth Sync
|
|
421
421
|
|
|
422
|
-
Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`.
|
|
422
|
+
Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states.
|
|
423
423
|
|
|
424
424
|
For the full design, safety boundaries, `.env` examples, and troubleshooting, read the [Cross-Node Auth Sync Setup Guide](./cross-node-auth-sync.md).
|
|
425
425
|
|
|
@@ -166,6 +166,8 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
166
166
|
|
|
167
167
|
注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
|
|
168
168
|
|
|
169
|
+
启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
|
|
170
|
+
|
|
169
171
|
5. 只有在完全理解 refresh token 轮换风险时,才测试:
|
|
170
172
|
|
|
171
173
|
```text
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -419,7 +419,7 @@ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放
|
|
|
419
419
|
|
|
420
420
|
### 6.4 跨节点 auth 同步
|
|
421
421
|
|
|
422
|
-
跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token
|
|
422
|
+
跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示。
|
|
423
423
|
|
|
424
424
|
完整设计、安全边界、`.env` 示例和排查步骤见 [跨节点 auth 同步配置指南](./cross-node-auth-sync.md)。
|
|
425
425
|
|