@wenathlan/devthink 2.0.15 → 2.0.19
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 +45 -0
- package/{dist/bridge.d.ts → bridge.d.ts} +1 -1
- package/bridge.d.ts.map +1 -0
- package/bridge.js +1877 -0
- package/bun.d.ts.map +1 -0
- package/bun.js +52329 -0
- package/caps/background.json +712 -0
- package/caps/cli.json +360 -0
- package/caps/library.json +3611 -0
- package/caps/mcp.json +126 -0
- package/caps/pagebridge.json +377 -0
- package/caps/popup.json +51 -0
- package/caps/sidepanel.json +522 -0
- package/checksums.txt +142 -0
- package/cli.d.ts.map +1 -0
- package/{dist/cli.js → cli.js} +2 -2
- package/companion.d.ts.map +1 -0
- package/companion.js +389 -0
- package/crossbrowser.d.ts.map +1 -0
- package/crossbrowser.js +1191 -0
- package/dashdone.d.ts.map +1 -0
- package/dashdone.js +368 -0
- package/deno.d.ts.map +1 -0
- package/deno.js +52325 -0
- package/devthink.umd.js +52346 -0
- package/fixtures/bun.ts +7 -0
- package/fixtures/cjs.js +6 -0
- package/fixtures/deno.ts +6 -0
- package/fixtures/example-org-pagestate.json +22 -0
- package/fixtures/importers/automa-workflow.json +18 -0
- package/fixtures/importers/selenium-side.json +20 -0
- package/fixtures/importers/tabular-plan.csv +7 -0
- package/fixtures/importers/uivision-macro.json +10 -0
- package/fixtures/importers/v1-plan.json +13 -0
- package/fixtures/mcp-client.mjs +14 -0
- package/fixtures/pages/checkout.html +192 -0
- package/fixtures/pages/dashboard.html +110 -0
- package/fixtures/pages/feed.html +113 -0
- package/fixtures/pages/product-grid.html +150 -0
- package/fixtures/plans/form-inventory-plan.json +11 -0
- package/fixtures/plans/release-notes-plan.json +11 -0
- package/fixtures/recipes/agentscompete.json +11 -0
- package/fixtures/recipes/agentsforms.json +11 -0
- package/fixtures/recipes/agentsmonitor.json +11 -0
- package/fixtures/recipes/agentsreview.json +29 -0
- package/fixtures/recipes/agentswarm.json +11 -0
- package/fixtures/recipes/fillcalendar.json +12 -0
- package/fixtures/recipes/fillcheckout.json +13 -0
- package/fixtures/recipes/filldependentdropdowns.json +12 -0
- package/fixtures/recipes/fillfromcsv.json +13 -0
- package/fixtures/recipes/fillloginform.json +12 -0
- package/fixtures/recipes/fillmultistepwizard.json +13 -0
- package/fixtures/recipes/fillregistration.json +12 -0
- package/fixtures/recipes/fillsearchfilters.json +12 -0
- package/fixtures/recipes/filltypeahead.json +11 -0
- package/fixtures/recipes/monitoralerts.json +12 -0
- package/fixtures/recipes/monitoravailability.json +11 -0
- package/fixtures/recipes/monitornetworkquiet.json +12 -0
- package/fixtures/recipes/monitorpagechanges.json +11 -0
- package/fixtures/recipes/monitorpricechange.json +11 -0
- package/fixtures/recipes/monitorschedule.json +12 -0
- package/fixtures/recipes/scrapedynamicprices.json +13 -0
- package/fixtures/recipes/scrapeimagegallery.json +12 -0
- package/fixtures/recipes/scrapeinfinitefeed.json +13 -0
- package/fixtures/recipes/scrapenestedlists.json +12 -0
- package/fixtures/recipes/scrapeproductgrid.json +12 -0
- package/fixtures/recipes/scrapereviews.json +12 -0
- package/fixtures/recipes/scrapesearchresults.json +12 -0
- package/fixtures/recipes/scrapetablepagination.json +12 -0
- package/fixtures/recipes/testaccessibility.json +34 -0
- package/fixtures/recipes/testconsoleerrors.json +12 -0
- package/fixtures/recipes/testdeeplinks.json +12 -0
- package/fixtures/recipes/testformvalidation.json +13 -0
- package/fixtures/recipes/testlayoutshift.json +12 -0
- package/fixtures/recipes/testlinkcheck.json +12 -0
- package/fixtures/recipes/testloadtiming.json +12 -0
- package/fixtures/recipes/testvisualdiff.json +12 -0
- package/fixtures/workflows/changelog-digest-workflow.json +13 -0
- package/fixtures/workflows/form-survey-workflow.json +13 -0
- package/gallery.json +706 -0
- package/hardening.d.ts.map +1 -0
- package/hardening.js +188 -0
- package/headless.d.ts.map +1 -0
- package/headless.js +11522 -0
- package/http.d.ts.map +1 -0
- package/http.js +2165 -0
- package/index.cjs +54682 -0
- package/index.d.ts.map +1 -0
- package/index.js +52310 -0
- package/index.neutral.js +46343 -0
- package/{web/manifest.json → manifest.json} +1 -1
- package/mcp.d.ts.map +1 -0
- package/mcp.js +20515 -0
- package/memory.d.ts.map +1 -0
- package/{memory.ts → memory.js} +2310 -3834
- package/nativehost.template.json +10 -0
- package/node.cjs +54686 -0
- package/node.d.ts.map +1 -0
- package/pack.d.ts.map +1 -0
- package/pack.js +805 -0
- package/package.json +83 -176
- package/policy.d.ts.map +1 -0
- package/{policy.ts → policy.js} +4679 -5073
- package/progress.d.ts.map +1 -0
- package/progress.js +645 -0
- package/protocol.d.ts.map +1 -0
- package/protocol.js +8334 -0
- package/schemas/audit.schema.json +226 -0
- package/schemas/capability.schema.json +113 -0
- package/schemas/envelope.schema.json +162 -0
- package/schemas/memory.schema.json +51 -0
- package/schemas/observation.schema.json +113 -0
- package/schemas/plan.schema.json +208 -0
- package/schemas/progress.schema.json +126 -0
- package/schemas/proposal.schema.json +68 -0
- package/schemas/session.schema.json +82 -0
- package/schemas/tool.schema.json +94 -0
- package/server.d.ts.map +1 -0
- package/server.js +28877 -0
- package/umd-example.html +18 -0
- package/umd.d.ts.map +1 -0
- package/accounts.ts +0 -2087
- package/agent.ts +0 -2331
- package/antigravity.ts +0 -2139
- package/apifreeze.ts +0 -4146
- package/auth.ts +0 -642
- package/background.ts +0 -16837
- package/bridge.ts +0 -2080
- package/build.ts +0 -54
- package/bun.ts +0 -26
- package/capture.ts +0 -1614
- package/checksecrets.ts +0 -72
- package/cjs.ts +0 -9
- package/cli.ts +0 -3500
- package/commands.ts +0 -1101
- package/companion.ts +0 -450
- package/compatibility.ts +0 -37
- package/config.ts +0 -3137
- package/constants.ts +0 -943
- package/core.ts +0 -1473
- package/crossbrowser.ts +0 -1359
- package/dashdone.ts +0 -605
- package/data.ts +0 -943
- package/database.ts +0 -133
- package/debug.ts +0 -850
- package/deno.ts +0 -27
- package/devthink.ts +0 -707
- package/dist/accounts.d.ts +0 -409
- package/dist/agent.d.ts +0 -837
- package/dist/antigravity.d.ts +0 -380
- package/dist/apifreeze.d.ts +0 -113
- package/dist/auth.d.ts +0 -224
- package/dist/capture.d.ts +0 -595
- package/dist/cjs.d.ts +0 -6
- package/dist/commands.d.ts +0 -310
- package/dist/compatibility.d.ts +0 -17
- package/dist/config.d.ts +0 -1853
- package/dist/constants.d.ts +0 -637
- package/dist/core.d.ts +0 -361
- package/dist/data.d.ts +0 -330
- package/dist/database.d.ts +0 -24
- package/dist/debug.d.ts +0 -270
- package/dist/engine.d.ts +0 -74
- package/dist/environments.d.ts +0 -244
- package/dist/evidence.d.ts +0 -277
- package/dist/export.d.ts +0 -311
- package/dist/fingerprint.d.ts +0 -594
- package/dist/flow.d.ts +0 -300
- package/dist/gates.d.ts +0 -103
- package/dist/identity.d.ts +0 -51
- package/dist/ids.d.ts +0 -7
- package/dist/llm.d.ts +0 -652
- package/dist/models.d.ts +0 -1807
- package/dist/net.d.ts +0 -325
- package/dist/neutral.d.ts +0 -41
- package/dist/oauth.d.ts +0 -703
- package/dist/page.d.ts +0 -1166
- package/dist/perf.d.ts +0 -481
- package/dist/plan.d.ts +0 -134
- package/dist/project.d.ts +0 -395
- package/dist/providers.d.ts +0 -37
- package/dist/quota.d.ts +0 -453
- package/dist/recovery.d.ts +0 -621
- package/dist/request.d.ts +0 -1492
- package/dist/run.d.ts +0 -779
- package/dist/runtime.d.ts +0 -212
- package/dist/search.d.ts +0 -375
- package/dist/security.d.ts +0 -534
- package/dist/serve.d.ts +0 -136
- package/dist/session.d.ts +0 -296
- package/dist/storage.d.ts +0 -53
- package/dist/streaming.d.ts +0 -1119
- package/dist/swarm.d.ts +0 -328
- package/dist/tools.d.ts +0 -170
- package/dist/types.d.ts +0 -8357
- package/dist/utils.d.ts +0 -82
- package/dist/version.d.ts +0 -7
- package/dist/versionregistry.d.ts +0 -406
- package/dist/views.d.ts +0 -579
- package/dist/workbenchsession.d.ts +0 -69
- package/dist/workflow.d.ts +0 -649
- package/docs/readme.md +0 -92
- package/docs/schemas/antigravity.json +0 -192
- package/engine.ts +0 -1524
- package/environments.ts +0 -1005
- package/evidence.ts +0 -643
- package/export.ts +0 -889
- package/fingerprint.ts +0 -1458
- package/flow.ts +0 -1190
- package/gates.ts +0 -307
- package/gateway.ts +0 -34
- package/hardening.ts +0 -191
- package/headless.ts +0 -369
- package/http.ts +0 -2110
- package/identity.ts +0 -246
- package/ids.ts +0 -26
- package/index.ts +0 -369
- package/llm.ts +0 -2682
- package/mcp.ts +0 -1835
- package/models.ts +0 -3805
- package/modes.ts +0 -43
- package/net.ts +0 -1214
- package/neutral.ts +0 -362
- package/node.ts +0 -26
- package/oauth.ts +0 -2216
- package/offscreen.ts +0 -170
- package/pack.ts +0 -956
- package/page.ts +0 -7824
- package/pagebridge.ts +0 -1116
- package/perf.ts +0 -957
- package/plan.ts +0 -672
- package/plugin.ts +0 -76
- package/prisma.config.ts +0 -27
- package/progress.ts +0 -1308
- package/project.ts +0 -1291
- package/protocol.ts +0 -4187
- package/providers.ts +0 -246
- package/quota.ts +0 -1887
- package/recovery.ts +0 -2433
- package/request.ts +0 -4482
- package/run.ts +0 -2135
- package/runtime.ts +0 -526
- package/search.ts +0 -1760
- package/security.ts +0 -1673
- package/serve.ts +0 -419
- package/server.ts +0 -5538
- package/session.ts +0 -1162
- package/storage.ts +0 -182
- package/streaming.ts +0 -4101
- package/swarm.ts +0 -884
- package/sync.ts +0 -56
- package/tests/agentcert.test.ts +0 -128
- package/tests/agentfleet.test.ts +0 -214
- package/tests/agentreview.test.ts +0 -241
- package/tests/agentstream.test.ts +0 -335
- package/tests/agentwork.test.ts +0 -356
- package/tests/apifreeze.test.ts +0 -290
- package/tests/apimap.test.ts +0 -112
- package/tests/approvalgate.test.ts +0 -175
- package/tests/artifactmanifest.test.ts +0 -204
- package/tests/attentionfeed.test.ts +0 -163
- package/tests/backgroundruns.test.ts +0 -100
- package/tests/batchscheduling.test.ts +0 -131
- package/tests/blackboard.test.ts +0 -238
- package/tests/bridge.test.ts +0 -1088
- package/tests/browsercoverage.test.ts +0 -134
- package/tests/browserpolyfills.test.ts +0 -124
- package/tests/build.mjs +0 -1260
- package/tests/clientauth.test.ts +0 -223
- package/tests/clitools.test.ts +0 -578
- package/tests/confirmgates.test.ts +0 -190
- package/tests/containerpack.test.ts +0 -143
- package/tests/controlflow.test.ts +0 -2120
- package/tests/coordination.test.ts +0 -345
- package/tests/copyscreen.test.ts +0 -139
- package/tests/core.test.ts +0 -417
- package/tests/costcert.test.ts +0 -95
- package/tests/crossbrowser.test.ts +0 -115
- package/tests/cspaudit.test.ts +0 -79
- package/tests/dashdone.test.ts +0 -615
- package/tests/datagrid.test.ts +0 -137
- package/tests/doccheck.test.ts +0 -107
- package/tests/dombatch.test.ts +0 -109
- package/tests/emulation.test.ts +0 -282
- package/tests/environments.test.ts +0 -259
- package/tests/evidenceviews.test.ts +0 -88
- package/tests/exporttools.test.ts +0 -185
- package/tests/firefoxprep.test.ts +0 -134
- package/tests/flowlibrary.test.ts +0 -242
- package/tests/flowrun.test.ts +0 -303
- package/tests/forensics.test.ts +0 -439
- package/tests/gateway.test.ts +0 -939
- package/tests/headlesslib.test.ts +0 -171
- package/tests/httpstream.test.ts +0 -277
- package/tests/immutablelog.test.ts +0 -157
- package/tests/inboundguard.test.ts +0 -177
- package/tests/lazyload.test.ts +0 -72
- package/tests/librarymodes.test.ts +0 -623
- package/tests/llm.test.ts +0 -696
- package/tests/manifest.test.ts +0 -226
- package/tests/maskinputs.test.ts +0 -150
- package/tests/matrixverify.test.ts +0 -111
- package/tests/mavenpack.test.ts +0 -75
- package/tests/mcpserver.test.ts +0 -704
- package/tests/memory.test.ts +0 -4155
- package/tests/memorycare.test.ts +0 -230
- package/tests/migrateplan.test.ts +0 -349
- package/tests/minimization.test.ts +0 -414
- package/tests/modelroute.test.ts +0 -112
- package/tests/multiagent.test.ts +0 -440
- package/tests/nativebridge.test.ts +0 -807
- package/tests/navigation.test.ts +0 -284
- package/tests/nugetpack.test.ts +0 -83
- package/tests/orchestration.test.ts +0 -449
- package/tests/originpolicy.test.ts +0 -405
- package/tests/outputcompare.test.ts +0 -97
- package/tests/package.test.ts +0 -91
- package/tests/pagebridge.test.ts +0 -5909
- package/tests/pentest.test.ts +0 -75
- package/tests/perfrecords.test.ts +0 -257
- package/tests/permdiff.test.ts +0 -85
- package/tests/phishguard.test.ts +0 -128
- package/tests/pickerviews.test.ts +0 -155
- package/tests/pipelines.test.ts +0 -381
- package/tests/planlint.test.ts +0 -240
- package/tests/planreview.test.ts +0 -383
- package/tests/platformtargets.test.ts +0 -56
- package/tests/policy.test.ts +0 -8284
- package/tests/portability.test.ts +0 -96
- package/tests/progress.test.ts +0 -1036
- package/tests/promptlibrary.test.ts +0 -116
- package/tests/protocol.test.ts +0 -4793
- package/tests/publishingpipeline.test.ts +0 -314
- package/tests/quickactions.test.ts +0 -164
- package/tests/readiness.test.ts +0 -78
- package/tests/recipes.test.ts +0 -163
- package/tests/redactshots.test.ts +0 -216
- package/tests/registernvidiakeys.ts +0 -51
- package/tests/relayserve.test.ts +0 -304
- package/tests/releasenotes.test.ts +0 -276
- package/tests/resilience.test.ts +0 -467
- package/tests/resourceaware.test.ts +0 -187
- package/tests/runbudget.test.ts +0 -125
- package/tests/runhistory.test.ts +0 -299
- package/tests/runreplay.test.ts +0 -113
- package/tests/runresume.test.ts +0 -184
- package/tests/runstate.test.ts +0 -288
- package/tests/runtimeadapters.test.ts +0 -251
- package/tests/safariskeleton.test.ts +0 -115
- package/tests/sandboxframe.test.ts +0 -196
- package/tests/sbom.test.ts +0 -105
- package/tests/secretvault.test.ts +0 -188
- package/tests/selcache.test.ts +0 -111
- package/tests/sessioninterface.test.ts +0 -791
- package/tests/sessions.test.ts +0 -591
- package/tests/siteprefs.test.ts +0 -228
- package/tests/snapshotdelta.test.ts +0 -165
- package/tests/statusviews.test.ts +0 -237
- package/tests/stepmeter.test.ts +0 -200
- package/tests/streamparse.test.ts +0 -163
- package/tests/stylepruning.test.ts +0 -90
- package/tests/surfaces.test.ts +0 -282
- package/tests/sweep.test.ts +0 -78
- package/tests/syncbridge.test.ts +0 -164
- package/tests/taskqueue.test.ts +0 -206
- package/tests/toolcalls.test.ts +0 -336
- package/tests/toolcatalog.test.ts +0 -201
- package/tests/torture-agent.test.ts +0 -706
- package/tests/torture-auth.test.ts +0 -461
- package/tests/torture-commands.test.ts +0 -895
- package/tests/torture-data.test.ts +0 -923
- package/tests/torture-environments.test.ts +0 -285
- package/tests/torture-export.test.ts +0 -263
- package/tests/torture-http.test.ts +0 -1379
- package/tests/torture-llm.test.ts +0 -808
- package/tests/torture-memory.test.ts +0 -1084
- package/tests/torture-plan.test.ts +0 -829
- package/tests/torture-run.test.ts +0 -626
- package/tests/torture-session.test.ts +0 -928
- package/tests/torture-swarm.test.ts +0 -1245
- package/tests/torture-tools.test.ts +0 -964
- package/tests/torture-workflow.test.ts +0 -2354
- package/tests/tourviews.test.ts +0 -52
- package/tests/transparency.test.ts +0 -147
- package/tests/trigger.test.ts +0 -941
- package/tests/v1sunset.test.ts +0 -428
- package/tests/virtlist.test.ts +0 -94
- package/tests/vision.test.ts +0 -540
- package/tests/vsixpack.test.ts +0 -145
- package/tests/webapi.test.ts +0 -523
- package/tests/workflow.test.ts +0 -1785
- package/tests/workfloweditor.test.ts +0 -759
- package/tests/xpipack.test.ts +0 -128
- package/tools.ts +0 -734
- package/types.ts +0 -9908
- package/umd.ts +0 -9
- package/utils.ts +0 -249
- package/version.ts +0 -8
- package/versionregistry.ts +0 -1310
- package/views.ts +0 -2153
- package/vitest.config.ts +0 -12
- package/web/App.tsx +0 -78
- package/web/README.md +0 -47
- package/web/capacitor.config.ts +0 -46
- package/web/const.ts +0 -19
- package/web/control.shell.tsx +0 -66
- package/web/dashboardpage.ts +0 -554
- package/web/db.ts +0 -493
- package/web/design.html +0 -541
- package/web/gatewayview/Gateway.tsx +0 -345
- package/web/gatewayview/config.ts +0 -1042
- package/web/gatewayview/definition.ts +0 -174
- package/web/icons.ts +0 -15
- package/web/optionspage.ts +0 -691
- package/web/popup.ts +0 -1651
- package/web/schema.prisma +0 -176
- package/web/sidepanel.ts +0 -7941
- package/web/sitemanifest.json +0 -13
- package/web/terminal-ui.tsx +0 -622
- package/web/transparencypage.ts +0 -541
- package/web/vite.config.ts +0 -39
- package/web/workspace.ts +0 -23
- package/workbenchmemory.ts +0 -104
- package/workbenchsession.ts +0 -304
- package/workflow.ts +0 -4495
- /package/{dist/bun.d.ts → bun.d.ts} +0 -0
- /package/{dist/cli.d.ts → cli.d.ts} +0 -0
- /package/{dist/companion.d.ts → companion.d.ts} +0 -0
- /package/{dist/crossbrowser.d.ts → crossbrowser.d.ts} +0 -0
- /package/{dist/dashdone.d.ts → dashdone.d.ts} +0 -0
- /package/{dist/deno.d.ts → deno.d.ts} +0 -0
- /package/{dist/hardening.d.ts → hardening.d.ts} +0 -0
- /package/{dist/headless.d.ts → headless.d.ts} +0 -0
- /package/{dist/http.d.ts → http.d.ts} +0 -0
- /package/{dist/index.d.ts → index.d.ts} +0 -0
- /package/{dist/mcp.d.ts → mcp.d.ts} +0 -0
- /package/{dist/memory.d.ts → memory.d.ts} +0 -0
- /package/{dist/node.d.ts → node.d.ts} +0 -0
- /package/{dist/pack.d.ts → pack.d.ts} +0 -0
- /package/{dist/policy.d.ts → policy.d.ts} +0 -0
- /package/{dist/progress.d.ts → progress.d.ts} +0 -0
- /package/{dist/protocol.d.ts → protocol.d.ts} +0 -0
- /package/{dist/server.d.ts → server.d.ts} +0 -0
- /package/{dist/umd.d.ts → umd.d.ts} +0 -0
package/accounts.ts
DELETED
|
@@ -1,2087 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview accounts.ts — the account manager core of the merged provider lineage
|
|
3
|
-
* @module auth/accounts
|
|
4
|
-
* @description
|
|
5
|
-
* Production-ready multi-account management for the provider plugin.
|
|
6
|
-
* MERGED FILE - superset of every previous duplicate:
|
|
7
|
-
* - Multiple Google accounts with refresh token support
|
|
8
|
-
* - Storage at ~/.config/opencode/antigravity-accounts.json (chmod 600, atomic write)
|
|
9
|
-
* - Round-robin + sticky active account rotation
|
|
10
|
-
* - Double-checked locking for access token refresh
|
|
11
|
-
* - Quota/429 detection with automatic rotation
|
|
12
|
-
* - Background refresh of tokens close to expiry
|
|
13
|
-
* - Import from antigravity-manager (multiple legacy formats)
|
|
14
|
-
* - Official Gemini CLI bypass headers
|
|
15
|
-
* - Ported from the flat-file variant: legacy flat-file reader (Account[] format,
|
|
16
|
-
* type Account, AccountsFileV3 wrapper), QuotaInfo reporting type,
|
|
17
|
-
* QuotaExhaustedOptions, RefreshResult, ensureAccountsFile/loadAccountsSnapshot
|
|
18
|
-
* helpers and resolveAccountsPath/resolveConfigDir/
|
|
19
|
-
* resolveAntagravityManagerCandidates path resolvers.
|
|
20
|
-
*
|
|
21
|
-
* Zero external dependencies: only node:* builtins + global fetch (Node 18+).
|
|
22
|
-
*
|
|
23
|
-
* @author ONDA 3 - Core Modules
|
|
24
|
-
* @license MIT
|
|
25
|
-
* @version 3.1.0
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import * as fs from "node:fs";
|
|
29
|
-
import * as fsp from "node:fs/promises";
|
|
30
|
-
import * as path from "node:path";
|
|
31
|
-
import * as os from "node:os";
|
|
32
|
-
import * as crypto from "node:crypto";
|
|
33
|
-
|
|
34
|
-
// Deduplicated against the frozen owner (constants.ts) and the canonical
|
|
35
|
-
// in-set file (project.ts): symbols whose local definitions were value-identical
|
|
36
|
-
// are imported and re-exported below, so each has exactly one definition
|
|
37
|
-
// (library dedupe task 2-d1).
|
|
38
|
-
import {
|
|
39
|
-
GEMINI_CLI_OAUTH_CLIENT_ID,
|
|
40
|
-
GEMINI_CLI_OAUTH_CLIENT_SECRET,
|
|
41
|
-
OAUTH_TOKEN_URL,
|
|
42
|
-
PROJECT_FALLBACK,
|
|
43
|
-
resolveOpenCodeBaseDir,
|
|
44
|
-
} from "./constants.js";
|
|
45
|
-
// v2.1.15 Phase B: the minimal Gemini CLI UA and the gl-node api-client
|
|
46
|
-
// snapshot come from the owners (constants.js / fingerprint.js).
|
|
47
|
-
import {
|
|
48
|
-
GEMINI_CLI_USER_AGENT_MINIMAL as GEMINI_CLI_USER_AGENT,
|
|
49
|
-
GEMINI_CLI_USER_AGENT as GEMINI_CLI_USER_AGENT_FULL,
|
|
50
|
-
} from "./constants.js";
|
|
51
|
-
import { X_GOOG_API_CLIENT_GEMINI_CLI as X_GOOG_API_CLIENT } from "./fingerprint.js";
|
|
52
|
-
import { resolveConfigDir as configResolveConfigDir } from "./config.js";
|
|
53
|
-
// v2.1.16 single-owner fix + the grand merge consolidation: the real
|
|
54
|
-
// antigravity-cli client pair is imported from constants.js (the layer-0
|
|
55
|
-
// raw-value owner — env-overridable, live-validated); the former local
|
|
56
|
-
// segment-assembled copies were byte-identical duplicates. The owner never
|
|
57
|
-
// rides an import cycle, so this module reads it at evaluation time in any
|
|
58
|
-
// order (auth.ts keeps the same value through its own constants alias).
|
|
59
|
-
import {
|
|
60
|
-
ANTIGRAVITY_CLI_OAUTH_CLIENT_ID as AGCLI_CLIENT_ID_OWNER,
|
|
61
|
-
ANTIGRAVITY_CLI_OAUTH_CLIENT_SECRET as AGCLI_CLIENT_SECRET_OWNER,
|
|
62
|
-
} from "./constants.js";
|
|
63
|
-
import {
|
|
64
|
-
CLOUDCODE_BASE_URL,
|
|
65
|
-
CLOUDCODE_ENDPOINTS,
|
|
66
|
-
CLIENT_METADATA_STRING as CLIENT_METADATA,
|
|
67
|
-
GEMINI_CLI_SCOPES,
|
|
68
|
-
OAUTH_USERINFO_URL_V3,
|
|
69
|
-
} from "./constants.js";
|
|
70
|
-
import { GEMINI_CLI_SCOPES as OAUTH_SCOPES } from "./constants.js";
|
|
71
|
-
|
|
72
|
-
// Re-exports preserve this module's public surface after the dedupe.
|
|
73
|
-
export {
|
|
74
|
-
GEMINI_CLI_OAUTH_CLIENT_ID,
|
|
75
|
-
GEMINI_CLI_OAUTH_CLIENT_SECRET,
|
|
76
|
-
OAUTH_TOKEN_URL,
|
|
77
|
-
PROJECT_FALLBACK,
|
|
78
|
-
GEMINI_CLI_USER_AGENT,
|
|
79
|
-
GEMINI_CLI_USER_AGENT_FULL,
|
|
80
|
-
X_GOOG_API_CLIENT,
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
// ============================================================================
|
|
84
|
-
// SECTION 00. HARDENED CONSTANTS - GEMINI CLI BYPASS
|
|
85
|
-
// ============================================================================
|
|
86
|
-
|
|
87
|
-
/** Official Gemini CLI OAuth2 Client ID - raw without suffix */
|
|
88
|
-
export const GEMINI_CLI_CLIENT_ID_RAW = "681255809395-oo8f…b135j" as const;
|
|
89
|
-
|
|
90
|
-
// GEMINI_CLI_OAUTH_CLIENT_ID / GEMINI_CLI_OAUTH_CLIENT_SECRET: identical to the
|
|
91
|
-
// constants.ts owner definitions — imported and re-exported above.
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Antigravity CLI OAuth client (`agy` binary) — the client the provider
|
|
95
|
-
* account console presents for NEW logins since 2.1.8. v2.1.16 single-owner
|
|
96
|
-
* fix: imported from constants.js (the layer-0 raw-value owner —
|
|
97
|
-
* env-overridable through ANTIGRAVITY_CLIENT_ID /
|
|
98
|
-
* ANTIGRAVITY_CLIENT_SECRET); the former local segment-assembled copy was a
|
|
99
|
-
* byte-identical duplicate.
|
|
100
|
-
*/
|
|
101
|
-
export const ANTIGRAVITY_CLI_OAUTH_CLIENT_ID: string = AGCLI_CLIENT_ID_OWNER;
|
|
102
|
-
|
|
103
|
-
/** Antigravity CLI client secret — alias of the constants.js owner (v2.1.16). */
|
|
104
|
-
export const ANTIGRAVITY_CLI_OAUTH_CLIENT_SECRET: string = AGCLI_CLIENT_SECRET_OWNER;
|
|
105
|
-
|
|
106
|
-
// local variant: diverges from constants (3-scope list vs constants' 8-scope GEMINI_CLI_SCOPES; the value matches constants' GEMINI_CLI_SCOPES under a different name)
|
|
107
|
-
// v2.1.15 Phase C: GEMINI_CLI_SCOPES (3-scope Gemini CLI set) is constants.js
|
|
108
|
-
// GEMINI_CLI_SCOPES — imported from the owner.
|
|
109
|
-
|
|
110
|
-
// v2.1.15 Phase C: the v3 userinfo URL moved to constants.js as
|
|
111
|
-
// OAUTH_USERINFO_URL_V3 (the v2 URL stays the canonical OAUTH_USERINFO_URL_V3).
|
|
112
|
-
|
|
113
|
-
/** Bypass headers - canonical values required by WAVE 3.
|
|
114
|
-
* v2.1.16 single-owner fix: alias of constants.GEMINI_CLI_USER_AGENT (the
|
|
115
|
-
* full live-validated UA string — byte-identical to the former local copy). */
|
|
116
|
-
// GEMINI_CLI_USER_AGENT / X_GOOG_API_CLIENT: static values identical to the
|
|
117
|
-
// canonical project.ts definitions — imported and re-exported above
|
|
118
|
-
// (constants.ts's same-named symbols are dynamic and therefore divergent).
|
|
119
|
-
|
|
120
|
-
// local variant: diverges from constants (static GEMINI plugin metadata string vs the dynamic buildClientMetadata() value)
|
|
121
|
-
|
|
122
|
-
// local variant: no same-named owner export (constants owns the individual endpoint paths and CODE_ASSIST_ENDPOINTS under other names)
|
|
123
|
-
|
|
124
|
-
// PROJECT_FALLBACK: identical "rising-fact-p41fc" — imported and re-exported above.
|
|
125
|
-
|
|
126
|
-
// v2.1.15 Phase A: MODELS_2026 (the 11-id legacy bypass list) and the
|
|
127
|
-
// Model2026 union moved to models.ts (MODELS_2026_LEGACY / Model2026) —
|
|
128
|
-
// imported under the historical name and re-exported so the public surface
|
|
129
|
-
// is unchanged.
|
|
130
|
-
import { MODELS_2026_LEGACY as MODELS_2026 } from "./models.js";
|
|
131
|
-
import type { Model2026 } from "./models.js";
|
|
132
|
-
export { MODELS_2026 };
|
|
133
|
-
export type { Model2026 };
|
|
134
|
-
|
|
135
|
-
// ============================================================================
|
|
136
|
-
// SECTION 01. CORE TYPES
|
|
137
|
-
// ============================================================================
|
|
138
|
-
|
|
139
|
-
export interface OAuthClientConfig {
|
|
140
|
-
clientId: string;
|
|
141
|
-
clientSecret: string;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export type OAuthClientKey = "gemini-cli" | "antigravity-manager" | "antigravity" | "custom" | string;
|
|
145
|
-
|
|
146
|
-
export interface AccountMetadataV3 {
|
|
147
|
-
/** Normalized lower-case email */
|
|
148
|
-
email: string;
|
|
149
|
-
/** OAuth2 refresh token (long-lived) */
|
|
150
|
-
refreshToken: string;
|
|
151
|
-
/** Current access token (short-lived, 1h) */
|
|
152
|
-
accessToken?: string | undefined;
|
|
153
|
-
/** Epoch ms when accessToken expires */
|
|
154
|
-
expiryDate?: number | undefined;
|
|
155
|
-
/** Linked Cloud Project ID (or fallback) */
|
|
156
|
-
projectId?: string | undefined;
|
|
157
|
-
/** Whether the account is enabled for use */
|
|
158
|
-
enabled: boolean;
|
|
159
|
-
/** Identifying key of the OAuth client used */
|
|
160
|
-
oauthClientKey: OAuthClientKey;
|
|
161
|
-
/** Custom OAuth config, if oauthClientKey=custom */
|
|
162
|
-
oauthClientConfig?: OAuthClientConfig | undefined;
|
|
163
|
-
/** Epoch ms of last successful refresh */
|
|
164
|
-
lastRefresh?: number | undefined;
|
|
165
|
-
/** Epoch ms of record creation */
|
|
166
|
-
createdAt: number;
|
|
167
|
-
/** Epoch ms of last use (for LRU / rotation) */
|
|
168
|
-
lastUsedAt?: number | undefined;
|
|
169
|
-
/** Consecutive failure counter (quota, 401, etc) */
|
|
170
|
-
failureCount?: number | undefined;
|
|
171
|
-
/** Reason for temporary disablement */
|
|
172
|
-
disabledReason?: string | undefined;
|
|
173
|
-
/** Epoch ms until which the account stays disabled (cooldown) */
|
|
174
|
-
disabledUntil?: number | undefined;
|
|
175
|
-
/** Scopes this account holds (optional, for audit) */
|
|
176
|
-
scopes?: string[] | undefined;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export interface AccountStore {
|
|
180
|
-
/** Schema version - always 3 */
|
|
181
|
-
version: 3;
|
|
182
|
-
/** Account list */
|
|
183
|
-
accounts: AccountMetadataV3[];
|
|
184
|
-
/** Active email in sticky mode */
|
|
185
|
-
activeEmail?: string | null;
|
|
186
|
-
/** Round-robin rotation index */
|
|
187
|
-
rotationIndex?: number;
|
|
188
|
-
/** Selection strategy: round-robin (default) or sticky */
|
|
189
|
-
rotationStrategy?: "round-robin" | "sticky";
|
|
190
|
-
/** When the store was created */
|
|
191
|
-
createdAt?: number | undefined;
|
|
192
|
-
/** Last update */
|
|
193
|
-
updatedAt?: number | undefined;
|
|
194
|
-
/** Last error-driven rotation */
|
|
195
|
-
lastRotationAt?: number | undefined;
|
|
196
|
-
/** Email that caused the last rotation (for debug) */
|
|
197
|
-
lastRotatedFrom?: string | undefined;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export type RotationStrategy = "round-robin" | "sticky";
|
|
201
|
-
|
|
202
|
-
export interface AddAccountInput {
|
|
203
|
-
email: string;
|
|
204
|
-
refreshToken: string;
|
|
205
|
-
accessToken?: string;
|
|
206
|
-
expiryDate?: number;
|
|
207
|
-
projectId?: string;
|
|
208
|
-
enabled?: boolean;
|
|
209
|
-
oauthClientKey?: OAuthClientKey;
|
|
210
|
-
oauthClientConfig?: OAuthClientConfig;
|
|
211
|
-
scopes?: string[];
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export interface ImportResult {
|
|
215
|
-
imported: number;
|
|
216
|
-
skipped: number;
|
|
217
|
-
updated: number;
|
|
218
|
-
errors: string[];
|
|
219
|
-
sources: string[];
|
|
220
|
-
accounts: AccountMetadataV3[];
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export interface TokenRefreshResponse {
|
|
224
|
-
access_token: string;
|
|
225
|
-
expires_in: number;
|
|
226
|
-
token_type: string;
|
|
227
|
-
scope?: string;
|
|
228
|
-
id_token?: string;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ============================================================================
|
|
232
|
-
// SECTION 02. LEGACY FLAT-FILE COMPAT TYPES (ported from flat variant)
|
|
233
|
-
// ============================================================================
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Quota snapshot per account, mirrors v1internal:retrieveUserQuotaSummary
|
|
237
|
-
* but kept lightweight in storage. Reporting-only type.
|
|
238
|
-
*/
|
|
239
|
-
export interface QuotaInfo {
|
|
240
|
-
/** Remaining percentage or tokens, optional */
|
|
241
|
-
remaining?: number;
|
|
242
|
-
/** Limit */
|
|
243
|
-
limit?: number;
|
|
244
|
-
/** Reset timestamp ms */
|
|
245
|
-
resetAt?: number;
|
|
246
|
-
/** True when exhausted */
|
|
247
|
-
exhausted?: boolean;
|
|
248
|
-
/** Raw group mapped from model to group, e.g. antigravity, gemini-cli */
|
|
249
|
-
group?: string;
|
|
250
|
-
/** Last check ms */
|
|
251
|
-
lastChecked?: number;
|
|
252
|
-
/** Per-model counters when dual pool */
|
|
253
|
-
pools?: Record<string, { remaining?: number; resetAt?: number }>;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* V3 legacy Account - flat storage format compatible with the
|
|
258
|
-
* antigravity-manager flat export {email, refresh_token}.
|
|
259
|
-
*/
|
|
260
|
-
export interface Account {
|
|
261
|
-
/** Unique email, lowercased */
|
|
262
|
-
email: string;
|
|
263
|
-
/** Google OAuth refresh token (1//...) */
|
|
264
|
-
refreshToken: string;
|
|
265
|
-
/** Cached bearer access token */
|
|
266
|
-
accessToken?: string | undefined;
|
|
267
|
-
/** Expiry epoch ms (Date.now() + expires_in*1000) */
|
|
268
|
-
expiry?: number | undefined;
|
|
269
|
-
/** Cloud project id resolved via loadCodeAssist / onboardUser */
|
|
270
|
-
projectId?: string | undefined;
|
|
271
|
-
/** Soft disabled flag - user can toggle */
|
|
272
|
-
disabled?: boolean | undefined;
|
|
273
|
-
/** Quota exhausted until epoch ms - skip in rotation */
|
|
274
|
-
quotaExhaustedUntil?: number | undefined;
|
|
275
|
-
/** Last usage epoch ms */
|
|
276
|
-
lastUsed?: number | undefined;
|
|
277
|
-
/** Creation epoch ms */
|
|
278
|
-
createdAt?: number | undefined;
|
|
279
|
-
/** Update epoch ms */
|
|
280
|
-
updatedAt?: number | undefined;
|
|
281
|
-
/** Cached quota snapshot */
|
|
282
|
-
quota?: QuotaInfo | undefined;
|
|
283
|
-
/** Optional stable id (uuid) */
|
|
284
|
-
id?: string | undefined;
|
|
285
|
-
/** Account pool type */
|
|
286
|
-
type?: "antigravity" | "gemini-cli" | "auto" | string | undefined;
|
|
287
|
-
/** Display name from userinfo */
|
|
288
|
-
displayName?: string | undefined;
|
|
289
|
-
/** Optional metadata - not persisted critically */
|
|
290
|
-
metadata?: Record<string, unknown> | undefined;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Flat file wrapper - versioned for forward migration */
|
|
294
|
-
export interface AccountsFileV3 {
|
|
295
|
-
version: 3;
|
|
296
|
-
accounts: Account[];
|
|
297
|
-
lastRotationIndex?: number;
|
|
298
|
-
lastStickyEmail?: string | null;
|
|
299
|
-
updatedAt?: number;
|
|
300
|
-
/** Optional fallback project cache */
|
|
301
|
-
fallbackProjectId?: string;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Result of a token refresh in flat shape */
|
|
305
|
-
export interface RefreshResult {
|
|
306
|
-
accessToken: string;
|
|
307
|
-
/** Expiry epoch ms */
|
|
308
|
-
expiry: number;
|
|
309
|
-
projectId?: string;
|
|
310
|
-
raw?: Record<string, unknown>;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Options for marking quota exhaustion */
|
|
314
|
-
export interface QuotaExhaustedOptions {
|
|
315
|
-
durationMs?: number;
|
|
316
|
-
reason?: string;
|
|
317
|
-
group?: string;
|
|
318
|
-
model?: string;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ============================================================================
|
|
322
|
-
// SECTION 03. PATH & FS HELPERS - PRODUCTION-READY
|
|
323
|
-
// ============================================================================
|
|
324
|
-
|
|
325
|
-
// resolveOpenCodeBaseDir: identical to the constants.ts owner definition — imported above.
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Resolves the config directory honoring OPENCODE_CONFIG_DIR env fallback.
|
|
329
|
-
* Pure resolver - no side effects here.
|
|
330
|
-
*/
|
|
331
|
-
// v2.1.15 Phase C: resolveConfigDir moved to config.js (configuration
|
|
332
|
-
// management owner) — re-exported below for the module surface.
|
|
333
|
-
import { resolveConfigDir } from "./config.js";
|
|
334
|
-
export { resolveConfigDir };
|
|
335
|
-
|
|
336
|
-
export function resolveAccountsFilePath(customDir?: string): string {
|
|
337
|
-
if (customDir) {
|
|
338
|
-
return path.join(path.resolve(customDir), "antigravity-accounts.json");
|
|
339
|
-
}
|
|
340
|
-
const base = resolveOpenCodeBaseDir();
|
|
341
|
-
return path.join(base, "antigravity-accounts.json");
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Resolves the full accounts file path.
|
|
346
|
-
* When custom is provided it is treated as a complete file path override;
|
|
347
|
-
* otherwise resolves to <configDir>/antigravity-accounts.json.
|
|
348
|
-
*/
|
|
349
|
-
export function resolveAccountsPath(custom?: string): string {
|
|
350
|
-
if (custom && custom.trim().length > 0) return path.resolve(custom);
|
|
351
|
-
const dir = resolveConfigDir();
|
|
352
|
-
return path.join(dir, "antigravity-accounts.json");
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Candidate paths for antigravity-manager import.
|
|
357
|
-
* Honors ANTIGRAVITY_MANAGER_ACCOUNTS_PATH env override and Windows AppData.
|
|
358
|
-
*/
|
|
359
|
-
export function resolveAntagravityManagerCandidates(): string[] {
|
|
360
|
-
const home = os.homedir();
|
|
361
|
-
const custom = process.env.ANTIGRAVITY_MANAGER_ACCOUNTS_PATH?.trim();
|
|
362
|
-
const list: string[] = [];
|
|
363
|
-
if (custom) list.push(path.resolve(custom));
|
|
364
|
-
list.push(
|
|
365
|
-
path.join(home, ".config", "antigravity-manager", "accounts.json"),
|
|
366
|
-
path.join(home, ".config", "antigravity-manager", "credentials.json"),
|
|
367
|
-
path.join(home, ".antigravity-manager", "accounts.json"),
|
|
368
|
-
path.join(home, ".antigravity-manager", "credentials.json"),
|
|
369
|
-
path.join(home, "Library", "Application Support", "antigravity-manager", "accounts.json"),
|
|
370
|
-
path.join(home, ".config", "zerogravity", "accounts.json"),
|
|
371
|
-
path.join(home, ".config", "opencode", "antigravity-accounts.json.bak"),
|
|
372
|
-
);
|
|
373
|
-
// Windows AppData
|
|
374
|
-
if (process.env.APPDATA) {
|
|
375
|
-
list.push(
|
|
376
|
-
path.join(process.env.APPDATA, "antigravity-manager", "accounts.json"),
|
|
377
|
-
path.join(process.env.APPDATA, "antigravity-manager", "credentials.json"),
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
return list;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function ensureDirExists(dir: string): void {
|
|
384
|
-
try {
|
|
385
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
386
|
-
} catch {
|
|
387
|
-
// best effort - ignore if it already exists
|
|
388
|
-
}
|
|
389
|
-
// Guarantee permission 0700 even if it already existed
|
|
390
|
-
try {
|
|
391
|
-
fs.chmodSync(dir, 0o700);
|
|
392
|
-
} catch {
|
|
393
|
-
// may fail on some filesystems / containers
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Ensures dir exists asynchronously, tolerant to races.
|
|
399
|
-
*/
|
|
400
|
-
async function ensureDirExistsAsync(dir: string): Promise<void> {
|
|
401
|
-
try {
|
|
402
|
-
await fsp.mkdir(dir, { recursive: true });
|
|
403
|
-
} catch {
|
|
404
|
-
// best effort chmod even if it exists
|
|
405
|
-
try {
|
|
406
|
-
await fsp.chmod(dir, 0o755);
|
|
407
|
-
} catch {
|
|
408
|
-
// ignore
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Atomic write with chmod 600
|
|
415
|
-
* - Writes to a temp file in the same directory
|
|
416
|
-
* - chmod 600
|
|
417
|
-
* - Atomic rename
|
|
418
|
-
*/
|
|
419
|
-
function atomicWriteFileJson(filePath: string, data: string): void {
|
|
420
|
-
const dir = path.dirname(filePath);
|
|
421
|
-
ensureDirExists(dir);
|
|
422
|
-
|
|
423
|
-
const tmpSuffix = crypto.randomBytes(6).toString("hex");
|
|
424
|
-
const tmpPath = `${filePath}.tmp.${process.pid}.${tmpSuffix}`;
|
|
425
|
-
|
|
426
|
-
try {
|
|
427
|
-
// Write temp with restrictive mode
|
|
428
|
-
fs.writeFileSync(tmpPath, data, { encoding: "utf8", mode: 0o600 });
|
|
429
|
-
try {
|
|
430
|
-
fs.chmodSync(tmpPath, 0o600);
|
|
431
|
-
} catch {
|
|
432
|
-
// ignore
|
|
433
|
-
}
|
|
434
|
-
// Atomic rename (same filesystem)
|
|
435
|
-
fs.renameSync(tmpPath, filePath);
|
|
436
|
-
try {
|
|
437
|
-
fs.chmodSync(filePath, 0o600);
|
|
438
|
-
} catch {
|
|
439
|
-
// ignore
|
|
440
|
-
}
|
|
441
|
-
} finally {
|
|
442
|
-
// Best-effort cleanup of temp if it failed before rename
|
|
443
|
-
try {
|
|
444
|
-
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
445
|
-
} catch {
|
|
446
|
-
// ignore
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Async atomic write: temp + rename + chmod 600.
|
|
453
|
-
* Observer-safe - no partial file exposure.
|
|
454
|
-
*/
|
|
455
|
-
async function atomicWriteFileJsonAsync(targetPath: string, data: string): Promise<void> {
|
|
456
|
-
const dir = path.dirname(targetPath);
|
|
457
|
-
await ensureDirExistsAsync(dir);
|
|
458
|
-
|
|
459
|
-
const rand = crypto.randomBytes(6).toString("hex");
|
|
460
|
-
const tmpName = `.antigravity-accounts.tmp-${process.pid}-${rand}.json`;
|
|
461
|
-
const tmpPath = path.join(dir, tmpName);
|
|
462
|
-
|
|
463
|
-
// Write temp
|
|
464
|
-
await fsp.writeFile(tmpPath, data, { encoding: "utf8", mode: 0o600 });
|
|
465
|
-
try {
|
|
466
|
-
await fsp.chmod(tmpPath, 0o600);
|
|
467
|
-
} catch {
|
|
468
|
-
// ignore on windows
|
|
469
|
-
}
|
|
470
|
-
// Atomic rename
|
|
471
|
-
await fsp.rename(tmpPath, targetPath);
|
|
472
|
-
try {
|
|
473
|
-
await fsp.chmod(targetPath, 0o600);
|
|
474
|
-
} catch {
|
|
475
|
-
// windows may not support chmod 600
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function normalizeEmail(email: string): string {
|
|
480
|
-
return email.trim().toLowerCase();
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function isValidEmail(email: string): boolean {
|
|
484
|
-
// Linear-time validation (regex-free; the former /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
485
|
-
// had nested quantifiers and was flagged as ReDoS-prone on uncontrolled input).
|
|
486
|
-
if (email.length === 0 || /\s/.test(email)) return false;
|
|
487
|
-
const at = email.indexOf("@");
|
|
488
|
-
if (at <= 0 || email.indexOf("@", at + 1) !== -1) return false;
|
|
489
|
-
const domain = email.slice(at + 1);
|
|
490
|
-
const dot = domain.lastIndexOf(".");
|
|
491
|
-
return dot > 0 && dot < domain.length - 1;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function nowMs(): number {
|
|
495
|
-
return Date.now();
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function buildDefaultStore(): AccountStore {
|
|
499
|
-
const t = nowMs();
|
|
500
|
-
return {
|
|
501
|
-
version: 3,
|
|
502
|
-
accounts: [],
|
|
503
|
-
activeEmail: null,
|
|
504
|
-
rotationIndex: 0,
|
|
505
|
-
rotationStrategy: "round-robin",
|
|
506
|
-
createdAt: t,
|
|
507
|
-
updatedAt: t,
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function sanitizeStore(raw: unknown): AccountStore {
|
|
512
|
-
const def = buildDefaultStore();
|
|
513
|
-
|
|
514
|
-
if (!raw || typeof raw !== "object") return def;
|
|
515
|
-
|
|
516
|
-
const obj = raw as Record<string, unknown>;
|
|
517
|
-
|
|
518
|
-
// Supports old versions without version field or with version 1/2
|
|
519
|
-
const accountsRaw = Array.isArray(obj.accounts) ? obj.accounts : [];
|
|
520
|
-
const accounts: AccountMetadataV3[] = [];
|
|
521
|
-
|
|
522
|
-
for (const a of accountsRaw) {
|
|
523
|
-
const normalized = normalizeAccountRaw(a);
|
|
524
|
-
if (normalized) accounts.push(normalized);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Also supports legacy format where root is an array
|
|
528
|
-
if (accounts.length === 0 && Array.isArray(raw)) {
|
|
529
|
-
for (const a of raw as unknown[]) {
|
|
530
|
-
const n = normalizeAccountRaw(a);
|
|
531
|
-
if (n) accounts.push(n);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
const activeEmail = typeof obj.activeEmail === "string" ? normalizeEmail(obj.activeEmail) : null;
|
|
536
|
-
|
|
537
|
-
const rotIdx =
|
|
538
|
-
typeof obj.rotationIndex === "number" && Number.isFinite(obj.rotationIndex)
|
|
539
|
-
? Math.max(0, Math.floor(obj.rotationIndex))
|
|
540
|
-
: 0;
|
|
541
|
-
|
|
542
|
-
const rotationStrategy =
|
|
543
|
-
obj.rotationStrategy === "sticky" || obj.rotationStrategy === "round-robin" ? obj.rotationStrategy : "round-robin";
|
|
544
|
-
|
|
545
|
-
return {
|
|
546
|
-
version: 3,
|
|
547
|
-
accounts,
|
|
548
|
-
activeEmail,
|
|
549
|
-
rotationIndex: rotIdx % Math.max(1, accounts.length || 1),
|
|
550
|
-
rotationStrategy,
|
|
551
|
-
createdAt: typeof obj.createdAt === "number" ? obj.createdAt : def.createdAt,
|
|
552
|
-
updatedAt: nowMs(),
|
|
553
|
-
lastRotationAt: typeof obj.lastRotationAt === "number" ? obj.lastRotationAt : undefined,
|
|
554
|
-
lastRotatedFrom: typeof obj.lastRotatedFrom === "string" ? obj.lastRotatedFrom : undefined,
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function normalizeAccountRaw(raw: unknown): AccountMetadataV3 | null {
|
|
559
|
-
if (!raw || typeof raw !== "object") return null;
|
|
560
|
-
const r = raw as Record<string, unknown>;
|
|
561
|
-
|
|
562
|
-
const emailRaw =
|
|
563
|
-
(r.email as string) ?? (r.user_email as string) ?? (r.userEmail as string) ?? (r.account as string) ?? "";
|
|
564
|
-
|
|
565
|
-
if (typeof emailRaw !== "string" || !isValidEmail(emailRaw)) return null;
|
|
566
|
-
const email = normalizeEmail(emailRaw);
|
|
567
|
-
|
|
568
|
-
const refreshTokenRaw =
|
|
569
|
-
(r.refreshToken as string) ??
|
|
570
|
-
(r.refresh_token as string) ??
|
|
571
|
-
(r.refresh as string) ??
|
|
572
|
-
(r.token as any)?.refresh_token ??
|
|
573
|
-
(r.tokens as any)?.refresh_token ??
|
|
574
|
-
"";
|
|
575
|
-
|
|
576
|
-
if (typeof refreshTokenRaw !== "string" || refreshTokenRaw.length < 10) return null;
|
|
577
|
-
|
|
578
|
-
const accessToken =
|
|
579
|
-
typeof r.accessToken === "string"
|
|
580
|
-
? (r.accessToken as string)
|
|
581
|
-
: typeof r.access_token === "string"
|
|
582
|
-
? (r.access_token as string)
|
|
583
|
-
: ((r.token as any)?.access_token ?? undefined);
|
|
584
|
-
|
|
585
|
-
let expiryDate: number | undefined;
|
|
586
|
-
if (typeof r.expiryDate === "number") expiryDate = r.expiryDate;
|
|
587
|
-
else if (typeof r.expiry_date === "number") expiryDate = r.expiry_date;
|
|
588
|
-
else if (typeof r.expiryDate === "string") {
|
|
589
|
-
const n = Number(r.expiryDate);
|
|
590
|
-
if (Number.isFinite(n)) expiryDate = n;
|
|
591
|
-
} else if (typeof r.expires_at === "number") {
|
|
592
|
-
// May be seconds or ms - heuristic
|
|
593
|
-
expiryDate = r.expires_at > 1e12 ? r.expires_at : r.expires_at * 1000;
|
|
594
|
-
} else if ((r.token as any)?.expiry_date) {
|
|
595
|
-
expiryDate = (r.token as any).expiry_date;
|
|
596
|
-
} else if (typeof r.expires_in === "number") {
|
|
597
|
-
// If expires_in exists but no expiry, compute from lastRefresh or now
|
|
598
|
-
const base = typeof r.lastRefresh === "number" ? r.lastRefresh : nowMs();
|
|
599
|
-
expiryDate = base + r.expires_in * 1000;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const projectId =
|
|
603
|
-
(r.projectId as string) ??
|
|
604
|
-
(r.project_id as string) ??
|
|
605
|
-
(r.cloudaicompanionProject as string) ??
|
|
606
|
-
(r.project as string) ??
|
|
607
|
-
PROJECT_FALLBACK;
|
|
608
|
-
|
|
609
|
-
const enabled = typeof r.enabled === "boolean" ? r.enabled : true;
|
|
610
|
-
|
|
611
|
-
const oauthClientKey: OAuthClientKey =
|
|
612
|
-
typeof r.oauthClientKey === "string"
|
|
613
|
-
? (r.oauthClientKey as string)
|
|
614
|
-
: typeof r.oauth_client_key === "string"
|
|
615
|
-
? (r.oauth_client_key as string)
|
|
616
|
-
: "gemini-cli";
|
|
617
|
-
|
|
618
|
-
let oauthClientConfig: OAuthClientConfig | undefined;
|
|
619
|
-
if (r.oauthClientConfig && typeof r.oauthClientConfig === "object") {
|
|
620
|
-
const cfg = r.oauthClientConfig as Record<string, unknown>;
|
|
621
|
-
if (typeof cfg.clientId === "string" && typeof cfg.clientSecret === "string") {
|
|
622
|
-
oauthClientConfig = {
|
|
623
|
-
clientId: cfg.clientId,
|
|
624
|
-
clientSecret: cfg.clientSecret,
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const lastRefresh =
|
|
630
|
-
typeof r.lastRefresh === "number" ? r.lastRefresh : typeof r.last_refresh === "number" ? r.last_refresh : undefined;
|
|
631
|
-
|
|
632
|
-
const createdAt =
|
|
633
|
-
typeof r.createdAt === "number" ? r.createdAt : typeof r.created_at === "number" ? r.created_at : nowMs();
|
|
634
|
-
|
|
635
|
-
const lastUsedAt = typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined;
|
|
636
|
-
|
|
637
|
-
const failureCount = typeof r.failureCount === "number" ? Math.max(0, r.failureCount) : 0;
|
|
638
|
-
|
|
639
|
-
const disabledReason = typeof r.disabledReason === "string" ? r.disabledReason : undefined;
|
|
640
|
-
|
|
641
|
-
const disabledUntil = typeof r.disabledUntil === "number" ? r.disabledUntil : undefined;
|
|
642
|
-
|
|
643
|
-
const scopes = Array.isArray(r.scopes) ? (r.scopes as string[]).filter((s) => typeof s === "string") : undefined;
|
|
644
|
-
|
|
645
|
-
return {
|
|
646
|
-
email,
|
|
647
|
-
refreshToken: refreshTokenRaw,
|
|
648
|
-
accessToken: typeof accessToken === "string" ? accessToken : undefined,
|
|
649
|
-
expiryDate,
|
|
650
|
-
projectId: typeof projectId === "string" ? projectId : PROJECT_FALLBACK,
|
|
651
|
-
enabled,
|
|
652
|
-
oauthClientKey,
|
|
653
|
-
oauthClientConfig,
|
|
654
|
-
lastRefresh,
|
|
655
|
-
createdAt,
|
|
656
|
-
lastUsedAt,
|
|
657
|
-
failureCount,
|
|
658
|
-
disabledReason,
|
|
659
|
-
disabledUntil,
|
|
660
|
-
scopes,
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// ============================================================================
|
|
665
|
-
// SECTION 04. LEGACY FLAT-FILE READER (Account[] format)
|
|
666
|
-
// Parses legacy file shapes into normalized Account arrays.
|
|
667
|
-
// ============================================================================
|
|
668
|
-
|
|
669
|
-
/** Legacy shapes tolerated by the flat-file reader */
|
|
670
|
-
type LegacyFlatFile =
|
|
671
|
-
| AccountsFileV3
|
|
672
|
-
| { accounts: Account[] }
|
|
673
|
-
| { accounts: Record<string, Account> }
|
|
674
|
-
| Account[]
|
|
675
|
-
| { version?: number; [k: string]: unknown };
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Sanitizes a flat-format account for persistence -
|
|
679
|
-
* strips undefined, normalizes email.
|
|
680
|
-
*/
|
|
681
|
-
function sanitizeLegacyAccount(acc: Account): Account {
|
|
682
|
-
const now = Date.now();
|
|
683
|
-
const email = acc.email.toLowerCase().trim();
|
|
684
|
-
return {
|
|
685
|
-
email,
|
|
686
|
-
refreshToken: acc.refreshToken.trim(),
|
|
687
|
-
accessToken: acc.accessToken?.trim() || undefined,
|
|
688
|
-
expiry: acc.expiry,
|
|
689
|
-
projectId: acc.projectId?.trim() || PROJECT_FALLBACK,
|
|
690
|
-
disabled: !!acc.disabled,
|
|
691
|
-
quotaExhaustedUntil: acc.quotaExhaustedUntil,
|
|
692
|
-
lastUsed: acc.lastUsed,
|
|
693
|
-
createdAt: acc.createdAt ?? now,
|
|
694
|
-
updatedAt: now,
|
|
695
|
-
quota: acc.quota,
|
|
696
|
-
id: acc.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
|
|
697
|
-
type: acc.type ?? "antigravity",
|
|
698
|
-
displayName: acc.displayName,
|
|
699
|
-
metadata: acc.metadata,
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
/**
|
|
704
|
-
* Legacy flat-file reader.
|
|
705
|
-
* Tolerates: flat array (antigravity-manager export), AccountsFileV3 wrapper,
|
|
706
|
-
* {accounts: [...]} array, {accounts: {email: {...}}} map and
|
|
707
|
-
* single-account object forms. Returns normalized Account[] plus
|
|
708
|
-
* rotation metadata when present.
|
|
709
|
-
*/
|
|
710
|
-
function parseLegacyAccounts(raw: unknown): { accounts: Account[]; rotationIndex: number; stickyEmail: string | null } {
|
|
711
|
-
const now = Date.now();
|
|
712
|
-
let accounts: Account[] = [];
|
|
713
|
-
let rotationIndex = 0;
|
|
714
|
-
let stickyEmail: string | null = null;
|
|
715
|
-
|
|
716
|
-
if (!raw) return { accounts, rotationIndex, stickyEmail };
|
|
717
|
-
|
|
718
|
-
// Flat array - antigravity-manager export
|
|
719
|
-
if (Array.isArray(raw)) {
|
|
720
|
-
accounts = raw
|
|
721
|
-
.map((item: any) => {
|
|
722
|
-
if (!item) return null;
|
|
723
|
-
const email = (item.email ?? item.email_address ?? item.id ?? "").toString().toLowerCase().trim();
|
|
724
|
-
const rt = (item.refresh_token ?? item.refreshToken ?? item.refreshTokenEncrypted ?? "").toString().trim();
|
|
725
|
-
if (!email || !rt) return null;
|
|
726
|
-
const acc: Account = {
|
|
727
|
-
email,
|
|
728
|
-
refreshToken: rt,
|
|
729
|
-
accessToken: item.access_token ?? item.accessToken,
|
|
730
|
-
expiry: item.expiry ?? item.expires_at ?? undefined,
|
|
731
|
-
projectId: item.projectId ?? item.project_id ?? PROJECT_FALLBACK,
|
|
732
|
-
disabled: !!item.disabled,
|
|
733
|
-
createdAt: item.createdAt ?? now,
|
|
734
|
-
updatedAt: now,
|
|
735
|
-
id: item.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
|
|
736
|
-
type: item.type ?? "antigravity",
|
|
737
|
-
};
|
|
738
|
-
// Normalize expiry if seconds
|
|
739
|
-
if (acc.expiry && acc.expiry < 1e12) {
|
|
740
|
-
// seconds -> ms if it looks like seconds
|
|
741
|
-
if (acc.expiry > 1e9 && acc.expiry < 1e12) acc.expiry = acc.expiry * 1000;
|
|
742
|
-
}
|
|
743
|
-
return acc;
|
|
744
|
-
})
|
|
745
|
-
.filter(Boolean) as Account[];
|
|
746
|
-
return { accounts, rotationIndex, stickyEmail };
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
if (typeof raw === "object") {
|
|
750
|
-
const obj = raw as any;
|
|
751
|
-
// AccountsFileV3 / {accounts: [...]}
|
|
752
|
-
if (Array.isArray(obj.accounts)) {
|
|
753
|
-
accounts = obj.accounts
|
|
754
|
-
.map((a: any) => {
|
|
755
|
-
if (!a?.email || (!a?.refreshToken && !a?.refresh_token)) return null;
|
|
756
|
-
const email = a.email.toString().toLowerCase().trim();
|
|
757
|
-
const rt = (a.refreshToken ?? a.refresh_token ?? "").toString().trim();
|
|
758
|
-
if (!email || !rt) return null;
|
|
759
|
-
return {
|
|
760
|
-
...a,
|
|
761
|
-
email,
|
|
762
|
-
refreshToken: rt,
|
|
763
|
-
id: a.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
|
|
764
|
-
createdAt: a.createdAt ?? now,
|
|
765
|
-
updatedAt: a.updatedAt ?? now,
|
|
766
|
-
} as Account;
|
|
767
|
-
})
|
|
768
|
-
.filter(Boolean) as Account[];
|
|
769
|
-
rotationIndex = Number.isFinite(obj.lastRotationIndex) ? obj.lastRotationIndex : 0;
|
|
770
|
-
stickyEmail = obj.lastStickyEmail ?? null;
|
|
771
|
-
return { accounts, rotationIndex, stickyEmail };
|
|
772
|
-
}
|
|
773
|
-
// Map form {accounts: {email: {...}}}
|
|
774
|
-
if (obj.accounts && typeof obj.accounts === "object" && !Array.isArray(obj.accounts)) {
|
|
775
|
-
const map = obj.accounts as Record<string, any>;
|
|
776
|
-
accounts = Object.entries(map)
|
|
777
|
-
.map(([emailKey, val]: [string, any]) => {
|
|
778
|
-
const email = (val?.email ?? emailKey).toString().toLowerCase().trim();
|
|
779
|
-
const rt = (val?.refreshToken ?? val?.refresh_token ?? "").toString().trim();
|
|
780
|
-
if (!email || !rt) return null;
|
|
781
|
-
return {
|
|
782
|
-
email,
|
|
783
|
-
refreshToken: rt,
|
|
784
|
-
accessToken: val.accessToken ?? val.access_token,
|
|
785
|
-
expiry: val.expiry,
|
|
786
|
-
projectId: val.projectId ?? PROJECT_FALLBACK,
|
|
787
|
-
disabled: !!val.disabled,
|
|
788
|
-
createdAt: val.createdAt ?? now,
|
|
789
|
-
updatedAt: now,
|
|
790
|
-
id: val.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
|
|
791
|
-
type: val.type ?? "antigravity",
|
|
792
|
-
} as Account;
|
|
793
|
-
})
|
|
794
|
-
.filter(Boolean) as Account[];
|
|
795
|
-
return { accounts, rotationIndex, stickyEmail };
|
|
796
|
-
}
|
|
797
|
-
// Single account object?
|
|
798
|
-
if (obj.email && (obj.refreshToken || obj.refresh_token)) {
|
|
799
|
-
const email = obj.email.toString().toLowerCase().trim();
|
|
800
|
-
const rt = (obj.refreshToken ?? obj.refresh_token).toString().trim();
|
|
801
|
-
if (email && rt) {
|
|
802
|
-
accounts = [
|
|
803
|
-
{
|
|
804
|
-
email,
|
|
805
|
-
refreshToken: rt,
|
|
806
|
-
accessToken: obj.accessToken,
|
|
807
|
-
expiry: obj.expiry,
|
|
808
|
-
projectId: obj.projectId,
|
|
809
|
-
createdAt: now,
|
|
810
|
-
updatedAt: now,
|
|
811
|
-
id: crypto.randomUUID?.() ?? `${email}-${now}`,
|
|
812
|
-
},
|
|
813
|
-
];
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
return { accounts, rotationIndex, stickyEmail };
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/**
|
|
822
|
-
* Helper to ensure the accounts file exists with safe perms, idempotent.
|
|
823
|
-
* Creates an empty AccountsFileV3 payload when missing.
|
|
824
|
-
*
|
|
825
|
-
* @param filePath optional full file path override
|
|
826
|
-
* @returns resolved file path
|
|
827
|
-
*/
|
|
828
|
-
export async function ensureAccountsFile(filePath?: string): Promise<string> {
|
|
829
|
-
const fp = resolveAccountsPath(filePath);
|
|
830
|
-
await ensureDirExistsAsync(path.dirname(fp));
|
|
831
|
-
try {
|
|
832
|
-
await fsp.access(fp, fs.constants.F_OK);
|
|
833
|
-
try {
|
|
834
|
-
await fsp.chmod(fp, 0o600);
|
|
835
|
-
} catch {
|
|
836
|
-
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
837
|
-
}
|
|
838
|
-
} catch {
|
|
839
|
-
// Create empty v3 file
|
|
840
|
-
const empty: AccountsFileV3 = {
|
|
841
|
-
version: 3,
|
|
842
|
-
accounts: [],
|
|
843
|
-
lastRotationIndex: 0,
|
|
844
|
-
lastStickyEmail: null,
|
|
845
|
-
updatedAt: Date.now(),
|
|
846
|
-
fallbackProjectId: PROJECT_FALLBACK,
|
|
847
|
-
};
|
|
848
|
-
await atomicWriteFileJsonAsync(fp, JSON.stringify(empty, null, 2));
|
|
849
|
-
}
|
|
850
|
-
return fp;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* Quick utility - reads accounts without a manager instance.
|
|
855
|
-
* Uses the legacy flat-file reader, tolerant to every historical shape.
|
|
856
|
-
*
|
|
857
|
-
* @param filePath optional full file path override
|
|
858
|
-
* @returns sanitized Account[] snapshot (empty on missing/corrupt file)
|
|
859
|
-
*/
|
|
860
|
-
export async function loadAccountsSnapshot(filePath?: string): Promise<Account[]> {
|
|
861
|
-
const fp = resolveAccountsPath(filePath);
|
|
862
|
-
try {
|
|
863
|
-
const content = await fsp.readFile(fp, "utf8");
|
|
864
|
-
const { accounts } = parseLegacyAccounts(JSON.parse(content));
|
|
865
|
-
return accounts.map(sanitizeLegacyAccount);
|
|
866
|
-
} catch {
|
|
867
|
-
return [];
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// ============================================================================
|
|
872
|
-
// SECTION 05. OAUTH REFRESH - BARE FETCH WITH BYPASS HEADERS
|
|
873
|
-
// ============================================================================
|
|
874
|
-
|
|
875
|
-
const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 min
|
|
876
|
-
const BACKGROUND_REFRESH_BUFFER_MS = 10 * 60 * 1000; // 10 min
|
|
877
|
-
|
|
878
|
-
function buildTokenRefreshHeaders(): Record<string, string> {
|
|
879
|
-
return {
|
|
880
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
881
|
-
"User-Agent": GEMINI_CLI_USER_AGENT_FULL,
|
|
882
|
-
"X-Goog-Api-Client": X_GOOG_API_CLIENT,
|
|
883
|
-
"Client-Metadata": CLIENT_METADATA,
|
|
884
|
-
Accept: "application/json",
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
function resolveOAuthClientForAccount(acct: AccountMetadataV3): OAuthClientConfig {
|
|
889
|
-
if (acct.oauthClientConfig?.clientId && acct.oauthClientConfig?.clientSecret) {
|
|
890
|
-
return acct.oauthClientConfig;
|
|
891
|
-
}
|
|
892
|
-
// Antigravity CLI client (default for logins since 2.1.8)
|
|
893
|
-
if (acct.oauthClientKey === "antigravity-cli") {
|
|
894
|
-
return {
|
|
895
|
-
clientId: ANTIGRAVITY_CLI_OAUTH_CLIENT_ID,
|
|
896
|
-
clientSecret: ANTIGRAVITY_CLI_OAUTH_CLIENT_SECRET,
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
// Hardened default Gemini CLI
|
|
900
|
-
return {
|
|
901
|
-
clientId: GEMINI_CLI_OAUTH_CLIENT_ID,
|
|
902
|
-
clientSecret: GEMINI_CLI_OAUTH_CLIENT_SECRET,
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
async function performTokenRefresh(acct: AccountMetadataV3): Promise<TokenRefreshResponse> {
|
|
907
|
-
const client = resolveOAuthClientForAccount(acct);
|
|
908
|
-
|
|
909
|
-
const body = new URLSearchParams({
|
|
910
|
-
client_id: client.clientId,
|
|
911
|
-
client_secret: client.clientSecret,
|
|
912
|
-
refresh_token: acct.refreshToken,
|
|
913
|
-
grant_type: "refresh_token",
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
const headers = buildTokenRefreshHeaders();
|
|
917
|
-
|
|
918
|
-
const controller = new AbortController();
|
|
919
|
-
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
920
|
-
|
|
921
|
-
try {
|
|
922
|
-
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
923
|
-
method: "POST",
|
|
924
|
-
headers,
|
|
925
|
-
body: body.toString(),
|
|
926
|
-
signal: controller.signal,
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
const text = await res.text();
|
|
930
|
-
let json: any;
|
|
931
|
-
try {
|
|
932
|
-
json = JSON.parse(text);
|
|
933
|
-
} catch {
|
|
934
|
-
json = { raw: text };
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
if (!res.ok) {
|
|
938
|
-
const err: any = new Error(
|
|
939
|
-
`Token refresh failed for ${acct.email}: ${res.status} ${json.error_description || json.error || text.slice(0, 500)}`,
|
|
940
|
-
);
|
|
941
|
-
err.status = res.status;
|
|
942
|
-
err.code = json.error;
|
|
943
|
-
err.response = json;
|
|
944
|
-
err.email = acct.email;
|
|
945
|
-
throw err;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
if (!json.access_token || typeof json.expires_in !== "number") {
|
|
949
|
-
throw new Error(`Invalid token response for ${acct.email}: missing access_token/expires_in`);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
return json as TokenRefreshResponse;
|
|
953
|
-
} finally {
|
|
954
|
-
clearTimeout(timeout);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
function isTokenExpired(acct: AccountMetadataV3, bufferMs = REFRESH_BUFFER_MS): boolean {
|
|
959
|
-
if (!acct.accessToken) return true;
|
|
960
|
-
if (!acct.expiryDate) return true;
|
|
961
|
-
return acct.expiryDate <= nowMs() + bufferMs;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// ============================================================================
|
|
965
|
-
// SECTION 06. QUOTA / 429 DETECTION
|
|
966
|
-
// ============================================================================
|
|
967
|
-
|
|
968
|
-
const QUOTA_ERROR_PATTERNS = [
|
|
969
|
-
/quota/i,
|
|
970
|
-
/rate limit/i,
|
|
971
|
-
/rate_limit/i,
|
|
972
|
-
/too many requests/i,
|
|
973
|
-
/429/,
|
|
974
|
-
/resource_exhausted/i,
|
|
975
|
-
/RESOURCE_EXHAUSTED/,
|
|
976
|
-
/quota_exceeded/i,
|
|
977
|
-
/QUOTA_EXCEEDED/,
|
|
978
|
-
/user_rate_limit/i,
|
|
979
|
-
/Request throttled/i,
|
|
980
|
-
/billing/i, // sometimes quota comes with billing
|
|
981
|
-
];
|
|
982
|
-
|
|
983
|
-
export function isQuotaError(err: unknown): boolean {
|
|
984
|
-
if (!err) return false;
|
|
985
|
-
|
|
986
|
-
// Direct number
|
|
987
|
-
if (typeof err === "number") return err === 429;
|
|
988
|
-
|
|
989
|
-
const asAny = err as any;
|
|
990
|
-
|
|
991
|
-
// status / code fields
|
|
992
|
-
const status =
|
|
993
|
-
asAny.status ??
|
|
994
|
-
asAny.statusCode ??
|
|
995
|
-
asAny.code ??
|
|
996
|
-
(asAny.response && (asAny.response.status || asAny.response.statusCode));
|
|
997
|
-
|
|
998
|
-
if (status === 429) return true;
|
|
999
|
-
if (status === 403 || status === "403") {
|
|
1000
|
-
// 403 may be quota, check message
|
|
1001
|
-
const msg = (
|
|
1002
|
-
asAny.message ||
|
|
1003
|
-
asAny.error_description ||
|
|
1004
|
-
asAny.error ||
|
|
1005
|
-
JSON.stringify(asAny.response || asAny) ||
|
|
1006
|
-
""
|
|
1007
|
-
).toString();
|
|
1008
|
-
if (QUOTA_ERROR_PATTERNS.some((re) => re.test(msg))) return true;
|
|
1009
|
-
// For generic 403 on Code Assist we treat it as quota only when endpoint is generate*
|
|
1010
|
-
// Conservative: only if it mentions quota
|
|
1011
|
-
return false;
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
const message = (
|
|
1015
|
-
asAny.message ||
|
|
1016
|
-
asAny.error ||
|
|
1017
|
-
asAny.error_description ||
|
|
1018
|
-
(typeof err === "string" ? err : "") ||
|
|
1019
|
-
JSON.stringify(asAny).slice(0, 2000)
|
|
1020
|
-
)
|
|
1021
|
-
.toString()
|
|
1022
|
-
.toLowerCase();
|
|
1023
|
-
|
|
1024
|
-
return QUOTA_ERROR_PATTERNS.some((re) => re.test(message));
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
function parseErrorStatus(err: unknown): number | undefined {
|
|
1028
|
-
if (!err) return undefined;
|
|
1029
|
-
const a = err as any;
|
|
1030
|
-
return (
|
|
1031
|
-
a.status ?? a.statusCode ?? a.code ?? (a.response && (a.response.status || a.response.statusCode)) ?? undefined
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// ============================================================================
|
|
1036
|
-
// SECTION 07. AccountManager CLASS
|
|
1037
|
-
// ============================================================================
|
|
1038
|
-
|
|
1039
|
-
export interface AccountManagerOptions {
|
|
1040
|
-
/** Override of the base directory (default ~/.config/opencode) */
|
|
1041
|
-
configDir?: string;
|
|
1042
|
-
/** File name (default antigravity-accounts.json) */
|
|
1043
|
-
fileName?: string;
|
|
1044
|
-
/** Default strategy if store does not have one */
|
|
1045
|
-
rotationStrategy?: RotationStrategy;
|
|
1046
|
-
/** Custom full path (takes precedence over configDir+fileName) */
|
|
1047
|
-
filePath?: string;
|
|
1048
|
-
/** Optional logger */
|
|
1049
|
-
logger?: {
|
|
1050
|
-
info?: (msg: string, ...args: any[]) => void;
|
|
1051
|
-
warn?: (msg: string, ...args: any[]) => void;
|
|
1052
|
-
error?: (msg: string, ...args: any[]) => void;
|
|
1053
|
-
debug?: (msg: string, ...args: any[]) => void;
|
|
1054
|
-
};
|
|
1055
|
-
/** Disables background auto-refresh timer */
|
|
1056
|
-
disableBackgroundTimer?: boolean;
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
export class AccountManager {
|
|
1060
|
-
private readonly storePath: string;
|
|
1061
|
-
private store: AccountStore;
|
|
1062
|
-
private rotationIdx: number;
|
|
1063
|
-
private readonly refreshLocks = new Map<string, Promise<AccountMetadataV3>>();
|
|
1064
|
-
private backgroundTimer?: NodeJS.Timeout | undefined;
|
|
1065
|
-
private readonly logger: AccountManagerOptions["logger"];
|
|
1066
|
-
private readonly options: AccountManagerOptions;
|
|
1067
|
-
|
|
1068
|
-
constructor(opts: AccountManagerOptions = {}) {
|
|
1069
|
-
this.options = opts;
|
|
1070
|
-
this.logger = opts.logger ?? {};
|
|
1071
|
-
|
|
1072
|
-
if (opts.filePath) {
|
|
1073
|
-
this.storePath = path.resolve(opts.filePath);
|
|
1074
|
-
} else {
|
|
1075
|
-
const baseDir = opts.configDir ? path.resolve(opts.configDir) : resolveOpenCodeBaseDir();
|
|
1076
|
-
const fileName = opts.fileName ?? "antigravity-accounts.json";
|
|
1077
|
-
this.storePath = path.join(baseDir, fileName);
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
this.store = this.loadAccounts();
|
|
1081
|
-
this.rotationIdx = this.store.rotationIndex ?? 0;
|
|
1082
|
-
|
|
1083
|
-
// Auto background timer if enabled (every 5 min)
|
|
1084
|
-
if (!opts.disableBackgroundTimer) {
|
|
1085
|
-
// unref so it does not block exit
|
|
1086
|
-
this.backgroundTimer = setInterval(
|
|
1087
|
-
() => {
|
|
1088
|
-
this.backgroundRefresh().catch((e) => {
|
|
1089
|
-
this.logger?.warn?.(`[accounts] backgroundRefresh failed: ${(e as Error).message}`);
|
|
1090
|
-
});
|
|
1091
|
-
},
|
|
1092
|
-
5 * 60 * 1000,
|
|
1093
|
-
);
|
|
1094
|
-
if (this.backgroundTimer && typeof (this.backgroundTimer as any).unref === "function") {
|
|
1095
|
-
(this.backgroundTimer as any).unref();
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// --------------------------------------------------------------------------
|
|
1101
|
-
// STORAGE - loadAccounts / saveAccounts (atomic chmod 600)
|
|
1102
|
-
// --------------------------------------------------------------------------
|
|
1103
|
-
|
|
1104
|
-
/**
|
|
1105
|
-
* Loads accounts from ~/.config/opencode/antigravity-accounts.json
|
|
1106
|
-
* - Creates the file if missing with default store
|
|
1107
|
-
* - chmod 600 on every read/write
|
|
1108
|
-
* - Sanitizes schema V3
|
|
1109
|
-
*/
|
|
1110
|
-
loadAccounts(): AccountStore {
|
|
1111
|
-
let rawContent: string | null = null;
|
|
1112
|
-
|
|
1113
|
-
try {
|
|
1114
|
-
// Ensure dir exists before trying to read
|
|
1115
|
-
ensureDirExists(path.dirname(this.storePath));
|
|
1116
|
-
|
|
1117
|
-
if (!fs.existsSync(this.storePath)) {
|
|
1118
|
-
const def = buildDefaultStore();
|
|
1119
|
-
if (this.options.rotationStrategy) {
|
|
1120
|
-
def.rotationStrategy = this.options.rotationStrategy;
|
|
1121
|
-
}
|
|
1122
|
-
this.store = def;
|
|
1123
|
-
this.saveAccounts(); // creates initial file with 600
|
|
1124
|
-
this.logger?.info?.(`[accounts] created new store at ${this.storePath}`);
|
|
1125
|
-
return { ...def };
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// chmod 600 even on read (hardening)
|
|
1129
|
-
try {
|
|
1130
|
-
fs.chmodSync(this.storePath, 0o600);
|
|
1131
|
-
} catch {
|
|
1132
|
-
// ignore
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
rawContent = fs.readFileSync(this.storePath, { encoding: "utf8" });
|
|
1136
|
-
} catch (err) {
|
|
1137
|
-
this.logger?.warn?.(
|
|
1138
|
-
`[accounts] failed to read store at ${this.storePath}: ${(err as Error).message}, using default`,
|
|
1139
|
-
);
|
|
1140
|
-
const def = buildDefaultStore();
|
|
1141
|
-
this.store = def;
|
|
1142
|
-
return { ...def };
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
try {
|
|
1146
|
-
const parsed = rawContent ? JSON.parse(rawContent) : null;
|
|
1147
|
-
const sanitized = sanitizeStore(parsed);
|
|
1148
|
-
if (this.options.rotationStrategy && !parsed?.rotationStrategy) {
|
|
1149
|
-
sanitized.rotationStrategy = this.options.rotationStrategy;
|
|
1150
|
-
}
|
|
1151
|
-
// Normalize rotation index against current size
|
|
1152
|
-
const len = Math.max(1, sanitized.accounts.length);
|
|
1153
|
-
sanitized.rotationIndex = (sanitized.rotationIndex ?? 0) % len;
|
|
1154
|
-
|
|
1155
|
-
this.store = sanitized;
|
|
1156
|
-
this.rotationIdx = sanitized.rotationIndex ?? 0;
|
|
1157
|
-
return { ...sanitized, accounts: [...sanitized.accounts] };
|
|
1158
|
-
} catch (err) {
|
|
1159
|
-
this.logger?.warn?.(`[accounts] invalid JSON at ${this.storePath}: ${(err as Error).message}, returning default`);
|
|
1160
|
-
const def = buildDefaultStore();
|
|
1161
|
-
this.store = def;
|
|
1162
|
-
return { ...def };
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
/**
|
|
1167
|
-
* Saves store to disk atomically with chmod 600
|
|
1168
|
-
*/
|
|
1169
|
-
saveAccounts(): void {
|
|
1170
|
-
const toSave: AccountStore = {
|
|
1171
|
-
...this.store,
|
|
1172
|
-
version: 3,
|
|
1173
|
-
rotationIndex: this.rotationIdx,
|
|
1174
|
-
updatedAt: nowMs(),
|
|
1175
|
-
};
|
|
1176
|
-
|
|
1177
|
-
// Sanitize again before saving (removes undefined extras)
|
|
1178
|
-
const cleanAccounts = toSave.accounts.map((a) => {
|
|
1179
|
-
// Remove undefined fields for clean JSON
|
|
1180
|
-
const copy: Record<string, unknown> = { ...a };
|
|
1181
|
-
Object.keys(copy).forEach((k) => {
|
|
1182
|
-
if (copy[k] === undefined) delete copy[k];
|
|
1183
|
-
});
|
|
1184
|
-
return copy as unknown as AccountMetadataV3;
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
const payload: AccountStore = {
|
|
1188
|
-
...toSave,
|
|
1189
|
-
accounts: cleanAccounts,
|
|
1190
|
-
};
|
|
1191
|
-
|
|
1192
|
-
const json = JSON.stringify(payload, null, 2) + "\n";
|
|
1193
|
-
|
|
1194
|
-
try {
|
|
1195
|
-
atomicWriteFileJson(this.storePath, json);
|
|
1196
|
-
this.logger?.debug?.(`[accounts] saved ${cleanAccounts.length} accounts to ${this.storePath}`);
|
|
1197
|
-
} catch (err) {
|
|
1198
|
-
this.logger?.error?.(`[accounts] failed to save store: ${(err as Error).message}`);
|
|
1199
|
-
throw err;
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
/**
|
|
1204
|
-
* Saves a custom store atomically - used for tests / import
|
|
1205
|
-
*/
|
|
1206
|
-
saveAccountsAtomic(store: AccountStore): void {
|
|
1207
|
-
this.store = sanitizeStore(store);
|
|
1208
|
-
this.rotationIdx = this.store.rotationIndex ?? 0;
|
|
1209
|
-
this.saveAccounts();
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
// --------------------------------------------------------------------------
|
|
1213
|
-
// QUERY - counters and listings
|
|
1214
|
-
// --------------------------------------------------------------------------
|
|
1215
|
-
|
|
1216
|
-
getAccountCount(): number {
|
|
1217
|
-
return this.store.accounts.length;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
getEnabledAccounts(): AccountMetadataV3[] {
|
|
1221
|
-
const t = nowMs();
|
|
1222
|
-
return this.store.accounts.filter((a) => {
|
|
1223
|
-
if (!a.enabled) return false;
|
|
1224
|
-
// Respect disabledUntil cooldown
|
|
1225
|
-
if (a.disabledUntil && a.disabledUntil > t) return false;
|
|
1226
|
-
// If disabledUntil expired but still enabled=false for a temporary reason, lazily re-enable?
|
|
1227
|
-
// Do not auto re-enable here, only filter; expiry logic is handled in backgroundRefresh
|
|
1228
|
-
return true;
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
listAccounts(): AccountMetadataV3[] {
|
|
1233
|
-
return [...this.store.accounts];
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
/** List with refreshToken masked (safe for log/UI) */
|
|
1237
|
-
listAccountsMasked(): Array<Omit<AccountMetadataV3, "refreshToken"> & { refreshTokenMasked: string }> {
|
|
1238
|
-
return this.store.accounts.map((a) => {
|
|
1239
|
-
const masked = a.refreshToken.length > 12 ? `${a.refreshToken.slice(0, 6)}...${a.refreshToken.slice(-4)}` : "***";
|
|
1240
|
-
const { refreshToken: _rt, ...rest } = a;
|
|
1241
|
-
return {
|
|
1242
|
-
...rest,
|
|
1243
|
-
refreshTokenMasked: masked,
|
|
1244
|
-
};
|
|
1245
|
-
});
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
findAccount(email: string): AccountMetadataV3 | undefined {
|
|
1249
|
-
const norm = normalizeEmail(email);
|
|
1250
|
-
return this.store.accounts.find((a) => normalizeEmail(a.email) === norm);
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
private findAccountIndex(email: string): number {
|
|
1254
|
-
const norm = normalizeEmail(email);
|
|
1255
|
-
return this.store.accounts.findIndex((a) => normalizeEmail(a.email) === norm);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// --------------------------------------------------------------------------
|
|
1259
|
-
// MUTATIONS - add / remove / enable / disable / setActive
|
|
1260
|
-
// --------------------------------------------------------------------------
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* Adds a new account. If email already exists, updates refreshToken and other data.
|
|
1264
|
-
* Returns the added/updated account.
|
|
1265
|
-
*/
|
|
1266
|
-
addAccount(input: AddAccountInput): AccountMetadataV3 {
|
|
1267
|
-
if (!input.email || !isValidEmail(input.email)) {
|
|
1268
|
-
throw new Error(`addAccount: invalid email ${input.email}`);
|
|
1269
|
-
}
|
|
1270
|
-
if (!input.refreshToken || input.refreshToken.length < 10) {
|
|
1271
|
-
throw new Error(`addAccount: invalid refreshToken for ${input.email}`);
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
const email = normalizeEmail(input.email);
|
|
1275
|
-
const now = nowMs();
|
|
1276
|
-
|
|
1277
|
-
const existingIdx = this.findAccountIndex(email);
|
|
1278
|
-
|
|
1279
|
-
const base: AccountMetadataV3 = {
|
|
1280
|
-
email,
|
|
1281
|
-
refreshToken: input.refreshToken,
|
|
1282
|
-
accessToken: input.accessToken,
|
|
1283
|
-
expiryDate: input.expiryDate,
|
|
1284
|
-
projectId: input.projectId ?? PROJECT_FALLBACK,
|
|
1285
|
-
enabled: input.enabled ?? true,
|
|
1286
|
-
oauthClientKey: input.oauthClientKey ?? "gemini-cli",
|
|
1287
|
-
oauthClientConfig: input.oauthClientConfig,
|
|
1288
|
-
lastRefresh: undefined,
|
|
1289
|
-
createdAt: now,
|
|
1290
|
-
lastUsedAt: undefined,
|
|
1291
|
-
failureCount: 0,
|
|
1292
|
-
scopes: input.scopes,
|
|
1293
|
-
};
|
|
1294
|
-
|
|
1295
|
-
if (existingIdx >= 0) {
|
|
1296
|
-
// Update preserving createdAt and enabled if not specified
|
|
1297
|
-
const prev = this.store.accounts[existingIdx];
|
|
1298
|
-
const merged: AccountMetadataV3 = {
|
|
1299
|
-
...(prev as AccountMetadataV3),
|
|
1300
|
-
...base,
|
|
1301
|
-
createdAt: (prev as AccountMetadataV3).createdAt,
|
|
1302
|
-
enabled: input.enabled ?? (prev as AccountMetadataV3).enabled,
|
|
1303
|
-
failureCount: 0, // reset on re-add
|
|
1304
|
-
disabledReason: undefined,
|
|
1305
|
-
disabledUntil: undefined,
|
|
1306
|
-
};
|
|
1307
|
-
// If a new refreshToken came in, update it; input always carries refreshToken in our case
|
|
1308
|
-
this.store.accounts[existingIdx] = merged;
|
|
1309
|
-
this.logger?.info?.(`[accounts] updated existing account ${email}`);
|
|
1310
|
-
this.saveAccounts();
|
|
1311
|
-
return merged;
|
|
1312
|
-
} else {
|
|
1313
|
-
this.store.accounts.push(base);
|
|
1314
|
-
// First account becomes active
|
|
1315
|
-
if (!this.store.activeEmail) {
|
|
1316
|
-
this.store.activeEmail = email;
|
|
1317
|
-
}
|
|
1318
|
-
this.logger?.info?.(`[accounts] added account ${email}`);
|
|
1319
|
-
this.saveAccounts();
|
|
1320
|
-
return base;
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/**
|
|
1325
|
-
* Removes account by email. Returns true if removed.
|
|
1326
|
-
*/
|
|
1327
|
-
removeAccount(email: string): boolean {
|
|
1328
|
-
const idx = this.findAccountIndex(email);
|
|
1329
|
-
if (idx < 0) return false;
|
|
1330
|
-
|
|
1331
|
-
const removed = this.store.accounts.splice(idx, 1)[0];
|
|
1332
|
-
this.logger?.info?.(`[accounts] removed account ${(removed as AccountMetadataV3).email}`);
|
|
1333
|
-
|
|
1334
|
-
// Adjust activeEmail if it was the removed one
|
|
1335
|
-
if (this.store.activeEmail && normalizeEmail(this.store.activeEmail) === normalizeEmail(email)) {
|
|
1336
|
-
this.store.activeEmail = this.store.accounts.length > 0 ? this.store.accounts[0]!.email : null;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// Adjust rotationIndex
|
|
1340
|
-
if (this.rotationIdx >= this.store.accounts.length) {
|
|
1341
|
-
this.rotationIdx = 0;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
this.saveAccounts();
|
|
1345
|
-
return true;
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
getActiveAccount(): AccountMetadataV3 | null {
|
|
1349
|
-
const enabled = this.getEnabledAccounts();
|
|
1350
|
-
if (enabled.length === 0) {
|
|
1351
|
-
// Fallback: do not return an account in cooldown; return null if really empty
|
|
1352
|
-
// If accounts exist but all are in cooldown, return the one with oldest disabledUntil (next to be released)
|
|
1353
|
-
const all = this.store.accounts.filter((a) => a.enabled);
|
|
1354
|
-
if (all.length === 0) return null;
|
|
1355
|
-
const sorted = [...all].sort((a, b) => (a.disabledUntil ?? 0) - (b.disabledUntil ?? 0));
|
|
1356
|
-
return sorted[0] ?? null;
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
const strategy = this.store.rotationStrategy ?? "round-robin";
|
|
1360
|
-
|
|
1361
|
-
if (strategy === "sticky" && this.store.activeEmail) {
|
|
1362
|
-
const sticky = enabled.find((a) => normalizeEmail(a.email) === normalizeEmail(this.store.activeEmail!));
|
|
1363
|
-
if (sticky) {
|
|
1364
|
-
// Lazy update of lastUsedAt
|
|
1365
|
-
sticky.lastUsedAt = nowMs();
|
|
1366
|
-
return sticky;
|
|
1367
|
-
}
|
|
1368
|
-
// Sticky points to a disabled account - fall back to round-robin
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
// Round-robin
|
|
1372
|
-
if (this.rotationIdx >= enabled.length || this.rotationIdx < 0) {
|
|
1373
|
-
this.rotationIdx = 0;
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
const account = enabled[this.rotationIdx]!;
|
|
1377
|
-
// Advance pointer for next call (in-memory; persisted below)
|
|
1378
|
-
this.rotationIdx = (this.rotationIdx + 1) % enabled.length;
|
|
1379
|
-
this.store.rotationIndex = this.rotationIdx;
|
|
1380
|
-
this.store.activeEmail = account.email;
|
|
1381
|
-
account.lastUsedAt = nowMs();
|
|
1382
|
-
|
|
1383
|
-
// Lazy non-blocking save? To persist rotation, save but do not fail on error
|
|
1384
|
-
try {
|
|
1385
|
-
this.saveAccounts();
|
|
1386
|
-
} catch {
|
|
1387
|
-
// ignore save error in getActiveAccount to keep hot path
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
return account;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
/** Sets the active account (sticky-mode override). Optionally sets strategy to sticky. */
|
|
1394
|
-
setActive(email: string, opts?: { sticky?: boolean }): AccountMetadataV3 {
|
|
1395
|
-
const acct = this.findAccount(email);
|
|
1396
|
-
if (!acct) throw new Error(`setActive: account not found ${email}`);
|
|
1397
|
-
|
|
1398
|
-
// If account is disabled and not in expired cooldown, still allow but warn
|
|
1399
|
-
if (!acct.enabled) {
|
|
1400
|
-
this.logger?.warn?.(`[accounts] setActive called for disabled account ${email}, enabling temporarily`);
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
this.store.activeEmail = acct.email;
|
|
1404
|
-
if (opts?.sticky) {
|
|
1405
|
-
this.store.rotationStrategy = "sticky";
|
|
1406
|
-
}
|
|
1407
|
-
// Align rotationIdx so round-robin continues from here if strategy changes
|
|
1408
|
-
const enabled = this.getEnabledAccounts();
|
|
1409
|
-
const idx = enabled.findIndex((a) => normalizeEmail(a.email) === normalizeEmail(email));
|
|
1410
|
-
if (idx >= 0) this.rotationIdx = idx;
|
|
1411
|
-
|
|
1412
|
-
acct.lastUsedAt = nowMs();
|
|
1413
|
-
this.saveAccounts();
|
|
1414
|
-
return acct;
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
enableAccount(email: string): AccountMetadataV3 {
|
|
1418
|
-
const acct = this.findAccount(email);
|
|
1419
|
-
if (!acct) throw new Error(`enableAccount: not found ${email}`);
|
|
1420
|
-
acct.enabled = true;
|
|
1421
|
-
acct.failureCount = 0;
|
|
1422
|
-
acct.disabledReason = undefined;
|
|
1423
|
-
acct.disabledUntil = undefined;
|
|
1424
|
-
this.saveAccounts();
|
|
1425
|
-
return acct;
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
disableAccount(email: string, reason?: string, cooldownMs?: number): AccountMetadataV3 {
|
|
1429
|
-
const acct = this.findAccount(email);
|
|
1430
|
-
if (!acct) throw new Error(`disableAccount: not found ${email}`);
|
|
1431
|
-
acct.enabled = false;
|
|
1432
|
-
acct.disabledReason = reason ?? "manually disabled";
|
|
1433
|
-
if (cooldownMs && cooldownMs > 0) {
|
|
1434
|
-
acct.disabledUntil = nowMs() + cooldownMs;
|
|
1435
|
-
// With cooldown, keep enabled=false until expiry; background will re-enable
|
|
1436
|
-
}
|
|
1437
|
-
this.saveAccounts();
|
|
1438
|
-
return acct;
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// Compat aliases required by consumers
|
|
1442
|
-
enable(email: string): AccountMetadataV3 {
|
|
1443
|
-
return this.enableAccount(email);
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
disable(email: string, reason?: string, cooldownMs?: number): AccountMetadataV3 {
|
|
1447
|
-
return this.disableAccount(email, reason, cooldownMs);
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
// --------------------------------------------------------------------------
|
|
1451
|
-
// ROTATION ON ERROR - 429/quota detection
|
|
1452
|
-
// --------------------------------------------------------------------------
|
|
1453
|
-
|
|
1454
|
-
/**
|
|
1455
|
-
* Detects quota/429 errors and rotates to the next enabled account.
|
|
1456
|
-
* Accepts flexible signatures:
|
|
1457
|
-
* - rotateOnError(error)
|
|
1458
|
-
* - rotateOnError(email, error)
|
|
1459
|
-
* - rotateOnError(error, email)
|
|
1460
|
-
*/
|
|
1461
|
-
rotateOnError(errorOrEmail: unknown, maybeErrorOrEmail?: unknown): AccountMetadataV3 | null {
|
|
1462
|
-
let currentEmail: string | undefined;
|
|
1463
|
-
let error: unknown;
|
|
1464
|
-
|
|
1465
|
-
// Signature parsing heuristic
|
|
1466
|
-
if (typeof errorOrEmail === "string" && isValidEmail(errorOrEmail)) {
|
|
1467
|
-
currentEmail = errorOrEmail;
|
|
1468
|
-
error = maybeErrorOrEmail;
|
|
1469
|
-
} else if (typeof maybeErrorOrEmail === "string" && isValidEmail(maybeErrorOrEmail as string)) {
|
|
1470
|
-
currentEmail = maybeErrorOrEmail as string;
|
|
1471
|
-
error = errorOrEmail;
|
|
1472
|
-
} else {
|
|
1473
|
-
// First arg is the error
|
|
1474
|
-
error = errorOrEmail;
|
|
1475
|
-
if (typeof maybeErrorOrEmail === "string" && isValidEmail(maybeErrorOrEmail as string)) {
|
|
1476
|
-
currentEmail = maybeErrorOrEmail as string;
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
if (!isQuotaError(error)) {
|
|
1481
|
-
// Not quota - no rotation
|
|
1482
|
-
this.logger?.debug?.(
|
|
1483
|
-
`[accounts] rotateOnError called but not quota error: ${String((error as any)?.message ?? error).slice(0, 200)}`,
|
|
1484
|
-
);
|
|
1485
|
-
return null;
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
// Resolve current account
|
|
1489
|
-
let currentAcct: AccountMetadataV3 | undefined;
|
|
1490
|
-
if (currentEmail) {
|
|
1491
|
-
currentAcct = this.findAccount(currentEmail);
|
|
1492
|
-
} else {
|
|
1493
|
-
// Try using store activeEmail
|
|
1494
|
-
const active = this.getActiveAccount();
|
|
1495
|
-
if (active) {
|
|
1496
|
-
currentAcct = active;
|
|
1497
|
-
currentEmail = active.email;
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
if (currentAcct) {
|
|
1502
|
-
currentAcct.failureCount = (currentAcct.failureCount ?? 0) + 1;
|
|
1503
|
-
// On consecutive failure >= 3 apply 30min cooldown
|
|
1504
|
-
const cooldown =
|
|
1505
|
-
(currentAcct.failureCount ?? 0) >= 3
|
|
1506
|
-
? 30 * 60 * 1000
|
|
1507
|
-
: (currentAcct.failureCount ?? 0) >= 2
|
|
1508
|
-
? 10 * 60 * 1000
|
|
1509
|
-
: 5 * 60 * 1000;
|
|
1510
|
-
|
|
1511
|
-
currentAcct.disabledUntil = nowMs() + cooldown;
|
|
1512
|
-
currentAcct.disabledReason = `quota/429 detected (${parseErrorStatus(error) ?? "quota"}) failureCount=${currentAcct.failureCount}`;
|
|
1513
|
-
|
|
1514
|
-
// If failureCount >= 5, hard-disable until import/manual enable
|
|
1515
|
-
if ((currentAcct.failureCount ?? 0) >= 5) {
|
|
1516
|
-
currentAcct.enabled = false;
|
|
1517
|
-
this.logger?.warn?.(
|
|
1518
|
-
`[accounts] account ${currentAcct.email} disabled after ${currentAcct.failureCount} quota failures`,
|
|
1519
|
-
);
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
this.store.lastRotationAt = nowMs();
|
|
1523
|
-
this.store.lastRotatedFrom = currentAcct.email;
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
const enabled = this.getEnabledAccounts().filter((a) => {
|
|
1527
|
-
// Exclude the current account that caused the error
|
|
1528
|
-
if (currentEmail && normalizeEmail(a.email) === normalizeEmail(currentEmail)) return false;
|
|
1529
|
-
return true;
|
|
1530
|
-
});
|
|
1531
|
-
|
|
1532
|
-
if (enabled.length === 0) {
|
|
1533
|
-
// Nothing else available - try all, including current if not hard-disabled
|
|
1534
|
-
const allEnabled = this.getEnabledAccounts();
|
|
1535
|
-
if (allEnabled.length === 0) {
|
|
1536
|
-
this.logger?.warn?.(`[accounts] rotateOnError: no enabled accounts left`);
|
|
1537
|
-
try {
|
|
1538
|
-
this.saveAccounts();
|
|
1539
|
-
} catch {
|
|
1540
|
-
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
1541
|
-
}
|
|
1542
|
-
return null;
|
|
1543
|
-
}
|
|
1544
|
-
// If only the current one was available and it is now in cooldown, nowhere to rotate
|
|
1545
|
-
// Return null and caller must back off
|
|
1546
|
-
this.logger?.warn?.(
|
|
1547
|
-
`[accounts] rotateOnError: only current account ${currentEmail} was enabled, now in cooldown`,
|
|
1548
|
-
);
|
|
1549
|
-
try {
|
|
1550
|
-
this.saveAccounts();
|
|
1551
|
-
} catch {
|
|
1552
|
-
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
1553
|
-
}
|
|
1554
|
-
return null;
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// Round-robin to next
|
|
1558
|
-
if (this.rotationIdx >= enabled.length) this.rotationIdx = 0;
|
|
1559
|
-
const next = enabled[this.rotationIdx]!;
|
|
1560
|
-
this.rotationIdx = (this.rotationIdx + 1) % enabled.length;
|
|
1561
|
-
this.store.rotationIndex = this.rotationIdx;
|
|
1562
|
-
this.store.activeEmail = next.email;
|
|
1563
|
-
next.lastUsedAt = nowMs();
|
|
1564
|
-
|
|
1565
|
-
this.logger?.info?.(`[accounts] rotated from ${currentEmail ?? "unknown"} to ${next.email} due to quota error`);
|
|
1566
|
-
|
|
1567
|
-
try {
|
|
1568
|
-
this.saveAccounts();
|
|
1569
|
-
} catch {
|
|
1570
|
-
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
return next;
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
// --------------------------------------------------------------------------
|
|
1577
|
-
// TOKEN REFRESH - double-checked locking
|
|
1578
|
-
// --------------------------------------------------------------------------
|
|
1579
|
-
|
|
1580
|
-
/**
|
|
1581
|
-
* Refresh with double-checked locking
|
|
1582
|
-
* - Checks expiry before lock
|
|
1583
|
-
* - If a lock exists, waits for it and re-checks
|
|
1584
|
-
* - Only then performs the refresh
|
|
1585
|
-
*/
|
|
1586
|
-
async refreshAccessTokenIfNeeded(email: string, force = false): Promise<AccountMetadataV3> {
|
|
1587
|
-
const acct = this.findAccount(email);
|
|
1588
|
-
if (!acct) throw new Error(`refreshAccessTokenIfNeeded: account not found ${email}`);
|
|
1589
|
-
|
|
1590
|
-
// Fast path - token still valid and not forced
|
|
1591
|
-
if (!force && !isTokenExpired(acct)) {
|
|
1592
|
-
return acct;
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
const key = normalizeEmail(acct.email);
|
|
1596
|
-
|
|
1597
|
-
// If a lock already exists, wait and re-check (double-checked)
|
|
1598
|
-
const existingLock = this.refreshLocks.get(key);
|
|
1599
|
-
if (existingLock) {
|
|
1600
|
-
this.logger?.debug?.(`[accounts] waiting existing refresh lock for ${acct.email}`);
|
|
1601
|
-
const waited = await existingLock;
|
|
1602
|
-
// Second check after lock release
|
|
1603
|
-
if (!force && !isTokenExpired(waited)) {
|
|
1604
|
-
return waited;
|
|
1605
|
-
}
|
|
1606
|
-
// If still expired (refresh failed and kept old token?), continue to new refresh below
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
// Create new lock
|
|
1610
|
-
const refreshPromise = (async (): Promise<AccountMetadataV3> => {
|
|
1611
|
-
// Double-checked locking - second check inside the lock
|
|
1612
|
-
const fresh = this.findAccount(email);
|
|
1613
|
-
if (!fresh) throw new Error(`Account disappeared during refresh ${email}`);
|
|
1614
|
-
|
|
1615
|
-
if (!force && !isTokenExpired(fresh)) {
|
|
1616
|
-
return fresh;
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
this.logger?.info?.(`[accounts] refreshing access token for ${fresh.email}`);
|
|
1620
|
-
|
|
1621
|
-
try {
|
|
1622
|
-
const tokenRes = await performTokenRefresh(fresh);
|
|
1623
|
-
|
|
1624
|
-
// Update account in memory (fetch latest reference again)
|
|
1625
|
-
const targetIdx = this.findAccountIndex(email);
|
|
1626
|
-
if (targetIdx < 0) throw new Error(`Account vanished after refresh ${email}`);
|
|
1627
|
-
const target = this.store.accounts[targetIdx]!;
|
|
1628
|
-
|
|
1629
|
-
target.accessToken = tokenRes.access_token;
|
|
1630
|
-
target.expiryDate = nowMs() + tokenRes.expires_in * 1000;
|
|
1631
|
-
target.lastRefresh = nowMs();
|
|
1632
|
-
target.failureCount = 0;
|
|
1633
|
-
target.disabledReason = undefined;
|
|
1634
|
-
target.disabledUntil = undefined;
|
|
1635
|
-
// Re-enable if disabled due to auth error
|
|
1636
|
-
const prevDisabledReason = target.disabledReason as string | undefined;
|
|
1637
|
-
if (!target.enabled && prevDisabledReason?.includes("auth")) {
|
|
1638
|
-
target.enabled = true;
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
// Project update? not during token step
|
|
1642
|
-
|
|
1643
|
-
this.saveAccounts();
|
|
1644
|
-
this.logger?.info?.(`[accounts] refresh OK for ${target.email}, expires in ${tokenRes.expires_in}s`);
|
|
1645
|
-
return target;
|
|
1646
|
-
} catch (err) {
|
|
1647
|
-
const status = parseErrorStatus(err);
|
|
1648
|
-
const errMsg = (err as Error).message || String(err);
|
|
1649
|
-
|
|
1650
|
-
this.logger?.warn?.(`[accounts] refresh failed for ${acct.email}: ${status ?? ""} ${errMsg.slice(0, 300)}`);
|
|
1651
|
-
|
|
1652
|
-
// If refresh failed with invalid_grant / 400 / 401 -> refresh token invalid, disable
|
|
1653
|
-
if (
|
|
1654
|
-
status === 400 ||
|
|
1655
|
-
status === 401 ||
|
|
1656
|
-
errMsg.includes("invalid_grant") ||
|
|
1657
|
-
errMsg.includes("expired") ||
|
|
1658
|
-
errMsg.includes("revoked")
|
|
1659
|
-
) {
|
|
1660
|
-
const idx = this.findAccountIndex(email);
|
|
1661
|
-
if (idx >= 0) {
|
|
1662
|
-
const t = this.store.accounts[idx]!;
|
|
1663
|
-
t.failureCount = (t.failureCount ?? 0) + 1;
|
|
1664
|
-
if ((t.failureCount ?? 0) >= 2) {
|
|
1665
|
-
t.enabled = false;
|
|
1666
|
-
t.disabledReason = `refresh failed: ${errMsg.slice(0, 200)}`;
|
|
1667
|
-
}
|
|
1668
|
-
try {
|
|
1669
|
-
this.saveAccounts();
|
|
1670
|
-
} catch {
|
|
1671
|
-
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
throw err;
|
|
1677
|
-
}
|
|
1678
|
-
})();
|
|
1679
|
-
|
|
1680
|
-
this.refreshLocks.set(key, refreshPromise);
|
|
1681
|
-
|
|
1682
|
-
try {
|
|
1683
|
-
const result = await refreshPromise;
|
|
1684
|
-
return result;
|
|
1685
|
-
} finally {
|
|
1686
|
-
// Always clear lock
|
|
1687
|
-
if (this.refreshLocks.get(key) === refreshPromise) {
|
|
1688
|
-
this.refreshLocks.delete(key);
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
/**
|
|
1694
|
-
* Returns a valid access token, refreshing if needed.
|
|
1695
|
-
* If email is omitted, uses the active account (round-robin/sticky).
|
|
1696
|
-
*/
|
|
1697
|
-
async getValidAccessToken(email?: string): Promise<string> {
|
|
1698
|
-
let acct: AccountMetadataV3 | null | undefined;
|
|
1699
|
-
|
|
1700
|
-
if (email) {
|
|
1701
|
-
acct = this.findAccount(email);
|
|
1702
|
-
if (!acct) throw new Error(`getValidAccessToken: account not found ${email}`);
|
|
1703
|
-
} else {
|
|
1704
|
-
acct = this.getActiveAccount();
|
|
1705
|
-
if (!acct) throw new Error(`getValidAccessToken: no active account available`);
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
if (!acct.enabled) {
|
|
1709
|
-
// If in cooldown but expired, lazily re-enable
|
|
1710
|
-
if (acct.disabledUntil && acct.disabledUntil <= nowMs()) {
|
|
1711
|
-
acct.enabled = true;
|
|
1712
|
-
acct.disabledUntil = undefined;
|
|
1713
|
-
acct.disabledReason = undefined;
|
|
1714
|
-
acct.failureCount = 0;
|
|
1715
|
-
try {
|
|
1716
|
-
this.saveAccounts();
|
|
1717
|
-
} catch {
|
|
1718
|
-
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
1719
|
-
}
|
|
1720
|
-
} else {
|
|
1721
|
-
throw new Error(`Account ${acct.email} is disabled: ${acct.disabledReason ?? "unknown"}`);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
const refreshed = await this.refreshAccessTokenIfNeeded(acct.email);
|
|
1726
|
-
if (!refreshed.accessToken) {
|
|
1727
|
-
throw new Error(`Failed to obtain access token for ${refreshed.email}`);
|
|
1728
|
-
}
|
|
1729
|
-
return refreshed.accessToken;
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
// --------------------------------------------------------------------------
|
|
1733
|
-
// BACKGROUND REFRESH
|
|
1734
|
-
// --------------------------------------------------------------------------
|
|
1735
|
-
|
|
1736
|
-
/**
|
|
1737
|
-
* Background refresh of all enabled accounts close to expiry
|
|
1738
|
-
* - Does not block callers
|
|
1739
|
-
* - Uses allSettled so the batch never fails as a whole
|
|
1740
|
-
*/
|
|
1741
|
-
async backgroundRefresh(): Promise<void> {
|
|
1742
|
-
// Reload from disk first to pick up external changes
|
|
1743
|
-
try {
|
|
1744
|
-
this.loadAccounts();
|
|
1745
|
-
} catch {
|
|
1746
|
-
// ignore
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
const enabled = this.getEnabledAccounts();
|
|
1750
|
-
if (enabled.length === 0) return;
|
|
1751
|
-
|
|
1752
|
-
const toRefresh = enabled.filter((a) => {
|
|
1753
|
-
// Skip if future disabledUntil set
|
|
1754
|
-
if (a.disabledUntil && a.disabledUntil > nowMs()) return false;
|
|
1755
|
-
// If token missing or expiring within BACKGROUND buffer, refresh
|
|
1756
|
-
return isTokenExpired(a, BACKGROUND_REFRESH_BUFFER_MS);
|
|
1757
|
-
});
|
|
1758
|
-
|
|
1759
|
-
if (toRefresh.length === 0) {
|
|
1760
|
-
this.logger?.debug?.(`[accounts] backgroundRefresh: no accounts need refresh`);
|
|
1761
|
-
return;
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
this.logger?.info?.(`[accounts] backgroundRefresh: refreshing ${toRefresh.length}/${enabled.length} accounts`);
|
|
1765
|
-
|
|
1766
|
-
const results = await Promise.allSettled(
|
|
1767
|
-
toRefresh.map((a) =>
|
|
1768
|
-
this.refreshAccessTokenIfNeeded(a.email).catch((e) => {
|
|
1769
|
-
// Do not break allSettled with an extra throw
|
|
1770
|
-
throw e;
|
|
1771
|
-
}),
|
|
1772
|
-
),
|
|
1773
|
-
);
|
|
1774
|
-
|
|
1775
|
-
const fails = results.filter((r) => r.status === "rejected").length;
|
|
1776
|
-
if (fails > 0) {
|
|
1777
|
-
this.logger?.warn?.(`[accounts] backgroundRefresh: ${fails} failures`);
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
/**
|
|
1782
|
-
* Starts background refresh without awaiting (fire-and-forget)
|
|
1783
|
-
*/
|
|
1784
|
-
triggerBackgroundRefresh(): void {
|
|
1785
|
-
this.backgroundRefresh().catch(() => {});
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
// --------------------------------------------------------------------------
|
|
1789
|
-
// IMPORT FROM antigravity-manager
|
|
1790
|
-
// --------------------------------------------------------------------------
|
|
1791
|
-
|
|
1792
|
-
private static getAntigravityManagerCandidatePaths(): string[] {
|
|
1793
|
-
const home = os.homedir();
|
|
1794
|
-
return [
|
|
1795
|
-
path.join(home, ".config", "antigravity-manager", "accounts.json"),
|
|
1796
|
-
path.join(home, ".antigravity-manager", "accounts.json"),
|
|
1797
|
-
path.join(home, ".config", "antigravity-manager", "credentials.json"),
|
|
1798
|
-
path.join(home, ".config", "antigravity", "accounts.json"),
|
|
1799
|
-
path.join(home, ".antigravity", "accounts.json"),
|
|
1800
|
-
path.join(home, ".antigravity", "auth.json"),
|
|
1801
|
-
path.join(home, ".config", "antigravity", "auth.json"),
|
|
1802
|
-
path.join(home, ".gemini", "antigravity", "accounts.json"),
|
|
1803
|
-
path.join(home, ".config", "opencode", "auth", "antigravity-accounts.json"),
|
|
1804
|
-
path.join(home, ".config", "opencode", "auth", "gemini-cli-tokens.json"),
|
|
1805
|
-
path.join(home, ".config", "opencode", "antigravity.json"),
|
|
1806
|
-
// Legacy antigravity-manager sqlite? No, json only.
|
|
1807
|
-
];
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
private static extractAccountsFromRaw(raw: unknown): unknown[] {
|
|
1811
|
-
if (!raw) return [];
|
|
1812
|
-
|
|
1813
|
-
// Case 1: direct array
|
|
1814
|
-
if (Array.isArray(raw)) return raw;
|
|
1815
|
-
|
|
1816
|
-
if (typeof raw !== "object") return [];
|
|
1817
|
-
|
|
1818
|
-
const obj = raw as Record<string, unknown>;
|
|
1819
|
-
|
|
1820
|
-
// Case 2: { accounts: [...] }
|
|
1821
|
-
if (Array.isArray(obj.accounts)) return obj.accounts as unknown[];
|
|
1822
|
-
|
|
1823
|
-
// Case 3: { credentials: [...] } or { tokens: [...] }
|
|
1824
|
-
if (Array.isArray(obj.credentials)) return obj.credentials as unknown[];
|
|
1825
|
-
if (Array.isArray(obj.tokens)) return obj.tokens as unknown[];
|
|
1826
|
-
|
|
1827
|
-
// Case 4: { <email>: { refresh_token, ... } } map
|
|
1828
|
-
const keys = Object.keys(obj);
|
|
1829
|
-
const looksLikeEmailMap = keys.some((k) => k.includes("@"));
|
|
1830
|
-
if (looksLikeEmailMap) {
|
|
1831
|
-
return keys.map((k) => {
|
|
1832
|
-
const v = obj[k];
|
|
1833
|
-
if (typeof v === "object" && v !== null) {
|
|
1834
|
-
return { email: k, ...(v as object) };
|
|
1835
|
-
}
|
|
1836
|
-
return { email: k, refreshToken: v };
|
|
1837
|
-
});
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
// Case 5: { version: ..., data: [...] } wrapper
|
|
1841
|
-
if (Array.isArray(obj.data)) return obj.data as unknown[];
|
|
1842
|
-
if (obj.data && typeof obj.data === "object") {
|
|
1843
|
-
const inner = obj.data as Record<string, unknown>;
|
|
1844
|
-
if (Array.isArray(inner.accounts)) return inner.accounts as unknown[];
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
// Case 6: single account object
|
|
1848
|
-
if ((obj.email as string) || (obj.refresh_token as string) || (obj.refreshToken as string)) {
|
|
1849
|
-
return [raw];
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
return [];
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
/**
|
|
1856
|
-
* Imports accounts from existing antigravity-manager installations.
|
|
1857
|
-
* Tries multiple paths and legacy formats (including the flat Account[]
|
|
1858
|
-
* reader), merging into the current store without duplicates.
|
|
1859
|
-
*/
|
|
1860
|
-
async importFromAntigravityManager(): Promise<ImportResult> {
|
|
1861
|
-
// Union of classic candidates and the portable resolver
|
|
1862
|
-
// (adds ANTIGRAVITY_MANAGER_ACCOUNTS_PATH env + macOS/Windows AppData locations)
|
|
1863
|
-
const merged = new Set<string>(AccountManager.getAntigravityManagerCandidatePaths());
|
|
1864
|
-
for (const c of resolveAntagravityManagerCandidates()) merged.add(c);
|
|
1865
|
-
const candidates = Array.from(merged);
|
|
1866
|
-
|
|
1867
|
-
const result: ImportResult = {
|
|
1868
|
-
imported: 0,
|
|
1869
|
-
skipped: 0,
|
|
1870
|
-
updated: 0,
|
|
1871
|
-
errors: [],
|
|
1872
|
-
sources: [],
|
|
1873
|
-
accounts: [],
|
|
1874
|
-
};
|
|
1875
|
-
|
|
1876
|
-
// Reload current state for safe merge
|
|
1877
|
-
try {
|
|
1878
|
-
this.loadAccounts();
|
|
1879
|
-
} catch {
|
|
1880
|
-
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
const existingEmails = new Set(this.store.accounts.map((a) => normalizeEmail(a.email)));
|
|
1884
|
-
|
|
1885
|
-
for (const p of candidates) {
|
|
1886
|
-
// Skip our own target file to avoid re-importing itself
|
|
1887
|
-
if (path.resolve(p) === path.resolve(this.storePath)) continue;
|
|
1888
|
-
|
|
1889
|
-
if (!fs.existsSync(p)) continue;
|
|
1890
|
-
|
|
1891
|
-
try {
|
|
1892
|
-
const content = fs.readFileSync(p, { encoding: "utf8" });
|
|
1893
|
-
const parsed = JSON.parse(content);
|
|
1894
|
-
const rawAccounts = AccountManager.extractAccountsFromRaw(parsed);
|
|
1895
|
-
|
|
1896
|
-
if (rawAccounts.length === 0) {
|
|
1897
|
-
// Fall back to the strict flat-file reader for exotic shapes
|
|
1898
|
-
const legacy = parseLegacyAccounts(parsed);
|
|
1899
|
-
if (legacy.accounts.length > 0) {
|
|
1900
|
-
rawAccounts.push(...legacy.accounts);
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
if (rawAccounts.length === 0) {
|
|
1905
|
-
result.errors.push(`${p}: no accounts extracted`);
|
|
1906
|
-
continue;
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
result.sources.push(p);
|
|
1910
|
-
this.logger?.info?.(`[accounts] import candidate ${p} -> ${rawAccounts.length} raw`);
|
|
1911
|
-
|
|
1912
|
-
for (const raw of rawAccounts) {
|
|
1913
|
-
const normalized = normalizeAccountRaw(raw);
|
|
1914
|
-
if (!normalized) {
|
|
1915
|
-
result.skipped++;
|
|
1916
|
-
continue;
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
const normEmail = normalizeEmail(normalized.email);
|
|
1920
|
-
if (existingEmails.has(normEmail)) {
|
|
1921
|
-
// Exists already: update if new one has newer accessToken or old had failures
|
|
1922
|
-
const existing = this.findAccount(normEmail);
|
|
1923
|
-
if (existing) {
|
|
1924
|
-
const shouldUpdate =
|
|
1925
|
-
(normalized.accessToken &&
|
|
1926
|
-
normalized.expiryDate &&
|
|
1927
|
-
(!existing.expiryDate || normalized.expiryDate > existing.expiryDate)) ||
|
|
1928
|
-
(existing.failureCount ?? 0) > 0;
|
|
1929
|
-
|
|
1930
|
-
if (shouldUpdate) {
|
|
1931
|
-
existing.refreshToken = normalized.refreshToken;
|
|
1932
|
-
if (normalized.accessToken) existing.accessToken = normalized.accessToken;
|
|
1933
|
-
if (normalized.expiryDate) existing.expiryDate = normalized.expiryDate;
|
|
1934
|
-
if (normalized.projectId) existing.projectId = normalized.projectId;
|
|
1935
|
-
existing.failureCount = 0;
|
|
1936
|
-
existing.disabledReason = undefined;
|
|
1937
|
-
existing.disabledUntil = undefined;
|
|
1938
|
-
existing.enabled = true;
|
|
1939
|
-
result.updated++;
|
|
1940
|
-
} else {
|
|
1941
|
-
result.skipped++;
|
|
1942
|
-
}
|
|
1943
|
-
} else {
|
|
1944
|
-
result.skipped++;
|
|
1945
|
-
}
|
|
1946
|
-
continue;
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
// Force antigravity-manager client key for tracking
|
|
1950
|
-
if (!normalized.oauthClientKey || normalized.oauthClientKey === "gemini-cli") {
|
|
1951
|
-
normalized.oauthClientKey = "antigravity-manager";
|
|
1952
|
-
}
|
|
1953
|
-
// Guarantee default oauth config when missing
|
|
1954
|
-
if (!normalized.oauthClientConfig) {
|
|
1955
|
-
normalized.oauthClientConfig = {
|
|
1956
|
-
clientId: GEMINI_CLI_OAUTH_CLIENT_ID,
|
|
1957
|
-
clientSecret: GEMINI_CLI_OAUTH_CLIENT_SECRET,
|
|
1958
|
-
};
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
this.store.accounts.push(normalized);
|
|
1962
|
-
existingEmails.add(normEmail);
|
|
1963
|
-
result.imported++;
|
|
1964
|
-
result.accounts.push(normalized);
|
|
1965
|
-
}
|
|
1966
|
-
} catch (e) {
|
|
1967
|
-
result.errors.push(`${p}: ${(e as Error).message}`);
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
if (result.imported > 0 || result.updated > 0) {
|
|
1972
|
-
try {
|
|
1973
|
-
this.saveAccounts();
|
|
1974
|
-
} catch (e) {
|
|
1975
|
-
result.errors.push(`save after import failed: ${(e as Error).message}`);
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
this.logger?.info?.(
|
|
1980
|
-
`[accounts] import finished: imported=${result.imported} updated=${result.updated} skipped=${result.skipped} sources=${result.sources.length}`,
|
|
1981
|
-
);
|
|
1982
|
-
|
|
1983
|
-
return result;
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
/** Alias compat requested by consumers */
|
|
1987
|
-
async importFromAntigravity(): Promise<ImportResult> {
|
|
1988
|
-
return this.importFromAntigravityManager();
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
/** Additional alias for backwards compatibility */
|
|
1992
|
-
async importAccounts(): Promise<ImportResult> {
|
|
1993
|
-
return this.importFromAntigravityManager();
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
// --------------------------------------------------------------------------
|
|
1997
|
-
// LIFECYCLE / UTIL
|
|
1998
|
-
// --------------------------------------------------------------------------
|
|
1999
|
-
|
|
2000
|
-
/** Clears the background timer (useful in tests) */
|
|
2001
|
-
destroy(): void {
|
|
2002
|
-
if (this.backgroundTimer) {
|
|
2003
|
-
clearInterval(this.backgroundTimer);
|
|
2004
|
-
this.backgroundTimer = undefined;
|
|
2005
|
-
}
|
|
2006
|
-
this.refreshLocks.clear();
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
/** File path */
|
|
2010
|
-
getStorePath(): string {
|
|
2011
|
-
return this.storePath;
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
/** Raw store (clone) */
|
|
2015
|
-
getStore(): AccountStore {
|
|
2016
|
-
return {
|
|
2017
|
-
...this.store,
|
|
2018
|
-
accounts: [...this.store.accounts],
|
|
2019
|
-
};
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
/** Sets the rotation strategy */
|
|
2023
|
-
setRotationStrategy(strategy: RotationStrategy): void {
|
|
2024
|
-
this.store.rotationStrategy = strategy;
|
|
2025
|
-
this.saveAccounts();
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
// ============================================================================
|
|
2030
|
-
// SECTION 08. SINGLETON DEFAULT + EXPORT HELPERS
|
|
2031
|
-
// ============================================================================
|
|
2032
|
-
|
|
2033
|
-
let _defaultManager: AccountManager | null = null;
|
|
2034
|
-
|
|
2035
|
-
/** Returns the default lazy singleton instance */
|
|
2036
|
-
export function getDefaultAccountManager(opts?: AccountManagerOptions): AccountManager {
|
|
2037
|
-
if (!_defaultManager) {
|
|
2038
|
-
_defaultManager = new AccountManager(opts);
|
|
2039
|
-
}
|
|
2040
|
-
return _defaultManager;
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
/** Resets the singleton (tests) */
|
|
2044
|
-
export function resetDefaultAccountManager(): void {
|
|
2045
|
-
if (_defaultManager) {
|
|
2046
|
-
_defaultManager.destroy();
|
|
2047
|
-
_defaultManager = null;
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
/** Quick helper to obtain a valid token using the singleton */
|
|
2052
|
-
export async function getValidAccessToken(email?: string, opts?: AccountManagerOptions): Promise<string> {
|
|
2053
|
-
const mgr = getDefaultAccountManager(opts);
|
|
2054
|
-
return mgr.getValidAccessToken(email);
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
/** Quick helper to obtain the active account */
|
|
2058
|
-
export function getActiveAccount(opts?: AccountManagerOptions): AccountMetadataV3 | null {
|
|
2059
|
-
const mgr = getDefaultAccountManager(opts);
|
|
2060
|
-
return mgr.getActiveAccount();
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
|
-
// ============================================================================
|
|
2064
|
-
// SECTION 09. DEFAULT EXPORT - CJS/ESM compat
|
|
2065
|
-
// ============================================================================
|
|
2066
|
-
|
|
2067
|
-
export default AccountManager;
|
|
2068
|
-
|
|
2069
|
-
export const ACCOUNTS_FILE_PATH = resolveAccountsFilePath();
|
|
2070
|
-
|
|
2071
|
-
// local variant: diverges from constants (different barrel shape — accounts-specific fields)
|
|
2072
|
-
export const ACCOUNTS_INFO = {
|
|
2073
|
-
CLIENT_ID_RAW: GEMINI_CLI_CLIENT_ID_RAW,
|
|
2074
|
-
CLIENT_ID: GEMINI_CLI_OAUTH_CLIENT_ID,
|
|
2075
|
-
CLIENT_SECRET: GEMINI_CLI_OAUTH_CLIENT_SECRET,
|
|
2076
|
-
SCOPES: GEMINI_CLI_SCOPES,
|
|
2077
|
-
TOKEN_URL: OAUTH_TOKEN_URL,
|
|
2078
|
-
USER_AGENT: GEMINI_CLI_USER_AGENT,
|
|
2079
|
-
USER_AGENT_FULL: GEMINI_CLI_USER_AGENT_FULL,
|
|
2080
|
-
X_GOOG_API_CLIENT,
|
|
2081
|
-
CLIENT_METADATA,
|
|
2082
|
-
CLOUDCODE_BASE_URL,
|
|
2083
|
-
CLOUDCODE_ENDPOINTS,
|
|
2084
|
-
PROJECT_FALLBACK,
|
|
2085
|
-
MODELS_2026,
|
|
2086
|
-
ACCOUNTS_FILE_PATH,
|
|
2087
|
-
} as const;
|