@foxden-app/foxclaw 0.5.12 → 0.5.13

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.5.13 - 2026-06-08
6
+
7
+ ### 中文
8
+ - `auth.json_team_<localpart>` 候选现在会校验文件内 ChatGPT email localpart 是否匹配候选名;不匹配时 `/auth` 标为无效,避免继续显示另一位 seat 的额度。
9
+ - 本机 auth mirror 不再传播 team 候选名与文件身份不一致的 auth,并会在启动 reconcile 时用仍然匹配候选名的 runtime 副本修复错误副本。
10
+ - `/auth refresh all` 和当前候选额度刷新会跳过身份与 `team_` 候选名不匹配的文件,避免脏额度快照再次写入。
11
+
12
+ ### English
13
+ - `auth.json_team_<localpart>` candidates now verify that the ChatGPT email local part inside the auth file matches the candidate name; mismatches are marked invalid in `/auth` instead of displaying another seat's quota.
14
+ - The local auth mirror no longer propagates team candidates whose filename identity and auth payload disagree, and startup reconciliation can repair bad copies from a runtime copy that still matches the candidate name.
15
+ - `/auth refresh all` and current-candidate quota refresh now skip files whose identity does not match the `team_` candidate name, preventing dirty quota snapshots from being recorded again.
16
+
5
17
  ## 0.5.12 - 2026-06-08
6
18
 
7
19
  ### 中文
@@ -109,3 +109,4 @@ export declare function isAuthCandidateName(name: string): boolean;
109
109
  export declare function readChatGptAuthRecord(filePath: string): Promise<ChatGptAuthRecord | null>;
110
110
  export declare function readChatGptAuthMetadata(filePath: string): Promise<ChatGptAuthMetadata | null>;
111
111
  export declare function parseChatGptAuthMetadata(raw: string): ChatGptAuthMetadata | null;
