@foxden-app/foxclaw 0.4.17 → 0.5.1
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 +11 -1
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/cross_node_sync.d.ts +102 -0
- package/dist/auth/cross_node_sync.js +222 -37
- 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,27 @@
|
|
|
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.1 - 2026-06-04
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 串行化跨节点 auth 远端导入验证,避免多个同步包同时触发 usage 验证并反复重启同一个 Codex app-server,导致误报 `Codex app bridge stopped` 或 `SIGTERM`。
|
|
9
|
+
|
|
10
|
+
### English
|
|
11
|
+
- Serialized cross-node auth remote import validation so multiple incoming bundles no longer validate in parallel and repeatedly restart the same Codex app-server, avoiding false `Codex app bridge stopped` or `SIGTERM` failures.
|
|
12
|
+
|
|
13
|
+
## 0.5.0 - 2026-06-04
|
|
6
14
|
|
|
7
15
|
### 中文
|
|
8
16
|
- 明确跨节点 auth 同步推荐架构:每节点一个联系人 bot;多 bot 模式默认使用 `TG_BOT_TOKENS` 的第一个 token 作为联系人,同节点其他 bot 继续走本机 auth 镜像。
|
|
9
17
|
- `/auth sync status` 和 `/status` 现在显示联系人 bot,避免在非联系人 bot 上执行命令时误判实际发包身份。
|
|
10
18
|
- `/auth sync test` 升级为等待 peer 加密 pong 的真实握手,并显示未回应 peer;同时文档补充 BotFather MiniApp 入口、不要误进 Configure Mini App,以及 `push all` 只代表发送成功、不代表对端已导入。
|
|
19
|
+
- 跨节点 auth 同步现在会通过联系人 bot 私聊通知发送、接收、排队、导入、跳过、失败和恢复查询进度;所有 peer 都无法提供可用副本时会明确提示人工介入。
|
|
11
20
|
|
|
12
21
|
### English
|
|
13
22
|
- 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
23
|
- `/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
24
|
- `/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.
|
|
25
|
+
- 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
26
|
|
|
17
27
|
## 0.4.16 - 2026-06-04
|
|
18
28
|
|
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>;
|
|
@@ -73,8 +169,10 @@ export declare class CrossNodeAuthSync {
|
|
|
73
169
|
private readonly pendingTests;
|
|
74
170
|
private seenNonces;
|
|
75
171
|
private timer;
|
|
172
|
+
private importProcessorActive;
|
|
76
173
|
private activeRemoteLease;
|
|
77
174
|
private activeLocalLease;
|
|
175
|
+
private lastNotifiedError;
|
|
78
176
|
private state;
|
|
79
177
|
constructor(config: AuthSyncConfig, logger: Logger, transport: AuthSyncTransport, callbacks: AuthSyncImportCallbacks);
|
|
80
178
|
initialize(): Promise<void>;
|
|
@@ -99,6 +197,7 @@ export declare class CrossNodeAuthSync {
|
|
|
99
197
|
private handleMessage;
|
|
100
198
|
private handlePullRequest;
|
|
101
199
|
private handlePullResponse;
|
|
200
|
+
private markPullPeerUnavailable;
|
|
102
201
|
private handleDigest;
|
|
103
202
|
private handleLeaseRequest;
|
|
104
203
|
private handleLeaseReply;
|
|
@@ -116,6 +215,9 @@ export declare class CrossNodeAuthSync {
|
|
|
116
215
|
private matchConfiguredPeer;
|
|
117
216
|
private expireLeases;
|
|
118
217
|
private recordError;
|
|
218
|
+
private rejectImport;
|
|
219
|
+
private notify;
|
|
220
|
+
private notifyError;
|
|
119
221
|
private writeState;
|
|
120
222
|
}
|
|
121
223
|
export declare function readAccessTokenExpiresAtMs(raw: string): number | null;
|
|
@@ -25,8 +25,10 @@ export class CrossNodeAuthSync {
|
|
|
25
25
|
pendingTests = new Map();
|
|
26
26
|
seenNonces = new Map();
|
|
27
27
|
timer = null;
|
|
28
|
+
importProcessorActive = false;
|
|
28
29
|
activeRemoteLease = null;
|
|
29
30
|
activeLocalLease = null;
|
|
31
|
+
lastNotifiedError = null;
|
|
30
32
|
state = {
|
|
31
33
|
lastSentAt: null,
|
|
32
34
|
lastReceivedAt: null,
|
|
@@ -106,6 +108,7 @@ export class CrossNodeAuthSync {
|
|
|
106
108
|
}
|
|
107
109
|
isIdle() {
|
|
108
110
|
return this.pendingImports.length === 0
|
|
111
|
+
&& !this.importProcessorActive
|
|
109
112
|
&& this.pendingPulls.size === 0
|
|
110
113
|
&& this.pendingLeases.size === 0
|
|
111
114
|
&& this.pendingTests.size === 0;
|
|
@@ -116,10 +119,24 @@ export class CrossNodeAuthSync {
|
|
|
116
119
|
const record = await this.callbacks.readLocalCandidate(candidateName);
|
|
117
120
|
if (!record)
|
|
118
121
|
return false;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
this.notify({ kind: 'candidate_publish_started', candidateName, peers: [...this.peers] });
|
|
123
|
+
try {
|
|
124
|
+
await this.sendToAll({
|
|
125
|
+
kind: 'push.bundle',
|
|
126
|
+
...bundleFromRecord(record),
|
|
127
|
+
});
|
|
128
|
+
this.notify({ kind: 'candidate_publish_completed', candidateName, peers: [...this.peers] });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.recordError(`candidate publish failed for ${candidateName}: ${formatError(error)}`, false);
|
|
132
|
+
this.notify({
|
|
133
|
+
kind: 'candidate_publish_failed',
|
|
134
|
+
candidateName,
|
|
135
|
+
peers: [...this.peers],
|
|
136
|
+
reason: formatError(error),
|
|
137
|
+
});
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
123
140
|
return true;
|
|
124
141
|
}
|
|
125
142
|
async pushAll() {
|
|
@@ -127,18 +144,34 @@ export class CrossNodeAuthSync {
|
|
|
127
144
|
return { sent: 0, skipped: 0 };
|
|
128
145
|
let sent = 0;
|
|
129
146
|
let skipped = 0;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
147
|
+
const records = await this.callbacks.listLocalCandidates();
|
|
148
|
+
this.notify({ kind: 'push_all_started', candidateCount: records.length, peers: [...this.peers] });
|
|
149
|
+
try {
|
|
150
|
+
for (const record of records) {
|
|
151
|
+
if (!isAuthCandidateName(record.candidateName)) {
|
|
152
|
+
skipped += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
await this.sendToAll({
|
|
156
|
+
kind: 'push.bundle',
|
|
157
|
+
...bundleFromRecord(record),
|
|
158
|
+
});
|
|
159
|
+
sent += 1;
|
|
134
160
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
161
|
+
this.notify({ kind: 'push_all_completed', sent, skipped, peers: [...this.peers] });
|
|
162
|
+
return { sent, skipped };
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
this.recordError(`push all failed: ${formatError(error)}`, false);
|
|
166
|
+
this.notify({
|
|
167
|
+
kind: 'push_all_failed',
|
|
168
|
+
sent,
|
|
169
|
+
skipped,
|
|
170
|
+
peers: [...this.peers],
|
|
171
|
+
reason: formatError(error),
|
|
138
172
|
});
|
|
139
|
-
|
|
173
|
+
throw error;
|
|
140
174
|
}
|
|
141
|
-
return { sent, skipped };
|
|
142
175
|
}
|
|
143
176
|
async publishDigest() {
|
|
144
177
|
if (!this.isReady())
|
|
@@ -154,18 +187,28 @@ export class CrossNodeAuthSync {
|
|
|
154
187
|
if (!this.isReady() || !isAuthCandidateName(candidateName))
|
|
155
188
|
return false;
|
|
156
189
|
const requestId = crypto.randomUUID();
|
|
190
|
+
const peers = [...this.peers];
|
|
191
|
+
this.notify({ kind: 'recovery_started', candidateName, peers });
|
|
157
192
|
const result = await new Promise((resolve) => {
|
|
158
193
|
const timer = setTimeout(() => {
|
|
159
194
|
const pending = this.pendingPulls.get(requestId);
|
|
160
195
|
if (pending) {
|
|
161
196
|
pending.finished = true;
|
|
162
197
|
this.pendingPulls.delete(requestId);
|
|
198
|
+
this.notify({
|
|
199
|
+
kind: 'recovery_failed',
|
|
200
|
+
candidateName,
|
|
201
|
+
peers,
|
|
202
|
+
reason: `timed out waiting for ${this.peers.length} auth sync peer response(s)`,
|
|
203
|
+
});
|
|
163
204
|
resolve(false);
|
|
164
205
|
}
|
|
165
206
|
}, PULL_TIMEOUT_MS);
|
|
166
207
|
timer.unref();
|
|
167
208
|
this.pendingPulls.set(requestId, {
|
|
168
209
|
candidateName,
|
|
210
|
+
peers,
|
|
211
|
+
emptyReplies: new Map(),
|
|
169
212
|
resolve,
|
|
170
213
|
timer,
|
|
171
214
|
finished: false,
|
|
@@ -179,7 +222,13 @@ export class CrossNodeAuthSync {
|
|
|
179
222
|
}).catch((error) => {
|
|
180
223
|
clearTimeout(timer);
|
|
181
224
|
this.pendingPulls.delete(requestId);
|
|
182
|
-
this.recordError(`pull request failed: ${formatError(error)}
|
|
225
|
+
this.recordError(`pull request failed: ${formatError(error)}`, false);
|
|
226
|
+
this.notify({
|
|
227
|
+
kind: 'recovery_failed',
|
|
228
|
+
candidateName,
|
|
229
|
+
peers: [...this.peers],
|
|
230
|
+
reason: formatError(error),
|
|
231
|
+
});
|
|
183
232
|
resolve(false);
|
|
184
233
|
});
|
|
185
234
|
});
|
|
@@ -301,7 +350,7 @@ export class CrossNodeAuthSync {
|
|
|
301
350
|
this.enqueueImport(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
302
351
|
return;
|
|
303
352
|
case 'pull.request':
|
|
304
|
-
await this.handlePullRequest(message, normalizePeerIdentity(peer));
|
|
353
|
+
await this.handlePullRequest(message, senderNodeId, normalizePeerIdentity(peer));
|
|
305
354
|
return;
|
|
306
355
|
case 'pull.response':
|
|
307
356
|
await this.handlePullResponse(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
@@ -336,20 +385,47 @@ export class CrossNodeAuthSync {
|
|
|
336
385
|
return;
|
|
337
386
|
}
|
|
338
387
|
}
|
|
339
|
-
async handlePullRequest(message, peer) {
|
|
388
|
+
async handlePullRequest(message, requesterNodeId, peer) {
|
|
340
389
|
if (!isAuthCandidateName(message.candidateName))
|
|
341
390
|
return;
|
|
391
|
+
this.notify({
|
|
392
|
+
kind: 'pull_request_received',
|
|
393
|
+
candidateName: message.candidateName,
|
|
394
|
+
peer,
|
|
395
|
+
requesterNodeId,
|
|
396
|
+
});
|
|
342
397
|
const record = await this.callbacks.readLocalCandidate(message.candidateName);
|
|
343
398
|
if (!record) {
|
|
344
399
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'candidate not found' });
|
|
400
|
+
this.notify({
|
|
401
|
+
kind: 'pull_response_sent',
|
|
402
|
+
candidateName: message.candidateName,
|
|
403
|
+
peer,
|
|
404
|
+
result: 'candidate_not_found',
|
|
405
|
+
reason: 'candidate not found',
|
|
406
|
+
});
|
|
345
407
|
return;
|
|
346
408
|
}
|
|
347
409
|
if (message.accountId && record.accountId !== message.accountId) {
|
|
348
410
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'account mismatch' });
|
|
411
|
+
this.notify({
|
|
412
|
+
kind: 'pull_response_sent',
|
|
413
|
+
candidateName: message.candidateName,
|
|
414
|
+
peer,
|
|
415
|
+
result: 'account_mismatch',
|
|
416
|
+
reason: 'account mismatch',
|
|
417
|
+
});
|
|
349
418
|
return;
|
|
350
419
|
}
|
|
351
420
|
if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
|
|
352
421
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
|
|
422
|
+
this.notify({
|
|
423
|
+
kind: 'pull_response_sent',
|
|
424
|
+
candidateName: message.candidateName,
|
|
425
|
+
peer,
|
|
426
|
+
result: 'not_newer',
|
|
427
|
+
reason: 'not newer',
|
|
428
|
+
});
|
|
353
429
|
return;
|
|
354
430
|
}
|
|
355
431
|
await this.sendToPeer(peer, {
|
|
@@ -357,22 +433,68 @@ export class CrossNodeAuthSync {
|
|
|
357
433
|
requestId: message.requestId,
|
|
358
434
|
bundle: bundleFromRecord(record),
|
|
359
435
|
});
|
|
436
|
+
this.notify({
|
|
437
|
+
kind: 'pull_response_sent',
|
|
438
|
+
candidateName: message.candidateName,
|
|
439
|
+
peer,
|
|
440
|
+
result: 'sent',
|
|
441
|
+
reason: null,
|
|
442
|
+
});
|
|
360
443
|
}
|
|
361
444
|
async handlePullResponse(message, senderNodeId, sourceLabel, peer) {
|
|
362
445
|
const pending = this.pendingPulls.get(message.requestId);
|
|
363
446
|
if (!pending || pending.finished)
|
|
364
447
|
return;
|
|
448
|
+
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
365
449
|
if (!message.bundle || message.bundle.candidateName !== pending.candidateName) {
|
|
450
|
+
const reason = message.reason ?? 'peer did not return a matching candidate';
|
|
451
|
+
this.notify({
|
|
452
|
+
kind: 'recovery_peer_empty',
|
|
453
|
+
candidateName: pending.candidateName,
|
|
454
|
+
peer,
|
|
455
|
+
reason,
|
|
456
|
+
});
|
|
457
|
+
this.markPullPeerUnavailable(message.requestId, matchedPeer, reason);
|
|
366
458
|
return;
|
|
367
459
|
}
|
|
368
|
-
|
|
369
|
-
|
|
460
|
+
this.notify({
|
|
461
|
+
kind: 'recovery_peer_bundle_received',
|
|
462
|
+
candidateName: pending.candidateName,
|
|
463
|
+
peer,
|
|
464
|
+
sourceNodeId: senderNodeId,
|
|
465
|
+
});
|
|
466
|
+
const outcome = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
|
|
467
|
+
if (!outcome.imported) {
|
|
468
|
+
this.markPullPeerUnavailable(message.requestId, matchedPeer, outcome.reason ?? 'peer candidate was not imported');
|
|
370
469
|
return;
|
|
470
|
+
}
|
|
371
471
|
pending.finished = true;
|
|
372
472
|
clearTimeout(pending.timer);
|
|
373
473
|
this.pendingPulls.delete(message.requestId);
|
|
374
474
|
pending.resolve(true);
|
|
375
475
|
}
|
|
476
|
+
markPullPeerUnavailable(requestId, peer, reason) {
|
|
477
|
+
const pending = this.pendingPulls.get(requestId);
|
|
478
|
+
if (!pending || pending.finished)
|
|
479
|
+
return;
|
|
480
|
+
pending.emptyReplies.set(peer, reason);
|
|
481
|
+
if (!pending.peers.every(peerName => pending.emptyReplies.has(peerName))) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
pending.finished = true;
|
|
485
|
+
clearTimeout(pending.timer);
|
|
486
|
+
this.pendingPulls.delete(requestId);
|
|
487
|
+
const details = pending.peers
|
|
488
|
+
.map(peerName => `${peerName}: ${pending.emptyReplies.get(peerName) ?? 'no usable candidate'}`)
|
|
489
|
+
.join('; ');
|
|
490
|
+
this.notify({
|
|
491
|
+
kind: 'recovery_failed',
|
|
492
|
+
candidateName: pending.candidateName,
|
|
493
|
+
peers: pending.peers,
|
|
494
|
+
reason: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
|
|
495
|
+
});
|
|
496
|
+
pending.resolve(false);
|
|
497
|
+
}
|
|
376
498
|
async handleDigest(message, peer) {
|
|
377
499
|
const remote = new Map(message.records.map(record => [record.candidateName, record]));
|
|
378
500
|
for (const local of await this.callbacks.listLocalCandidates()) {
|
|
@@ -428,6 +550,7 @@ export class CrossNodeAuthSync {
|
|
|
428
550
|
}
|
|
429
551
|
}
|
|
430
552
|
enqueueImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
|
|
553
|
+
const queued = this.importProcessorActive || !this.callbacks.isIdle() || this.pendingImports.length > 0;
|
|
431
554
|
this.pendingImports.push({
|
|
432
555
|
bundle,
|
|
433
556
|
sourceNodeId,
|
|
@@ -435,49 +558,63 @@ export class CrossNodeAuthSync {
|
|
|
435
558
|
receivedAt: Date.now(),
|
|
436
559
|
fromPeer,
|
|
437
560
|
});
|
|
561
|
+
this.notify({
|
|
562
|
+
kind: 'remote_bundle_received',
|
|
563
|
+
candidateName: bundle.candidateName,
|
|
564
|
+
sourceNodeId,
|
|
565
|
+
sourceLabel: sourceLabel ?? fromPeer,
|
|
566
|
+
peer: fromPeer,
|
|
567
|
+
queued,
|
|
568
|
+
queueLength: this.pendingImports.length,
|
|
569
|
+
});
|
|
438
570
|
void this.processPendingImports().catch((error) => {
|
|
439
571
|
this.recordError(`remote import failed: ${formatError(error)}`);
|
|
440
572
|
});
|
|
441
573
|
}
|
|
442
574
|
async processPendingImports() {
|
|
575
|
+
if (this.importProcessorActive)
|
|
576
|
+
return;
|
|
443
577
|
if (!this.callbacks.isIdle())
|
|
444
578
|
return;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
579
|
+
this.importProcessorActive = true;
|
|
580
|
+
try {
|
|
581
|
+
while (this.pendingImports.length > 0) {
|
|
582
|
+
if (!this.callbacks.isIdle())
|
|
583
|
+
return;
|
|
584
|
+
const pending = this.pendingImports.shift();
|
|
585
|
+
await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer, 'push');
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
this.importProcessorActive = false;
|
|
448
590
|
}
|
|
449
591
|
}
|
|
450
|
-
async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
|
|
592
|
+
async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode) {
|
|
593
|
+
const source = sourceLabel ?? fromPeer;
|
|
451
594
|
if (!isValidBundle(bundle)) {
|
|
452
|
-
this.
|
|
453
|
-
return false;
|
|
595
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, 'remote bundle shape is invalid');
|
|
454
596
|
}
|
|
455
597
|
const metadata = parseChatGptAuthMetadata(bundle.rawAuth);
|
|
456
598
|
if (!metadata || metadata.accountId !== bundle.accountId || metadata.lastRefreshMs !== bundle.lastRefreshMs) {
|
|
457
|
-
this.
|
|
458
|
-
return false;
|
|
599
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle metadata mismatch for ${bundle.candidateName}`);
|
|
459
600
|
}
|
|
460
601
|
if (sha256(bundle.rawAuth) !== bundle.authSha256) {
|
|
461
|
-
this.
|
|
462
|
-
return false;
|
|
602
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle hash mismatch for ${bundle.candidateName}`);
|
|
463
603
|
}
|
|
464
604
|
const expiresAt = readAccessTokenExpiresAtMs(bundle.rawAuth);
|
|
465
605
|
if (expiresAt === null || expiresAt <= Date.now() + REMOTE_ACCESS_TOKEN_MIN_TTL_MS) {
|
|
466
|
-
this.
|
|
467
|
-
return false;
|
|
606
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote access token is expired or missing exp for ${bundle.candidateName}`);
|
|
468
607
|
}
|
|
469
608
|
const validation = await this.callbacks.validateCandidate(bundle.candidateName, bundle.rawAuth, bundle.accountId);
|
|
470
609
|
if (!validation.ok) {
|
|
471
|
-
this.
|
|
472
|
-
return false;
|
|
610
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate validation failed for ${bundle.candidateName}: ${validation.reason ?? 'unknown'}`);
|
|
473
611
|
}
|
|
474
612
|
const result = await this.callbacks.importCandidate(bundle.candidateName, bundle.rawAuth, {
|
|
475
613
|
nodeId: sourceNodeId,
|
|
476
|
-
label:
|
|
614
|
+
label: source,
|
|
477
615
|
});
|
|
478
616
|
if (!result.ok) {
|
|
479
|
-
this.
|
|
480
|
-
return false;
|
|
617
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
|
|
481
618
|
}
|
|
482
619
|
if (result.imported) {
|
|
483
620
|
this.state.lastImportedAt = new Date().toISOString();
|
|
@@ -485,8 +622,27 @@ export class CrossNodeAuthSync {
|
|
|
485
622
|
this.state.lastError = null;
|
|
486
623
|
await this.writeState();
|
|
487
624
|
this.logger.info('auth.sync.imported', { candidateName: bundle.candidateName, sourceNodeId });
|
|
625
|
+
this.notify({
|
|
626
|
+
kind: 'remote_import_imported',
|
|
627
|
+
candidateName: bundle.candidateName,
|
|
628
|
+
sourceNodeId,
|
|
629
|
+
sourceLabel: source,
|
|
630
|
+
peer: fromPeer,
|
|
631
|
+
mode,
|
|
632
|
+
});
|
|
488
633
|
}
|
|
489
|
-
|
|
634
|
+
else {
|
|
635
|
+
this.notify({
|
|
636
|
+
kind: 'remote_import_skipped',
|
|
637
|
+
candidateName: bundle.candidateName,
|
|
638
|
+
sourceNodeId,
|
|
639
|
+
sourceLabel: source,
|
|
640
|
+
peer: fromPeer,
|
|
641
|
+
mode,
|
|
642
|
+
reason: result.reason ?? 'local candidate did not need an update',
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return { ok: true, imported: result.imported, reason: result.reason ?? null };
|
|
490
646
|
}
|
|
491
647
|
async sendToAll(message) {
|
|
492
648
|
await Promise.all(this.peers.map(peer => this.sendToPeer(peer, message)));
|
|
@@ -601,12 +757,41 @@ export class CrossNodeAuthSync {
|
|
|
601
757
|
this.activeLocalLease = null;
|
|
602
758
|
}
|
|
603
759
|
}
|
|
604
|
-
recordError(message) {
|
|
760
|
+
recordError(message, notify = true) {
|
|
605
761
|
this.state.lastError = message;
|
|
606
762
|
this.logger.warn('auth.sync.error', { error: message });
|
|
607
763
|
void this.writeState().catch((error) => {
|
|
608
764
|
this.logger.warn('auth.sync.state_write_failed', { error: formatError(error) });
|
|
609
765
|
});
|
|
766
|
+
if (notify) {
|
|
767
|
+
this.notifyError(message);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
|
|
771
|
+
this.recordError(reason, false);
|
|
772
|
+
this.notify({
|
|
773
|
+
kind: 'remote_import_failed',
|
|
774
|
+
candidateName: bundle.candidateName,
|
|
775
|
+
sourceNodeId,
|
|
776
|
+
sourceLabel,
|
|
777
|
+
peer: fromPeer,
|
|
778
|
+
mode,
|
|
779
|
+
reason,
|
|
780
|
+
});
|
|
781
|
+
return { ok: false, imported: false, reason };
|
|
782
|
+
}
|
|
783
|
+
notify(event) {
|
|
784
|
+
if (!this.callbacks.notify)
|
|
785
|
+
return;
|
|
786
|
+
void this.callbacks.notify(event).catch((error) => {
|
|
787
|
+
this.logger.warn('auth.sync.notify_failed', { error: formatError(error) });
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
notifyError(reason) {
|
|
791
|
+
if (this.lastNotifiedError === reason)
|
|
792
|
+
return;
|
|
793
|
+
this.lastNotifiedError = reason;
|
|
794
|
+
this.notify({ kind: 'sync_error', reason });
|
|
610
795
|
}
|
|
611
796
|
async writeState() {
|
|
612
797
|
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
|
|