Skip to content

Commit a1c923f

Browse files
committed
fix(accounts): avoid team auth snapshot collisions
Use a storage identity derived from account_id plus user_id when available, falling back to account_id for tokens without a user id. This keeps Codex auth payloads unchanged while preventing multiple users in the same workspace from overwriting each other's saved snapshots.
1 parent fcdf488 commit a1c923f

7 files changed

Lines changed: 248 additions & 139 deletions

File tree

scripts/import-working-accounts-from-db.sh

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def iso_now() -> str:
4949
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
5050
5151
52-
def parse_account_id(access_token: str) -> Optional[str]:
52+
def parse_token_identity(access_token: str) -> Optional[dict]:
5353
parts = access_token.split(".")
5454
if len(parts) < 2:
5555
return None
@@ -59,8 +59,14 @@ def parse_account_id(access_token: str) -> Optional[str]:
5959
except Exception:
6060
return None
6161
auth_claims = claims.get("https://api.openai.com/auth") or {}
62-
value = auth_claims.get("chatgpt_account_id") or auth_claims.get("user_id")
63-
return value.strip() if isinstance(value, str) and value.strip() else None
62+
account_id = auth_claims.get("chatgpt_account_id") or auth_claims.get("user_id")
63+
user_id = auth_claims.get("user_id")
64+
if not isinstance(account_id, str) or not account_id.strip():
65+
return None
66+
return {
67+
"account_id": account_id.strip(),
68+
"user_id": user_id.strip() if isinstance(user_id, str) and user_id.strip() else None,
69+
}
6470
6571
6672
def exchange_refresh_token(refresh_token: str) -> Optional[dict]:
@@ -89,56 +95,71 @@ def exchange_refresh_token(refresh_token: str) -> Optional[dict]:
8995
if not isinstance(access_token, str) or not access_token.strip():
9096
return None
9197
92-
account_id = parse_account_id(access_token)
93-
if not account_id:
98+
identity = parse_token_identity(access_token)
99+
if identity is None:
94100
return None
95101
96102
return {
97103
"access_token": access_token,
98104
"refresh_token": payload.get("refresh_token") or refresh_token,
99105
"id_token": payload.get("id_token"),
100-
"account_id": account_id,
106+
"account_id": identity["account_id"],
107+
"user_id": identity["user_id"],
101108
}
102109
103110
104-
def storage_id(account_id: str) -> str:
105-
return hashlib.sha256(account_id.encode("utf-8")).hexdigest()
111+
def storage_id(account_id: str, user_id: Optional[str]) -> str:
112+
key = f"{account_id}\0{user_id}" if user_id else account_id
113+
return hashlib.sha256(key.encode("utf-8")).hexdigest()
106114
107115
108116
def load_state() -> dict:
109117
if not STATE_PATH.exists():
110-
return {"activeAccountId": None, "accounts": []}
118+
return {"activeAccountId": None, "activeStorageId": None, "accounts": []}
111119
try:
112120
parsed = json.loads(STATE_PATH.read_text(encoding="utf-8"))
113121
except Exception:
114-
return {"activeAccountId": None, "accounts": []}
122+
return {"activeAccountId": None, "activeStorageId": None, "accounts": []}
115123
116124
accounts = parsed.get("accounts")
117125
if not isinstance(accounts, list):
118126
accounts = []
119127
active = parsed.get("activeAccountId")
120128
if not isinstance(active, str):
121129
active = None
122-
return {"activeAccountId": active, "accounts": accounts}
130+
active_storage = parsed.get("activeStorageId")
131+
if not isinstance(active_storage, str):
132+
active_storage = None
133+
return {"activeAccountId": active, "activeStorageId": active_storage, "accounts": accounts}
123134
124135
125136
def save_state(state: dict) -> None:
126137
STATE_PATH.write_text(json.dumps(state, indent=2), encoding="utf-8")
127138
os.chmod(STATE_PATH, 0o600)
128139
129140
130-
def upsert_account(state: dict, account_id: str, email: Optional[str], auth_mode: str = "chatgpt") -> None:
131-
sid = storage_id(account_id)
141+
def upsert_account(state: dict, account_id: str, user_id: Optional[str], email: Optional[str], auth_mode: str = "chatgpt") -> None:
142+
sid = storage_id(account_id, user_id)
143+
legacy_sid = storage_id(account_id, None)
132144
now = iso_now()
133145
existing = None
134146
for item in state["accounts"]:
135-
if isinstance(item, dict) and item.get("accountId") == account_id:
147+
if isinstance(item, dict) and item.get("storageId") == sid:
136148
existing = item
137149
break
150+
if (
151+
sid != legacy_sid
152+
and isinstance(item, dict)
153+
and item.get("storageId") == legacy_sid
154+
and item.get("accountId") == account_id
155+
and item.get("userId") is None
156+
):
157+
existing = item
138158
139159
entry = {
140160
"accountId": account_id,
141161
"storageId": sid,
162+
"userId": user_id,
142163
"authMode": auth_mode,
143164
"email": email,
144165
"planType": existing.get("planType") if isinstance(existing, dict) else None,
@@ -151,14 +172,20 @@ def upsert_account(state: dict, account_id: str, email: Optional[str], auth_mode
151172
"unavailableReason": existing.get("unavailableReason") if isinstance(existing, dict) else None,
152173
}
153174
175+
removed_storage_ids = {sid}
176+
if isinstance(existing, dict) and isinstance(existing.get("storageId"), str):
177+
removed_storage_ids.add(existing["storageId"])
178+
if state.get("activeStorageId") == existing["storageId"]:
179+
state["activeStorageId"] = sid
180+
154181
state["accounts"] = [
155182
entry,
156-
*[x for x in state["accounts"] if not (isinstance(x, dict) and x.get("accountId") == account_id)],
183+
*[x for x in state["accounts"] if not (isinstance(x, dict) and x.get("storageId") in removed_storage_ids)],
157184
]
158185
159186
160-
def write_snapshot(account_id: str, token_payload: dict) -> None:
161-
sid = storage_id(account_id)
187+
def write_snapshot(account_id: str, user_id: Optional[str], token_payload: dict) -> None:
188+
sid = storage_id(account_id, user_id)
162189
dest_dir = SNAP_ROOT / sid
163190
dest_dir.mkdir(parents=True, exist_ok=True)
164191
os.chmod(dest_dir, 0o700)
@@ -190,10 +217,10 @@ rows = conn.execute(
190217
conn.close()
191218
192219
state = load_state()
193-
existing_ids = {
194-
item.get("accountId")
220+
existing_storage_ids = {
221+
item.get("storageId")
195222
for item in state["accounts"]
196-
if isinstance(item, dict) and isinstance(item.get("accountId"), str)
223+
if isinstance(item, dict) and isinstance(item.get("storageId"), str)
197224
}
198225
199226
imported = 0
@@ -207,13 +234,15 @@ for row_id, email, refresh_token in rows:
207234
if token_payload is None:
208235
continue
209236
account_id = token_payload["account_id"]
210-
if account_id in existing_ids:
237+
user_id = token_payload.get("user_id")
238+
sid = storage_id(account_id, user_id)
239+
if sid in existing_storage_ids:
211240
continue
212-
write_snapshot(account_id, token_payload)
213-
upsert_account(state, account_id=account_id, email=email)
214-
existing_ids.add(account_id)
241+
write_snapshot(account_id, user_id, token_payload)
242+
upsert_account(state, account_id=account_id, user_id=user_id, email=email)
243+
existing_storage_ids.add(sid)
215244
imported += 1
216-
print(f"imported row_id={row_id} email={email} account_id={account_id}")
245+
print(f"imported row_id={row_id} email={email} account_id={account_id} user_id={user_id}")
217246
218247
save_state(state)
219248
print(f"done imported={imported} attempted={attempted} limit={LIMIT}")

src/App.vue

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
<div v-else class="sidebar-settings-account-list">
123123
<article
124124
v-for="account in accounts"
125-
:key="account.accountId"
125+
:key="account.storageId"
126126
class="sidebar-settings-account-item"
127127
:class="{
128128
'is-active': account.isActive,
@@ -131,8 +131,8 @@
131131
'is-remove-visible': isRemoveVisible(account),
132132
}"
133133
:title="buildAccountTitle(account)"
134-
@mouseenter="onAccountCardPointerEnter(account.accountId)"
135-
@mouseleave="onAccountCardPointerLeave(account.accountId)"
134+
@mouseenter="onAccountCardPointerEnter(account.storageId)"
135+
@mouseleave="onAccountCardPointerLeave(account.storageId)"
136136
>
137137
<div class="sidebar-settings-account-main">
138138
<p class="sidebar-settings-account-email">{{ account.email || t('Account') }}</p>
@@ -151,7 +151,7 @@
151151
class="sidebar-settings-account-switch"
152152
type="button"
153153
:disabled="isAccountActionDisabled(account) || account.isActive || isAccountUnavailable(account)"
154-
@click="onSwitchAccount(account.accountId)"
154+
@click="onSwitchAccount(account.storageId)"
155155
>
156156
{{ getAccountSwitchLabel(account) }}
157157
</button>
@@ -163,7 +163,7 @@
163163
}"
164164
type="button"
165165
:disabled="isAccountActionDisabled(account)"
166-
@click="onRemoveAccount(account.accountId)"
166+
@click="onRemoveAccount(account.storageId)"
167167
>
168168
{{ getAccountRemoveLabel(account) }}
169169
</button>
@@ -1777,15 +1777,15 @@ function isAccountUnavailable(account: UiAccountEntry): boolean {
17771777
17781778
function isAccountActionDisabled(account: UiAccountEntry): boolean {
17791779
return isRefreshingAccounts.value || isSwitchingAccounts.value || removingAccountId.value.length > 0
1780-
|| (account.isActive && removingAccountId.value !== account.accountId && isAccountSwitchBlocked.value)
1780+
|| (account.isActive && removingAccountId.value !== account.storageId && isAccountSwitchBlocked.value)
17811781
}
17821782
17831783
function isRemoveConfirmationActive(account: UiAccountEntry): boolean {
1784-
return confirmingRemoveAccountId.value === account.accountId
1784+
return confirmingRemoveAccountId.value === account.storageId
17851785
}
17861786
17871787
function isRemoveVisible(account: UiAccountEntry): boolean {
1788-
return hoveredAccountId.value === account.accountId || isRemoveConfirmationActive(account)
1788+
return hoveredAccountId.value === account.storageId || isRemoveConfirmationActive(account)
17891789
}
17901790
17911791
function getAccountSwitchLabel(account: UiAccountEntry): string {
@@ -1796,7 +1796,7 @@ function getAccountSwitchLabel(account: UiAccountEntry): string {
17961796
}
17971797
17981798
function getAccountRemoveLabel(account: UiAccountEntry): string {
1799-
if (removingAccountId.value === account.accountId) return t('Removing…')
1799+
if (removingAccountId.value === account.storageId) return t('Removing…')
18001800
if (isRemoveConfirmationActive(account)) return t('Click again to remove')
18011801
return t('Remove')
18021802
}
@@ -1885,10 +1885,10 @@ async function loadAccountsState(options: { silent?: boolean } = {}): Promise<vo
18851885
try {
18861886
const result = await getAccounts()
18871887
accounts.value = result.accounts
1888-
if (!result.accounts.some((account) => account.accountId === hoveredAccountId.value)) {
1888+
if (!result.accounts.some((account) => account.storageId === hoveredAccountId.value)) {
18891889
hoveredAccountId.value = ''
18901890
}
1891-
if (!result.accounts.some((account) => account.accountId === confirmingRemoveAccountId.value)) {
1891+
if (!result.accounts.some((account) => account.storageId === confirmingRemoveAccountId.value)) {
18921892
confirmingRemoveAccountId.value = ''
18931893
}
18941894
} catch (error) {
@@ -1918,7 +1918,7 @@ async function onRefreshAccounts(): Promise<void> {
19181918
}
19191919
}
19201920
1921-
async function onSwitchAccount(accountId: string): Promise<void> {
1921+
async function onSwitchAccount(storageId: string): Promise<void> {
19221922
if (isSwitchingAccounts.value || isRefreshingAccounts.value) return
19231923
if (isAccountSwitchBlocked.value) {
19241924
accountActionError.value = t('Finish the current turn and pending requests before switching accounts.')
@@ -1929,9 +1929,9 @@ async function onSwitchAccount(accountId: string): Promise<void> {
19291929
confirmingRemoveAccountId.value = ''
19301930
isSwitchingAccounts.value = true
19311931
try {
1932-
const nextActiveAccount = await switchAccount(accountId)
1932+
const nextActiveAccount = await switchAccount(storageId)
19331933
accounts.value = accounts.value.map((account) => (
1934-
account.accountId === accountId
1934+
account.storageId === storageId
19351935
? nextActiveAccount
19361936
: { ...account, isActive: false }
19371937
))
@@ -1948,12 +1948,12 @@ async function onSwitchAccount(accountId: string): Promise<void> {
19481948
}
19491949
}
19501950
1951-
async function onRemoveAccount(accountId: string): Promise<void> {
1951+
async function onRemoveAccount(storageId: string): Promise<void> {
19521952
if (isRefreshingAccounts.value || isSwitchingAccounts.value || removingAccountId.value.length > 0) return
1953-
const targetAccount = accounts.value.find((account) => account.accountId === accountId) ?? null
1953+
const targetAccount = accounts.value.find((account) => account.storageId === storageId) ?? null
19541954
if (!targetAccount) return
1955-
if (confirmingRemoveAccountId.value !== accountId) {
1956-
confirmingRemoveAccountId.value = accountId
1955+
if (confirmingRemoveAccountId.value !== storageId) {
1956+
confirmingRemoveAccountId.value = storageId
19571957
return
19581958
}
19591959
if (targetAccount.isActive && isAccountSwitchBlocked.value) {
@@ -1964,9 +1964,9 @@ async function onRemoveAccount(accountId: string): Promise<void> {
19641964
const removedWasActive = targetAccount.isActive
19651965
accountActionError.value = ''
19661966
confirmingRemoveAccountId.value = ''
1967-
removingAccountId.value = accountId
1967+
removingAccountId.value = storageId
19681968
try {
1969-
const result = await removeAccount(accountId)
1969+
const result = await removeAccount(storageId)
19701970
accounts.value = result.accounts
19711971
stopPolling()
19721972
startPolling()

src/api/codexGateway.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,10 @@ export type ThreadTerminalQuickCommand = {
364364

365365
export type AccountsListResult = {
366366
activeAccountId: string | null
367+
activeStorageId: string | null
367368
accounts: UiAccountEntry[]
368369
importedAccountId?: string
370+
importedStorageId?: string
369371
}
370372

371373
type ThreadFileChangeFallbackEntry = {
@@ -459,16 +461,23 @@ function normalizeRateLimitSnapshot(value: unknown): UiRateLimitSnapshot | null
459461
}
460462
}
461463

462-
function normalizeAccountEntry(value: unknown, activeAccountId: string | null = null): UiAccountEntry | null {
464+
function normalizeAccountEntry(
465+
value: unknown,
466+
activeAccountId: string | null = null,
467+
activeStorageId: string | null = null,
468+
): UiAccountEntry | null {
463469
const record = asRecord(value)
464470
if (!record) return null
465471
const accountId = readString(record.accountId)
472+
const storageId = readString(record.storageId) ?? accountId
466473
const quotaStatusRaw = readString(record.quotaStatus)
467474
const quotaStatus: UiAccountQuotaStatus =
468475
quotaStatusRaw === 'loading' || quotaStatusRaw === 'ready' || quotaStatusRaw === 'error' ? quotaStatusRaw : 'idle'
469476
if (!accountId) return null
470477
return {
471478
accountId,
479+
storageId: storageId ?? accountId,
480+
userId: readString(record.userId),
472481
authMode: readString(record.authMode),
473482
email: readString(record.email),
474483
planType: readString(record.planType),
@@ -480,7 +489,7 @@ function normalizeAccountEntry(value: unknown, activeAccountId: string | null =
480489
quotaError: readString(record.quotaError),
481490
unavailableReason: normalizeAccountUnavailableReason(record.unavailableReason)
482491
?? (isPaymentRequiredErrorMessage(readString(record.quotaError)) ? 'payment_required' : null),
483-
isActive: readBoolean(record.isActive) ?? accountId === activeAccountId,
492+
isActive: readBoolean(record.isActive) ?? (storageId === activeStorageId || accountId === activeAccountId),
484493
}
485494
}
486495

@@ -1175,12 +1184,15 @@ export async function getAccountRateLimits(): Promise<UiRateLimitSnapshot | null
11751184
function normalizeAccountsListResult(payload: unknown): AccountsListResult {
11761185
const record = asRecord(payload)
11771186
const activeAccountId = readString(record?.activeAccountId)
1187+
const activeStorageId = readString(record?.activeStorageId)
11781188
const data = Array.isArray(record?.accounts) ? record?.accounts : []
11791189
return {
11801190
activeAccountId,
1191+
activeStorageId,
11811192
importedAccountId: readString(record?.importedAccountId) ?? undefined,
1193+
importedStorageId: readString(record?.importedStorageId) ?? undefined,
11821194
accounts: data
1183-
.map((entry) => normalizeAccountEntry(entry, activeAccountId))
1195+
.map((entry) => normalizeAccountEntry(entry, activeAccountId, activeStorageId))
11841196
.filter((entry): entry is UiAccountEntry => entry !== null),
11851197
}
11861198
}
@@ -1207,30 +1219,30 @@ export async function refreshAccountsFromAuth(): Promise<AccountsListResult> {
12071219
return normalizeAccountsListResult(envelope?.data)
12081220
}
12091221

1210-
export async function switchAccount(accountId: string): Promise<UiAccountEntry> {
1222+
export async function switchAccount(storageId: string): Promise<UiAccountEntry> {
12111223
const response = await fetch('/codex-api/accounts/switch', {
12121224
method: 'POST',
12131225
headers: { 'Content-Type': 'application/json' },
1214-
body: JSON.stringify({ accountId }),
1226+
body: JSON.stringify({ storageId }),
12151227
})
12161228
const payload = (await response.json()) as unknown
12171229
if (!response.ok) {
12181230
throw new Error(getErrorMessageFromPayload(payload, 'Failed to switch account'))
12191231
}
12201232
const envelope = asRecord(payload)
12211233
const data = asRecord(envelope?.data)
1222-
const account = normalizeAccountEntry(data?.account, readString(data?.activeAccountId))
1234+
const account = normalizeAccountEntry(data?.account, readString(data?.activeAccountId), readString(data?.activeStorageId))
12231235
if (!account) {
12241236
throw new Error('Failed to switch account')
12251237
}
12261238
return account
12271239
}
12281240

1229-
export async function removeAccount(accountId: string): Promise<AccountsListResult> {
1241+
export async function removeAccount(storageId: string): Promise<AccountsListResult> {
12301242
const response = await fetch('/codex-api/accounts/remove', {
12311243
method: 'POST',
12321244
headers: { 'Content-Type': 'application/json' },
1233-
body: JSON.stringify({ accountId }),
1245+
body: JSON.stringify({ storageId }),
12341246
})
12351247
const payload = (await response.json()) as unknown
12361248
if (!response.ok) {

0 commit comments

Comments
 (0)