sidekick-shared 0.27.6 → 0.28.0
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 +28 -0
- package/README.md +58 -18
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -2
- package/dist/claudePaths.d.ts +11 -0
- package/dist/claudePaths.js +61 -0
- package/dist/claudeProfiles.d.ts +2 -9
- package/dist/claudeProfiles.js +10 -25
- package/dist/codexPaths.d.ts +22 -0
- package/dist/codexPaths.js +67 -0
- package/dist/codexProfiles.d.ts +1 -4
- package/dist/codexProfiles.js +5 -43
- package/dist/doctor.js +2 -2
- package/dist/esm/accountChangeSignal.js +15 -0
- package/dist/esm/accountChanges.js +152 -0
- package/dist/esm/accountHealth.js +288 -0
- package/dist/esm/accountLaunch.js +145 -0
- package/dist/esm/accountLease.js +60 -0
- package/dist/esm/accountManager.js +606 -0
- package/dist/esm/accountRegistry.js +214 -0
- package/dist/esm/accountStatus.js +51 -0
- package/dist/esm/accountSwitch.js +74 -0
- package/dist/esm/accountSync.js +156 -0
- package/dist/esm/accountSyncTypes.js +4 -0
- package/dist/esm/accounts.js +1291 -0
- package/dist/esm/aggregation/EventAggregator.js +2034 -0
- package/dist/esm/aggregation/FrequencyTracker.js +69 -0
- package/dist/esm/aggregation/HeatmapTracker.js +89 -0
- package/dist/esm/aggregation/PatternExtractor.js +168 -0
- package/dist/esm/aggregation/costProvenance.js +31 -0
- package/dist/esm/aggregation/snapshot.js +161 -0
- package/dist/esm/aggregation/types.js +5 -0
- package/dist/esm/analytics/codeImpact.js +18 -0
- package/dist/esm/analytics/compactionLedger.js +37 -0
- package/dist/esm/analytics/qualityScore.js +112 -0
- package/dist/esm/autoSwitch.js +185 -0
- package/dist/esm/browser.js +34 -0
- package/dist/esm/claudeCredentialDoctor.js +239 -0
- package/dist/esm/claudeCredentialLedger.js +91 -0
- package/dist/esm/claudeCredentials.js +111 -0
- package/dist/esm/claudeLiveGuard.js +43 -0
- package/dist/esm/claudePaths.js +22 -0
- package/dist/esm/claudeProfiles.js +142 -0
- package/dist/esm/claudeRefreshLock.js +106 -0
- package/dist/esm/codexAuth.js +101 -0
- package/dist/esm/codexCredentialDoctor.js +265 -0
- package/dist/esm/codexCredentialStore.js +164 -0
- package/dist/esm/codexKeepAlive.js +112 -0
- package/dist/esm/codexPaths.js +172 -0
- package/dist/esm/codexProfiles.js +1251 -0
- package/dist/esm/codexQuota.js +564 -0
- package/dist/esm/codexQuotaWatcher.js +363 -0
- package/dist/esm/context/composer.js +71 -0
- package/dist/esm/context/sessionContext.js +546 -0
- package/dist/esm/credentialIO.js +283 -0
- package/dist/esm/credentials.js +49 -0
- package/dist/esm/doctor.js +305 -0
- package/dist/esm/ensureDefaultAccounts.js +62 -0
- package/dist/esm/errorHistory.js +56 -0
- package/dist/esm/extractors/errorTaxonomy.js +43 -0
- package/dist/esm/extractors/gatherAssets.js +32 -0
- package/dist/esm/extractors/sessionAssets.js +176 -0
- package/dist/esm/extractors/sources/claudeAssets.js +158 -0
- package/dist/esm/extractors/sources/codexAssets.js +234 -0
- package/dist/esm/extractors/tokenUsage.js +41 -0
- package/dist/esm/extractors/toolCall.js +142 -0
- package/dist/esm/failingTools.js +65 -0
- package/dist/esm/fileHead.js +27 -0
- package/dist/esm/formatters/eventHighlighter.js +332 -0
- package/dist/esm/formatters/noiseClassifier.js +209 -0
- package/dist/esm/formatters/sessionDump.js +328 -0
- package/dist/esm/formatters/toolSummary.js +226 -0
- package/dist/esm/formatting.js +82 -0
- package/dist/esm/handoffUrl.js +23 -0
- package/dist/esm/historicalStore.js +305 -0
- package/dist/esm/humanPrompt.js +88 -0
- package/dist/esm/index.js +213 -0
- package/dist/esm/keepAlive.js +189 -0
- package/dist/esm/liveCredentialGuard.js +33 -0
- package/dist/esm/modelAliases.js +51 -0
- package/dist/esm/modelCatalog.js +139 -0
- package/dist/esm/modelContext.js +280 -0
- package/dist/esm/modelInfo.js +832 -0
- package/dist/esm/multiProviderQuotaService.js +450 -0
- package/dist/esm/node.js +46 -0
- package/dist/esm/observedContextWindows.js +165 -0
- package/dist/esm/observedSessionCollector.js +939 -0
- package/dist/esm/parsers/changelogParser.js +71 -0
- package/dist/esm/parsers/codexHistory.js +89 -0
- package/dist/esm/parsers/codexParser.js +906 -0
- package/dist/esm/parsers/debugLogParser.js +126 -0
- package/dist/esm/parsers/jsonl.js +66 -0
- package/dist/esm/parsers/mcpToolName.js +28 -0
- package/dist/esm/parsers/openCodeParser.js +625 -0
- package/dist/esm/parsers/planExtractor.js +335 -0
- package/dist/esm/parsers/sessionActivityDetector.js +246 -0
- package/dist/esm/parsers/sessionPathResolver.js +713 -0
- package/dist/esm/parsers/subagentScanner.js +360 -0
- package/dist/esm/parsers/subagentStatsCache.js +45 -0
- package/dist/esm/parsers/subagentTraceParser.js +325 -0
- package/dist/esm/paths.js +134 -0
- package/dist/esm/peakHours.js +194 -0
- package/dist/esm/phrases.js +1330 -0
- package/dist/esm/pricingCatalog.js +247 -0
- package/dist/esm/processDetection.js +200 -0
- package/dist/esm/projectMigration.js +59 -0
- package/dist/esm/promptHistory.js +1221 -0
- package/dist/esm/providerFailure.js +362 -0
- package/dist/esm/providerIds.js +6 -0
- package/dist/esm/providerQuota.js +1 -0
- package/dist/esm/providerServiceStatus.js +145 -0
- package/dist/esm/providerServiceStatusTypes.js +1 -0
- package/dist/esm/providerStatus.js +117 -0
- package/dist/esm/providers/claudeCode.js +575 -0
- package/dist/esm/providers/claudeListing.js +121 -0
- package/dist/esm/providers/codex.js +1034 -0
- package/dist/esm/providers/codexDatabase.js +297 -0
- package/dist/esm/providers/codexRollouts.js +215 -0
- package/dist/esm/providers/detect.js +185 -0
- package/dist/esm/providers/diagnostics.js +83 -0
- package/dist/esm/providers/directoryListingCache.js +206 -0
- package/dist/esm/providers/factory.js +54 -0
- package/dist/esm/providers/openCode.js +2008 -0
- package/dist/esm/providers/openCodeDatabase.js +419 -0
- package/dist/esm/providers/rolloutWalker.js +252 -0
- package/dist/esm/providers/types.js +12 -0
- package/dist/esm/quota.js +182 -0
- package/dist/esm/quotaHistory.js +364 -0
- package/dist/esm/quotaPoller.js +230 -0
- package/dist/esm/quotaPresentation.js +97 -0
- package/dist/esm/quotaResolver.js +231 -0
- package/dist/esm/quotaSnapshots.js +130 -0
- package/dist/esm/quotaThresholds.js +85 -0
- package/dist/esm/readers/decisions.js +24 -0
- package/dist/esm/readers/handoff.js +20 -0
- package/dist/esm/readers/helpers.js +27 -0
- package/dist/esm/readers/history.js +9 -0
- package/dist/esm/readers/notes.js +48 -0
- package/dist/esm/readers/plans.js +225 -0
- package/dist/esm/readers/tasks.js +19 -0
- package/dist/esm/report/htmlHelpers.js +280 -0
- package/dist/esm/report/htmlReportGenerator.js +704 -0
- package/dist/esm/report/index.js +7 -0
- package/dist/esm/report/logo.js +2 -0
- package/dist/esm/report/openBrowser.js +19 -0
- package/dist/esm/report/sessionReportInputs.js +33 -0
- package/dist/esm/report/transcriptParser.js +83 -0
- package/dist/esm/report/types.js +4 -0
- package/dist/esm/schemas/accountManager.js +204 -0
- package/dist/esm/schemas/accountStatus.js +25 -0
- package/dist/esm/schemas/assistantTurn.js +74 -0
- package/dist/esm/schemas/index.js +25 -0
- package/dist/esm/schemas/observedSessionV1.js +77 -0
- package/dist/esm/schemas/providerFailure.js +37 -0
- package/dist/esm/schemas/quota.js +132 -0
- package/dist/esm/schemas/quotaHistory.js +40 -0
- package/dist/esm/schemas/sessionEvent.js +216 -0
- package/dist/esm/schemas/stateFile.js +75 -0
- package/dist/esm/schemas/tokenEstimation.js +7 -0
- package/dist/esm/schemas/transcript.js +86 -0
- package/dist/esm/schemas/usageNormalization.js +38 -0
- package/dist/esm/search/advancedFilter.js +197 -0
- package/dist/esm/search/sessionSearch.js +66 -0
- package/dist/esm/sessionFingerprint.js +40 -0
- package/dist/esm/sessionMonitor.js +198 -0
- package/dist/esm/sessionPreviews.js +522 -0
- package/dist/esm/sessionPromptHistory.js +254 -0
- package/dist/esm/sessionPromptSignals.js +364 -0
- package/dist/esm/sessionStats.js +160 -0
- package/dist/esm/sessionTokenTotals.js +98 -0
- package/dist/esm/sessionTranscripts.js +49 -0
- package/dist/esm/stateFile.js +119 -0
- package/dist/esm/statusline/BurnRateCalculator.js +40 -0
- package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
- package/dist/esm/statusline/formatter.js +122 -0
- package/dist/esm/statusline/index.js +8 -0
- package/dist/esm/terminalSync.js +95 -0
- package/dist/esm/testing/credentialGuard.js +197 -0
- package/dist/esm/testing/index.js +12 -0
- package/dist/esm/testing/sessionLogFixtures.js +128 -0
- package/dist/esm/tokenEstimation.js +67 -0
- package/dist/esm/tokenSummary.js +113 -0
- package/dist/esm/toolCategory.js +82 -0
- package/dist/esm/transcript.js +257 -0
- package/dist/esm/turns/assistantTurn.js +373 -0
- package/dist/esm/types/codex.js +23 -0
- package/dist/esm/types/decisionLog.js +5 -0
- package/dist/esm/types/historicalData.js +47 -0
- package/dist/esm/types/knowledgeNote.js +15 -0
- package/dist/esm/types/observedSessionV1.js +257 -0
- package/dist/esm/types/opencode.js +12 -0
- package/dist/esm/types/plan.js +7 -0
- package/dist/esm/types/sessionEvent.js +10 -0
- package/dist/esm/types/taskPersistence.js +12 -0
- package/dist/esm/usage/billingBlocks.js +148 -0
- package/dist/esm/usage/claudeUsageDedupe.js +189 -0
- package/dist/esm/usage/importSessionHistory.js +84 -0
- package/dist/esm/usage/subagentUsage.js +18 -0
- package/dist/esm/usage/usageEvents.js +402 -0
- package/dist/esm/usage/usageReports.js +202 -0
- package/dist/esm/usageNormalization.js +95 -0
- package/dist/esm/watchers/eventBridge.js +219 -0
- package/dist/esm/watchers/factory.js +63 -0
- package/dist/esm/watchers/jsonlTail.js +289 -0
- package/dist/esm/watchers/jsonlWatcher.js +435 -0
- package/dist/esm/watchers/providerReaderWatcher.js +118 -0
- package/dist/esm/watchers/sqliteWatcher.js +285 -0
- package/dist/esm/watchers/types.js +4 -0
- package/dist/esm/workspaceRoots.js +56 -0
- package/dist/esm/writers/atomic.js +375 -0
- package/dist/esm/writers/decisions.js +51 -0
- package/dist/esm/writers/notes.js +46 -0
- package/dist/esm/writers/tasks.js +63 -0
- package/dist/esm/zaiQuota.js +323 -0
- package/dist/esm/zaiQuotaApi.js +271 -0
- package/dist/esm/zaiQuotaWatcher.js +265 -0
- package/dist/fileHead.d.ts +22 -0
- package/dist/fileHead.js +33 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +49 -15
- package/dist/node.d.ts +13 -1
- package/dist/node.js +43 -2
- package/dist/parsers/codexHistory.js +2 -2
- package/dist/parsers/codexParser.d.ts +12 -0
- package/dist/parsers/codexParser.js +34 -12
- package/dist/parsers/debugLogParser.js +2 -2
- package/dist/parsers/sessionPathResolver.js +5 -4
- package/dist/promptHistory.d.ts +32 -0
- package/dist/promptHistory.js +115 -59
- package/dist/providerFailure.d.ts +12 -6
- package/dist/providerFailure.js +45 -0
- package/dist/providers/claudeCode.js +7 -99
- package/dist/providers/claudeListing.d.ts +22 -0
- package/dist/providers/claudeListing.js +158 -0
- package/dist/providers/codex.d.ts +3 -1
- package/dist/providers/codex.js +26 -227
- package/dist/providers/codexRollouts.d.ts +44 -0
- package/dist/providers/codexRollouts.js +261 -0
- package/dist/providers/detect.js +4 -4
- package/dist/readers/plans.js +3 -3
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.js +7 -2
- package/dist/schemas/providerFailure.d.ts +154 -0
- package/dist/schemas/providerFailure.js +40 -0
- package/dist/sessionPromptHistory.d.ts +12 -7
- package/dist/sessionPromptHistory.js +15 -3
- package/dist/sessionPromptSignals.d.ts +9 -0
- package/dist/sessionPromptSignals.js +13 -0
- package/dist/testing/credentialGuard.d.ts +23 -0
- package/dist/testing/credentialGuard.js +233 -0
- package/dist/testing/index.d.ts +14 -0
- package/dist/testing/index.js +22 -0
- package/dist/testing/sessionLogFixtures.d.ts +28 -0
- package/dist/testing/sessionLogFixtures.js +169 -0
- package/dist/tokenEstimation.d.ts +2 -0
- package/dist/tokenEstimation.js +21 -11
- package/dist/toolCategory.d.ts +18 -0
- package/dist/toolCategory.js +86 -0
- package/dist/types/codex.d.ts +4 -0
- package/dist/usage/usageEvents.d.ts +16 -0
- package/dist/usage/usageEvents.js +53 -9
- package/dist/watchers/jsonlTail.d.ts +26 -1
- package/dist/watchers/jsonlTail.js +136 -28
- package/dist/watchers/jsonlWatcher.js +7 -0
- package/dist/watchers/providerReaderWatcher.js +1 -0
- package/dist/watchers/types.d.ts +6 -0
- package/dist/workspaceRoots.d.ts +23 -0
- package/dist/workspaceRoots.js +93 -0
- package/package.json +22 -2
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { execFile, spawnSync } from 'child_process';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import { getAccountsDir, getActiveSavedAccount, listSavedAccountProfiles, removeSavedAccountProfile, setActiveSavedAccount, upsertSavedAccountProfile, } from './accountRegistry';
|
|
6
|
+
// auth.json must be copied byte-for-byte (atomicWriteFile, not atomicWriteJson):
|
|
7
|
+
// re-serializing would drop fields added by newer codex versions, and the
|
|
8
|
+
// rotated refresh token inside is only valid in its freshest form.
|
|
9
|
+
import { atomicWriteFileSync as atomicWriteFile, atomicWriteJsonSync as atomicWriteJson, withFileLockSync, } from './writers/atomic';
|
|
10
|
+
import { identitiesMatch, codexRefreshFingerprint, compareCodexAuthFreshness, readAuthIdentityFromRaw, readLastRefresh, STALE_AUTH_THRESHOLD_MS, } from './codexAuth';
|
|
11
|
+
import { ensureCodexProfileDirs, forceFileCredentialStore, getCodexCredentialStoreMode, getCodexProfileDir, getCodexProfileHome, getCodexProfileStatePath, getCodexBackupPath, getCodexLoginDir, getCodexLoginsDir, getCodexLoginHome, getCodexLoginStatePath, getCodexProfilesDir, getSystemCodexHome, readFileOrNull, resolveSidekickCodexHome, } from './codexPaths';
|
|
12
|
+
import { getAccountHealth, readHealthSidecar, writeCodexHealthSidecar, } from './accountHealth';
|
|
13
|
+
import { finishSwitchResult, switchFailure, writeLastSwitch, } from './accountSwitch';
|
|
14
|
+
import { emptyProviderSyncReport } from './accountSyncTypes';
|
|
15
|
+
import { inspectRunningAccountConsumers, inspectRunningAccountConsumersSync, } from './processDetection';
|
|
16
|
+
import { readQuotaSnapshot, writeQuotaSnapshot } from './quotaSnapshots';
|
|
17
|
+
import { acquireAccountLease, isAccountLeaseHeld, releaseAccountLease } from './accountLease';
|
|
18
|
+
import { decideLiveCredentialWrite } from './liveCredentialGuard';
|
|
19
|
+
import { readCodexCredentialStore, readCodexBackup, writeCodexBackup, codexLoginOnly, codexSnapshotFingerprint, codexStoreMatches, codexInstalledSnapshot, isCodexLogin, isLegacyCodexLogin, isSidekickCodexHome, writeInactiveCodexCredentialStore, removeCodexProfileCredentials, writeLiveCodexCredentialStoreUnguarded, } from './codexCredentialStore';
|
|
20
|
+
export { getCodexProfilesDir, getCodexProfileHome, getSystemCodexHome, getCodexCredentialStoreMode, getCodexMonitoringHomes, resolveSidekickCodexHome, getCodexExecutionEnv, } from './codexPaths';
|
|
21
|
+
export const CODEX_KEYRING_FIX_HINT = 'Set `cli_auth_credentials_store = "file"` in ~/.codex/config.toml and run `codex login` again.';
|
|
22
|
+
function readPendingProfile(profileId) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(getCodexLoginStatePath(profileId), 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writePendingProfile(profileId, pending) {
|
|
31
|
+
const dir = getCodexLoginDir(profileId);
|
|
32
|
+
fs.mkdirSync(path.join(dir, 'codex-home'), { recursive: true, mode: 0o700 });
|
|
33
|
+
atomicWriteJson(path.join(dir, 'profile.json'), pending);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Copy the live `config.toml` into a profile home, forcing file-based
|
|
37
|
+
* credential storage so an isolated `codex login` writes `auth.json` (a
|
|
38
|
+
* keyring entry keyed to the profile home could never be swapped).
|
|
39
|
+
*/
|
|
40
|
+
function copySourceCodexConfig(sourceHome, targetHome) {
|
|
41
|
+
const source = readFileOrNull(path.join(sourceHome, 'config.toml')) ?? '';
|
|
42
|
+
atomicWriteFile(path.join(targetHome, 'config.toml'), forceFileCredentialStore(source));
|
|
43
|
+
}
|
|
44
|
+
/** Read the freshest saved login without creating a CLI-usable copy. */
|
|
45
|
+
export function readStoredCodexAccount(profileId) {
|
|
46
|
+
const home = readCodexCredentialStore(getCodexProfileHome(profileId));
|
|
47
|
+
const backup = readCodexBackup(profileId);
|
|
48
|
+
if (home.status === 'ok' && backup.status === 'ok') {
|
|
49
|
+
return codexLoginOnly(compareCodexAuthFreshness(backup.snapshot.authRaw, home.snapshot.authRaw) > 0
|
|
50
|
+
? backup.snapshot
|
|
51
|
+
: home.snapshot);
|
|
52
|
+
}
|
|
53
|
+
return home.status === 'ok'
|
|
54
|
+
? codexLoginOnly(home.snapshot)
|
|
55
|
+
: backup.status === 'ok'
|
|
56
|
+
? backup.snapshot
|
|
57
|
+
: null;
|
|
58
|
+
}
|
|
59
|
+
function profileMatchesSnapshot(profile, snapshot) {
|
|
60
|
+
const identity = readAuthIdentityFromRaw(snapshot.authRaw);
|
|
61
|
+
if (identitiesMatch(identity, profileIdentity(profile)))
|
|
62
|
+
return true;
|
|
63
|
+
// API keys have no account claims. Match their complete credential bytes, never the active pointer.
|
|
64
|
+
if (identity?.authMode === 'api-key' ||
|
|
65
|
+
(!snapshot.authRaw && isLegacyCodexLogin(snapshot.legacyRaw))) {
|
|
66
|
+
const saved = readStoredCodexAccount(profile.id);
|
|
67
|
+
return (saved !== null &&
|
|
68
|
+
codexSnapshotFingerprint(codexLoginOnly(snapshot)) === codexSnapshotFingerprint(saved));
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
export function isLiveCodexProfile(profile) {
|
|
73
|
+
const live = readCodexCredentialStore();
|
|
74
|
+
return live.status === 'ok' && profileMatchesSnapshot(profile, live.snapshot);
|
|
75
|
+
}
|
|
76
|
+
/** Caller holds the swap lock. Live-account credentials only enter private storage. */
|
|
77
|
+
export function storeCodexProfileAuthUnlocked(profileId, authRaw, legacyRaw, source) {
|
|
78
|
+
const candidate = codexLoginOnly({ authRaw, legacyRaw });
|
|
79
|
+
const profile = listCodexAccounts().find((p) => p.id === profileId);
|
|
80
|
+
const identity = readAuthIdentityFromRaw(authRaw);
|
|
81
|
+
if (profile &&
|
|
82
|
+
identity?.authMode === 'chatgpt' &&
|
|
83
|
+
(identity.email || identity.workspaceId) &&
|
|
84
|
+
(profile.email || profile.metadata?.workspaceId) &&
|
|
85
|
+
!identitiesMatch(identity, profileIdentity(profile))) {
|
|
86
|
+
throw new Error('The Codex credential belongs to a different saved account.');
|
|
87
|
+
}
|
|
88
|
+
const old = readCodexBackup(profileId);
|
|
89
|
+
const chosen = old.status === 'ok' && compareCodexAuthFreshness(old.snapshot.authRaw, authRaw) > 0
|
|
90
|
+
? old.snapshot
|
|
91
|
+
: candidate;
|
|
92
|
+
writeCodexBackup(profileId, chosen);
|
|
93
|
+
const live = readCodexCredentialStore();
|
|
94
|
+
const sameLink = live.status === 'ok' &&
|
|
95
|
+
codexRefreshFingerprint(chosen.authRaw) !== null &&
|
|
96
|
+
codexRefreshFingerprint(chosen.authRaw) === codexRefreshFingerprint(live.snapshot.authRaw);
|
|
97
|
+
const isLive = live.status === 'ok' &&
|
|
98
|
+
(profile
|
|
99
|
+
? profileMatchesSnapshot(profile, live.snapshot)
|
|
100
|
+
: identitiesMatch(readAuthIdentityFromRaw(chosen.authRaw), readAuthIdentityFromRaw(live.snapshot.authRaw)));
|
|
101
|
+
if (!isSidekickCodexHome(getSystemCodexHome()) &&
|
|
102
|
+
live.status !== 'error' &&
|
|
103
|
+
live.status !== 'corrupt' &&
|
|
104
|
+
!isLive &&
|
|
105
|
+
!sameLink) {
|
|
106
|
+
writeInactiveCodexCredentialStore(getCodexProfileHome(profileId), chosen);
|
|
107
|
+
}
|
|
108
|
+
if (chosen.authRaw)
|
|
109
|
+
writeCodexHealthSidecar(profileId, chosen.authRaw, source);
|
|
110
|
+
}
|
|
111
|
+
export function storeCodexProfileAuth(profileId, authRaw, legacyRaw, source) {
|
|
112
|
+
withCodexAuthSwapLock(() => storeCodexProfileAuthUnlocked(profileId, authRaw, legacyRaw, source));
|
|
113
|
+
}
|
|
114
|
+
function consumerBlocker(scan) {
|
|
115
|
+
if (!scan?.ok)
|
|
116
|
+
return 'Could not verify that Codex consumers are stopped; retry after closing Codex.';
|
|
117
|
+
if (scan.consumers.length)
|
|
118
|
+
return 'Close running Codex CLI, app, and IDE sessions before changing credentials. --force cannot bypass this check.';
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/** The sole installer of live Codex credentials, including rollback and repair. */
|
|
122
|
+
function installIntoLiveCodexStore(candidate, accountId, intent, context) {
|
|
123
|
+
if (isAccountLeaseHeld('codex', accountId))
|
|
124
|
+
return { ok: false, reason: 'Keep-alive holds this account; retry when it finishes.' };
|
|
125
|
+
const blocker = consumerBlocker(context.scan);
|
|
126
|
+
if (blocker)
|
|
127
|
+
return { ok: false, reason: blocker };
|
|
128
|
+
const mode = getCodexCredentialStoreMode();
|
|
129
|
+
if (mode !== 'file' && mode !== 'unknown')
|
|
130
|
+
return { ok: false, reason: CODEX_KEYRING_FIX_HINT };
|
|
131
|
+
if (isSidekickCodexHome(getSystemCodexHome()))
|
|
132
|
+
return { ok: false, reason: 'Unset CODEX_HOME; it points at a Sidekick-owned home.' };
|
|
133
|
+
const current = readCodexCredentialStore();
|
|
134
|
+
if (context.before.status === 'error' || !codexStoreMatches(current, context.before.snapshot)) {
|
|
135
|
+
return { ok: false, reason: 'The live Codex credential changed; nothing was overwritten.' };
|
|
136
|
+
}
|
|
137
|
+
const raw = current.status === 'ok' ? current.snapshot.authRaw : null;
|
|
138
|
+
const fp = codexRefreshFingerprint(raw);
|
|
139
|
+
const candidateIdentity = readAuthIdentityFromRaw(candidate.authRaw);
|
|
140
|
+
const candidateProfile = listCodexAccounts().find((p) => p.id === accountId);
|
|
141
|
+
const validIdentity = candidateIdentity?.authMode !== 'chatgpt' ||
|
|
142
|
+
(candidateProfile !== undefined &&
|
|
143
|
+
identitiesMatch(candidateIdentity, profileIdentity(candidateProfile)));
|
|
144
|
+
const decision = decideLiveCredentialWrite({
|
|
145
|
+
provider: 'Codex',
|
|
146
|
+
intent,
|
|
147
|
+
liveStatus: current.status,
|
|
148
|
+
candidateValid: validIdentity && (isCodexLogin(candidate.authRaw) || isLegacyCodexLogin(candidate.legacyRaw)),
|
|
149
|
+
liveHasCredential: current.status === 'ok',
|
|
150
|
+
liveAccountId: context.liveAccountId,
|
|
151
|
+
candidateAccountId: accountId,
|
|
152
|
+
sameLink: fp !== null && fp === codexRefreshFingerprint(candidate.authRaw),
|
|
153
|
+
strictlyNewer: compareCodexAuthFreshness(candidate.authRaw, raw) > 0,
|
|
154
|
+
capturedOutgoing: context.capturedOutgoing,
|
|
155
|
+
matchesWritten: context.written !== undefined && codexStoreMatches(current, context.written),
|
|
156
|
+
rejectCorrupt: true,
|
|
157
|
+
});
|
|
158
|
+
if (!decision.allow)
|
|
159
|
+
return { ok: false, reason: decision.reason };
|
|
160
|
+
if (current.status === 'error')
|
|
161
|
+
return { ok: false, reason: 'The live Codex login could not be read.' };
|
|
162
|
+
let expected;
|
|
163
|
+
try {
|
|
164
|
+
expected = codexInstalledSnapshot(current.snapshot, candidate);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: 'Legacy credentials would replace unrelated MCP data; sign in again.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
writeLiveCodexCredentialStoreUnguarded(expected);
|
|
174
|
+
if (!codexStoreMatches(readCodexCredentialStore(), expected))
|
|
175
|
+
throw new Error('verification failed');
|
|
176
|
+
return { ok: true, installed: expected };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { ok: false, reason: 'The live Codex write did not verify.', attempted: expected };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function readMetadataFromAuthJson(codexHome) {
|
|
183
|
+
const identity = readAuthIdentityFromRaw(readFileOrNull(path.join(codexHome, 'auth.json')));
|
|
184
|
+
if (!identity)
|
|
185
|
+
return {};
|
|
186
|
+
return {
|
|
187
|
+
email: identity.email,
|
|
188
|
+
workspaceId: identity.workspaceId,
|
|
189
|
+
planType: identity.planType,
|
|
190
|
+
authMode: identity.authMode,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function readMetadataFromLegacyCredentials(codexHome) {
|
|
194
|
+
const legacyPath = path.join(codexHome, '.credentials.json');
|
|
195
|
+
if (!fs.existsSync(legacyPath))
|
|
196
|
+
return {};
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(fs.readFileSync(legacyPath, 'utf8'));
|
|
199
|
+
if (typeof parsed.OPENAI_API_KEY === 'string' || typeof parsed.CODEX_API_KEY === 'string') {
|
|
200
|
+
return { authMode: 'api-key' };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Ignore malformed legacy credentials.
|
|
205
|
+
}
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
const PROBE_TIMEOUT_MS = 4000;
|
|
209
|
+
export function parseCodexLoginStatusOutput(status, stdout) {
|
|
210
|
+
const trimmed = stdout.trim();
|
|
211
|
+
if (status === 0 && /^Logged in/i.test(trimmed)) {
|
|
212
|
+
if (/API key/i.test(trimmed)) {
|
|
213
|
+
return { loggedIn: true, authMode: 'api-key' };
|
|
214
|
+
}
|
|
215
|
+
if (/ChatGPT/i.test(trimmed)) {
|
|
216
|
+
return { loggedIn: true, authMode: 'chatgpt' };
|
|
217
|
+
}
|
|
218
|
+
return { loggedIn: true };
|
|
219
|
+
}
|
|
220
|
+
return { loggedIn: false };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Blocks the caller's event loop for up to {@link PROBE_TIMEOUT_MS}. Async code
|
|
224
|
+
* paths must use {@link getCodexLoginStatusAsync} instead — external consumers
|
|
225
|
+
* embed this package in processes where a blocked event loop freezes all IPC.
|
|
226
|
+
*/
|
|
227
|
+
export function getCodexLoginStatus(codexHome) {
|
|
228
|
+
try {
|
|
229
|
+
const env = { ...process.env, CODEX_HOME: codexHome };
|
|
230
|
+
const result = spawnSync('codex', ['login', 'status'], {
|
|
231
|
+
encoding: 'utf8',
|
|
232
|
+
env,
|
|
233
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
234
|
+
killSignal: 'SIGKILL',
|
|
235
|
+
});
|
|
236
|
+
return parseCodexLoginStatusOutput(result.status, `${String(result.stdout ?? '')}\n${String(result.stderr ?? '')}`);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Ignore missing CLI or spawn errors.
|
|
240
|
+
}
|
|
241
|
+
return { loggedIn: false };
|
|
242
|
+
}
|
|
243
|
+
export function getCodexLoginStatusAsync(codexHome) {
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
try {
|
|
246
|
+
const env = { ...process.env, CODEX_HOME: codexHome };
|
|
247
|
+
execFile('codex', ['login', 'status'], { encoding: 'utf8', env, timeout: PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' }, (error, stdout, stderr) => {
|
|
248
|
+
// A missing CLI, non-zero exit, or timeout all mean "not logged in".
|
|
249
|
+
resolve(error
|
|
250
|
+
? { loggedIn: false }
|
|
251
|
+
: parseCodexLoginStatusOutput(0, `${String(stdout ?? '')}\n${String(stderr ?? '')}`));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
resolve({ loggedIn: false });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** File-based metadata only — never spawns the codex CLI. */
|
|
260
|
+
function readCodexAccountMetadataFromFiles(codexHome) {
|
|
261
|
+
const fromAuth = readMetadataFromAuthJson(codexHome);
|
|
262
|
+
if (fromAuth.email || fromAuth.workspaceId || fromAuth.planType || fromAuth.authMode) {
|
|
263
|
+
return fromAuth;
|
|
264
|
+
}
|
|
265
|
+
const fromLegacy = readMetadataFromLegacyCredentials(codexHome);
|
|
266
|
+
if (fromLegacy.authMode) {
|
|
267
|
+
return fromLegacy;
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
function metadataFromLoginStatus(status) {
|
|
272
|
+
return status.loggedIn ? { authMode: status.authMode ?? 'unknown' } : {};
|
|
273
|
+
}
|
|
274
|
+
export function readCodexAccountMetadata(codexHome) {
|
|
275
|
+
return (readCodexAccountMetadataFromFiles(codexHome) ??
|
|
276
|
+
metadataFromLoginStatus(getCodexLoginStatus(codexHome)));
|
|
277
|
+
}
|
|
278
|
+
/** {@link readCodexAccountMetadata} without blocking the event loop on the CLI probe. */
|
|
279
|
+
export async function readCodexAccountMetadataAsync(codexHome) {
|
|
280
|
+
return (readCodexAccountMetadataFromFiles(codexHome) ??
|
|
281
|
+
metadataFromLoginStatus(await getCodexLoginStatusAsync(codexHome)));
|
|
282
|
+
}
|
|
283
|
+
function hasCodexCredentialFiles(codexHome) {
|
|
284
|
+
return readCodexCredentialStore(codexHome).status === 'ok';
|
|
285
|
+
}
|
|
286
|
+
export function isCodexProfileAuthenticated(codexHome) {
|
|
287
|
+
return hasCodexCredentialFiles(codexHome);
|
|
288
|
+
}
|
|
289
|
+
export async function isCodexProfileAuthenticatedAsync(codexHome) {
|
|
290
|
+
return isCodexProfileAuthenticated(codexHome);
|
|
291
|
+
}
|
|
292
|
+
function ensureUniqueCodexLabel(label, excludeId) {
|
|
293
|
+
const normalized = label.trim().toLowerCase();
|
|
294
|
+
const conflict = listCodexAccounts().find((account) => account.id !== excludeId && (account.label ?? '').trim().toLowerCase() === normalized);
|
|
295
|
+
return conflict ? `A Codex account named "${label}" already exists.` : null;
|
|
296
|
+
}
|
|
297
|
+
export function listCodexAccounts() {
|
|
298
|
+
return listSavedAccountProfiles('codex');
|
|
299
|
+
}
|
|
300
|
+
export function getActiveCodexAccount() {
|
|
301
|
+
return getActiveSavedAccount('codex');
|
|
302
|
+
}
|
|
303
|
+
function profileIdentity(profile) {
|
|
304
|
+
return {
|
|
305
|
+
email: profile.email ?? profile.metadata?.email,
|
|
306
|
+
workspaceId: profile.metadata?.workspaceId,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Resolves the *currently logged-in* Codex account for display, preferring the
|
|
311
|
+
* live `auth.json` identity over the saved registry pointer (which only sidekick's
|
|
312
|
+
* own switch flow updates and therefore goes stale after a native `codex login`).
|
|
313
|
+
*
|
|
314
|
+
* Uses the cheap JWT decode from `auth.json` directly — NOT `readCodexAccountMetadata`,
|
|
315
|
+
* whose fallback spawns `codex login status` (a multi-second subprocess) unsuitable
|
|
316
|
+
* for a render path.
|
|
317
|
+
*
|
|
318
|
+
* Safe self-heal: when the live identity unambiguously matches a saved profile that
|
|
319
|
+
* isn't the current active pointer, the pointer is re-pointed so registry-keyed data
|
|
320
|
+
* (quota history, auto-switch) tracks reality too. Never creates or deletes profiles.
|
|
321
|
+
*/
|
|
322
|
+
export function resolveActiveCodexAccount(options = {}) {
|
|
323
|
+
const selfHeal = options.selfHeal ?? true;
|
|
324
|
+
const identity = readAuthIdentityFromRaw(readFileOrNull(path.join(resolveSidekickCodexHome(), 'auth.json')));
|
|
325
|
+
if (identity && (identity.email || identity.workspaceId)) {
|
|
326
|
+
const match = findProfileForIdentity(identity);
|
|
327
|
+
if (match) {
|
|
328
|
+
const active = getActiveSavedAccount('codex');
|
|
329
|
+
// See resolveActiveClaudeAccount: hot paths opt out of the repair write.
|
|
330
|
+
if (selfHeal && (!active || active.id !== match.id)) {
|
|
331
|
+
// Self-heal is best-effort: a registry write failure (read-only/full
|
|
332
|
+
// disk) must never break display, extension activation, or the quota
|
|
333
|
+
// watcher's hot path. We still return the correct live identity below.
|
|
334
|
+
try {
|
|
335
|
+
setActiveSavedAccount('codex', match.id, { silent: true });
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
/* keep going with the live identity */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
email: identity.email ?? match?.email ?? match?.metadata?.email,
|
|
344
|
+
label: match?.label,
|
|
345
|
+
providerAccountId: match?.providerAccountId ?? identity.workspaceId,
|
|
346
|
+
registryAccountId: match?.id,
|
|
347
|
+
source: 'live',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// api-key auth, unparseable token, or logged out → fall back to the registry.
|
|
351
|
+
const active = getActiveCodexAccount();
|
|
352
|
+
if (active) {
|
|
353
|
+
return {
|
|
354
|
+
email: active.email ?? active.metadata?.email,
|
|
355
|
+
label: active.label,
|
|
356
|
+
providerAccountId: active.providerAccountId,
|
|
357
|
+
registryAccountId: active.id,
|
|
358
|
+
source: 'registry',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return { source: 'none' };
|
|
362
|
+
}
|
|
363
|
+
// ── Prepare / finalize (add account) ─────────────────────────────────────
|
|
364
|
+
function keyringRefusal(mode) {
|
|
365
|
+
return {
|
|
366
|
+
success: false,
|
|
367
|
+
needsLogin: false,
|
|
368
|
+
error: `Codex credential storage (${mode}) is not file-based. ${CODEX_KEYRING_FIX_HINT}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function prepareCodexAccountCore(label, importCurrent, scan) {
|
|
372
|
+
const trimmedLabel = label.trim();
|
|
373
|
+
if (!trimmedLabel)
|
|
374
|
+
return { success: false, error: 'Codex accounts require a non-empty label.' };
|
|
375
|
+
const labelError = ensureUniqueCodexLabel(trimmedLabel);
|
|
376
|
+
if (labelError)
|
|
377
|
+
return { success: false, error: labelError };
|
|
378
|
+
const sourceHome = getSystemCodexHome();
|
|
379
|
+
const mode = getCodexCredentialStoreMode(sourceHome);
|
|
380
|
+
if (mode !== 'file' && mode !== 'unknown')
|
|
381
|
+
return keyringRefusal(mode);
|
|
382
|
+
if (isSidekickCodexHome(sourceHome))
|
|
383
|
+
return {
|
|
384
|
+
success: false,
|
|
385
|
+
error: 'Unset CODEX_HOME before adding an account; it names a Sidekick-owned home.',
|
|
386
|
+
};
|
|
387
|
+
const live = readCodexCredentialStore();
|
|
388
|
+
if (importCurrent && live.status === 'ok') {
|
|
389
|
+
const sync = syncCodexLiveStateUnlocked('live-sync', { scan });
|
|
390
|
+
const profile = listCodexAccounts().find((p) => profileMatchesSnapshot(p, live.snapshot));
|
|
391
|
+
if (!profile)
|
|
392
|
+
return {
|
|
393
|
+
success: false,
|
|
394
|
+
error: sync.warnings[0] ?? 'The live Codex login has no identifiable account.',
|
|
395
|
+
};
|
|
396
|
+
const manual = profile.metadata?.origin === 'live-sync' || profile.label === profile.email;
|
|
397
|
+
if (manual)
|
|
398
|
+
upsertSavedAccountProfile({
|
|
399
|
+
...profile,
|
|
400
|
+
label: trimmedLabel,
|
|
401
|
+
metadata: { ...profile.metadata, origin: 'manual' },
|
|
402
|
+
});
|
|
403
|
+
return {
|
|
404
|
+
success: true,
|
|
405
|
+
profileId: profile.id,
|
|
406
|
+
codexHome: sourceHome,
|
|
407
|
+
needsLogin: false,
|
|
408
|
+
warning: sync.warnings.join(' ') ||
|
|
409
|
+
(!sync.registered
|
|
410
|
+
? `This Codex login is already saved as ${profile.label ?? profile.id}.`
|
|
411
|
+
: undefined),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (importCurrent && (live.status === 'corrupt' || live.status === 'error')) {
|
|
415
|
+
return {
|
|
416
|
+
success: false,
|
|
417
|
+
error: 'The live Codex credential could not be read; retry after Codex finishes writing it.',
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const profileId = randomUUID();
|
|
421
|
+
try {
|
|
422
|
+
writePendingProfile(profileId, { label: trimmedLabel, addedAt: new Date().toISOString() });
|
|
423
|
+
const codexHome = getCodexLoginHome(profileId);
|
|
424
|
+
copySourceCodexConfig(sourceHome, codexHome);
|
|
425
|
+
return { success: true, profileId, codexHome, needsLogin: true };
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
discardCodexLogin(profileId);
|
|
429
|
+
return { success: false, error: 'Could not prepare the isolated Codex login.' };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** Legacy callers can import current; explicit sign-in passes importCurrent: false. */
|
|
433
|
+
export function prepareCodexAccount(label, options = {}) {
|
|
434
|
+
const scan = options.importCurrent === false ? undefined : inspectRunningAccountConsumersSync('codex');
|
|
435
|
+
try {
|
|
436
|
+
return withCodexAuthSwapLock(() => prepareCodexAccountCore(label, options.importCurrent ?? true, scan));
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return { success: false, error: 'Could not acquire the Codex account-switch lock.' };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
export async function prepareCodexAccountAsync(label, options = {}) {
|
|
443
|
+
const scan = options.importCurrent === false ? undefined : await inspectRunningAccountConsumers('codex');
|
|
444
|
+
try {
|
|
445
|
+
return withCodexAuthSwapLock(() => prepareCodexAccountCore(label, options.importCurrent ?? true, scan));
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return { success: false, error: 'Could not acquire the Codex account-switch lock.' };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** Removes only a pending login, never a registered canonical profile. */
|
|
452
|
+
export function discardCodexLogin(id) {
|
|
453
|
+
fs.rmSync(getCodexLoginDir(id), { recursive: true, force: true });
|
|
454
|
+
if (!listCodexAccounts().some((p) => p.id === id) &&
|
|
455
|
+
fs.existsSync(getCodexProfileStatePath(id))) {
|
|
456
|
+
fs.rmSync(getCodexProfileDir(id), { recursive: true, force: true });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function finalizeCodexAccountCore(profileId, opts, scan) {
|
|
460
|
+
const pending = readPendingProfile(profileId);
|
|
461
|
+
if (!pending) {
|
|
462
|
+
const saved = listCodexAccounts().find((p) => p.id === profileId);
|
|
463
|
+
if (!saved)
|
|
464
|
+
return { success: false, error: `Codex profile ${profileId} was not prepared.` };
|
|
465
|
+
return opts.activate === false
|
|
466
|
+
? { success: true, profileId }
|
|
467
|
+
: performCodexAuthSwapCore(saved, {}, { scan });
|
|
468
|
+
}
|
|
469
|
+
const home = getCodexLoginHome(profileId);
|
|
470
|
+
const read = readCodexCredentialStore(home);
|
|
471
|
+
if (read.status !== 'ok')
|
|
472
|
+
return {
|
|
473
|
+
success: false,
|
|
474
|
+
error: 'Codex profile is not authenticated with readable file credentials yet.',
|
|
475
|
+
};
|
|
476
|
+
const identity = readAuthIdentityFromRaw(read.snapshot.authRaw);
|
|
477
|
+
const metadata = readCodexAccountMetadataFromFiles(home) ?? {};
|
|
478
|
+
const existing = identity ? findProfileForIdentity(identity, { fallbackToActive: false }) : null;
|
|
479
|
+
// Legacy pending homes must not become a saved profile before their credential is moved out.
|
|
480
|
+
const id = existing?.id ?? (home === getCodexProfileHome(profileId) ? randomUUID() : profileId);
|
|
481
|
+
const profile = {
|
|
482
|
+
...existing,
|
|
483
|
+
id,
|
|
484
|
+
providerId: 'codex',
|
|
485
|
+
label: existing?.label ?? pending.label,
|
|
486
|
+
email: metadata.email ?? existing?.email,
|
|
487
|
+
addedAt: existing?.addedAt ?? pending.addedAt,
|
|
488
|
+
metadata: { ...existing?.metadata, ...metadata, origin: 'login' },
|
|
489
|
+
};
|
|
490
|
+
try {
|
|
491
|
+
// Preserve first. No successful sign-in is lost if activation is blocked.
|
|
492
|
+
const previous = readCodexBackup(id);
|
|
493
|
+
writeCodexBackup(id, previous.status === 'ok' &&
|
|
494
|
+
compareCodexAuthFreshness(previous.snapshot.authRaw, read.snapshot.authRaw) > 0
|
|
495
|
+
? previous.snapshot
|
|
496
|
+
: read.snapshot);
|
|
497
|
+
removeCodexProfileCredentials(home, read.snapshot);
|
|
498
|
+
upsertSavedAccountProfile(profile);
|
|
499
|
+
ensureCodexProfileDirs(id);
|
|
500
|
+
copySourceCodexConfig(home, getCodexProfileHome(id));
|
|
501
|
+
storeCodexProfileAuthUnlocked(id, read.snapshot.authRaw, read.snapshot.legacyRaw, 'login');
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return {
|
|
505
|
+
success: false,
|
|
506
|
+
profileId: id,
|
|
507
|
+
error: 'Could not finish saving the Codex login; any verified private backup was kept.',
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
discardCodexLogin(profileId);
|
|
512
|
+
}
|
|
513
|
+
if (opts.activate === false)
|
|
514
|
+
return { success: true, profileId: id };
|
|
515
|
+
const result = performCodexAuthSwapCore(profile, {}, { scan });
|
|
516
|
+
return result.success
|
|
517
|
+
? { ...result, profileId: id }
|
|
518
|
+
: {
|
|
519
|
+
...result,
|
|
520
|
+
profileId: id,
|
|
521
|
+
needsLogin: false,
|
|
522
|
+
error: `The login was saved, but could not be activated: ${result.error}`,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
export function finalizeCodexAccount(profileId, opts = {}) {
|
|
526
|
+
const scan = inspectRunningAccountConsumersSync('codex');
|
|
527
|
+
try {
|
|
528
|
+
return withCodexAuthSwapLock(() => finalizeCodexAccountCore(profileId, opts, scan));
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return { success: false, error: 'Could not finalize the Codex login.' };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
export async function finalizeCodexAccountAsync(profileId, opts = {}) {
|
|
535
|
+
const scan = await inspectRunningAccountConsumers('codex');
|
|
536
|
+
try {
|
|
537
|
+
return withCodexAuthSwapLock(() => finalizeCodexAccountCore(profileId, opts, scan));
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return { success: false, error: 'Could not finalize the Codex login.' };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// ── Stash / identity matching ────────────────────────────────────────────
|
|
544
|
+
function getCodexStashDir() {
|
|
545
|
+
return path.join(getAccountsDir(), 'codex', 'stash');
|
|
546
|
+
}
|
|
547
|
+
function findProfileForIdentity(identity, options = {}) {
|
|
548
|
+
const profiles = listCodexAccounts();
|
|
549
|
+
if (identity?.workspaceId) {
|
|
550
|
+
const byWorkspace = profiles.find((profile) => profile.metadata?.workspaceId === identity.workspaceId);
|
|
551
|
+
if (byWorkspace)
|
|
552
|
+
return byWorkspace;
|
|
553
|
+
}
|
|
554
|
+
if (identity?.email) {
|
|
555
|
+
const byEmail = profiles.find((profile) => (profile.email ?? profile.metadata?.email) === identity.email &&
|
|
556
|
+
(!identity.workspaceId || !profile.metadata?.workspaceId));
|
|
557
|
+
if (byEmail)
|
|
558
|
+
return byEmail;
|
|
559
|
+
}
|
|
560
|
+
if (!identity?.workspaceId && !identity?.email && options.fallbackToActive !== false) {
|
|
561
|
+
// API-key auth or unparseable tokens carry no identity; assume the live
|
|
562
|
+
// file belongs to whichever account the registry says is active.
|
|
563
|
+
return getActiveCodexAccount();
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
// ── Live-state sync ──────────────────────────────────────────────────────
|
|
568
|
+
/**
|
|
569
|
+
* Serializes live-auth mutations across processes. Codex rotates refresh
|
|
570
|
+
* tokens, so two interleaved swaps stashing and restoring auth.json can
|
|
571
|
+
* resurrect a stale token and permanently invalidate the login. Lock ordering:
|
|
572
|
+
* this lock is taken first and the registry lock (inside setActiveSavedAccount
|
|
573
|
+
* / upsertSavedAccountProfile) inside it — never the reverse. Never spawn a
|
|
574
|
+
* child process while holding it.
|
|
575
|
+
*/
|
|
576
|
+
export function withCodexAuthSwapLock(operation) {
|
|
577
|
+
const lockDir = path.join(getAccountsDir(), 'codex');
|
|
578
|
+
fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
|
|
579
|
+
return withFileLockSync(path.join(lockDir, 'auth-swap.lock'), operation);
|
|
580
|
+
}
|
|
581
|
+
/** Merge owned profiles only after preserving their newest login in a verified backup. */
|
|
582
|
+
export function dedupeCodexProfiles(scan) {
|
|
583
|
+
if (consumerBlocker(scan))
|
|
584
|
+
return [];
|
|
585
|
+
const removed = [];
|
|
586
|
+
const groups = new Map();
|
|
587
|
+
for (const profile of listCodexAccounts()) {
|
|
588
|
+
const key = profile.metadata?.workspaceId
|
|
589
|
+
? `ws:${profile.metadata.workspaceId}`
|
|
590
|
+
: profile.email
|
|
591
|
+
? `email:${profile.email}`
|
|
592
|
+
: `id:${profile.id}`;
|
|
593
|
+
groups.set(key, [...(groups.get(key) ?? []), profile]);
|
|
594
|
+
}
|
|
595
|
+
for (const group of groups.values()) {
|
|
596
|
+
if (group.length < 2 || group.some((p) => isAccountLeaseHeld('codex', p.id)))
|
|
597
|
+
continue;
|
|
598
|
+
const sorted = [...group].sort((a, b) => Date.parse(a.addedAt) - Date.parse(b.addedAt));
|
|
599
|
+
const keeper = sorted[0];
|
|
600
|
+
let freshest = readStoredCodexAccount(keeper.id);
|
|
601
|
+
for (const p of sorted.slice(1)) {
|
|
602
|
+
const candidate = readStoredCodexAccount(p.id);
|
|
603
|
+
if (candidate &&
|
|
604
|
+
(!freshest || compareCodexAuthFreshness(candidate.authRaw, freshest.authRaw) > 0))
|
|
605
|
+
freshest = candidate;
|
|
606
|
+
}
|
|
607
|
+
if (!freshest)
|
|
608
|
+
continue;
|
|
609
|
+
writeCodexBackup(keeper.id, freshest);
|
|
610
|
+
for (const loser of sorted.slice(1)) {
|
|
611
|
+
const store = readCodexCredentialStore(getCodexProfileHome(loser.id));
|
|
612
|
+
if (store.status === 'error' || store.status === 'corrupt')
|
|
613
|
+
continue;
|
|
614
|
+
if (store.status === 'ok')
|
|
615
|
+
removeCodexProfileCredentials(getCodexProfileHome(loser.id), store.snapshot);
|
|
616
|
+
const snapshot = readQuotaSnapshot('codex', loser.id);
|
|
617
|
+
if (snapshot && !readQuotaSnapshot('codex', keeper.id))
|
|
618
|
+
writeQuotaSnapshot('codex', keeper.id, snapshot);
|
|
619
|
+
const archive = path.join(getCodexStashDir(), `dup-${loser.id}`);
|
|
620
|
+
fs.mkdirSync(path.dirname(archive), { recursive: true, mode: 0o700 });
|
|
621
|
+
if (fs.existsSync(getCodexProfileDir(loser.id)))
|
|
622
|
+
fs.renameSync(getCodexProfileDir(loser.id), archive);
|
|
623
|
+
if (getActiveSavedAccount('codex')?.id === loser.id)
|
|
624
|
+
setActiveSavedAccount('codex', keeper.id, { silent: true });
|
|
625
|
+
removeSavedAccountProfile('codex', loser.id);
|
|
626
|
+
fs.rmSync(getCodexBackupPath(loser.id), { force: true });
|
|
627
|
+
fs.rmSync(getCodexBackupPath(loser.id, true), { force: true });
|
|
628
|
+
removed.push(loser.id);
|
|
629
|
+
}
|
|
630
|
+
storeCodexProfileAuthUnlocked(keeper.id, freshest.authRaw, freshest.legacyRaw, 'migration');
|
|
631
|
+
}
|
|
632
|
+
return removed;
|
|
633
|
+
}
|
|
634
|
+
function codexJournalPath() {
|
|
635
|
+
return path.join(getAccountsDir(), 'codex', 'switch-journal.json');
|
|
636
|
+
}
|
|
637
|
+
function loginHash(snapshot) {
|
|
638
|
+
return codexSnapshotFingerprint(codexLoginOnly(snapshot));
|
|
639
|
+
}
|
|
640
|
+
function clearCodexJournal() {
|
|
641
|
+
fs.rmSync(codexJournalPath(), { force: true });
|
|
642
|
+
}
|
|
643
|
+
export function hasPendingCodexRepair() {
|
|
644
|
+
if (fs.existsSync(codexJournalPath()))
|
|
645
|
+
return true;
|
|
646
|
+
const live = readCodexCredentialStore();
|
|
647
|
+
if (live.status === 'error' || live.status === 'corrupt')
|
|
648
|
+
return true;
|
|
649
|
+
if (live.status !== 'ok')
|
|
650
|
+
return false;
|
|
651
|
+
return listCodexAccounts().some((p) => {
|
|
652
|
+
if (!profileMatchesSnapshot(p, live.snapshot))
|
|
653
|
+
return false;
|
|
654
|
+
const home = readCodexCredentialStore(getCodexProfileHome(p.id));
|
|
655
|
+
const backup = readCodexBackup(p.id);
|
|
656
|
+
return (home.status !== 'absent' ||
|
|
657
|
+
(backup.status === 'ok' &&
|
|
658
|
+
compareCodexAuthFreshness(backup.snapshot.authRaw, live.snapshot.authRaw) > 0));
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
function resolveCodexJournal(report, scan) {
|
|
662
|
+
if (!fs.existsSync(codexJournalPath()))
|
|
663
|
+
return;
|
|
664
|
+
let journal;
|
|
665
|
+
try {
|
|
666
|
+
journal = JSON.parse(fs.readFileSync(codexJournalPath(), 'utf8'));
|
|
667
|
+
if (!journal.to || !journal.fromHash || !journal.toHash)
|
|
668
|
+
throw new Error();
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
report.warnings.push('The Codex switch journal is unreadable; run accounts doctor.');
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const live = readCodexCredentialStore();
|
|
675
|
+
if (live.status === 'error' || live.status === 'corrupt') {
|
|
676
|
+
report.warnings.push('An interrupted Codex switch is pending; the live login could not be read.');
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const blocker = consumerBlocker(scan);
|
|
680
|
+
if (blocker) {
|
|
681
|
+
report.warnings.push(blocker);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const hash = loginHash(live.snapshot);
|
|
685
|
+
const to = listCodexAccounts().find((p) => p.id === journal.to);
|
|
686
|
+
if (hash === journal.toHash ||
|
|
687
|
+
(live.status === 'ok' && to && profileMatchesSnapshot(to, live.snapshot))) {
|
|
688
|
+
if (live.status !== 'ok')
|
|
689
|
+
return;
|
|
690
|
+
writeCodexBackup(journal.to, live.snapshot);
|
|
691
|
+
const target = readCodexCredentialStore(getCodexProfileHome(journal.to));
|
|
692
|
+
if (target.status === 'error' || target.status === 'corrupt')
|
|
693
|
+
throw new Error('The interrupted switch target is unreadable.');
|
|
694
|
+
if (target.status === 'ok')
|
|
695
|
+
removeCodexProfileCredentials(getCodexProfileHome(journal.to), target.snapshot);
|
|
696
|
+
if (journal.from) {
|
|
697
|
+
const backup = readCodexBackup(journal.from);
|
|
698
|
+
if (backup.status === 'ok' && loginHash(backup.snapshot) === journal.fromHash) {
|
|
699
|
+
if (readCodexCredentialStore(getCodexProfileHome(journal.from)).status === 'absent')
|
|
700
|
+
writeInactiveCodexCredentialStore(getCodexProfileHome(journal.from), backup.snapshot);
|
|
701
|
+
writeCodexHealthSidecar(journal.from, backup.snapshot.authRaw, 'switch');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
setActiveSavedAccount('codex', journal.to, { silent: true });
|
|
705
|
+
report.journal = 'finished';
|
|
706
|
+
}
|
|
707
|
+
else if (hash === journal.fromHash) {
|
|
708
|
+
const target = readCodexBackup(journal.to);
|
|
709
|
+
if (target.status === 'ok' && loginHash(target.snapshot) === journal.toHash) {
|
|
710
|
+
if (readCodexCredentialStore(getCodexProfileHome(journal.to)).status === 'absent')
|
|
711
|
+
writeInactiveCodexCredentialStore(getCodexProfileHome(journal.to), target.snapshot);
|
|
712
|
+
}
|
|
713
|
+
if (journal.from) {
|
|
714
|
+
const outgoing = readCodexCredentialStore(getCodexProfileHome(journal.from));
|
|
715
|
+
if (outgoing.status === 'ok')
|
|
716
|
+
removeCodexProfileCredentials(getCodexProfileHome(journal.from), outgoing.snapshot);
|
|
717
|
+
}
|
|
718
|
+
setActiveSavedAccount('codex', journal.from, { silent: true });
|
|
719
|
+
report.journal = 'rolled-back';
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
report.journal = 'cleared';
|
|
723
|
+
report.warnings.push('The live Codex login matches neither side of the interrupted switch; it was kept. Private backups are available for inspection.');
|
|
724
|
+
}
|
|
725
|
+
clearCodexJournal();
|
|
726
|
+
}
|
|
727
|
+
/** No subprocesses inside this function: hosts resolve process scans before taking the lock. */
|
|
728
|
+
export function syncCodexLiveStateUnlocked(source = 'live-sync', probes = {}) {
|
|
729
|
+
const report = emptyProviderSyncReport();
|
|
730
|
+
try {
|
|
731
|
+
const mode = getCodexCredentialStoreMode();
|
|
732
|
+
if (mode !== 'file' && mode !== 'unknown') {
|
|
733
|
+
report.skipped = 'keyring';
|
|
734
|
+
report.warnings.push(CODEX_KEYRING_FIX_HINT);
|
|
735
|
+
return report;
|
|
736
|
+
}
|
|
737
|
+
if (isSidekickCodexHome(getSystemCodexHome())) {
|
|
738
|
+
report.skipped = 'managed-home';
|
|
739
|
+
report.warnings.push('Unset CODEX_HOME before syncing; it points at a Sidekick-owned home.');
|
|
740
|
+
return report;
|
|
741
|
+
}
|
|
742
|
+
resolveCodexJournal(report, probes.scan);
|
|
743
|
+
let live = readCodexCredentialStore();
|
|
744
|
+
if (live.status === 'error' || live.status === 'corrupt') {
|
|
745
|
+
report.skipped = 'unreadable';
|
|
746
|
+
report.warnings.push('The live Codex login could not be read; nothing was replaced.');
|
|
747
|
+
return report;
|
|
748
|
+
}
|
|
749
|
+
if (live.status === 'absent') {
|
|
750
|
+
report.skipped = 'logged-out';
|
|
751
|
+
return report;
|
|
752
|
+
}
|
|
753
|
+
const merged = dedupeCodexProfiles(probes.scan);
|
|
754
|
+
if (merged.length)
|
|
755
|
+
report.merged = merged;
|
|
756
|
+
const identity = readAuthIdentityFromRaw(live.snapshot.authRaw);
|
|
757
|
+
const initialSnapshot = live.snapshot;
|
|
758
|
+
let profile = listCodexAccounts().find((p) => profileMatchesSnapshot(p, initialSnapshot));
|
|
759
|
+
if (!profile) {
|
|
760
|
+
if (!identity?.email &&
|
|
761
|
+
!identity?.workspaceId &&
|
|
762
|
+
identity?.authMode !== 'api-key' &&
|
|
763
|
+
!isLegacyCodexLogin(live.snapshot.legacyRaw)) {
|
|
764
|
+
report.skipped = 'no-identity';
|
|
765
|
+
return report;
|
|
766
|
+
}
|
|
767
|
+
profile = {
|
|
768
|
+
id: randomUUID(),
|
|
769
|
+
providerId: 'codex',
|
|
770
|
+
label: identity?.email ?? identity?.workspaceId ?? 'Codex API key',
|
|
771
|
+
email: identity?.email,
|
|
772
|
+
addedAt: new Date().toISOString(),
|
|
773
|
+
metadata: { ...identity, authMode: identity?.authMode ?? 'api-key', origin: 'live-sync' },
|
|
774
|
+
};
|
|
775
|
+
// API-key identities require their verified bytes before registry matching can work.
|
|
776
|
+
writeCodexBackup(profile.id, live.snapshot);
|
|
777
|
+
upsertSavedAccountProfile(profile);
|
|
778
|
+
report.registered = { id: profile.id, email: profile.email };
|
|
779
|
+
}
|
|
780
|
+
const home = getCodexProfileHome(profile.id);
|
|
781
|
+
ensureCodexProfileDirs(profile.id);
|
|
782
|
+
if (!fs.existsSync(path.join(home, 'config.toml')))
|
|
783
|
+
copySourceCodexConfig(getSystemCodexHome(), home);
|
|
784
|
+
const duplicate = readCodexCredentialStore(home);
|
|
785
|
+
const stored = readStoredCodexAccount(profile.id);
|
|
786
|
+
if (stored && compareCodexAuthFreshness(stored.authRaw, live.snapshot.authRaw) > 0) {
|
|
787
|
+
const install = installIntoLiveCodexStore(stored, profile.id, 'repair', {
|
|
788
|
+
before: live,
|
|
789
|
+
liveAccountId: profile.id,
|
|
790
|
+
scan: probes.scan,
|
|
791
|
+
});
|
|
792
|
+
if (install.ok) {
|
|
793
|
+
live = { status: 'ok', snapshot: install.installed };
|
|
794
|
+
report.repaired = profile.id;
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
report.warnings.push(`A newer Codex login is saved privately; repair is pending. ${install.reason}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const backup = readCodexBackup(profile.id);
|
|
801
|
+
if (backup.status !== 'ok' ||
|
|
802
|
+
compareCodexAuthFreshness(backup.snapshot.authRaw, live.snapshot.authRaw) <= 0) {
|
|
803
|
+
writeCodexBackup(profile.id, live.snapshot);
|
|
804
|
+
report.folded = profile.id;
|
|
805
|
+
}
|
|
806
|
+
writeCodexHealthSidecar(profile.id, live.snapshot.authRaw, source);
|
|
807
|
+
if (duplicate.status === 'ok') {
|
|
808
|
+
const blocker = consumerBlocker(probes.scan);
|
|
809
|
+
// Preserve a newer copy even when its promotion must wait.
|
|
810
|
+
if (compareCodexAuthFreshness(duplicate.snapshot.authRaw, live.snapshot.authRaw) > 0)
|
|
811
|
+
writeCodexBackup(profile.id, duplicate.snapshot);
|
|
812
|
+
if (blocker || isAccountLeaseHeld('codex', profile.id))
|
|
813
|
+
report.warnings.push(blocker ?? 'The profile is leased; duplicate cleanup is pending.');
|
|
814
|
+
else {
|
|
815
|
+
removeCodexProfileCredentials(home, duplicate.snapshot);
|
|
816
|
+
report.demoted = profile.id;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
else if (duplicate.status === 'corrupt' || duplicate.status === 'error') {
|
|
820
|
+
report.warnings.push('The live account has an unreadable profile credential; run accounts doctor.');
|
|
821
|
+
}
|
|
822
|
+
if (getActiveSavedAccount('codex')?.id !== profile.id) {
|
|
823
|
+
setActiveSavedAccount('codex', profile.id, { silent: true });
|
|
824
|
+
report.repointed = profile.id;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
report.warnings.push('Codex account sync could not finish; verified credentials were preserved for the next retry.');
|
|
829
|
+
}
|
|
830
|
+
return report;
|
|
831
|
+
}
|
|
832
|
+
export function syncCodexLiveState(source = 'live-sync') {
|
|
833
|
+
const scan = inspectRunningAccountConsumersSync('codex');
|
|
834
|
+
try {
|
|
835
|
+
return withCodexAuthSwapLock(() => syncCodexLiveStateUnlocked(source, { scan }));
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
return { warnings: ['Could not acquire the Codex account-switch lock.'] };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
export async function syncCodexLiveStateAsync(source = 'live-sync') {
|
|
842
|
+
const scan = await inspectRunningAccountConsumers('codex');
|
|
843
|
+
try {
|
|
844
|
+
return withCodexAuthSwapLock(() => syncCodexLiveStateUnlocked(source, { scan }));
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return { warnings: ['Could not acquire the Codex account-switch lock.'] };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/** Only abandoned Sidekick-owned login homes; authenticated copies are preserved privately first. */
|
|
851
|
+
export function cleanupAbandonedCodexLogins(olderThanMs = 24 * 60 * 60 * 1000, now = Date.now(), scan) {
|
|
852
|
+
const removed = [];
|
|
853
|
+
if (consumerBlocker(scan ?? inspectRunningAccountConsumersSync('codex')))
|
|
854
|
+
return removed;
|
|
855
|
+
withCodexAuthSwapLock(() => {
|
|
856
|
+
for (const root of [getCodexLoginsDir(), getCodexProfilesDir()]) {
|
|
857
|
+
if (!fs.existsSync(root))
|
|
858
|
+
continue;
|
|
859
|
+
for (const id of fs.readdirSync(root)) {
|
|
860
|
+
if (listCodexAccounts().some((p) => p.id === id))
|
|
861
|
+
continue;
|
|
862
|
+
const pending = readPendingProfile(id);
|
|
863
|
+
if (!pending || now - Date.parse(pending.addedAt) < olderThanMs)
|
|
864
|
+
continue;
|
|
865
|
+
const home = getCodexLoginHome(id);
|
|
866
|
+
const read = readCodexCredentialStore(home);
|
|
867
|
+
if (read.status === 'error' || read.status === 'corrupt')
|
|
868
|
+
continue;
|
|
869
|
+
if (read.status === 'ok') {
|
|
870
|
+
const identity = readAuthIdentityFromRaw(read.snapshot.authRaw);
|
|
871
|
+
const owner = identity
|
|
872
|
+
? findProfileForIdentity(identity, { fallbackToActive: false })
|
|
873
|
+
: null;
|
|
874
|
+
if (!owner)
|
|
875
|
+
continue; // An unregistered authenticated login needs a human decision.
|
|
876
|
+
const saved = readStoredCodexAccount(owner.id);
|
|
877
|
+
writeCodexBackup(owner.id, saved && compareCodexAuthFreshness(saved.authRaw, read.snapshot.authRaw) >= 0
|
|
878
|
+
? saved
|
|
879
|
+
: read.snapshot);
|
|
880
|
+
}
|
|
881
|
+
discardCodexLogin(id);
|
|
882
|
+
removed.push(id);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
return removed;
|
|
887
|
+
}
|
|
888
|
+
function performCodexAuthSwapCore(target, options, probes) {
|
|
889
|
+
const base = {
|
|
890
|
+
success: true,
|
|
891
|
+
provider: 'codex',
|
|
892
|
+
accountId: target.id,
|
|
893
|
+
previousAccountId: getActiveSavedAccount('codex')?.id ?? null,
|
|
894
|
+
verified: false,
|
|
895
|
+
verification: 'none',
|
|
896
|
+
warnings: [],
|
|
897
|
+
hints: [],
|
|
898
|
+
runningConsumers: probes.scan.consumers,
|
|
899
|
+
email: target.email ?? target.metadata?.email,
|
|
900
|
+
};
|
|
901
|
+
const fail = (error, extra = {}) => finishSwitchResult({ ...base, success: false, error, ...extra });
|
|
902
|
+
const mode = getCodexCredentialStoreMode();
|
|
903
|
+
if (mode !== 'file' && mode !== 'unknown')
|
|
904
|
+
return fail(CODEX_KEYRING_FIX_HINT);
|
|
905
|
+
if (isSidekickCodexHome(getSystemCodexHome()))
|
|
906
|
+
return fail('CODEX_HOME points at a Sidekick-owned home; unset it before switching.');
|
|
907
|
+
let before = readCodexCredentialStore();
|
|
908
|
+
if (before.status === 'error' || before.status === 'corrupt')
|
|
909
|
+
return fail('The live Codex login could not be read; it was left untouched.');
|
|
910
|
+
const saved = readStoredCodexAccount(target.id);
|
|
911
|
+
if (before.status === 'ok' && profileMatchesSnapshot(target, before.snapshot)) {
|
|
912
|
+
if (saved && compareCodexAuthFreshness(saved.authRaw, before.snapshot.authRaw) > 0) {
|
|
913
|
+
const result = installIntoLiveCodexStore(saved, target.id, 'relogin', {
|
|
914
|
+
before,
|
|
915
|
+
liveAccountId: target.id,
|
|
916
|
+
scan: probes.scan,
|
|
917
|
+
});
|
|
918
|
+
if (!result.ok)
|
|
919
|
+
return fail(result.reason);
|
|
920
|
+
}
|
|
921
|
+
const sync = syncCodexLiveStateUnlocked('switch', probes);
|
|
922
|
+
return finishSwitchResult({
|
|
923
|
+
...base,
|
|
924
|
+
alreadyActive: true,
|
|
925
|
+
verified: true,
|
|
926
|
+
verification: 'store',
|
|
927
|
+
warnings: sync.warnings,
|
|
928
|
+
health: getAccountHealth('codex', target.id),
|
|
929
|
+
hints: ['That account is already the live Codex login.'],
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
const blocker = consumerBlocker(probes.scan);
|
|
933
|
+
if (blocker)
|
|
934
|
+
return fail(blocker);
|
|
935
|
+
if (isAccountLeaseHeld('codex', target.id))
|
|
936
|
+
return fail('Keep-alive is refreshing this account; retry after it finishes.');
|
|
937
|
+
const sync = syncCodexLiveStateUnlocked('switch', probes);
|
|
938
|
+
base.warnings.push(...sync.warnings);
|
|
939
|
+
if (fs.existsSync(codexJournalPath()))
|
|
940
|
+
return fail('An interrupted Codex switch still needs recovery; run accounts doctor.');
|
|
941
|
+
before = readCodexCredentialStore();
|
|
942
|
+
if (before.status === 'error' || before.status === 'corrupt')
|
|
943
|
+
return fail('The live Codex credential could not be read.');
|
|
944
|
+
const beforeSnapshot = before.snapshot;
|
|
945
|
+
const outgoing = before.status === 'ok'
|
|
946
|
+
? listCodexAccounts().find((p) => profileMatchesSnapshot(p, beforeSnapshot))
|
|
947
|
+
: null;
|
|
948
|
+
if (before.status === 'ok' && !outgoing)
|
|
949
|
+
return fail('The live login has no unambiguous account identity to save it under; sign in with Codex and retry.');
|
|
950
|
+
base.previousAccountId = outgoing?.id ?? null;
|
|
951
|
+
if (outgoing?.id === target.id)
|
|
952
|
+
return fail('The live account changed during the switch; retry.');
|
|
953
|
+
const targetHome = getCodexProfileHome(target.id);
|
|
954
|
+
const targetBefore = readCodexCredentialStore(targetHome);
|
|
955
|
+
if (targetBefore.status === 'error' || targetBefore.status === 'corrupt')
|
|
956
|
+
return fail('The target profile is unreadable; the switch was deferred.');
|
|
957
|
+
const candidate = readStoredCodexAccount(target.id);
|
|
958
|
+
if (!candidate)
|
|
959
|
+
return fail('No readable stored credentials for this account; sign in again.', {
|
|
960
|
+
needsLogin: true,
|
|
961
|
+
});
|
|
962
|
+
const candidateIdentity = readAuthIdentityFromRaw(candidate.authRaw);
|
|
963
|
+
if (candidateIdentity?.authMode === 'chatgpt' &&
|
|
964
|
+
!identitiesMatch(candidateIdentity, profileIdentity(target))) {
|
|
965
|
+
return fail('The saved Codex credential does not belong to the selected account.');
|
|
966
|
+
}
|
|
967
|
+
const liveFp = codexRefreshFingerprint(beforeSnapshot.authRaw);
|
|
968
|
+
if (liveFp && liveFp === codexRefreshFingerprint(candidate.authRaw))
|
|
969
|
+
return fail('The target is another copy of the live refresh token; sign in again.');
|
|
970
|
+
const health = getAccountHealth('codex', target.id, { probe: 'store' });
|
|
971
|
+
base.health = health;
|
|
972
|
+
if ((health.state === 'expired' || health.state === 'missing') && !options.force)
|
|
973
|
+
return fail('Stored credentials have expired or are missing; sign in again.', {
|
|
974
|
+
needsLogin: true,
|
|
975
|
+
});
|
|
976
|
+
const refreshed = readLastRefresh(candidate.authRaw);
|
|
977
|
+
if (refreshed !== null && Date.now() - refreshed > STALE_AUTH_THRESHOLD_MS)
|
|
978
|
+
base.warnings.push('The stored Codex login has not been refreshed in over 8 days.');
|
|
979
|
+
// Capture and verify both sides before removing either usable copy. Mandatory even with force.
|
|
980
|
+
try {
|
|
981
|
+
if (outgoing)
|
|
982
|
+
writeCodexBackup(outgoing.id, beforeSnapshot);
|
|
983
|
+
writeCodexBackup(target.id, candidate);
|
|
984
|
+
atomicWriteJson(codexJournalPath(), {
|
|
985
|
+
from: outgoing?.id ?? null,
|
|
986
|
+
to: target.id,
|
|
987
|
+
fromHash: loginHash(beforeSnapshot),
|
|
988
|
+
toHash: loginHash(candidate),
|
|
989
|
+
at: new Date().toISOString(),
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
return fail('The outgoing login or switch journal could not be saved and verified; live was left untouched.');
|
|
994
|
+
}
|
|
995
|
+
if (!codexStoreMatches(readCodexCredentialStore(), beforeSnapshot))
|
|
996
|
+
return fail('The live login changed after capture; the switch was deferred.');
|
|
997
|
+
const profileStore = readCodexCredentialStore(targetHome);
|
|
998
|
+
if (!codexStoreMatches(profileStore, targetBefore.snapshot))
|
|
999
|
+
return fail('The target credential changed after selection; the switch was deferred.');
|
|
1000
|
+
if (profileStore.status === 'error' || profileStore.status === 'corrupt')
|
|
1001
|
+
return fail('The target profile is unreadable; the switch was deferred.');
|
|
1002
|
+
try {
|
|
1003
|
+
if (profileStore.status === 'ok')
|
|
1004
|
+
removeCodexProfileCredentials(targetHome, profileStore.snapshot);
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
return fail('The target copy could not be removed safely; the switch was deferred.');
|
|
1008
|
+
}
|
|
1009
|
+
const install = installIntoLiveCodexStore(candidate, target.id, 'switch', {
|
|
1010
|
+
before,
|
|
1011
|
+
liveAccountId: outgoing?.id ?? null,
|
|
1012
|
+
capturedOutgoing: Boolean(outgoing),
|
|
1013
|
+
scan: probes.scan,
|
|
1014
|
+
});
|
|
1015
|
+
const recover = (written) => {
|
|
1016
|
+
if (written) {
|
|
1017
|
+
installIntoLiveCodexStore(beforeSnapshot, outgoing?.id ?? target.id, 'rollback', {
|
|
1018
|
+
before: readCodexCredentialStore(),
|
|
1019
|
+
liveAccountId: target.id,
|
|
1020
|
+
written,
|
|
1021
|
+
scan: probes.scan,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
// Journal reconciliation never overwrites live and never restores a copy of its identity.
|
|
1025
|
+
resolveCodexJournal(emptyProviderSyncReport(), probes.scan);
|
|
1026
|
+
};
|
|
1027
|
+
if (!install.ok) {
|
|
1028
|
+
try {
|
|
1029
|
+
recover(install.attempted);
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
/* journal and private backups survive */
|
|
1033
|
+
}
|
|
1034
|
+
return fail(`${install.reason} Verified private backups were kept; run accounts doctor if recovery is pending.`, { verification: 'failed' });
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
setActiveSavedAccount('codex', target.id);
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
try {
|
|
1041
|
+
recover(install.installed);
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
/* journal survives */
|
|
1045
|
+
}
|
|
1046
|
+
return fail('The account registry could not be updated; recovery retained the verified backups.');
|
|
1047
|
+
}
|
|
1048
|
+
try {
|
|
1049
|
+
if (outgoing)
|
|
1050
|
+
storeCodexProfileAuthUnlocked(outgoing.id, beforeSnapshot.authRaw, beforeSnapshot.legacyRaw, 'switch');
|
|
1051
|
+
writeCodexHealthSidecar(target.id, candidate.authRaw, 'switch');
|
|
1052
|
+
clearCodexJournal();
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
base.warnings.push('The live switch succeeded; profile cleanup is pending and will retry on sync.');
|
|
1056
|
+
}
|
|
1057
|
+
let undoToken;
|
|
1058
|
+
try {
|
|
1059
|
+
undoToken = writeLastSwitch('codex', outgoing?.id ?? null, target.id).token;
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
base.warnings.push('The switch succeeded but could not be recorded for undo.');
|
|
1063
|
+
}
|
|
1064
|
+
return finishSwitchResult({
|
|
1065
|
+
...base,
|
|
1066
|
+
verified: true,
|
|
1067
|
+
verification: 'store',
|
|
1068
|
+
undoToken,
|
|
1069
|
+
hints: [`New Codex sessions use ${target.label ?? target.email ?? target.id}.`],
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
export function switchToCodexAccount(profileId, options = {}) {
|
|
1073
|
+
const target = listCodexAccounts().find((p) => p.id === profileId);
|
|
1074
|
+
if (!target)
|
|
1075
|
+
return switchFailure('codex', profileId, `Codex account ${profileId} not found.`);
|
|
1076
|
+
const scan = inspectRunningAccountConsumersSync('codex');
|
|
1077
|
+
try {
|
|
1078
|
+
return withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, { scan }));
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return switchFailure('codex', profileId, 'The Codex switch could not finish; private backups and any pending journal were kept.');
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
export async function switchToCodexAccountAsync(profileId, options = {}) {
|
|
1085
|
+
const target = listCodexAccounts().find((p) => p.id === profileId);
|
|
1086
|
+
if (!target)
|
|
1087
|
+
return switchFailure('codex', profileId, `Codex account ${profileId} not found.`);
|
|
1088
|
+
const scan = await inspectRunningAccountConsumers('codex');
|
|
1089
|
+
let result;
|
|
1090
|
+
try {
|
|
1091
|
+
result = withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, { scan }));
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
return switchFailure('codex', profileId, 'The Codex switch could not finish; private backups and any pending journal were kept.');
|
|
1095
|
+
}
|
|
1096
|
+
if (result.success && options.verifyWithCli && !result.alreadyActive) {
|
|
1097
|
+
const status = await getCodexLoginStatusAsync(getSystemCodexHome());
|
|
1098
|
+
if (status.loggedIn)
|
|
1099
|
+
result.verification = 'cli';
|
|
1100
|
+
else
|
|
1101
|
+
result = finishSwitchResult({
|
|
1102
|
+
...result,
|
|
1103
|
+
warnings: [
|
|
1104
|
+
...result.warnings,
|
|
1105
|
+
'Codex could not read the installed login; sign in again if prompted.',
|
|
1106
|
+
],
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
return result;
|
|
1110
|
+
}
|
|
1111
|
+
/** Legacy migration entry points now use the same guarded reconciliation as ordinary sync. */
|
|
1112
|
+
export function reconcileCodexAuthState() {
|
|
1113
|
+
syncCodexLiveState('migration');
|
|
1114
|
+
}
|
|
1115
|
+
export async function reconcileCodexAuthStateAsync() {
|
|
1116
|
+
await syncCodexLiveStateAsync('migration');
|
|
1117
|
+
}
|
|
1118
|
+
function codexKeepAliveBlockerUnlocked(id) {
|
|
1119
|
+
if (isSidekickCodexHome(getSystemCodexHome()))
|
|
1120
|
+
return 'unset CODEX_HOME before managing accounts; it names a Sidekick-owned home';
|
|
1121
|
+
const mode = getCodexCredentialStoreMode();
|
|
1122
|
+
if (mode !== 'file' && mode !== 'unknown')
|
|
1123
|
+
return 'live credential storage is not readable file mode';
|
|
1124
|
+
const profile = listCodexAccounts().find((p) => p.id === id);
|
|
1125
|
+
if (!profile)
|
|
1126
|
+
return 'account not found';
|
|
1127
|
+
const live = readCodexCredentialStore();
|
|
1128
|
+
if (live.status === 'error' || live.status === 'corrupt')
|
|
1129
|
+
return 'the live login could not be read';
|
|
1130
|
+
if (live.status === 'ok' && profileMatchesSnapshot(profile, live.snapshot))
|
|
1131
|
+
return 'live account; the CLI refreshes it in normal use';
|
|
1132
|
+
const store = readCodexCredentialStore(getCodexProfileHome(id));
|
|
1133
|
+
if (store.status !== 'ok')
|
|
1134
|
+
return 'no readable profile copy to refresh (keep-alive never restores backups)';
|
|
1135
|
+
const fp = codexRefreshFingerprint(store.snapshot.authRaw);
|
|
1136
|
+
if (!fp)
|
|
1137
|
+
return 'no refreshable ChatGPT login';
|
|
1138
|
+
if (live.status === 'ok' && fp === codexRefreshFingerprint(live.snapshot.authRaw))
|
|
1139
|
+
return 'same refresh token as the live login';
|
|
1140
|
+
if (readHealthSidecar('codex', id)?.source === 'live-sync')
|
|
1141
|
+
return 'copied from the live login; waits for a switch away from it';
|
|
1142
|
+
const backup = readCodexBackup(id);
|
|
1143
|
+
if (backup.status === 'error' || backup.status === 'corrupt')
|
|
1144
|
+
return 'its private backup could not be read';
|
|
1145
|
+
if (backup.status === 'ok' &&
|
|
1146
|
+
compareCodexAuthFreshness(backup.snapshot.authRaw, store.snapshot.authRaw) > 0)
|
|
1147
|
+
return 'its saved backup is newer than the profile copy';
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
export function getCodexKeepAliveBlocker(id) {
|
|
1151
|
+
try {
|
|
1152
|
+
return withCodexAuthSwapLock(() => codexKeepAliveBlockerUnlocked(id));
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
return 'could not acquire the account-switch lock';
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
export function beginCodexKeepAlive(id, leaseMs) {
|
|
1159
|
+
try {
|
|
1160
|
+
return withCodexAuthSwapLock(() => {
|
|
1161
|
+
const reason = codexKeepAliveBlockerUnlocked(id);
|
|
1162
|
+
if (reason)
|
|
1163
|
+
return { ok: false, reason };
|
|
1164
|
+
return acquireAccountLease('codex', id, leaseMs)
|
|
1165
|
+
? { ok: true }
|
|
1166
|
+
: { ok: false, reason: 'another keep-alive run holds this account' };
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
return { ok: false, reason: 'could not acquire the account-switch lock' };
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
export function finishCodexKeepAlive(id) {
|
|
1174
|
+
try {
|
|
1175
|
+
withCodexAuthSwapLock(() => {
|
|
1176
|
+
const profile = listCodexAccounts().find((p) => p.id === id);
|
|
1177
|
+
const store = readCodexCredentialStore(getCodexProfileHome(id));
|
|
1178
|
+
if (!profile || isLiveCodexProfile(profile) || store.status !== 'ok')
|
|
1179
|
+
return;
|
|
1180
|
+
if (!identitiesMatch(readAuthIdentityFromRaw(store.snapshot.authRaw), profileIdentity(profile)))
|
|
1181
|
+
return;
|
|
1182
|
+
const backup = readCodexBackup(id);
|
|
1183
|
+
if (backup.status === 'ok' &&
|
|
1184
|
+
compareCodexAuthFreshness(backup.snapshot.authRaw, store.snapshot.authRaw) > 0)
|
|
1185
|
+
return;
|
|
1186
|
+
writeCodexBackup(id, store.snapshot);
|
|
1187
|
+
writeCodexHealthSidecar(id, store.snapshot.authRaw, 'keepalive');
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
finally {
|
|
1191
|
+
releaseAccountLease('codex', id);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
export function restoreCodexProfileFromBackup(id, options = {}) {
|
|
1195
|
+
try {
|
|
1196
|
+
return withCodexAuthSwapLock(() => {
|
|
1197
|
+
if (isSidekickCodexHome(getSystemCodexHome()))
|
|
1198
|
+
return 'none';
|
|
1199
|
+
const profile = listCodexAccounts().find((p) => p.id === id);
|
|
1200
|
+
if (!profile)
|
|
1201
|
+
return 'none';
|
|
1202
|
+
const live = readCodexCredentialStore();
|
|
1203
|
+
if (live.status === 'error' || live.status === 'corrupt')
|
|
1204
|
+
return 'none';
|
|
1205
|
+
if (isLiveCodexProfile(profile))
|
|
1206
|
+
return 'live';
|
|
1207
|
+
const home = getCodexProfileHome(id);
|
|
1208
|
+
const store = readCodexCredentialStore(home);
|
|
1209
|
+
if (store.status === 'ok') {
|
|
1210
|
+
const fp = codexRefreshFingerprint(store.snapshot.authRaw);
|
|
1211
|
+
if (fp && live.status === 'ok' && fp === codexRefreshFingerprint(live.snapshot.authRaw))
|
|
1212
|
+
return 'none';
|
|
1213
|
+
if (!identitiesMatch(readAuthIdentityFromRaw(store.snapshot.authRaw), profileIdentity(profile)) &&
|
|
1214
|
+
readAuthIdentityFromRaw(store.snapshot.authRaw)?.authMode !== 'api-key' &&
|
|
1215
|
+
!isLegacyCodexLogin(store.snapshot.legacyRaw))
|
|
1216
|
+
return 'none';
|
|
1217
|
+
const backup = readCodexBackup(id);
|
|
1218
|
+
if (backup.status === 'ok' &&
|
|
1219
|
+
compareCodexAuthFreshness(backup.snapshot.authRaw, store.snapshot.authRaw) > 0)
|
|
1220
|
+
return 'none';
|
|
1221
|
+
return 'present';
|
|
1222
|
+
}
|
|
1223
|
+
if (store.status !== 'absent' || options.restore === false)
|
|
1224
|
+
return 'none';
|
|
1225
|
+
const backup = readCodexBackup(id);
|
|
1226
|
+
if (backup.status !== 'ok')
|
|
1227
|
+
return 'none';
|
|
1228
|
+
const fp = codexRefreshFingerprint(backup.snapshot.authRaw);
|
|
1229
|
+
if (fp && live.status === 'ok' && fp === codexRefreshFingerprint(live.snapshot.authRaw))
|
|
1230
|
+
return 'none';
|
|
1231
|
+
writeInactiveCodexCredentialStore(home, backup.snapshot);
|
|
1232
|
+
return 'restored';
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
catch {
|
|
1236
|
+
return 'none';
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
export function removeCodexAccount(profileId) {
|
|
1240
|
+
const removed = removeSavedAccountProfile('codex', profileId);
|
|
1241
|
+
if (!removed) {
|
|
1242
|
+
return { success: false, error: `Codex account ${profileId} not found.` };
|
|
1243
|
+
}
|
|
1244
|
+
fs.rmSync(getCodexProfileDir(profileId), { recursive: true, force: true });
|
|
1245
|
+
fs.rmSync(getCodexBackupPath(profileId), { force: true });
|
|
1246
|
+
fs.rmSync(getCodexBackupPath(profileId, true), { force: true });
|
|
1247
|
+
return { success: true };
|
|
1248
|
+
}
|
|
1249
|
+
export async function cleanupAbandonedCodexLoginsAsync(olderThanMs = 24 * 60 * 60 * 1000, now = Date.now()) {
|
|
1250
|
+
return cleanupAbandonedCodexLogins(olderThanMs, now, await inspectRunningAccountConsumers('codex'));
|
|
1251
|
+
}
|