112
+ export declare function chatGptAuthMetadataMatchesCandidateName(candidateName: string, metadata: Pick<ChatGptAuthMetadata, 'email'>): boolean;
@@ -159,9 +159,18 @@ export class AuthCandidateMirror {
159
159
  const destination = await readChatGptAuthRecord(destinationPath);
160
160
  if (!destination)
161
161
  return null;
162
+ const destinationMatchesName = chatGptAuthMetadataMatchesCandidateName(candidateName, destination);
162
163
  const sources = await this.collectAuthRecords();
163
164
  const newest = sources
164
- .filter(entry => authRecordsCompatible(entry.record, destination))
165
+ .filter((entry) => {
166
+ if (!chatGptAuthMetadataMatchesCandidateName(candidateName, entry.record)) {
167
+ return false;
168
+ }
169
+ if (!destinationMatchesName) {
170
+ return entry.candidateName === candidateName;
171
+ }
172
+ return authRecordsCompatible(entry.record, destination);
173
+ })
165
174
  .reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
166
175
  if (!newest || newest.record.lastRefreshMs <= destination.lastRefreshMs) {
167
176
  return null;
@@ -200,10 +209,17 @@ export class AuthCandidateMirror {
200
209
  skipped += this.runtimes.length;
201
210
  continue;
202
211
  }
212
+ if (!chatGptAuthMetadataMatchesCandidateName(name, canonical)) {
213
+ skipped += this.runtimes.length;
214
+ this.logger.warn('auth.mirror.distribution_identity_mismatch', { name });
215
+ continue;
216
+ }
203
217
  for (const runtime of this.runtimes) {
204
218
  const destinationPath = path.join(runtime.authDir, name);
205
219
  const destination = await readChatGptAuthRecord(destinationPath);
206
- if (destination && !authRecordsCompatible(destination, canonical)) {
220
+ if (destination
221
+ && chatGptAuthMetadataMatchesCandidateName(name, destination)
222
+ && !authRecordsCompatible(destination, canonical)) {
207
223
  skipped += 1;
208
224
  this.logger.warn('auth.mirror.distribution_conflict', { runtimeId: runtime.id, name });
209
225
  continue;
@@ -232,8 +248,11 @@ export class AuthCandidateMirror {
232
248
  if (!metadata) {
233
249
  return { ok: false, imported: false, reason: 'invalid ChatGPT auth payload' };
234
250
  }
251
+ if (!chatGptAuthMetadataMatchesCandidateName(candidateName, metadata)) {
252
+ return { ok: false, imported: false, reason: 'candidate auth identity does not match candidate name' };
253
+ }
235
254
  const existing = (await this.collectAuthRecords())
236
- .filter(entry => entry.candidateName === candidateName);
255
+ .filter(entry => entry.candidateName === candidateName && chatGptAuthMetadataMatchesCandidateName(candidateName, entry.record));
237
256
  const conflicting = existing.find(entry => !authRecordsCompatible(entry.record, metadata));
238
257
  if (conflicting) {
239
258
  return { ok: false, imported: false, reason: 'same candidate belongs to a different account or ChatGPT user' };
@@ -286,9 +305,15 @@ export class AuthCandidateMirror {
286
305
  const record = await readChatGptAuthRecord(sourcePath);
287
306
  if (!record)
288
307
  return false;
308
+ if (!chatGptAuthMetadataMatchesCandidateName(name, record)) {
309
+ this.logger.warn('auth.mirror.identity_mismatch', { runtimeId: runtime.id, name });
310
+ return false;
311
+ }
289
312
  const canonicalPath = path.join(this.canonicalDir, name);
290
313
  const canonical = await readChatGptAuthRecord(canonicalPath);
291
- if (canonical && !authRecordsCompatible(canonical, record)) {
314
+ if (canonical
315
+ && chatGptAuthMetadataMatchesCandidateName(name, canonical)
316
+ && !authRecordsCompatible(canonical, record)) {
292
317
  this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
293
318
  return false;
294
319
  }
@@ -315,7 +340,15 @@ export class AuthCandidateMirror {
315
340
  await atomicWrite(canonicalPath, record.raw);
316
341
  for (const target of this.runtimes) {
317
342
  if (target.id !== runtime.id) {
318
- await atomicWrite(path.join(target.authDir, name), record.raw);
343
+ const targetPath = path.join(target.authDir, name);
344
+ const targetRecord = await readChatGptAuthRecord(targetPath);
345
+ if (targetRecord
346
+ && chatGptAuthMetadataMatchesCandidateName(name, targetRecord)
347
+ && !authRecordsCompatible(targetRecord, record)) {
348
+ this.logger.warn('auth.mirror.target_conflict', { sourceRuntimeId: runtime.id, targetRuntimeId: target.id, name });
349
+ continue;
350
+ }
351
+ await atomicWrite(targetPath, record.raw);
319
352
  }
320
353
  }
321
354
  this.lastSyncedRefresh.set(name, record.lastRefreshMs);
@@ -422,12 +455,17 @@ export class AuthCandidateMirror {
422
455
  })))).filter((entry) => entry.record !== null);
423
456
  if (records.length === 0)
424
457
  return;
425
- const reference = records[0].record;
426
- if (records.some(entry => !authRecordsCompatible(reference, entry.record))) {
458
+ const trustedRecords = records.filter(entry => chatGptAuthMetadataMatchesCandidateName(name, entry.record));
459
+ if (trustedRecords.length === 0) {
460
+ this.logger.warn('auth.mirror.startup_identity_mismatch', { name });
461
+ return;
462
+ }
463
+ const reference = trustedRecords[0].record;
464
+ if (trustedRecords.some(entry => !authRecordsCompatible(reference, entry.record))) {
427
465
  this.logger.warn('auth.mirror.startup_conflict', { name });
428
466
  return;
429
467
  }
430
- const newest = records.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
468
+ const newest = trustedRecords.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
431
469
  this.lastSyncedRefresh.set(name, newest.record.lastRefreshMs);
432
470
  for (const destination of paths) {
433
471
  await atomicWrite(destination, newest.record.raw);
@@ -560,6 +598,26 @@ export function parseChatGptAuthMetadata(raw) {
560
598
  return null;
561
599
  }
562
600
  }
601
+ export function chatGptAuthMetadataMatchesCandidateName(candidateName, metadata) {
602
+ const expectedLocalPart = expectedTeamCandidateEmailLocalPart(candidateName);
603
+ if (!expectedLocalPart || !metadata.email) {
604
+ return true;
605
+ }
606
+ const actualLocalPart = metadata.email.split('@', 1)[0]?.trim().toLowerCase() ?? '';
607
+ return actualLocalPart === expectedLocalPart;
608
+ }
609
+ function expectedTeamCandidateEmailLocalPart(candidateName) {
610
+ const prefix = 'auth.json_team_';
611
+ if (!candidateName.startsWith(prefix)) {
612
+ return null;
613
+ }
614
+ const raw = candidateName.slice(prefix.length).trim().toLowerCase();
615
+ if (!raw || raw.includes('/') || raw.includes('\\')) {
616
+ return null;
617
+ }
618
+ const localPart = raw.includes('@') ? raw.split('@', 1)[0] : raw;
619
+ return /^[a-z0-9][a-z0-9._+-]*$/.test(localPart) ? localPart : null;
620
+ }
563
621
  function chatGptQuotaIdentityId(accountId, userId, email) {
564
622
  if (userId) {
565
623
  return `${accountId}:user:${userId}`;
@@ -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 { parseChatGptAuthMetadata, readChatGptAuthMetadata } from '../auth/mirror.js';
6
+ import { chatGptAuthMetadataMatchesCandidateName, 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';
@@ -5410,7 +5410,7 @@ export class BridgeSessionCore {
5410
5410
  try {
5411
5411
  for (const candidate of candidates) {
5412
5412
  const before = await readChatGptAuthMetadata(candidate.path);
5413
- if (!before) {
5413
+ if (!before || !chatGptAuthMetadataMatchesCandidateName(candidate.name, before)) {
5414
5414
  result.skipped.push(candidate.name);
5415
5415
  continue;
5416
5416
  }
@@ -5427,7 +5427,9 @@ export class BridgeSessionCore {
5427
5427
  throw new Error('Codex did not return ChatGPT rate limits after refresh');
5428
5428
  }
5429
5429
  const after = await readChatGptAuthMetadata(candidate.path);
5430
- if (!after || !chatGptAuthMetadataCompatible(before, after)) {
5430
+ if (!after
5431
+ || !chatGptAuthMetadataMatchesCandidateName(candidate.name, after)
5432
+ || !chatGptAuthMetadataCompatible(before, after)) {
5431
5433
  throw new Error('refreshed auth identity did not match the original candidate');
5432
5434
  }
5433
5435
  if (after.lastRefreshMs <= before.lastRefreshMs) {
@@ -5655,6 +5657,9 @@ export class BridgeSessionCore {
5655
5657
  }
5656
5658
  try {
5657
5659
  const metadata = await readChatGptAuthMetadata(candidate.path);
5660
+ if (!metadata || !chatGptAuthMetadataMatchesCandidateName(candidate.name, metadata)) {
5661
+ return;
5662
+ }
5658
5663
  const snapshot = selectCodexRateLimitSnapshot(await this.app.readAccountRateLimits());
5659
5664
  if (!snapshot) {
5660
5665
  return;
@@ -5701,13 +5706,16 @@ export class BridgeSessionCore {
5701
5706
  async readCodexAuthCandidateQuotaIdentities(candidates) {
5702
5707
  const entries = await Promise.all(candidates.map(async (candidate) => {
5703
5708
  const metadata = await readChatGptAuthMetadata(candidate.path);
5704
- candidate.credentialKind = metadata
5709
+ const metadataMatchesName = metadata
5710
+ ? chatGptAuthMetadataMatchesCandidateName(candidate.name, metadata)
5711
+ : false;
5712
+ candidate.credentialKind = metadata && metadataMatchesName
5705
5713
  ? 'chatgpt'
5706
5714
  : await isCodexApiKeyAuthCandidate(candidate.path)
5707
5715
  ? 'api-key'
5708
5716
  : 'invalid';
5709
- candidate.credentialLastRefreshMs = metadata?.lastRefreshMs ?? null;
5710
- return [candidate.name, metadata ? {
5717
+ candidate.credentialLastRefreshMs = metadata && metadataMatchesName ? metadata.lastRefreshMs : null;
5718
+ return [candidate.name, metadata && metadataMatchesName ? {
5711
5719
  accountId: metadata.accountId,
5712
5720
  quotaIdentityId: metadata.quotaIdentityId,
5713
5721
  } : null];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",