@foxden-app/foxclaw 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/auth/cross_node_sync.d.ts +25 -0
- package/dist/auth/cross_node_sync.js +120 -5
- package/dist/auth/mirror.d.ts +2 -0
- package/dist/auth/mirror.js +60 -0
- package/dist/controller/controller.d.ts +3 -1
- package/dist/controller/controller.js +24 -4
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +6 -0
- package/dist/main.js +47 -33
- package/dist/systemd.d.ts +10 -0
- package/dist/systemd.js +28 -0
- package/dist/types.d.ts +9 -0
- package/dist/update.d.ts +17 -0
- package/dist/update.js +85 -5
- package/docs/cross-node-auth-sync.md +6 -0
- package/docs/user-manual.md +2 -2
- package/docs/zh/cross-node-auth-sync.md +6 -0
- package/docs/zh/user-manual.md +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
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.3 - 2026-06-04
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复 0.5.2 中 `/update` 完成回报丢失的问题:Linux 自升级子进程现在通过独立的 user systemd transient service 运行,不再留在 `foxclaw.service` control group 里,因此 `KillMode=control-group` 重启服务时不会提前杀掉 updater。
|
|
9
|
+
- 保留 `KillMode=control-group` 的 app-server 清理能力,同时让升级进程能在服务重启后继续写入完成状态,由新服务启动后的轮询发送 Telegram 成功/失败回报。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Fixed missing `/update` completion reports in 0.5.2: on Linux the self-update worker now runs in a separate user systemd transient service instead of the `foxclaw.service` control group, so `KillMode=control-group` no longer kills the updater during service restart.
|
|
13
|
+
- Kept `KillMode=control-group` app-server cleanup while allowing the updater to write its final status after restart; the newly started service polls that status and sends the Telegram success/failure report.
|
|
14
|
+
|
|
15
|
+
## 0.5.2 - 2026-06-04
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- Linux systemd unit 改为停止整个 control group,避免升级或重启 FoxClaw 后旧的 `codex app-server --listen` 子进程残留。
|
|
19
|
+
- 启动时自动修复 `auth.json -> .auth-sync-validate-*` 临时验证 symlink 残留,恢复到 mirror 状态候选或同目录最近修改且可解析的真实 `auth.json_*` 候选,并清理临时文件。
|
|
20
|
+
- 跨节点 auth 同步把单候选验证/导入失败记录为“候选失败”,不再污染全局 `lastError`;`/status` 和 `/auth sync status` 会分开展示同步系统错误与候选失败。
|
|
21
|
+
- 手动 `/auth` 切换和 `/auth reload` 只做同节点本地镜像恢复,不再主动向跨节点 peer 查询;只有自动 auth 故障恢复才会跨节点 pull。
|
|
22
|
+
- auth 恢复超时通知补充 request id、候选名、peer、等待时长,并标明等待期间可达但本请求超时的 peer。
|
|
23
|
+
|
|
24
|
+
### English
|
|
25
|
+
- Changed the Linux systemd unit to stop the whole service control group so FoxClaw upgrades or restarts no longer leave old `codex app-server --listen` children behind.
|
|
26
|
+
- Added startup recovery for `auth.json -> .auth-sync-validate-*` validation symlink leftovers, restoring to the mirror-status candidate or the newest parseable real `auth.json_*` candidate in the same directory and removing stale temp files.
|
|
27
|
+
- Cross-node auth sync now records per-candidate validation/import failures as candidate failures instead of global `lastError`; `/status` and `/auth sync status` show sync-system errors separately from candidate failures.
|
|
28
|
+
- Manual `/auth` switching and `/auth reload` now recover from same-node local mirrors only and no longer query cross-node peers; cross-node pull is reserved for automatic auth-failure recovery.
|
|
29
|
+
- Auth recovery timeout notifications now include request id, candidate name, peers, wait duration, and whether a peer was reachable during the timed-out request.
|
|
30
|
+
|
|
5
31
|
## 0.5.1 - 2026-06-04
|
|
6
32
|
|
|
7
33
|
### 中文
|
|
@@ -29,8 +29,18 @@ export interface AuthSyncStatus {
|
|
|
29
29
|
lastPullAt: string | null;
|
|
30
30
|
lastPullCandidate: string | null;
|
|
31
31
|
lastError: string | null;
|
|
32
|
+
candidateFailures: AuthSyncCandidateFailure[];
|
|
32
33
|
activeLeaseId: string | null;
|
|
33
34
|
}
|
|
35
|
+
export interface AuthSyncCandidateFailure {
|
|
36
|
+
candidateName: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
sourceNodeId: string | null;
|
|
39
|
+
sourceLabel: string | null;
|
|
40
|
+
peer: string | null;
|
|
41
|
+
mode: AuthSyncRemoteImportMode;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
}
|
|
34
44
|
export interface AuthSyncValidationResult {
|
|
35
45
|
ok: boolean;
|
|
36
46
|
reason?: string | null;
|
|
@@ -99,7 +109,9 @@ export type AuthSyncNotification = {
|
|
|
99
109
|
} | {
|
|
100
110
|
kind: 'recovery_started';
|
|
101
111
|
candidateName: string;
|
|
112
|
+
requestId: string;
|
|
102
113
|
peers: string[];
|
|
114
|
+
timeoutMs: number;
|
|
103
115
|
} | {
|
|
104
116
|
kind: 'recovery_peer_empty';
|
|
105
117
|
candidateName: string;
|
|
@@ -113,8 +125,11 @@ export type AuthSyncNotification = {
|
|
|
113
125
|
} | {
|
|
114
126
|
kind: 'recovery_failed';
|
|
115
127
|
candidateName: string;
|
|
128
|
+
requestId?: string;
|
|
116
129
|
peers: string[];
|
|
117
130
|
reason: string;
|
|
131
|
+
waitMs?: number;
|
|
132
|
+
peerReachability?: AuthSyncPeerReachability[];
|
|
118
133
|
} | {
|
|
119
134
|
kind: 'pull_request_received';
|
|
120
135
|
candidateName: string;
|
|
@@ -141,6 +156,11 @@ export interface AuthSyncImportCallbacks {
|
|
|
141
156
|
isIdle: () => boolean;
|
|
142
157
|
notify?: (event: AuthSyncNotification) => Promise<void>;
|
|
143
158
|
}
|
|
159
|
+
export interface AuthSyncPeerReachability {
|
|
160
|
+
peer: string;
|
|
161
|
+
reachableDuringRequest: boolean;
|
|
162
|
+
lastReceivedAt: string | null;
|
|
163
|
+
}
|
|
144
164
|
export interface AuthSyncTransport {
|
|
145
165
|
send: (peer: string, envelope: string) => Promise<void>;
|
|
146
166
|
}
|
|
@@ -168,6 +188,7 @@ export declare class CrossNodeAuthSync {
|
|
|
168
188
|
private readonly pendingLeases;
|
|
169
189
|
private readonly pendingTests;
|
|
170
190
|
private seenNonces;
|
|
191
|
+
private lastPeerActivityAt;
|
|
171
192
|
private timer;
|
|
172
193
|
private importProcessorActive;
|
|
173
194
|
private activeRemoteLease;
|
|
@@ -216,6 +237,10 @@ export declare class CrossNodeAuthSync {
|
|
|
216
237
|
private expireLeases;
|
|
217
238
|
private recordError;
|
|
218
239
|
private rejectImport;
|
|
240
|
+
private recordCandidateFailure;
|
|
241
|
+
private clearCandidateFailure;
|
|
242
|
+
private notePeerActivity;
|
|
243
|
+
private describePeerReachability;
|
|
219
244
|
private notify;
|
|
220
245
|
private notifyError;
|
|
221
246
|
private writeState;
|
|
@@ -24,6 +24,7 @@ export class CrossNodeAuthSync {
|
|
|
24
24
|
pendingLeases = new Map();
|
|
25
25
|
pendingTests = new Map();
|
|
26
26
|
seenNonces = new Map();
|
|
27
|
+
lastPeerActivityAt = new Map();
|
|
27
28
|
timer = null;
|
|
28
29
|
importProcessorActive = false;
|
|
29
30
|
activeRemoteLease = null;
|
|
@@ -37,6 +38,7 @@ export class CrossNodeAuthSync {
|
|
|
37
38
|
lastPullAt: null,
|
|
38
39
|
lastPullCandidate: null,
|
|
39
40
|
lastError: null,
|
|
41
|
+
lastCandidateFailures: {},
|
|
40
42
|
};
|
|
41
43
|
constructor(config, logger, transport, callbacks) {
|
|
42
44
|
this.config = config;
|
|
@@ -64,6 +66,7 @@ export class CrossNodeAuthSync {
|
|
|
64
66
|
lastPullAt: stored.lastPullAt ?? null,
|
|
65
67
|
lastPullCandidate: stored.lastPullCandidate ?? null,
|
|
66
68
|
lastError: stored.lastError ?? null,
|
|
69
|
+
lastCandidateFailures: normalizeCandidateFailures(stored.lastCandidateFailures ?? {}),
|
|
67
70
|
};
|
|
68
71
|
await fs.mkdir(this.config.tempDir, { recursive: true, mode: 0o700 });
|
|
69
72
|
await this.writeState();
|
|
@@ -103,6 +106,8 @@ export class CrossNodeAuthSync {
|
|
|
103
106
|
lastPullAt: this.state.lastPullAt,
|
|
104
107
|
lastPullCandidate: this.state.lastPullCandidate,
|
|
105
108
|
lastError: this.state.lastError,
|
|
109
|
+
candidateFailures: Object.values(this.state.lastCandidateFailures)
|
|
110
|
+
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
|
|
106
111
|
activeLeaseId: this.activeLocalLease?.leaseId ?? this.activeRemoteLease?.leaseId ?? null,
|
|
107
112
|
};
|
|
108
113
|
}
|
|
@@ -188,30 +193,46 @@ export class CrossNodeAuthSync {
|
|
|
188
193
|
return false;
|
|
189
194
|
const requestId = crypto.randomUUID();
|
|
190
195
|
const peers = [...this.peers];
|
|
191
|
-
|
|
196
|
+
const startedAt = Date.now();
|
|
197
|
+
this.notify({ kind: 'recovery_started', candidateName, requestId, peers, timeoutMs: PULL_TIMEOUT_MS });
|
|
192
198
|
const result = await new Promise((resolve) => {
|
|
193
199
|
const timer = setTimeout(() => {
|
|
194
200
|
const pending = this.pendingPulls.get(requestId);
|
|
195
201
|
if (pending) {
|
|
196
202
|
pending.finished = true;
|
|
197
203
|
this.pendingPulls.delete(requestId);
|
|
204
|
+
const waitMs = Date.now() - pending.startedAt;
|
|
205
|
+
const peerReachability = this.describePeerReachability(pending.peers, pending.startedAt);
|
|
206
|
+
const reason = formatPullTimeoutReason(pending, waitMs, peerReachability);
|
|
207
|
+
this.logger.warn('auth.sync.pull_timeout', {
|
|
208
|
+
requestId,
|
|
209
|
+
candidateName,
|
|
210
|
+
peers: pending.peers,
|
|
211
|
+
waitMs,
|
|
212
|
+
peerReachability,
|
|
213
|
+
});
|
|
198
214
|
this.notify({
|
|
199
215
|
kind: 'recovery_failed',
|
|
200
216
|
candidateName,
|
|
217
|
+
requestId,
|
|
201
218
|
peers,
|
|
202
|
-
reason
|
|
219
|
+
reason,
|
|
220
|
+
waitMs,
|
|
221
|
+
peerReachability,
|
|
203
222
|
});
|
|
204
223
|
resolve(false);
|
|
205
224
|
}
|
|
206
225
|
}, PULL_TIMEOUT_MS);
|
|
207
226
|
timer.unref();
|
|
208
227
|
this.pendingPulls.set(requestId, {
|
|
228
|
+
requestId,
|
|
209
229
|
candidateName,
|
|
210
230
|
peers,
|
|
211
231
|
emptyReplies: new Map(),
|
|
212
232
|
resolve,
|
|
213
233
|
timer,
|
|
214
234
|
finished: false,
|
|
235
|
+
startedAt,
|
|
215
236
|
});
|
|
216
237
|
void this.sendToAll({
|
|
217
238
|
kind: 'pull.request',
|
|
@@ -223,11 +244,15 @@ export class CrossNodeAuthSync {
|
|
|
223
244
|
clearTimeout(timer);
|
|
224
245
|
this.pendingPulls.delete(requestId);
|
|
225
246
|
this.recordError(`pull request failed: ${formatError(error)}`, false);
|
|
247
|
+
const waitMs = Date.now() - startedAt;
|
|
226
248
|
this.notify({
|
|
227
249
|
kind: 'recovery_failed',
|
|
228
250
|
candidateName,
|
|
251
|
+
requestId,
|
|
229
252
|
peers: [...this.peers],
|
|
230
|
-
reason: formatError(error)
|
|
253
|
+
reason: `pull request send failed; requestId=${requestId}; candidate=${candidateName}; peers=${this.peers.join(', ') || 'none'}; waitMs=${waitMs}; error=${formatError(error)}`,
|
|
254
|
+
waitMs,
|
|
255
|
+
peerReachability: this.describePeerReachability(peers, startedAt),
|
|
231
256
|
});
|
|
232
257
|
resolve(false);
|
|
233
258
|
});
|
|
@@ -340,6 +365,7 @@ export class CrossNodeAuthSync {
|
|
|
340
365
|
this.seenNonces.set(nonceKey, Date.now());
|
|
341
366
|
this.state.lastReceivedAt = new Date().toISOString();
|
|
342
367
|
await this.writeState();
|
|
368
|
+
this.notePeerActivity(normalizePeerIdentity(peer));
|
|
343
369
|
await this.handleMessage(opened.message, opened.sender, peer);
|
|
344
370
|
return true;
|
|
345
371
|
}
|
|
@@ -616,6 +642,7 @@ export class CrossNodeAuthSync {
|
|
|
616
642
|
if (!result.ok) {
|
|
617
643
|
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
|
|
618
644
|
}
|
|
645
|
+
const clearedFailure = this.clearCandidateFailure(bundle.candidateName);
|
|
619
646
|
if (result.imported) {
|
|
620
647
|
this.state.lastImportedAt = new Date().toISOString();
|
|
621
648
|
this.state.lastImportCandidate = bundle.candidateName;
|
|
@@ -641,6 +668,9 @@ export class CrossNodeAuthSync {
|
|
|
641
668
|
mode,
|
|
642
669
|
reason: result.reason ?? 'local candidate did not need an update',
|
|
643
670
|
});
|
|
671
|
+
if (clearedFailure) {
|
|
672
|
+
await this.writeState();
|
|
673
|
+
}
|
|
644
674
|
}
|
|
645
675
|
return { ok: true, imported: result.imported, reason: result.reason ?? null };
|
|
646
676
|
}
|
|
@@ -767,8 +797,8 @@ export class CrossNodeAuthSync {
|
|
|
767
797
|
this.notifyError(message);
|
|
768
798
|
}
|
|
769
799
|
}
|
|
770
|
-
rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
|
|
771
|
-
this.
|
|
800
|
+
async rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
|
|
801
|
+
await this.recordCandidateFailure(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason);
|
|
772
802
|
this.notify({
|
|
773
803
|
kind: 'remote_import_failed',
|
|
774
804
|
candidateName: bundle.candidateName,
|
|
@@ -780,6 +810,51 @@ export class CrossNodeAuthSync {
|
|
|
780
810
|
});
|
|
781
811
|
return { ok: false, imported: false, reason };
|
|
782
812
|
}
|
|
813
|
+
async recordCandidateFailure(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
|
|
814
|
+
const candidateName = typeof bundle.candidateName === 'string' && bundle.candidateName.trim()
|
|
815
|
+
? bundle.candidateName
|
|
816
|
+
: 'invalid-bundle';
|
|
817
|
+
this.state.lastCandidateFailures[candidateName] = {
|
|
818
|
+
candidateName,
|
|
819
|
+
reason,
|
|
820
|
+
sourceNodeId,
|
|
821
|
+
sourceLabel,
|
|
822
|
+
peer: fromPeer,
|
|
823
|
+
mode,
|
|
824
|
+
updatedAt: new Date().toISOString(),
|
|
825
|
+
};
|
|
826
|
+
this.state.lastCandidateFailures = pruneCandidateFailures(this.state.lastCandidateFailures);
|
|
827
|
+
this.logger.warn('auth.sync.candidate_failed', {
|
|
828
|
+
candidateName,
|
|
829
|
+
sourceNodeId,
|
|
830
|
+
sourceLabel,
|
|
831
|
+
peer: fromPeer,
|
|
832
|
+
mode,
|
|
833
|
+
reason,
|
|
834
|
+
});
|
|
835
|
+
await this.writeState();
|
|
836
|
+
}
|
|
837
|
+
clearCandidateFailure(candidateName) {
|
|
838
|
+
if (!this.state.lastCandidateFailures[candidateName]) {
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
delete this.state.lastCandidateFailures[candidateName];
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
notePeerActivity(peer) {
|
|
845
|
+
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
846
|
+
this.lastPeerActivityAt.set(matchedPeer, Date.now());
|
|
847
|
+
}
|
|
848
|
+
describePeerReachability(peers, sinceMs) {
|
|
849
|
+
return peers.map((peer) => {
|
|
850
|
+
const lastActivityAt = this.lastPeerActivityAt.get(peer) ?? null;
|
|
851
|
+
return {
|
|
852
|
+
peer,
|
|
853
|
+
reachableDuringRequest: lastActivityAt !== null && lastActivityAt >= sinceMs,
|
|
854
|
+
lastReceivedAt: lastActivityAt === null ? null : new Date(lastActivityAt).toISOString(),
|
|
855
|
+
};
|
|
856
|
+
});
|
|
857
|
+
}
|
|
783
858
|
notify(event) {
|
|
784
859
|
if (!this.callbacks.notify)
|
|
785
860
|
return;
|
|
@@ -886,6 +961,46 @@ function hashAccountId(accountId) {
|
|
|
886
961
|
function sha256(value) {
|
|
887
962
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
888
963
|
}
|
|
964
|
+
function normalizeCandidateFailures(raw) {
|
|
965
|
+
const failures = {};
|
|
966
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
967
|
+
const candidateName = typeof value.candidateName === 'string' && value.candidateName.trim()
|
|
968
|
+
? value.candidateName
|
|
969
|
+
: key;
|
|
970
|
+
const reason = typeof value.reason === 'string' ? value.reason : '';
|
|
971
|
+
const mode = value.mode === 'pull' ? 'pull' : 'push';
|
|
972
|
+
const updatedAt = typeof value.updatedAt === 'string' && Number.isFinite(Date.parse(value.updatedAt))
|
|
973
|
+
? value.updatedAt
|
|
974
|
+
: new Date().toISOString();
|
|
975
|
+
if (!candidateName || !reason) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
failures[candidateName] = {
|
|
979
|
+
candidateName,
|
|
980
|
+
reason,
|
|
981
|
+
sourceNodeId: typeof value.sourceNodeId === 'string' ? value.sourceNodeId : null,
|
|
982
|
+
sourceLabel: typeof value.sourceLabel === 'string' ? value.sourceLabel : null,
|
|
983
|
+
peer: typeof value.peer === 'string' ? value.peer : null,
|
|
984
|
+
mode,
|
|
985
|
+
updatedAt,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
return pruneCandidateFailures(failures);
|
|
989
|
+
}
|
|
990
|
+
function pruneCandidateFailures(failures) {
|
|
991
|
+
return Object.fromEntries(Object.entries(failures)
|
|
992
|
+
.sort(([, left], [, right]) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
|
|
993
|
+
.slice(0, 20));
|
|
994
|
+
}
|
|
995
|
+
function formatPullTimeoutReason(pending, waitMs, peerReachability) {
|
|
996
|
+
const reachable = peerReachability
|
|
997
|
+
.filter((entry) => entry.reachableDuringRequest)
|
|
998
|
+
.map((entry) => entry.peer);
|
|
999
|
+
const reachableSuffix = reachable.length > 0
|
|
1000
|
+
? `; peer reachable but this request timed out: ${reachable.join(', ')}`
|
|
1001
|
+
: '';
|
|
1002
|
+
return `timed out waiting for ${pending.peers.length} auth sync peer response(s); requestId=${pending.requestId}; candidate=${pending.candidateName}; peers=${pending.peers.join(', ') || 'none'}; waitMs=${waitMs}${reachableSuffix}`;
|
|
1003
|
+
}
|
|
889
1004
|
function normalizeConfiguredPeer(peer) {
|
|
890
1005
|
const value = peer.trim();
|
|
891
1006
|
if (!value)
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -93,6 +93,8 @@ export declare class AuthCandidateMirror {
|
|
|
93
93
|
private resolveCanonicalCurrentCandidate;
|
|
94
94
|
private findNewestRecord;
|
|
95
95
|
private runtimeLabel;
|
|
96
|
+
private recoverInterruptedValidationSymlink;
|
|
97
|
+
private selectValidationRecoveryCandidate;
|
|
96
98
|
}
|
|
97
99
|
export declare function isAuthCandidateName(name: string): boolean;
|
|
98
100
|
export declare function readChatGptAuthRecord(filePath: string): Promise<ChatGptAuthRecord | null>;
|
package/dist/auth/mirror.js
CHANGED
|
@@ -23,7 +23,12 @@ export class AuthCandidateMirror {
|
|
|
23
23
|
async initialize() {
|
|
24
24
|
this.lastStatus = await readMirrorStatus(this.statusPath);
|
|
25
25
|
await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
|
|
26
|
+
await this.recoverInterruptedValidationSymlink(this.canonicalDir);
|
|
26
27
|
await this.ensureCanonicalDefaultCandidate();
|
|
28
|
+
for (const runtime of this.runtimes) {
|
|
29
|
+
await fs.mkdir(runtime.authDir, { recursive: true, mode: 0o700 });
|
|
30
|
+
await this.recoverInterruptedValidationSymlink(runtime.authDir);
|
|
31
|
+
}
|
|
27
32
|
const candidateNames = await this.collectCandidateNames();
|
|
28
33
|
for (const name of candidateNames) {
|
|
29
34
|
await this.reconcileCandidateAtStartup(name);
|
|
@@ -40,6 +45,7 @@ export class AuthCandidateMirror {
|
|
|
40
45
|
await atomicCopy(path.join(this.canonicalDir, name), destination);
|
|
41
46
|
}
|
|
42
47
|
}
|
|
48
|
+
await this.recoverInterruptedValidationSymlink(runtime.authDir);
|
|
43
49
|
if (defaultCandidate && !(await exists(path.join(runtime.authDir, 'auth.json')))) {
|
|
44
50
|
await pointAuthSymlink(runtime.authDir, defaultCandidate);
|
|
45
51
|
}
|
|
@@ -402,6 +408,54 @@ export class AuthCandidateMirror {
|
|
|
402
408
|
const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
|
|
403
409
|
return runtime?.label ?? runtimeId;
|
|
404
410
|
}
|
|
411
|
+
async recoverInterruptedValidationSymlink(authDir) {
|
|
412
|
+
const authPath = path.join(authDir, 'auth.json');
|
|
413
|
+
const oldTarget = await fs.readlink(authPath).catch(() => null);
|
|
414
|
+
if (!oldTarget)
|
|
415
|
+
return;
|
|
416
|
+
const oldTargetPath = path.resolve(authDir, oldTarget);
|
|
417
|
+
if (!path.basename(oldTargetPath).startsWith('.auth-sync-validate-'))
|
|
418
|
+
return;
|
|
419
|
+
const candidateName = await this.selectValidationRecoveryCandidate(authDir);
|
|
420
|
+
if (!candidateName) {
|
|
421
|
+
this.logger.warn('codex.auth_temp_symlink_recovery_failed', {
|
|
422
|
+
authDir,
|
|
423
|
+
oldTarget: oldTargetPath,
|
|
424
|
+
reason: 'no parseable auth candidate found',
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
await pointAuthSymlink(authDir, candidateName);
|
|
429
|
+
await removeValidationTempFiles(authDir);
|
|
430
|
+
const newTarget = path.join(authDir, candidateName);
|
|
431
|
+
this.logger.warn('codex.auth_temp_symlink_recovered', {
|
|
432
|
+
authDir,
|
|
433
|
+
oldTarget: oldTargetPath,
|
|
434
|
+
newTarget,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
async selectValidationRecoveryCandidate(authDir) {
|
|
438
|
+
const statusCandidate = this.lastStatus?.candidateName ?? null;
|
|
439
|
+
if (statusCandidate && isAuthCandidateName(statusCandidate)) {
|
|
440
|
+
const record = await readChatGptAuthRecord(path.join(authDir, statusCandidate));
|
|
441
|
+
if (record) {
|
|
442
|
+
return statusCandidate;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const candidates = [];
|
|
446
|
+
for (const name of await listAuthCandidateNames(authDir)) {
|
|
447
|
+
const candidatePath = path.join(authDir, name);
|
|
448
|
+
const [record, stat] = await Promise.all([
|
|
449
|
+
readChatGptAuthRecord(candidatePath),
|
|
450
|
+
fs.stat(candidatePath).catch(() => null),
|
|
451
|
+
]);
|
|
452
|
+
if (record && stat?.isFile()) {
|
|
453
|
+
candidates.push({ name, mtimeMs: stat.mtimeMs });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
candidates.sort((left, right) => right.mtimeMs - left.mtimeMs || left.name.localeCompare(right.name));
|
|
457
|
+
return candidates[0]?.name ?? null;
|
|
458
|
+
}
|
|
405
459
|
}
|
|
406
460
|
export function isAuthCandidateName(name) {
|
|
407
461
|
return name !== 'auth.json'
|
|
@@ -461,6 +515,12 @@ async function pointAuthSymlink(dir, candidateName) {
|
|
|
461
515
|
await fs.symlink(path.join(dir, candidateName), temporary);
|
|
462
516
|
await fs.rename(temporary, path.join(dir, 'auth.json'));
|
|
463
517
|
}
|
|
518
|
+
async function removeValidationTempFiles(dir) {
|
|
519
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
520
|
+
await Promise.all(entries
|
|
521
|
+
.filter((entry) => entry.isFile() && entry.name.startsWith('.auth-sync-validate-'))
|
|
522
|
+
.map((entry) => fs.rm(path.join(dir, entry.name), { force: true }).catch(() => undefined)));
|
|
523
|
+
}
|
|
464
524
|
async function resolveFinalPath(sourcePath) {
|
|
465
525
|
try {
|
|
466
526
|
return await fs.realpath(sourcePath);
|
|
@@ -9,7 +9,9 @@ import type { SelfUpdateRuntime, SelfUpdateStatus } from '../update.js';
|
|
|
9
9
|
export interface CoreCoordinator {
|
|
10
10
|
canSelfUpdate?: () => boolean;
|
|
11
11
|
authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
|
|
12
|
-
recoverAuthCandidate?: (runtimeId: string, candidateName: string
|
|
12
|
+
recoverAuthCandidate?: (runtimeId: string, candidateName: string, options?: {
|
|
13
|
+
crossNode?: boolean;
|
|
14
|
+
}) => Promise<boolean>;
|
|
13
15
|
acquireAuthRefreshLease?: (reason: string) => Promise<{
|
|
14
16
|
ok: boolean;
|
|
15
17
|
leaseId: string | null;
|
|
@@ -437,6 +437,14 @@ export class BridgeSessionCore {
|
|
|
437
437
|
if (serviceStatus.authSync.lastError) {
|
|
438
438
|
lines.push(t(locale, 'status_auth_sync_error', { value: serviceStatus.authSync.lastError }));
|
|
439
439
|
}
|
|
440
|
+
if (serviceStatus.authSync.candidateFailures?.length) {
|
|
441
|
+
lines.push(t(locale, 'status_auth_sync_candidate_failures', {
|
|
442
|
+
value: serviceStatus.authSync.candidateFailures
|
|
443
|
+
.slice(0, 3)
|
|
444
|
+
.map((failure) => `${failure.candidateName}: ${failure.reason}`)
|
|
445
|
+
.join('; '),
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
440
448
|
}
|
|
441
449
|
if (serviceStatus.lastUpdate) {
|
|
442
450
|
lines.push(t(locale, 'status_last_update', {
|
|
@@ -3846,7 +3854,7 @@ export class BridgeSessionCore {
|
|
|
3846
3854
|
await this.sendMessage(scopeId, t(locale, 'auth_reload_restarting'));
|
|
3847
3855
|
const currentCandidate = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
|
|
3848
3856
|
const recovered = currentCandidate
|
|
3849
|
-
? await this.recoverCodexAuthCandidate(currentCandidate.name)
|
|
3857
|
+
? await this.recoverCodexAuthCandidate(currentCandidate.name, { crossNode: false })
|
|
3850
3858
|
: false;
|
|
3851
3859
|
this.pendingTurnErrors.clear();
|
|
3852
3860
|
this.attachedThreads.clear();
|
|
@@ -4846,7 +4854,7 @@ export class BridgeSessionCore {
|
|
|
4846
4854
|
};
|
|
4847
4855
|
}
|
|
4848
4856
|
async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic, sendResult = true) {
|
|
4849
|
-
const recovered = await this.recoverCodexAuthCandidate(candidate.name);
|
|
4857
|
+
const recovered = await this.recoverCodexAuthCandidate(candidate.name, { crossNode: automatic });
|
|
4850
4858
|
const result = await switchCodexAuth(candidate.path, this.resolveAuthDir());
|
|
4851
4859
|
this.authRotationFailedTargets.delete(candidate.path);
|
|
4852
4860
|
this.pendingTurnErrors.clear();
|
|
@@ -4863,12 +4871,12 @@ export class BridgeSessionCore {
|
|
|
4863
4871
|
lines.push(...await this.buildCodexUsageStatusLines(locale));
|
|
4864
4872
|
await this.sendMessage(scopeId, lines.join('\n'));
|
|
4865
4873
|
}
|
|
4866
|
-
async recoverCodexAuthCandidate(candidateName) {
|
|
4874
|
+
async recoverCodexAuthCandidate(candidateName, options = { crossNode: true }) {
|
|
4867
4875
|
if (!this.coordinator?.recoverAuthCandidate) {
|
|
4868
4876
|
return false;
|
|
4869
4877
|
}
|
|
4870
4878
|
try {
|
|
4871
|
-
return await this.coordinator.recoverAuthCandidate(this.authRuntimeId(), candidateName);
|
|
4879
|
+
return await this.coordinator.recoverAuthCandidate(this.authRuntimeId(), candidateName, options);
|
|
4872
4880
|
}
|
|
4873
4881
|
catch (error) {
|
|
4874
4882
|
this.logger.warn('codex.auth_candidate_recovery_failed', {
|
|
@@ -8144,6 +8152,18 @@ function formatAuthSyncStatus(locale, status) {
|
|
|
8144
8152
|
if (status.lastError) {
|
|
8145
8153
|
lines.push(t(locale, 'auth_sync_status_error', { value: status.lastError }));
|
|
8146
8154
|
}
|
|
8155
|
+
if (status.candidateFailures?.length) {
|
|
8156
|
+
lines.push(t(locale, 'auth_sync_status_candidate_failures'));
|
|
8157
|
+
for (const failure of status.candidateFailures.slice(0, 5)) {
|
|
8158
|
+
lines.push(t(locale, 'auth_sync_status_candidate_failure', {
|
|
8159
|
+
candidate: failure.candidateName,
|
|
8160
|
+
reason: failure.reason,
|
|
8161
|
+
source: failure.sourceLabel ?? failure.sourceNodeId ?? t(locale, 'unknown'),
|
|
8162
|
+
peer: failure.peer ?? t(locale, 'unknown'),
|
|
8163
|
+
time: failure.updatedAt,
|
|
8164
|
+
}));
|
|
8165
|
+
}
|
|
8166
|
+
}
|
|
8147
8167
|
return lines.join('\n');
|
|
8148
8168
|
}
|
|
8149
8169
|
function normalizeHelpUsageKey(name) {
|
package/dist/i18n.d.ts
CHANGED
|
@@ -127,6 +127,7 @@ declare const MESSAGES: {
|
|
|
127
127
|
readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
|
|
128
128
|
readonly status_auth_sync: "Cross-node auth sync: node {node}, contact {contact}, peers {peers}, pending imports {pending}";
|
|
129
129
|
readonly status_auth_sync_error: "Cross-node auth sync error: {value}";
|
|
130
|
+
readonly status_auth_sync_candidate_failures: "Auth sync candidate failures: {value}";
|
|
130
131
|
readonly status_last_update_none: "Last service update: none recorded";
|
|
131
132
|
readonly status_last_update: "Last service update: {from} -> {to} at {time}";
|
|
132
133
|
readonly status_last_codex_update: "Last Codex update: {value}";
|
|
@@ -218,6 +219,8 @@ declare const MESSAGES: {
|
|
|
218
219
|
readonly auth_sync_status_pull: "Last pull: {value}";
|
|
219
220
|
readonly auth_sync_status_lease: "Active refresh lease: {value}";
|
|
220
221
|
readonly auth_sync_status_error: "Last error: {value}";
|
|
222
|
+
readonly auth_sync_status_candidate_failures: "Candidate failures:";
|
|
223
|
+
readonly auth_sync_status_candidate_failure: "- {candidate}: {reason} (source {source}, peer {peer}, at {time})";
|
|
221
224
|
readonly button_login_device: "🔑 Login";
|
|
222
225
|
readonly button_auth_reload: "🔄 Reload auth";
|
|
223
226
|
readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
|
|
@@ -754,6 +757,7 @@ declare const MESSAGES: {
|
|
|
754
757
|
readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
|
|
755
758
|
readonly status_auth_sync: "跨节点 auth 同步:节点 {node},联系人 {contact},peer {peers},待导入 {pending}";
|
|
756
759
|
readonly status_auth_sync_error: "跨节点 auth 同步错误:{value}";
|
|
760
|
+
readonly status_auth_sync_candidate_failures: "auth 同步候选失败:{value}";
|
|
757
761
|
readonly status_last_update_none: "最近服务升级:暂无记录";
|
|
758
762
|
readonly status_last_update: "最近服务升级:{from} -> {to}({time})";
|
|
759
763
|
readonly status_last_codex_update: "最近 Codex 升级:{value}";
|
|
@@ -845,6 +849,8 @@ declare const MESSAGES: {
|
|
|
845
849
|
readonly auth_sync_status_pull: "最近拉取:{value}";
|
|
846
850
|
readonly auth_sync_status_lease: "当前刷新锁:{value}";
|
|
847
851
|
readonly auth_sync_status_error: "最近错误:{value}";
|
|
852
|
+
readonly auth_sync_status_candidate_failures: "候选失败:";
|
|
853
|
+
readonly auth_sync_status_candidate_failure: "- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})";
|
|
848
854
|
readonly button_login_device: "🔑 设备登录";
|
|
849
855
|
readonly button_auth_reload: "🔄 重载 auth";
|
|
850
856
|
readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
|
package/dist/i18n.js
CHANGED
|
@@ -125,6 +125,7 @@ const MESSAGES = {
|
|
|
125
125
|
status_auth_mirror_synced: 'Last auth mirror: {candidate} from {source} at {time}',
|
|
126
126
|
status_auth_sync: 'Cross-node auth sync: node {node}, contact {contact}, peers {peers}, pending imports {pending}',
|
|
127
127
|
status_auth_sync_error: 'Cross-node auth sync error: {value}',
|
|
128
|
+
status_auth_sync_candidate_failures: 'Auth sync candidate failures: {value}',
|
|
128
129
|
status_last_update_none: 'Last service update: none recorded',
|
|
129
130
|
status_last_update: 'Last service update: {from} -> {to} at {time}',
|
|
130
131
|
status_last_codex_update: 'Last Codex update: {value}',
|
|
@@ -216,6 +217,8 @@ const MESSAGES = {
|
|
|
216
217
|
auth_sync_status_pull: 'Last pull: {value}',
|
|
217
218
|
auth_sync_status_lease: 'Active refresh lease: {value}',
|
|
218
219
|
auth_sync_status_error: 'Last error: {value}',
|
|
220
|
+
auth_sync_status_candidate_failures: 'Candidate failures:',
|
|
221
|
+
auth_sync_status_candidate_failure: '- {candidate}: {reason} (source {source}, peer {peer}, at {time})',
|
|
219
222
|
button_login_device: '🔑 Login',
|
|
220
223
|
button_auth_reload: '🔄 Reload auth',
|
|
221
224
|
button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
|
|
@@ -752,6 +755,7 @@ const MESSAGES = {
|
|
|
752
755
|
status_auth_mirror_synced: '最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步',
|
|
753
756
|
status_auth_sync: '跨节点 auth 同步:节点 {node},联系人 {contact},peer {peers},待导入 {pending}',
|
|
754
757
|
status_auth_sync_error: '跨节点 auth 同步错误:{value}',
|
|
758
|
+
status_auth_sync_candidate_failures: 'auth 同步候选失败:{value}',
|
|
755
759
|
status_last_update_none: '最近服务升级:暂无记录',
|
|
756
760
|
status_last_update: '最近服务升级:{from} -> {to}({time})',
|
|
757
761
|
status_last_codex_update: '最近 Codex 升级:{value}',
|
|
@@ -843,6 +847,8 @@ const MESSAGES = {
|
|
|
843
847
|
auth_sync_status_pull: '最近拉取:{value}',
|
|
844
848
|
auth_sync_status_lease: '当前刷新锁:{value}',
|
|
845
849
|
auth_sync_status_error: '最近错误:{value}',
|
|
850
|
+
auth_sync_status_candidate_failures: '候选失败:',
|
|
851
|
+
auth_sync_status_candidate_failure: '- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})',
|
|
846
852
|
button_login_device: '🔑 设备登录',
|
|
847
853
|
button_auth_reload: '🔄 重载 auth',
|
|
848
854
|
button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
|
package/dist/main.js
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { APP_HOME, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
|
|
10
10
|
import { acquireProcessLock, LockHeldError } from './lock.js';
|
|
11
11
|
import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
|
|
12
|
-
import { refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
|
|
12
|
+
import { buildFoxclawSystemdUnitText, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
|
|
13
13
|
import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
|
|
14
14
|
const rawCommand = process.argv[2];
|
|
15
15
|
const command = rawCommand || 'serve';
|
|
@@ -288,10 +288,12 @@ async function runServeCli() {
|
|
|
288
288
|
canSelfUpdate: () => authSyncLocalIdle()
|
|
289
289
|
&& (!authSync || authSync.isIdle()),
|
|
290
290
|
authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
|
|
291
|
-
recoverAuthCandidate: async (runtimeId, candidateName) => {
|
|
291
|
+
recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
|
|
292
292
|
const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
|
|
293
293
|
if (local)
|
|
294
294
|
return true;
|
|
295
|
+
if (options.crossNode === false)
|
|
296
|
+
return false;
|
|
295
297
|
const current = await mirror.readRuntimeCandidate(runtimeId, candidateName)
|
|
296
298
|
?? await mirror.readNewestCandidate(candidateName);
|
|
297
299
|
return await authSync?.requestRecovery(candidateName, {
|
|
@@ -442,10 +444,12 @@ async function runServeCli() {
|
|
|
442
444
|
canSelfUpdate: () => singleAuthSyncLocalIdle()
|
|
443
445
|
&& (!singleAuthSync || singleAuthSync.isIdle()),
|
|
444
446
|
authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
|
|
445
|
-
recoverAuthCandidate: async (runtimeId, candidateName) => {
|
|
447
|
+
recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
|
|
446
448
|
const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
|
|
447
449
|
if (local)
|
|
448
450
|
return true;
|
|
451
|
+
if (options.crossNode === false)
|
|
452
|
+
return false;
|
|
449
453
|
const current = await singleMirror?.readRuntimeCandidate(runtimeId, candidateName)
|
|
450
454
|
?? await singleMirror?.readNewestCandidate(candidateName)
|
|
451
455
|
?? null;
|
|
@@ -612,13 +616,21 @@ function formatAuthSyncNotification(locale, event) {
|
|
|
612
616
|
case 'remote_import_failed':
|
|
613
617
|
return `${event.mode === 'pull' ? '跨节点拉取导入失败' : '跨节点 auth 导入失败'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}\n需要注意:如果其他候选也无法恢复,请人工介入重新登录或刷新这个 auth。`;
|
|
614
618
|
case 'recovery_started':
|
|
615
|
-
return `auth 恢复开始:${event.candidateName}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers}`;
|
|
619
|
+
return `auth 恢复开始:${event.candidateName}\nRequest:${event.requestId}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers},最长等待 ${event.timeoutMs}ms`;
|
|
616
620
|
case 'recovery_peer_empty':
|
|
617
621
|
return `auth 恢复收到 peer 回应但没有可用副本:${event.candidateName}\nPeer:${event.peer}\n原因:${event.reason}`;
|
|
618
622
|
case 'recovery_peer_bundle_received':
|
|
619
623
|
return `auth 恢复收到 peer 候选:${event.candidateName}\nPeer:${event.peer},来源节点:${event.sourceNodeId}\n处理:正在验证 usage 后导入。`;
|
|
620
624
|
case 'recovery_failed':
|
|
621
|
-
return
|
|
625
|
+
return [
|
|
626
|
+
`auth 恢复已穷尽:${event.candidateName}`,
|
|
627
|
+
event.requestId ? `Request:${event.requestId}` : null,
|
|
628
|
+
`已查询 peer:${peers}`,
|
|
629
|
+
event.waitMs !== undefined ? `等待:${event.waitMs}ms` : null,
|
|
630
|
+
formatPeerReachability(event.peerReachability, 'zh'),
|
|
631
|
+
`原因:${event.reason}`,
|
|
632
|
+
'请人工介入:使用 /auth add <name> 或设备登录重新生成可用 auth。',
|
|
633
|
+
].filter(Boolean).join('\n');
|
|
622
634
|
case 'pull_request_received':
|
|
623
635
|
return `收到 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer},请求节点:${event.requesterNodeId}`;
|
|
624
636
|
case 'pull_response_sent':
|
|
@@ -653,13 +665,21 @@ function formatAuthSyncNotification(locale, event) {
|
|
|
653
665
|
case 'remote_import_failed':
|
|
654
666
|
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
667
|
case 'recovery_started':
|
|
656
|
-
return `Auth recovery started: ${event.candidateName}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}`;
|
|
668
|
+
return `Auth recovery started: ${event.candidateName}\nRequest: ${event.requestId}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}; timeout ${event.timeoutMs}ms`;
|
|
657
669
|
case 'recovery_peer_empty':
|
|
658
670
|
return `Auth recovery peer replied without usable auth: ${event.candidateName}\nPeer: ${event.peer}\nReason: ${event.reason}`;
|
|
659
671
|
case 'recovery_peer_bundle_received':
|
|
660
672
|
return `Auth recovery peer returned a candidate: ${event.candidateName}\nPeer: ${event.peer}, source node: ${event.sourceNodeId}\nAction: validating usage before import.`;
|
|
661
673
|
case 'recovery_failed':
|
|
662
|
-
return
|
|
674
|
+
return [
|
|
675
|
+
`Auth recovery exhausted: ${event.candidateName}`,
|
|
676
|
+
event.requestId ? `Request: ${event.requestId}` : null,
|
|
677
|
+
`Peers checked: ${peers}`,
|
|
678
|
+
event.waitMs !== undefined ? `Wait: ${event.waitMs}ms` : null,
|
|
679
|
+
formatPeerReachability(event.peerReachability, 'en'),
|
|
680
|
+
`Reason: ${event.reason}`,
|
|
681
|
+
'Manual action: use /auth add <name> or device login to create a usable auth.',
|
|
682
|
+
].filter(Boolean).join('\n');
|
|
663
683
|
case 'pull_request_received':
|
|
664
684
|
return `Received peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}, requester node: ${event.requesterNodeId}`;
|
|
665
685
|
case 'pull_response_sent':
|
|
@@ -671,6 +691,17 @@ function formatAuthSyncNotification(locale, event) {
|
|
|
671
691
|
function formatPeerList(peers, locale) {
|
|
672
692
|
return peers.length > 0 ? peers.join(', ') : (locale === 'zh' ? '无' : 'none');
|
|
673
693
|
}
|
|
694
|
+
function formatPeerReachability(reachability, locale) {
|
|
695
|
+
const reachable = (reachability ?? []).filter((entry) => entry.reachableDuringRequest);
|
|
696
|
+
if (reachable.length === 0)
|
|
697
|
+
return null;
|
|
698
|
+
const details = reachable
|
|
699
|
+
.map((entry) => `${entry.peer}${entry.lastReceivedAt ? ` @ ${entry.lastReceivedAt}` : ''}`)
|
|
700
|
+
.join(', ');
|
|
701
|
+
return locale === 'zh'
|
|
702
|
+
? `注意:这些 peer 在本次等待期间有其他 auth sync 消息,说明 peer 可达但这个请求超时:${details}`
|
|
703
|
+
: `Note: these peers sent other auth sync messages during this wait, so the peer was reachable but this request timed out: ${details}`;
|
|
704
|
+
}
|
|
674
705
|
function formatSource(sourceLabel, sourceNodeId) {
|
|
675
706
|
return sourceLabel === sourceNodeId ? sourceNodeId : `${sourceLabel} / ${sourceNodeId}`;
|
|
676
707
|
}
|
|
@@ -1168,32 +1199,15 @@ function installSystemd() {
|
|
|
1168
1199
|
fs.mkdirSync(configDir, { recursive: true });
|
|
1169
1200
|
fs.mkdirSync(path.join(APP_HOME, 'logs'), { recursive: true });
|
|
1170
1201
|
const escapedEntryPoint = systemdEscape(entryPoint);
|
|
1171
|
-
fs.writeFileSync(unitPath,
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
Type=simple
|
|
1181
|
-
WorkingDirectory=${systemdEscape(configDir)}
|
|
1182
|
-
EnvironmentFile=-${systemdEscape(envPath)}
|
|
1183
|
-
Environment=HOME=${systemdEscape(process.env.HOME || '')}
|
|
1184
|
-
Environment=USER=${systemdEscape(process.env.USER || '')}
|
|
1185
|
-
Environment=LOGNAME=${systemdEscape(process.env.LOGNAME || process.env.USER || '')}
|
|
1186
|
-
Environment=PATH=${systemdEscape(pathValue)}
|
|
1187
|
-
Environment=FOXCLAW_ENV=${systemdEscape(envPath)}
|
|
1188
|
-
ExecStart=${execStart}
|
|
1189
|
-
Restart=always
|
|
1190
|
-
RestartSec=10
|
|
1191
|
-
TimeoutStopSec=45
|
|
1192
|
-
KillMode=process
|
|
1193
|
-
|
|
1194
|
-
[Install]
|
|
1195
|
-
WantedBy=default.target
|
|
1196
|
-
`);
|
|
1202
|
+
fs.writeFileSync(unitPath, buildFoxclawSystemdUnitText({
|
|
1203
|
+
workingDirectory: systemdEscape(configDir),
|
|
1204
|
+
envPath: systemdEscape(envPath),
|
|
1205
|
+
home: systemdEscape(process.env.HOME || ''),
|
|
1206
|
+
user: systemdEscape(process.env.USER || ''),
|
|
1207
|
+
logname: systemdEscape(process.env.LOGNAME || process.env.USER || ''),
|
|
1208
|
+
pathValue: systemdEscape(pathValue),
|
|
1209
|
+
execStart,
|
|
1210
|
+
}));
|
|
1197
1211
|
if (proxychainsConf) {
|
|
1198
1212
|
console.log(`[OK] systemd proxychains enabled: ${proxychainsConf}`);
|
|
1199
1213
|
}
|
package/dist/systemd.d.ts
CHANGED
|
@@ -2,6 +2,16 @@ export interface SystemdDropInUpdate {
|
|
|
2
2
|
path: string;
|
|
3
3
|
replacements: number;
|
|
4
4
|
}
|
|
5
|
+
export interface FoxclawSystemdUnitTextOptions {
|
|
6
|
+
workingDirectory: string;
|
|
7
|
+
envPath: string;
|
|
8
|
+
home: string;
|
|
9
|
+
user: string;
|
|
10
|
+
logname: string;
|
|
11
|
+
pathValue: string;
|
|
12
|
+
execStart: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildFoxclawSystemdUnitText(options: FoxclawSystemdUnitTextOptions): string;
|
|
5
15
|
export declare function refreshFoxclawExecStartDropIns(userSystemdDir: string, unitName: string, escapedEntryPoint: string): SystemdDropInUpdate[];
|
|
6
16
|
export declare function removeFoxclawExecStartDropIns(userSystemdDir: string, unitName: string): SystemdDropInUpdate[];
|
|
7
17
|
export declare function refreshFoxclawExecStartText(text: string, escapedEntryPoint: string): {
|
package/dist/systemd.js
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
const FOXCLAW_MAIN_PATH_RE = /\S*(?:\.pnpm\/@foxden-app\+foxclaw@[^/\s]+\/node_modules\/@foxden-app\/foxclaw|node_modules\/@foxden-app\/foxclaw)\/dist\/main\.js/g;
|
|
4
|
+
export function buildFoxclawSystemdUnitText(options) {
|
|
5
|
+
return `[Unit]
|
|
6
|
+
Description=FoxClaw local Codex execution bridge
|
|
7
|
+
Documentation=https://github.com/foxden-app/foxclaw
|
|
8
|
+
After=network-online.target
|
|
9
|
+
Wants=network-online.target
|
|
10
|
+
StartLimitIntervalSec=300
|
|
11
|
+
StartLimitBurst=5
|
|
12
|
+
|
|
13
|
+
[Service]
|
|
14
|
+
Type=simple
|
|
15
|
+
WorkingDirectory=${options.workingDirectory}
|
|
16
|
+
EnvironmentFile=-${options.envPath}
|
|
17
|
+
Environment=HOME=${options.home}
|
|
18
|
+
Environment=USER=${options.user}
|
|
19
|
+
Environment=LOGNAME=${options.logname}
|
|
20
|
+
Environment=PATH=${options.pathValue}
|
|
21
|
+
Environment=FOXCLAW_ENV=${options.envPath}
|
|
22
|
+
ExecStart=${options.execStart}
|
|
23
|
+
Restart=always
|
|
24
|
+
RestartSec=10
|
|
25
|
+
TimeoutStopSec=45
|
|
26
|
+
KillMode=control-group
|
|
27
|
+
|
|
28
|
+
[Install]
|
|
29
|
+
WantedBy=default.target
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
4
32
|
export function refreshFoxclawExecStartDropIns(userSystemdDir, unitName, escapedEntryPoint) {
|
|
5
33
|
const dropInDir = path.join(userSystemdDir, `${unitName}.d`);
|
|
6
34
|
let names;
|
package/dist/types.d.ts
CHANGED
|
@@ -373,6 +373,15 @@ export interface RuntimeStatus {
|
|
|
373
373
|
lastPullAt: string | null;
|
|
374
374
|
lastPullCandidate: string | null;
|
|
375
375
|
lastError: string | null;
|
|
376
|
+
candidateFailures?: Array<{
|
|
377
|
+
candidateName: string;
|
|
378
|
+
reason: string;
|
|
379
|
+
sourceNodeId: string | null;
|
|
380
|
+
sourceLabel: string | null;
|
|
381
|
+
peer: string | null;
|
|
382
|
+
mode: 'push' | 'pull';
|
|
383
|
+
updatedAt: string;
|
|
384
|
+
}>;
|
|
376
385
|
activeLeaseId: string | null;
|
|
377
386
|
} | null;
|
|
378
387
|
lastUpdate?: {
|
package/dist/update.d.ts
CHANGED
|
@@ -40,6 +40,12 @@ interface PerformSelfUpdateOptions {
|
|
|
40
40
|
codexCliBin?: string;
|
|
41
41
|
env?: NodeJS.ProcessEnv;
|
|
42
42
|
}
|
|
43
|
+
export interface SelfUpdateLaunchCommand {
|
|
44
|
+
command: string;
|
|
45
|
+
args: string[];
|
|
46
|
+
env: NodeJS.ProcessEnv;
|
|
47
|
+
viaSystemdRun: boolean;
|
|
48
|
+
}
|
|
43
49
|
export interface SelfUpdateOutcome {
|
|
44
50
|
ok: boolean;
|
|
45
51
|
fromVersion: string;
|
|
@@ -53,5 +59,16 @@ export declare function resolveCodexUpdateInstaller(codexCliBin: string, nodePat
|
|
|
53
59
|
export declare function readSelfUpdateStatus(statusFile: string): SelfUpdateStatus | null;
|
|
54
60
|
export declare function writeSelfUpdateStatus(statusFile: string, status: SelfUpdateStatus): void;
|
|
55
61
|
export declare function createSelfUpdateRuntime(options: CreateSelfUpdateRuntimeOptions): SelfUpdateRuntime;
|
|
62
|
+
export declare function buildSelfUpdateLaunchCommand(options: {
|
|
63
|
+
entryPoint: string;
|
|
64
|
+
nodePath: string;
|
|
65
|
+
statusFile: string;
|
|
66
|
+
logPath: string;
|
|
67
|
+
codexCliBin?: string;
|
|
68
|
+
env?: NodeJS.ProcessEnv;
|
|
69
|
+
platform?: NodeJS.Platform;
|
|
70
|
+
systemdRunPath?: string | null;
|
|
71
|
+
unitName?: string;
|
|
72
|
+
}): SelfUpdateLaunchCommand;
|
|
56
73
|
export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
|
|
57
74
|
export {};
|
package/dist/update.js
CHANGED
|
@@ -171,12 +171,33 @@ export function createSelfUpdateRuntime(options) {
|
|
|
171
171
|
fs.mkdirSync(path.dirname(options.logPath), { recursive: true });
|
|
172
172
|
const logFd = fs.openSync(options.logPath, 'a', 0o600);
|
|
173
173
|
try {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
174
|
+
const launch = buildSelfUpdateLaunchCommand({
|
|
175
|
+
entryPoint: options.entryPoint,
|
|
176
|
+
nodePath: options.nodePath,
|
|
177
|
+
statusFile,
|
|
178
|
+
logPath: options.logPath,
|
|
179
|
+
...(options.codexCliBin ? { codexCliBin: options.codexCliBin } : {}),
|
|
178
180
|
});
|
|
179
|
-
|
|
181
|
+
if (launch.viaSystemdRun) {
|
|
182
|
+
const result = spawnSync(launch.command, launch.args, {
|
|
183
|
+
stdio: ['ignore', logFd, logFd],
|
|
184
|
+
env: launch.env,
|
|
185
|
+
});
|
|
186
|
+
if (result.error) {
|
|
187
|
+
throw result.error;
|
|
188
|
+
}
|
|
189
|
+
if (result.status !== 0) {
|
|
190
|
+
throw new Error(`${launch.command} ${launch.args.join(' ')} exited with status ${result.status ?? 'unknown'}.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const child = spawn(launch.command, launch.args, {
|
|
195
|
+
detached: true,
|
|
196
|
+
stdio: ['ignore', logFd, logFd],
|
|
197
|
+
env: launch.env,
|
|
198
|
+
});
|
|
199
|
+
child.unref();
|
|
200
|
+
}
|
|
180
201
|
}
|
|
181
202
|
catch (error) {
|
|
182
203
|
writeSelfUpdateStatus(statusFile, {
|
|
@@ -205,6 +226,40 @@ export function createSelfUpdateRuntime(options) {
|
|
|
205
226
|
},
|
|
206
227
|
};
|
|
207
228
|
}
|
|
229
|
+
export function buildSelfUpdateLaunchCommand(options) {
|
|
230
|
+
const env = options.codexCliBin
|
|
231
|
+
? { ...(options.env ?? process.env), CODEX_CLI_BIN: options.codexCliBin }
|
|
232
|
+
: { ...(options.env ?? process.env) };
|
|
233
|
+
const updateArgs = [options.entryPoint, 'update', '--notification-file', options.statusFile];
|
|
234
|
+
const platform = options.platform ?? process.platform;
|
|
235
|
+
const systemdRunPath = options.systemdRunPath === undefined
|
|
236
|
+
? resolveCommand('systemd-run', env)
|
|
237
|
+
: options.systemdRunPath;
|
|
238
|
+
if (platform === 'linux' && systemdRunPath) {
|
|
239
|
+
const unitName = options.unitName ?? `foxclaw-update-${process.pid}-${Date.now()}`;
|
|
240
|
+
return {
|
|
241
|
+
command: systemdRunPath,
|
|
242
|
+
args: [
|
|
243
|
+
'--user',
|
|
244
|
+
'--collect',
|
|
245
|
+
`--unit=${unitName}`,
|
|
246
|
+
`--property=StandardOutput=append:${options.logPath}`,
|
|
247
|
+
`--property=StandardError=append:${options.logPath}`,
|
|
248
|
+
...systemdSetEnvArgs(env),
|
|
249
|
+
options.nodePath,
|
|
250
|
+
...updateArgs,
|
|
251
|
+
],
|
|
252
|
+
env,
|
|
253
|
+
viaSystemdRun: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
command: options.nodePath,
|
|
258
|
+
args: updateArgs,
|
|
259
|
+
env,
|
|
260
|
+
viaSystemdRun: false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
208
263
|
export function performSelfUpdate(options) {
|
|
209
264
|
const env = options.env ?? process.env;
|
|
210
265
|
let toVersion = null;
|
|
@@ -295,6 +350,31 @@ function executableCandidates(commandName, nodePath, env, preferred = []) {
|
|
|
295
350
|
...(env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, commandName)),
|
|
296
351
|
].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
|
|
297
352
|
}
|
|
353
|
+
function resolveCommand(commandName, env) {
|
|
354
|
+
for (const directory of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
355
|
+
const candidate = path.join(directory, commandName);
|
|
356
|
+
if (fs.existsSync(candidate)) {
|
|
357
|
+
return candidate;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const fallback of ['/usr/bin', '/bin', '/usr/local/bin']) {
|
|
361
|
+
const candidate = path.join(fallback, commandName);
|
|
362
|
+
if (fs.existsSync(candidate)) {
|
|
363
|
+
return candidate;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
function systemdSetEnvArgs(env) {
|
|
369
|
+
return Object.entries(env)
|
|
370
|
+
.filter((entry) => {
|
|
371
|
+
const [key, value] = entry;
|
|
372
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
|
|
373
|
+
&& typeof value === 'string'
|
|
374
|
+
&& !value.includes('\0');
|
|
375
|
+
})
|
|
376
|
+
.map(([key, value]) => `--setenv=${key}=${value}`);
|
|
377
|
+
}
|
|
298
378
|
function buildInstallerEnv(entryPoint, installer, env) {
|
|
299
379
|
const pnpmHome = installer.manager === 'pnpm'
|
|
300
380
|
? installer.pnpmHome ?? inferPnpmHomeFromEntryPoint(entryPoint)
|
|
@@ -168,6 +168,12 @@ Note: `/auth sync push all` saying “sent” only means this node successfully
|
|
|
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
|
|
|
171
|
+
Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from per-auth `Candidate failures`. For example, a remote candidate that returns `token_invalidated` or has an expired access token is recorded under that candidate name only; current `auth.json` health is still determined by validating the current auth usage. `local candidate is already newer or equal` is a normal skip, not an error.
|
|
172
|
+
|
|
173
|
+
Manual `/auth` switches and `/auth reload` recover from same-node local mirrors only and do not send cross-node pull requests. FoxClaw queries peers only during automatic recovery after it detects a real auth problem. Recovery timeout notifications include the request id, candidate name, peer list, and wait duration; if another auth sync message arrived from the same peer during that wait, the notification says the peer was reachable but this request timed out.
|
|
174
|
+
|
|
175
|
+
If an upgrade or restart interrupts remote candidate usage validation, older versions could leave `auth.json -> .auth-sync-validate-*` behind. Starting in 0.5.2, FoxClaw checks for this at startup, restores `auth.json` to the mirror-status candidate or the newest parseable real `auth.json_*` candidate in the same directory, and removes stale validation temp files.
|
|
176
|
+
|
|
171
177
|
5. Only test refresh-token rotation after you understand the risk:
|
|
172
178
|
|
|
173
179
|
```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`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states.
|
|
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; per-candidate validation failures are shown as candidate failures instead of overwriting the sync-system last error.
|
|
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
|
|
|
@@ -460,7 +460,7 @@ Dual-active behavior:
|
|
|
460
460
|
|
|
461
461
|
Commands:
|
|
462
462
|
|
|
463
|
-
- `/auth sync status`: show node id, peers, recent sends/receives/imports, pending imports,
|
|
463
|
+
- `/auth sync status`: show node id, peers, recent sends/receives/imports, pending imports, the sync-system latest error, and per-candidate failures.
|
|
464
464
|
- `/auth sync test`: send an encrypted ping and wait for peer pong replies to verify peer config, shared key, and Bot-to-Bot private messages.
|
|
465
465
|
- `/auth sync push all`: manually broadcast all locally verified candidates without refreshing tokens. “Sent” does not mean the peer imported files; check `/auth sync status` and `/auth` on the peer.
|
|
466
466
|
|
|
@@ -168,6 +168,12 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
168
168
|
|
|
169
169
|
启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
|
|
170
170
|
|
|
171
|
+
从 0.5.2 起,`/auth sync status` 会把同步系统级 `最近错误` 和单个 auth 的 `候选失败` 分开显示。比如某个远端候选返回 `token_invalidated` 或 access token 过期时,只会记录到该候选名下面;当前 `auth.json` 是否健康仍以当前 auth 的 usage 验证为准。`local candidate is already newer or equal` 属于正常跳过,不会记为错误。
|
|
172
|
+
|
|
173
|
+
手动 `/auth` 切换和 `/auth reload` 只会尝试同节点本地 mirror 恢复,不会主动向跨节点 peer 发起 pull。只有 FoxClaw 检测到当前 auth 真的出现认证问题并进入自动恢复时,才会向 peer 查询可用副本。恢复超时通知会包含 request id、候选名、peer 列表和等待时长;如果等待期间收到过同 peer 的其他 auth sync 消息,通知会标明 peer 可达但该请求超时。
|
|
174
|
+
|
|
175
|
+
如果升级或重启正好打断远端候选 usage 验证,旧版本可能留下 `auth.json -> .auth-sync-validate-*` 临时 symlink。0.5.2 起 FoxClaw 启动时会自动检测并恢复到 mirror 状态记录的候选,或同目录最近修改且可解析的真实 `auth.json_*` 候选,然后清理临时文件。
|
|
176
|
+
|
|
171
177
|
5. 只有在完全理解 refresh token 轮换风险时,才测试:
|
|
172
178
|
|
|
173
179
|
```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。联系人 bot
|
|
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
|
|
|
@@ -460,7 +460,7 @@ AUTH_SYNC_NODE_ID=workstation-a
|
|
|
460
460
|
|
|
461
461
|
命令:
|
|
462
462
|
|
|
463
|
-
- `/auth sync status`:查看 node id、peer
|
|
463
|
+
- `/auth sync status`:查看 node id、peer、最近收发、最近导入、待导入、系统级最近错误和单候选失败。
|
|
464
464
|
- `/auth sync test`:发送加密 ping 并等待 peer 返回 pong,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
|
|
465
465
|
- `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token;“已发送”不等于对端已经导入,需要在 peer 上看 `/auth sync status` 和 `/auth`。
|
|
466
466
|
|