@foxden-app/foxclaw 0.4.13 → 0.4.15
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/.env.example +12 -2
- package/CHANGELOG.md +24 -0
- package/README.md +3 -1
- package/README_EN.md +3 -1
- package/dist/auth/cross_node_sync.d.ts +112 -0
- package/dist/auth/cross_node_sync.js +682 -0
- package/dist/auth/mirror.d.ts +37 -1
- package/dist/auth/mirror.js +136 -3
- package/dist/config.d.ts +10 -0
- package/dist/config.js +14 -0
- package/dist/controller/controller.d.ts +20 -0
- package/dist/controller/controller.js +173 -4
- package/dist/i18n.d.ts +40 -2
- package/dist/i18n.js +40 -2
- package/dist/main.js +192 -8
- package/dist/telegram/api.d.ts +6 -0
- package/dist/telegram/api.js +50 -0
- package/dist/telegram/gateway.d.ts +9 -0
- package/dist/telegram/gateway.js +33 -2
- package/dist/types.d.ts +14 -0
- package/docs/user-manual.md +36 -0
- package/docs/zh/user-manual.md +36 -0
- package/package.json +1 -1
- package/skills/foxclaw/SKILL.md +15 -7
- package/skills/foxclaw/agents/openai.yaml +1 -1
- package/skills/npm-publish/SKILL.md +2 -0
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export interface AuthMirrorStatus {
|
|
|
12
12
|
sourceLabel: string;
|
|
13
13
|
syncedAt: string;
|
|
14
14
|
}
|
|
15
|
+
export interface AuthMirrorExternalStatus extends AuthMirrorStatus {
|
|
16
|
+
remoteNodeId?: string | null;
|
|
17
|
+
}
|
|
15
18
|
export interface AuthMirrorRecovery {
|
|
16
19
|
candidateName: string;
|
|
17
20
|
sourceRuntimeId: string;
|
|
@@ -31,26 +34,55 @@ export interface ChatGptAuthMetadata {
|
|
|
31
34
|
accountId: string;
|
|
32
35
|
lastRefreshMs: number;
|
|
33
36
|
}
|
|
37
|
+
export interface ChatGptAuthRecord extends ChatGptAuthMetadata {
|
|
38
|
+
raw: string;
|
|
39
|
+
}
|
|
40
|
+
export interface AuthMirrorCandidateRecord extends ChatGptAuthRecord {
|
|
41
|
+
candidateName: string;
|
|
42
|
+
sourceRuntimeId: string;
|
|
43
|
+
sourceLabel: string;
|
|
44
|
+
}
|
|
45
|
+
export interface AuthMirrorImportResult {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
imported: boolean;
|
|
48
|
+
reason?: string | null;
|
|
49
|
+
record?: AuthMirrorCandidateRecord;
|
|
50
|
+
}
|
|
51
|
+
export interface AuthMirrorSyncedEvent {
|
|
52
|
+
status: AuthMirrorStatus;
|
|
53
|
+
record: AuthMirrorCandidateRecord;
|
|
54
|
+
}
|
|
55
|
+
export interface AuthMirrorHooks {
|
|
56
|
+
onSynced?: (event: AuthMirrorSyncedEvent) => Promise<void> | void;
|
|
57
|
+
}
|
|
34
58
|
export declare class AuthCandidateMirror {
|
|
35
59
|
private readonly canonicalDir;
|
|
36
60
|
private readonly runtimes;
|
|
37
61
|
private readonly logger;
|
|
38
62
|
private readonly statusPath;
|
|
63
|
+
private readonly hooks;
|
|
39
64
|
private timer;
|
|
40
65
|
private readonly lastSyncedRefresh;
|
|
41
66
|
private readonly lastValidationFailures;
|
|
42
67
|
private readonly activeCandidateSyncs;
|
|
43
68
|
private activeOperations;
|
|
44
69
|
private lastStatus;
|
|
45
|
-
constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
|
|
70
|
+
constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null, hooks?: AuthMirrorHooks);
|
|
46
71
|
initialize(): Promise<void>;
|
|
47
72
|
start(): void;
|
|
48
73
|
stop(): void;
|
|
49
74
|
isIdle(): boolean;
|
|
50
75
|
getStatus(): AuthMirrorStatus | null;
|
|
76
|
+
readNewestCandidate(candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
|
|
77
|
+
readRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
|
|
78
|
+
listNewestCandidates(): Promise<AuthMirrorCandidateRecord[]>;
|
|
51
79
|
syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
|
|
52
80
|
recoverRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorRecovery | null>;
|
|
53
81
|
private scan;
|
|
82
|
+
importExternalCandidate(candidateName: string, raw: string, source: {
|
|
83
|
+
nodeId: string;
|
|
84
|
+
label?: string | null;
|
|
85
|
+
}): Promise<AuthMirrorImportResult>;
|
|
54
86
|
private propagateValidatedCandidate;
|
|
55
87
|
private validateRuntimeCandidate;
|
|
56
88
|
private withActivity;
|
|
@@ -59,6 +91,10 @@ export declare class AuthCandidateMirror {
|
|
|
59
91
|
private collectAuthRecords;
|
|
60
92
|
private reconcileCandidateAtStartup;
|
|
61
93
|
private resolveCanonicalCurrentCandidate;
|
|
94
|
+
private findNewestRecord;
|
|
95
|
+
private runtimeLabel;
|
|
62
96
|
}
|
|
63
97
|
export declare function isAuthCandidateName(name: string): boolean;
|
|
98
|
+
export declare function readChatGptAuthRecord(filePath: string): Promise<ChatGptAuthRecord | null>;
|
|
64
99
|
export declare function readChatGptAuthMetadata(filePath: string): Promise<ChatGptAuthMetadata | null>;
|
|
100
|
+
export declare function parseChatGptAuthMetadata(raw: string): ChatGptAuthMetadata | null;
|
package/dist/auth/mirror.js
CHANGED
|
@@ -6,17 +6,19 @@ export class AuthCandidateMirror {
|
|
|
6
6
|
runtimes;
|
|
7
7
|
logger;
|
|
8
8
|
statusPath;
|
|
9
|
+
hooks;
|
|
9
10
|
timer = null;
|
|
10
11
|
lastSyncedRefresh = new Map();
|
|
11
12
|
lastValidationFailures = new Map();
|
|
12
13
|
activeCandidateSyncs = new Set();
|
|
13
14
|
activeOperations = 0;
|
|
14
15
|
lastStatus = null;
|
|
15
|
-
constructor(canonicalDir, runtimes, logger, statusPath = null) {
|
|
16
|
+
constructor(canonicalDir, runtimes, logger, statusPath = null, hooks = {}) {
|
|
16
17
|
this.canonicalDir = canonicalDir;
|
|
17
18
|
this.runtimes = runtimes;
|
|
18
19
|
this.logger = logger;
|
|
19
20
|
this.statusPath = statusPath;
|
|
21
|
+
this.hooks = hooks;
|
|
20
22
|
}
|
|
21
23
|
async initialize() {
|
|
22
24
|
this.lastStatus = await readMirrorStatus(this.statusPath);
|
|
@@ -65,6 +67,51 @@ export class AuthCandidateMirror {
|
|
|
65
67
|
getStatus() {
|
|
66
68
|
return this.lastStatus;
|
|
67
69
|
}
|
|
70
|
+
async readNewestCandidate(candidateName) {
|
|
71
|
+
if (!isAuthCandidateName(candidateName))
|
|
72
|
+
return null;
|
|
73
|
+
return this.withActivity(async () => this.findNewestRecord((entry) => entry.candidateName === candidateName));
|
|
74
|
+
}
|
|
75
|
+
async readRuntimeCandidate(runtimeId, candidateName) {
|
|
76
|
+
if (!isAuthCandidateName(candidateName))
|
|
77
|
+
return null;
|
|
78
|
+
return this.withActivity(async () => {
|
|
79
|
+
const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
|
|
80
|
+
const authDir = runtimeId === 'canonical' ? this.canonicalDir : runtime?.authDir;
|
|
81
|
+
if (!authDir)
|
|
82
|
+
return null;
|
|
83
|
+
const record = await readChatGptAuthRecord(path.join(authDir, candidateName));
|
|
84
|
+
if (!record)
|
|
85
|
+
return null;
|
|
86
|
+
return {
|
|
87
|
+
...record,
|
|
88
|
+
candidateName,
|
|
89
|
+
sourceRuntimeId: runtimeId,
|
|
90
|
+
sourceLabel: runtime?.label ?? runtimeId,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async listNewestCandidates() {
|
|
95
|
+
return this.withActivity(async () => {
|
|
96
|
+
const byName = new Map();
|
|
97
|
+
for (const entry of await this.collectAuthRecords()) {
|
|
98
|
+
if (!isAuthCandidateName(entry.candidateName))
|
|
99
|
+
continue;
|
|
100
|
+
const sourceLabel = this.runtimeLabel(entry.runtimeId);
|
|
101
|
+
const candidate = {
|
|
102
|
+
...entry.record,
|
|
103
|
+
candidateName: entry.candidateName,
|
|
104
|
+
sourceRuntimeId: entry.runtimeId,
|
|
105
|
+
sourceLabel,
|
|
106
|
+
};
|
|
107
|
+
const current = byName.get(entry.candidateName);
|
|
108
|
+
if (!current || candidate.lastRefreshMs > current.lastRefreshMs) {
|
|
109
|
+
byName.set(entry.candidateName, candidate);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [...byName.values()].sort((a, b) => a.candidateName.localeCompare(b.candidateName));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
68
115
|
async syncRuntimeCandidate(runtimeId, candidateName) {
|
|
69
116
|
if (!isAuthCandidateName(candidateName))
|
|
70
117
|
return false;
|
|
@@ -116,6 +163,64 @@ export class AuthCandidateMirror {
|
|
|
116
163
|
}
|
|
117
164
|
});
|
|
118
165
|
}
|
|
166
|
+
async importExternalCandidate(candidateName, raw, source) {
|
|
167
|
+
if (!isAuthCandidateName(candidateName)) {
|
|
168
|
+
return { ok: false, imported: false, reason: 'invalid candidate name' };
|
|
169
|
+
}
|
|
170
|
+
return this.withActivity(async () => {
|
|
171
|
+
if (this.activeCandidateSyncs.has(candidateName)) {
|
|
172
|
+
return { ok: false, imported: false, reason: 'candidate sync already active' };
|
|
173
|
+
}
|
|
174
|
+
this.activeCandidateSyncs.add(candidateName);
|
|
175
|
+
try {
|
|
176
|
+
const metadata = parseChatGptAuthMetadata(raw);
|
|
177
|
+
if (!metadata) {
|
|
178
|
+
return { ok: false, imported: false, reason: 'invalid ChatGPT auth payload' };
|
|
179
|
+
}
|
|
180
|
+
const existing = (await this.collectAuthRecords())
|
|
181
|
+
.filter(entry => entry.candidateName === candidateName);
|
|
182
|
+
const conflicting = existing.find(entry => entry.record.accountId !== metadata.accountId);
|
|
183
|
+
if (conflicting) {
|
|
184
|
+
return { ok: false, imported: false, reason: 'same candidate belongs to a different account' };
|
|
185
|
+
}
|
|
186
|
+
const newest = existing.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
|
|
187
|
+
const previousRefresh = Math.max(newest?.record.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(candidateName) ?? 0);
|
|
188
|
+
if (metadata.lastRefreshMs <= previousRefresh) {
|
|
189
|
+
return { ok: true, imported: false, reason: 'local candidate is already newer or equal' };
|
|
190
|
+
}
|
|
191
|
+
await atomicWrite(path.join(this.canonicalDir, candidateName), raw);
|
|
192
|
+
for (const target of this.runtimes) {
|
|
193
|
+
await atomicWrite(path.join(target.authDir, candidateName), raw);
|
|
194
|
+
}
|
|
195
|
+
this.lastSyncedRefresh.set(candidateName, metadata.lastRefreshMs);
|
|
196
|
+
const sourceLabel = source.label?.trim() || source.nodeId;
|
|
197
|
+
this.lastStatus = {
|
|
198
|
+
candidateName,
|
|
199
|
+
sourceRuntimeId: `remote:${source.nodeId}`,
|
|
200
|
+
sourceLabel,
|
|
201
|
+
syncedAt: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
await writeMirrorStatus(this.statusPath, this.lastStatus);
|
|
204
|
+
this.logger.info('auth.mirror.remote_imported', { candidateName, sourceNodeId: source.nodeId });
|
|
205
|
+
const message = `${candidateName} has been synchronized from remote node ${sourceLabel}.`;
|
|
206
|
+
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
imported: true,
|
|
210
|
+
record: {
|
|
211
|
+
raw,
|
|
212
|
+
...metadata,
|
|
213
|
+
candidateName,
|
|
214
|
+
sourceRuntimeId: `remote:${source.nodeId}`,
|
|
215
|
+
sourceLabel,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
this.activeCandidateSyncs.delete(candidateName);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
119
224
|
async propagateValidatedCandidate(runtime, name) {
|
|
120
225
|
if (this.activeCandidateSyncs.has(name)) {
|
|
121
226
|
return false;
|
|
@@ -170,6 +275,15 @@ export class AuthCandidateMirror {
|
|
|
170
275
|
this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
|
|
171
276
|
const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
|
|
172
277
|
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
|
|
278
|
+
await this.hooks.onSynced?.({
|
|
279
|
+
status: this.lastStatus,
|
|
280
|
+
record: {
|
|
281
|
+
...record,
|
|
282
|
+
candidateName: name,
|
|
283
|
+
sourceRuntimeId: runtime.id,
|
|
284
|
+
sourceLabel,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
173
287
|
return true;
|
|
174
288
|
}
|
|
175
289
|
finally {
|
|
@@ -269,6 +383,25 @@ export class AuthCandidateMirror {
|
|
|
269
383
|
const name = path.basename(finalPath);
|
|
270
384
|
return isAuthCandidateName(name) ? name : null;
|
|
271
385
|
}
|
|
386
|
+
async findNewestRecord(predicate) {
|
|
387
|
+
const newest = (await this.collectAuthRecords())
|
|
388
|
+
.filter(predicate)
|
|
389
|
+
.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
|
|
390
|
+
if (!newest)
|
|
391
|
+
return null;
|
|
392
|
+
return {
|
|
393
|
+
...newest.record,
|
|
394
|
+
candidateName: newest.candidateName,
|
|
395
|
+
sourceRuntimeId: newest.runtimeId,
|
|
396
|
+
sourceLabel: this.runtimeLabel(newest.runtimeId),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
runtimeLabel(runtimeId) {
|
|
400
|
+
if (runtimeId === 'canonical')
|
|
401
|
+
return 'canonical';
|
|
402
|
+
const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
|
|
403
|
+
return runtime?.label ?? runtimeId;
|
|
404
|
+
}
|
|
272
405
|
}
|
|
273
406
|
export function isAuthCandidateName(name) {
|
|
274
407
|
return name !== 'auth.json'
|
|
@@ -281,7 +414,7 @@ async function listAuthCandidateNames(dir) {
|
|
|
281
414
|
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && isAuthCandidateName(entry.name))
|
|
282
415
|
.map((entry) => entry.name);
|
|
283
416
|
}
|
|
284
|
-
async function readChatGptAuthRecord(filePath) {
|
|
417
|
+
export async function readChatGptAuthRecord(filePath) {
|
|
285
418
|
try {
|
|
286
419
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
287
420
|
const metadata = parseChatGptAuthMetadata(raw);
|
|
@@ -301,7 +434,7 @@ export async function readChatGptAuthMetadata(filePath) {
|
|
|
301
434
|
return null;
|
|
302
435
|
}
|
|
303
436
|
}
|
|
304
|
-
function parseChatGptAuthMetadata(raw) {
|
|
437
|
+
export function parseChatGptAuthMetadata(raw) {
|
|
305
438
|
try {
|
|
306
439
|
const parsed = JSON.parse(raw);
|
|
307
440
|
const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
|
package/dist/config.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare const DEFAULT_LOCK_PATH: string;
|
|
|
8
8
|
export declare const DEFAULT_CODEX_APP_SERVER_STATE_PATH: string;
|
|
9
9
|
export declare const DEFAULT_CODEX_APP_SERVER_LOG_PATH: string;
|
|
10
10
|
export declare const DEFAULT_CODEX_TELEGRAM_HOME: string;
|
|
11
|
+
export declare const DEFAULT_AUTH_SYNC_STATE_PATH: string;
|
|
12
|
+
export declare const DEFAULT_AUTH_SYNC_TEMP_DIR: string;
|
|
11
13
|
export declare const DEFAULT_ENV_PATH: string;
|
|
12
14
|
export declare function resolveEnvPath(): string;
|
|
13
15
|
export declare function getLoadedEnvPath(): string | null;
|
|
@@ -51,6 +53,14 @@ export interface AppConfig {
|
|
|
51
53
|
weixinMediaDir: string;
|
|
52
54
|
/** Optional `SKRouteTag` header for some IDC deployments. */
|
|
53
55
|
wxIlinkRouteTag: string | null;
|
|
56
|
+
authSyncEnabled: boolean;
|
|
57
|
+
authSyncTransport: 'telegram-private';
|
|
58
|
+
authSyncKey: string | null;
|
|
59
|
+
authSyncPeers: string[];
|
|
60
|
+
authSyncNodeId: string | null;
|
|
61
|
+
authSyncClusterId: string;
|
|
62
|
+
authSyncStatePath: string;
|
|
63
|
+
authSyncTempDir: string;
|
|
54
64
|
}
|
|
55
65
|
export declare function loadConfig(): AppConfig;
|
|
56
66
|
export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
|
package/dist/config.js
CHANGED
|
@@ -11,6 +11,8 @@ export const DEFAULT_LOCK_PATH = path.join(APP_HOME, 'runtime', 'bridge.lock');
|
|
|
11
11
|
export const DEFAULT_CODEX_APP_SERVER_STATE_PATH = path.join(APP_HOME, 'runtime', 'codex-app-server.json');
|
|
12
12
|
export const DEFAULT_CODEX_APP_SERVER_LOG_PATH = path.join(APP_HOME, 'logs', 'codex-app-server.log');
|
|
13
13
|
export const DEFAULT_CODEX_TELEGRAM_HOME = path.join(APP_HOME, 'codex', 'telegram');
|
|
14
|
+
export const DEFAULT_AUTH_SYNC_STATE_PATH = path.join(APP_HOME, 'runtime', 'auth-sync.json');
|
|
15
|
+
export const DEFAULT_AUTH_SYNC_TEMP_DIR = path.join(APP_HOME, 'runtime', 'auth-sync');
|
|
14
16
|
export const DEFAULT_ENV_PATH = path.join(APP_HOME, '.env');
|
|
15
17
|
let envLoaded = false;
|
|
16
18
|
let loadedEnvPath = null;
|
|
@@ -85,6 +87,14 @@ export function loadConfig() {
|
|
|
85
87
|
weixinSyncBufDir: process.env.WEIXIN_SYNC_BUF_DIR || path.join(APP_HOME, 'weixin', 'sync-buf'),
|
|
86
88
|
weixinMediaDir: process.env.WEIXIN_MEDIA_DIR || path.join(APP_HOME, 'weixin', 'media'),
|
|
87
89
|
wxIlinkRouteTag: optional('WX_ILINK_ROUTE_TAG'),
|
|
90
|
+
authSyncEnabled: boolEnv('AUTH_SYNC_ENABLED', false),
|
|
91
|
+
authSyncTransport: 'telegram-private',
|
|
92
|
+
authSyncKey: optional('AUTH_SYNC_KEY'),
|
|
93
|
+
authSyncPeers: parseCommaSeparatedIds(process.env.AUTH_SYNC_PEERS),
|
|
94
|
+
authSyncNodeId: optional('AUTH_SYNC_NODE_ID'),
|
|
95
|
+
authSyncClusterId: process.env.AUTH_SYNC_CLUSTER_ID?.trim() || 'default',
|
|
96
|
+
authSyncStatePath: process.env.AUTH_SYNC_STATE_PATH || DEFAULT_AUTH_SYNC_STATE_PATH,
|
|
97
|
+
authSyncTempDir: process.env.AUTH_SYNC_TEMP_DIR || DEFAULT_AUTH_SYNC_TEMP_DIR,
|
|
88
98
|
};
|
|
89
99
|
ensureAppDirs(config);
|
|
90
100
|
return config;
|
|
@@ -102,7 +112,11 @@ export function ensureAppDirs(config) {
|
|
|
102
112
|
path.dirname(config.lockPath),
|
|
103
113
|
path.dirname(config.codexAppServerStatePath),
|
|
104
114
|
path.dirname(config.codexAppServerLogPath),
|
|
115
|
+
path.dirname(config.authSyncStatePath),
|
|
105
116
|
];
|
|
117
|
+
if (config.authSyncEnabled) {
|
|
118
|
+
dirs.push(config.authSyncTempDir);
|
|
119
|
+
}
|
|
106
120
|
if (config.wxEnabled) {
|
|
107
121
|
dirs.push(config.weixinAccountsDir, config.weixinSyncBufDir, config.weixinMediaDir);
|
|
108
122
|
}
|
|
@@ -10,11 +10,26 @@ export interface CoreCoordinator {
|
|
|
10
10
|
canSelfUpdate?: () => boolean;
|
|
11
11
|
authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
|
|
12
12
|
recoverAuthCandidate?: (runtimeId: string, candidateName: string) => Promise<boolean>;
|
|
13
|
+
acquireAuthRefreshLease?: (reason: string) => Promise<{
|
|
14
|
+
ok: boolean;
|
|
15
|
+
leaseId: string | null;
|
|
16
|
+
reason?: string | null;
|
|
17
|
+
}>;
|
|
18
|
+
releaseAuthRefreshLease?: (leaseId: string | null) => Promise<void>;
|
|
19
|
+
getAuthSyncStatus?: () => RuntimeStatus['authSync'];
|
|
20
|
+
authSyncPushAll?: () => Promise<{
|
|
21
|
+
sent: number;
|
|
22
|
+
skipped: number;
|
|
23
|
+
}>;
|
|
24
|
+
authSyncTest?: () => Promise<{
|
|
25
|
+
sent: number;
|
|
26
|
+
}>;
|
|
13
27
|
statusUpdated?: (status: RuntimeStatus) => void;
|
|
14
28
|
getServiceStatus?: () => Promise<{
|
|
15
29
|
bots: NonNullable<RuntimeStatus['bots']>;
|
|
16
30
|
weixinRuntime?: RuntimeStatus['weixinRuntime'];
|
|
17
31
|
authMirror?: RuntimeStatus['authMirror'];
|
|
32
|
+
authSync?: RuntimeStatus['authSync'];
|
|
18
33
|
lastUpdate?: SelfUpdateStatus | null;
|
|
19
34
|
}>;
|
|
20
35
|
selfUpdateCompleted?: (status: SelfUpdateStatus) => void;
|
|
@@ -157,6 +172,10 @@ export declare class BridgeSessionCore {
|
|
|
157
172
|
private registerActiveTurn;
|
|
158
173
|
private createActiveTurnState;
|
|
159
174
|
getCurrentAuthLabel(): Promise<string | null>;
|
|
175
|
+
validateExternalCodexAuthCandidate(candidateName: string, rawAuth: string, expectedAccountId: string): Promise<{
|
|
176
|
+
ok: boolean;
|
|
177
|
+
reason?: string | null;
|
|
178
|
+
}>;
|
|
160
179
|
isIdleForServiceUpdate(): boolean;
|
|
161
180
|
private hasLocalBlockingActivity;
|
|
162
181
|
private authRuntimeId;
|
|
@@ -240,6 +259,7 @@ export declare class BridgeSessionCore {
|
|
|
240
259
|
private formatSelfUpdateResult;
|
|
241
260
|
private formatCodexUpdateResult;
|
|
242
261
|
private handleAuthCommand;
|
|
262
|
+
private handleAuthSyncCommand;
|
|
243
263
|
private handleAuthRefreshAllCommand;
|
|
244
264
|
private handleAuthUseCommand;
|
|
245
265
|
private handleAuthToggleCommand;
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { normalizeLocale, t } from '../i18n.js';
|
|
6
|
-
import { readChatGptAuthMetadata } from '../auth/mirror.js';
|
|
6
|
+
import { parseChatGptAuthMetadata, readChatGptAuthMetadata } from '../auth/mirror.js';
|
|
7
7
|
import { parseCommand } from './commands.js';
|
|
8
8
|
import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPanelKeyboard, buildThreadListKeyboard, buildThreadsKeyboard, clampEffortToModel, formatAccessPresetLabel, formatActiveTurnMessageModeLabel, formatAccessSettingsMessage, formatApprovalPolicyLabel, formatCollaborationModeLabel, formatModelSettingsMessage, formatSandboxModeLabel, formatServiceTierStatusLabel, formatSetupPanelMessage, formatThreadContextSummary, formatThreadsMessage, formatWeixinAccessCopyPaste, formatWeixinModelCopyPaste, formatWeixinThreadsCopyPaste, formatWeixinWhereNavCopyPaste, formatWhereMessage, normalizeRequestedEffort, resolveCurrentModel, resolveActiveTurnMessageMode, resolveRequestedModel, } from './presentation.js';
|
|
9
9
|
import { clampServiceTierToModel, resolveFastTierForModel } from './service_tier.js';
|
|
@@ -427,6 +427,16 @@ export class BridgeSessionCore {
|
|
|
427
427
|
time: serviceStatus.authMirror.syncedAt,
|
|
428
428
|
})
|
|
429
429
|
: t(locale, 'status_auth_mirror_none'));
|
|
430
|
+
if (serviceStatus.authSync?.enabled) {
|
|
431
|
+
lines.push(t(locale, 'status_auth_sync', {
|
|
432
|
+
node: serviceStatus.authSync.nodeId ?? t(locale, 'unknown'),
|
|
433
|
+
peers: serviceStatus.authSync.peers.length,
|
|
434
|
+
pending: serviceStatus.authSync.pendingImports,
|
|
435
|
+
}));
|
|
436
|
+
if (serviceStatus.authSync.lastError) {
|
|
437
|
+
lines.push(t(locale, 'status_auth_sync_error', { value: serviceStatus.authSync.lastError }));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
430
440
|
if (serviceStatus.lastUpdate) {
|
|
431
441
|
lines.push(t(locale, 'status_last_update', {
|
|
432
442
|
from: serviceStatus.lastUpdate.fromVersion,
|
|
@@ -2619,6 +2629,55 @@ export class BridgeSessionCore {
|
|
|
2619
2629
|
async getCurrentAuthLabel() {
|
|
2620
2630
|
return (await this.listCodexAuthState()).currentLabel;
|
|
2621
2631
|
}
|
|
2632
|
+
async validateExternalCodexAuthCandidate(candidateName, rawAuth, expectedAccountId) {
|
|
2633
|
+
if (!this.isIdleForServiceUpdate()) {
|
|
2634
|
+
return { ok: false, reason: 'runtime is not idle' };
|
|
2635
|
+
}
|
|
2636
|
+
const metadata = parseChatGptAuthMetadata(rawAuth);
|
|
2637
|
+
if (!metadata || metadata.accountId !== expectedAccountId) {
|
|
2638
|
+
return { ok: false, reason: 'remote auth account id mismatch' };
|
|
2639
|
+
}
|
|
2640
|
+
const state = await this.listCodexAuthState();
|
|
2641
|
+
const existing = state.candidates.find(candidate => candidate.name === candidateName) ?? null;
|
|
2642
|
+
if (existing) {
|
|
2643
|
+
const existingMetadata = await readChatGptAuthMetadata(existing.path);
|
|
2644
|
+
if (existingMetadata && existingMetadata.accountId !== expectedAccountId) {
|
|
2645
|
+
return { ok: false, reason: 'same candidate belongs to a different account' };
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
const authStat = await fs.lstat(state.authPath).catch(() => null);
|
|
2649
|
+
const originalRegularAuth = authStat?.isFile()
|
|
2650
|
+
? await fs.readFile(state.authPath, 'utf8').catch(() => null)
|
|
2651
|
+
: null;
|
|
2652
|
+
const tempPath = path.join(state.authDir, `.auth-sync-validate-${process.pid}-${Date.now()}.json`);
|
|
2653
|
+
try {
|
|
2654
|
+
await fs.writeFile(tempPath, rawAuth, { encoding: 'utf8', mode: 0o600 });
|
|
2655
|
+
await pointCodexAuthAtTarget(state.authDir, state.authPath, tempPath);
|
|
2656
|
+
this.pendingTurnErrors.clear();
|
|
2657
|
+
this.attachedThreads.clear();
|
|
2658
|
+
await this.app.restart();
|
|
2659
|
+
const account = await this.app.readAccount(false);
|
|
2660
|
+
const rateLimits = await this.app.readAccountRateLimits();
|
|
2661
|
+
if (!account || !rateLimits || !selectCodexRateLimitSnapshot(rateLimits)) {
|
|
2662
|
+
return { ok: false, reason: 'Codex did not validate ChatGPT usage for remote auth' };
|
|
2663
|
+
}
|
|
2664
|
+
return { ok: true };
|
|
2665
|
+
}
|
|
2666
|
+
catch (error) {
|
|
2667
|
+
return { ok: false, reason: formatUserError(error) };
|
|
2668
|
+
}
|
|
2669
|
+
finally {
|
|
2670
|
+
await restoreCodexAuthTarget(state.authDir, state.authPath, state.currentTargetPath, originalRegularAuth).catch((error) => {
|
|
2671
|
+
this.logger.warn('codex.auth_sync_restore_failed', { error: toErrorMeta(error) });
|
|
2672
|
+
});
|
|
2673
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
2674
|
+
this.pendingTurnErrors.clear();
|
|
2675
|
+
this.attachedThreads.clear();
|
|
2676
|
+
await this.app.restart().catch((error) => {
|
|
2677
|
+
this.logger.warn('codex.auth_sync_restart_restore_failed', { error: toErrorMeta(error) });
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2622
2681
|
isIdleForServiceUpdate() {
|
|
2623
2682
|
return this.activeTurns.size === 0
|
|
2624
2683
|
&& this.pendingApprovalMessages.size === 0
|
|
@@ -3894,6 +3953,10 @@ export class BridgeSessionCore {
|
|
|
3894
3953
|
}
|
|
3895
3954
|
async handleAuthCommand(scopeId, locale, args) {
|
|
3896
3955
|
const action = args[0]?.toLowerCase() ?? 'list';
|
|
3956
|
+
if (action === 'sync') {
|
|
3957
|
+
await this.handleAuthSyncCommand(scopeId, locale, args.slice(1));
|
|
3958
|
+
return;
|
|
3959
|
+
}
|
|
3897
3960
|
if (action === 'reload' || action === 'restart') {
|
|
3898
3961
|
await this.handleAuthReloadCommand(scopeId, locale);
|
|
3899
3962
|
return;
|
|
@@ -3934,6 +3997,39 @@ export class BridgeSessionCore {
|
|
|
3934
3997
|
const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null, record), authChoiceKeyboard(locale, record));
|
|
3935
3998
|
record.messageId = messageId;
|
|
3936
3999
|
}
|
|
4000
|
+
async handleAuthSyncCommand(scopeId, locale, args) {
|
|
4001
|
+
const action = args[0]?.toLowerCase() ?? 'status';
|
|
4002
|
+
if (action === 'status') {
|
|
4003
|
+
await this.sendMessage(scopeId, formatAuthSyncStatus(locale, this.coordinator?.getAuthSyncStatus?.() ?? null));
|
|
4004
|
+
return;
|
|
4005
|
+
}
|
|
4006
|
+
if (action === 'test') {
|
|
4007
|
+
const result = await this.coordinator?.authSyncTest?.();
|
|
4008
|
+
if (!result) {
|
|
4009
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_test_sent', { count: result.sent }));
|
|
4013
|
+
return;
|
|
4014
|
+
}
|
|
4015
|
+
if (action === 'push' && args[1]?.toLowerCase() === 'all') {
|
|
4016
|
+
if (!this.canRunGlobalAuthRefresh()) {
|
|
4017
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_push_blocked_active'));
|
|
4018
|
+
return;
|
|
4019
|
+
}
|
|
4020
|
+
const result = await this.coordinator?.authSyncPushAll?.();
|
|
4021
|
+
if (!result) {
|
|
4022
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
|
|
4023
|
+
return;
|
|
4024
|
+
}
|
|
4025
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_push_done', {
|
|
4026
|
+
sent: result.sent,
|
|
4027
|
+
skipped: result.skipped,
|
|
4028
|
+
}));
|
|
4029
|
+
return;
|
|
4030
|
+
}
|
|
4031
|
+
await this.sendMessage(scopeId, t(locale, 'usage_auth_sync'));
|
|
4032
|
+
}
|
|
3937
4033
|
async handleAuthRefreshAllCommand(scopeId, locale, confirmed = false) {
|
|
3938
4034
|
if (!this.canRunGlobalAuthRefresh()) {
|
|
3939
4035
|
await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_blocked_active'));
|
|
@@ -3949,7 +4045,18 @@ export class BridgeSessionCore {
|
|
|
3949
4045
|
return;
|
|
3950
4046
|
}
|
|
3951
4047
|
await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_starting'));
|
|
3952
|
-
const
|
|
4048
|
+
const lease = await this.coordinator?.acquireAuthRefreshLease?.('auth refresh all');
|
|
4049
|
+
if (lease && !lease.ok) {
|
|
4050
|
+
await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_lease_failed', { error: lease.reason ?? t(locale, 'unknown') }));
|
|
4051
|
+
return;
|
|
4052
|
+
}
|
|
4053
|
+
let result;
|
|
4054
|
+
try {
|
|
4055
|
+
result = await this.refreshAllCodexAuthCandidates();
|
|
4056
|
+
}
|
|
4057
|
+
finally {
|
|
4058
|
+
await this.coordinator?.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
|
|
4059
|
+
}
|
|
3953
4060
|
const state = await this.listCodexAuthState();
|
|
3954
4061
|
await this.applySharedCodexAuthQuotaSnapshots(state);
|
|
3955
4062
|
const record = createPendingAuthChoiceList(scopeId, state.candidates);
|
|
@@ -4460,7 +4567,20 @@ export class BridgeSessionCore {
|
|
|
4460
4567
|
if (record.messageId !== null) {
|
|
4461
4568
|
await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_refresh_all_starting'), []);
|
|
4462
4569
|
}
|
|
4463
|
-
const
|
|
4570
|
+
const lease = await this.coordinator?.acquireAuthRefreshLease?.('auth refresh all');
|
|
4571
|
+
if (lease && !lease.ok) {
|
|
4572
|
+
if (record.messageId !== null) {
|
|
4573
|
+
await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_refresh_all_lease_failed', { error: lease.reason ?? t(locale, 'unknown') }), authChoiceKeyboard(locale, record));
|
|
4574
|
+
}
|
|
4575
|
+
return;
|
|
4576
|
+
}
|
|
4577
|
+
let result;
|
|
4578
|
+
try {
|
|
4579
|
+
result = await this.refreshAllCodexAuthCandidates();
|
|
4580
|
+
}
|
|
4581
|
+
finally {
|
|
4582
|
+
await this.coordinator?.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
|
|
4583
|
+
}
|
|
4464
4584
|
const state = await this.listCodexAuthState();
|
|
4465
4585
|
await this.applySharedCodexAuthQuotaSnapshots(state);
|
|
4466
4586
|
record.candidates = state.candidates;
|
|
@@ -4578,8 +4698,27 @@ export class BridgeSessionCore {
|
|
|
4578
4698
|
this.authRotationInProgress = true;
|
|
4579
4699
|
try {
|
|
4580
4700
|
const failedTargets = rotation.retry?.failedAuthTargets ?? this.authRotationFailedTargets;
|
|
4581
|
-
const selection = await this.selectNextCodexAuthCandidate(failedTargets);
|
|
4582
4701
|
const locale = this.localeForChat(rotation.scopeId);
|
|
4702
|
+
const current = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
|
|
4703
|
+
if (current) {
|
|
4704
|
+
const recoveredCurrent = await this.recoverCodexAuthCandidate(current.name);
|
|
4705
|
+
if (recoveredCurrent) {
|
|
4706
|
+
await this.sendMessage(rotation.scopeId, t(locale, 'auth_auto_recovered_current', {
|
|
4707
|
+
value: current.name,
|
|
4708
|
+
error: formatShortStatusError(rotation.reason),
|
|
4709
|
+
}));
|
|
4710
|
+
this.pendingTurnErrors.clear();
|
|
4711
|
+
this.attachedThreads.clear();
|
|
4712
|
+
await this.app.restart();
|
|
4713
|
+
await this.syncCodexAuthCandidate(current.name);
|
|
4714
|
+
if (rotation.retry) {
|
|
4715
|
+
await this.retryTurnAfterAuthRotation(rotation.scopeId, locale, rotation.retry);
|
|
4716
|
+
return true;
|
|
4717
|
+
}
|
|
4718
|
+
return false;
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
const selection = await this.selectNextCodexAuthCandidate(failedTargets);
|
|
4583
4722
|
if (!selection) {
|
|
4584
4723
|
await this.sendMessage(rotation.scopeId, t(locale, 'auth_auto_no_candidate', {
|
|
4585
4724
|
error: formatShortStatusError(rotation.reason),
|
|
@@ -7971,6 +8110,36 @@ function formatAuthRefreshAllResult(locale, result) {
|
|
|
7971
8110
|
}
|
|
7972
8111
|
return lines.join('\n');
|
|
7973
8112
|
}
|
|
8113
|
+
function formatAuthSyncStatus(locale, status) {
|
|
8114
|
+
if (!status?.enabled) {
|
|
8115
|
+
return t(locale, 'auth_sync_disabled');
|
|
8116
|
+
}
|
|
8117
|
+
const lines = [
|
|
8118
|
+
t(locale, 'auth_sync_status_title'),
|
|
8119
|
+
t(locale, 'auth_sync_status_node', { value: status.nodeId ?? t(locale, 'unknown') }),
|
|
8120
|
+
t(locale, 'auth_sync_status_peers', { value: status.peers.length === 0 ? t(locale, 'none') : status.peers.join(', ') }),
|
|
8121
|
+
t(locale, 'auth_sync_status_pending', { value: status.pendingImports }),
|
|
8122
|
+
t(locale, 'auth_sync_status_sent', { value: status.lastSentAt ?? t(locale, 'none') }),
|
|
8123
|
+
t(locale, 'auth_sync_status_received', { value: status.lastReceivedAt ?? t(locale, 'none') }),
|
|
8124
|
+
t(locale, 'auth_sync_status_imported', {
|
|
8125
|
+
value: status.lastImportedAt
|
|
8126
|
+
? `${status.lastImportCandidate ?? t(locale, 'unknown')} @ ${status.lastImportedAt}`
|
|
8127
|
+
: t(locale, 'none'),
|
|
8128
|
+
}),
|
|
8129
|
+
t(locale, 'auth_sync_status_pull', {
|
|
8130
|
+
value: status.lastPullAt
|
|
8131
|
+
? `${status.lastPullCandidate ?? t(locale, 'unknown')} @ ${status.lastPullAt}`
|
|
8132
|
+
: t(locale, 'none'),
|
|
8133
|
+
}),
|
|
8134
|
+
];
|
|
8135
|
+
if (status.activeLeaseId) {
|
|
8136
|
+
lines.push(t(locale, 'auth_sync_status_lease', { value: status.activeLeaseId }));
|
|
8137
|
+
}
|
|
8138
|
+
if (status.lastError) {
|
|
8139
|
+
lines.push(t(locale, 'auth_sync_status_error', { value: status.lastError }));
|
|
8140
|
+
}
|
|
8141
|
+
return lines.join('\n');
|
|
8142
|
+
}
|
|
7974
8143
|
function normalizeHelpUsageKey(name) {
|
|
7975
8144
|
const normalized = name.toLowerCase();
|
|
7976
8145
|
switch (normalized) {
|