opencode-swap 0.4.0__py3-none-any.whl
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.
- opencode_swap/__init__.py +3 -0
- opencode_swap/__main__.py +4 -0
- opencode_swap/atomic.py +95 -0
- opencode_swap/backup.py +128 -0
- opencode_swap/cli.py +799 -0
- opencode_swap/exceptions.py +60 -0
- opencode_swap/locking.py +70 -0
- opencode_swap/macos_keychain.py +136 -0
- opencode_swap/models.py +190 -0
- opencode_swap/oauth_jwt.py +66 -0
- opencode_swap/oauth_refresh.py +156 -0
- opencode_swap/opencode_auth.py +54 -0
- opencode_swap/paths.py +83 -0
- opencode_swap/process_detection.py +29 -0
- opencode_swap/providers/__init__.py +26 -0
- opencode_swap/providers/api.py +50 -0
- opencode_swap/providers/base.py +91 -0
- opencode_swap/providers/common.py +144 -0
- opencode_swap/providers/github_copilot.py +46 -0
- opencode_swap/providers/openai.py +186 -0
- opencode_swap/providers/poe.py +64 -0
- opencode_swap/providers/xai.py +72 -0
- opencode_swap/providers/zai.py +31 -0
- opencode_swap/sealed.py +83 -0
- opencode_swap/store.py +551 -0
- opencode_swap/switcher.py +952 -0
- opencode_swap/transfer.py +149 -0
- opencode_swap/usage.py +304 -0
- opencode_swap-0.4.0.dist-info/METADATA +307 -0
- opencode_swap-0.4.0.dist-info/RECORD +33 -0
- opencode_swap-0.4.0.dist-info/WHEEL +4 -0
- opencode_swap-0.4.0.dist-info/entry_points.txt +3 -0
- opencode_swap-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
"""Orchestrates opencode-swap's account operations.
|
|
2
|
+
|
|
3
|
+
Ties together paths.py (where OpenCode's and our own state live),
|
|
4
|
+
opencode_auth.py (safe auth.json I/O), the Provider seam (record
|
|
5
|
+
interpretation), and store.py (SecretStore + Registry) into the actual
|
|
6
|
+
add/remove/rename/current commands. `use` (the risky part — swapping the
|
|
7
|
+
live account, with sync-back and atomic replace) lands separately.
|
|
8
|
+
|
|
9
|
+
Every mutating operation holds the same FileLock, serializing opencode-swap's
|
|
10
|
+
own concurrent invocations (see locking.py). This lock has no relationship
|
|
11
|
+
to OpenCode's own process.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from contextlib import suppress
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from enum import Enum, auto
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from opencode_swap import backup, opencode_auth, paths, transfer, usage
|
|
25
|
+
from opencode_swap.exceptions import AccountExistsError, AuthFileError, OpenCodeSwapError, RefreshError, SchemaError
|
|
26
|
+
from opencode_swap.locking import FileLock
|
|
27
|
+
from opencode_swap.models import (
|
|
28
|
+
AccountDesc,
|
|
29
|
+
AccountMeta,
|
|
30
|
+
AuthRecord,
|
|
31
|
+
ImportConflictAction,
|
|
32
|
+
JsonObject,
|
|
33
|
+
Platform,
|
|
34
|
+
SwitchTransaction,
|
|
35
|
+
Validity,
|
|
36
|
+
normalize_account_name,
|
|
37
|
+
normalize_provider_id,
|
|
38
|
+
)
|
|
39
|
+
from opencode_swap.providers import get_provider
|
|
40
|
+
from opencode_swap.providers.base import Provider
|
|
41
|
+
from opencode_swap.store import Registry, SecretStore
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _secret_key(provider_id: str, name: str) -> str:
|
|
45
|
+
return f"{provider_id}:{name}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _now_iso() -> str:
|
|
49
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _transfer_added(value: str) -> str:
|
|
53
|
+
# %z (not a literal "Z" in the format string) is what makes this an aware
|
|
54
|
+
# parse: Python's strptime treats a literal "Z" in the input as UTC under
|
|
55
|
+
# %z, same as _now_iso()'s producer side, and re-formatting with a
|
|
56
|
+
# literal "Z" here round-trips to the identical string for any input this
|
|
57
|
+
# produced. The point of the round trip is strict-canonical-format
|
|
58
|
+
# validation, not timezone conversion.
|
|
59
|
+
normalized = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z").strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
60
|
+
if normalized != value:
|
|
61
|
+
raise ValueError("timestamp is not canonical")
|
|
62
|
+
return normalized
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _without_archive_credential(value: str | None, credentials: set[str]) -> str | None:
|
|
66
|
+
if value is None or any(secret in value for secret in credentials):
|
|
67
|
+
return None
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _same_orphan_record(provider: Provider, stored: AuthRecord, incoming: AuthRecord) -> bool:
|
|
72
|
+
if stored.raw == incoming.raw:
|
|
73
|
+
return True
|
|
74
|
+
stored_identity = provider.identity(stored)
|
|
75
|
+
return stored_identity == provider.identity(incoming) and provider.identity_is_stable(stored)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RefreshOutcome(Enum):
|
|
79
|
+
"""Why `Switcher._ensure_refreshed` did or didn't perform a standalone
|
|
80
|
+
refresh, for callers (currently `refresh_account`/the `refresh` CLI
|
|
81
|
+
command) that need to report an accurate reason rather than let a
|
|
82
|
+
caller-still-EXPIRED `Validity` alone imply the wrong one -- EXPIRED
|
|
83
|
+
from an ambiguous live state and EXPIRED from "this provider/type has
|
|
84
|
+
no standalone refresh at all" are different situations that call for
|
|
85
|
+
different messages.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
LIVE = auto() # resolved via live auth.json; OpenCode owns its own refresh, never attempted here
|
|
89
|
+
AMBIGUOUS = auto() # live-ownership couldn't be verified; refresh skipped despite being supported
|
|
90
|
+
NO_SUPPORT = auto() # this provider/record type has no standalone refresh at all (Provider.refresh returned None)
|
|
91
|
+
RESOLVED = auto() # resolved from the stored record via normal means: already valid, or freshly refreshed
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class AccountRefreshResult:
|
|
96
|
+
"""`Switcher.refresh_account`'s result: the account's resulting
|
|
97
|
+
validity, plus why (see `RefreshOutcome`) -- so a caller reporting
|
|
98
|
+
"still expired" can say whether that's because this provider/type has
|
|
99
|
+
no standalone refresh at all, or because the live account state
|
|
100
|
+
couldn't be safely verified this time (worth retrying), rather than
|
|
101
|
+
conflating the two under one misleading message.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
validity: Validity
|
|
105
|
+
outcome: RefreshOutcome
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Switcher:
|
|
110
|
+
opencode_auth_path: Path
|
|
111
|
+
data_root: Path
|
|
112
|
+
platform: Platform | None = None # override for tests; None = auto-detect
|
|
113
|
+
registry: Registry = field(init=False)
|
|
114
|
+
secrets: SecretStore = field(init=False)
|
|
115
|
+
lock: FileLock = field(init=False)
|
|
116
|
+
|
|
117
|
+
def __post_init__(self) -> None:
|
|
118
|
+
self.data_root.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
self.data_root.chmod(0o700)
|
|
120
|
+
self.registry = Registry(self.data_root / "registry.json")
|
|
121
|
+
self.secrets = SecretStore(self.data_root / "secrets", platform=self.platform)
|
|
122
|
+
self.lock = FileLock(self.data_root / ".lock")
|
|
123
|
+
with self.lock:
|
|
124
|
+
self.registry.migrate()
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def default(cls) -> Switcher:
|
|
128
|
+
return cls(opencode_auth_path=paths.get_opencode_auth_path(), data_root=paths.get_data_root())
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _provider(provider_id: str) -> Provider:
|
|
132
|
+
return get_provider(normalize_provider_id(provider_id))
|
|
133
|
+
|
|
134
|
+
def _sweep_secret_upgrades(self) -> None:
|
|
135
|
+
"""Best-effort: reseal any account still on an older secret-store
|
|
136
|
+
format into the current v3 envelope (SecretStore.upgrade). Called
|
|
137
|
+
under `self.lock` at the start of every mutating operation so a
|
|
138
|
+
record that isn't otherwise rewritten by the operation itself
|
|
139
|
+
still gets upgraded, without two concurrent invocations racing to
|
|
140
|
+
upgrade the same key."""
|
|
141
|
+
for provider_id, name in self.registry.scoped_accounts():
|
|
142
|
+
self.secrets.upgrade(_secret_key(provider_id, name))
|
|
143
|
+
|
|
144
|
+
def _load_record(self, provider_id: str, name: str, *, confirmed: bool = False) -> AuthRecord | None:
|
|
145
|
+
key = _secret_key(provider_id, name)
|
|
146
|
+
stored = self.secrets.get_confirmed(key) if confirmed else self.secrets.get(key)
|
|
147
|
+
if stored is None:
|
|
148
|
+
return None
|
|
149
|
+
return self._parse_stored_record(provider_id, name, stored)
|
|
150
|
+
|
|
151
|
+
def _parse_stored_record(self, provider_id: str, name: str, stored: str) -> AuthRecord:
|
|
152
|
+
meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
153
|
+
try:
|
|
154
|
+
raw = json.loads(stored)
|
|
155
|
+
except json.JSONDecodeError as exc:
|
|
156
|
+
raise SchemaError(f"stored credentials for '{name}' are not valid JSON") from exc
|
|
157
|
+
record = self._provider(provider_id).extract({provider_id: raw})
|
|
158
|
+
if record is None: # extract only returns None for an absent provider key
|
|
159
|
+
raise SchemaError(f"stored credentials for '{name}' are missing")
|
|
160
|
+
if meta is not None and record.type != meta.type:
|
|
161
|
+
raise SchemaError(f"stored credentials for '{name}' do not match registry type")
|
|
162
|
+
return record
|
|
163
|
+
|
|
164
|
+
def _find_by_identity(self, provider_id: str, identity: str, *, confirmed: bool = False) -> str | None:
|
|
165
|
+
provider = self._provider(provider_id)
|
|
166
|
+
for _stored_provider, name in self.registry.scoped_accounts(provider_id):
|
|
167
|
+
record = self._load_record(provider_id, name, confirmed=confirmed)
|
|
168
|
+
if record is None:
|
|
169
|
+
continue
|
|
170
|
+
if provider.identity(record) == identity:
|
|
171
|
+
return name
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def _live_attribution(self, provider_id: str, name: str) -> tuple[AuthRecord | None, bool]:
|
|
175
|
+
"""Attempt to attribute the live OpenCode-auth.json record to the
|
|
176
|
+
saved account `name`. Returns `(live_record, ambiguous)`:
|
|
177
|
+
|
|
178
|
+
- `(record, False)`: attribution succeeded -- `record` is `name`'s
|
|
179
|
+
current live credential, more current than whatever
|
|
180
|
+
opencode-swap last captured into the secret store (OpenCode
|
|
181
|
+
rewrites auth.json in place on every token refresh; see
|
|
182
|
+
docs/opencode-auth.md#loading-and-refresh).
|
|
183
|
+
- `(None, False)`: there is definitely no live record to attribute
|
|
184
|
+
-- auth.json doesn't exist, or this provider has no live entry.
|
|
185
|
+
Safe to treat `name` as not currently live.
|
|
186
|
+
- `(None, True)`: attribution is ambiguous. Two distinct cases collapse
|
|
187
|
+
to this, both meaning "do not treat `name` as safe to
|
|
188
|
+
standalone-refresh":
|
|
189
|
+
1. auth.json exists but can't be read or parsed. A present-but-
|
|
190
|
+
unreadable file might still hold `name`'s live credential --
|
|
191
|
+
unlike a genuinely absent file, this is not proof of absence.
|
|
192
|
+
2. A live record exists whose identity matches no saved
|
|
193
|
+
account, `name` is this provider's registry-active account,
|
|
194
|
+
shares the live record's type, and `name`'s own stored
|
|
195
|
+
identity is unstable. This is the shape of an in-flight
|
|
196
|
+
unstable-to-stable identity transition (OpenCode rotated
|
|
197
|
+
`name`'s token and the new one happens to carry an
|
|
198
|
+
`accountId` claim the old one lacked) -- or an unrelated
|
|
199
|
+
foreign login. Either way, `name`'s stored refresh token may
|
|
200
|
+
already have been invalidated by whatever this rotation was.
|
|
201
|
+
|
|
202
|
+
Never accepts attribution through the registry's active-name hint
|
|
203
|
+
alone (case 2 above refuses, it never accepts): `use_account` only
|
|
204
|
+
ever uses that same hint to decide whether to *refuse* an ambiguous
|
|
205
|
+
live credential (stash it as unclaimed and raise, see its
|
|
206
|
+
ambiguous-identity branch) -- never to *accept* it as belonging to
|
|
207
|
+
the active account. A foreign OpenCode login with no stable
|
|
208
|
+
identity would otherwise be silently attributed to whatever account
|
|
209
|
+
the registry last recorded as active, and a caller that then syncs
|
|
210
|
+
it back (see `_ensure_refreshed`) would overwrite that account's
|
|
211
|
+
real stored credentials with the foreign one.
|
|
212
|
+
"""
|
|
213
|
+
if not self.opencode_auth_path.exists():
|
|
214
|
+
return None, False
|
|
215
|
+
try:
|
|
216
|
+
live_record = self._read_live_record(provider_id)
|
|
217
|
+
except AuthFileError:
|
|
218
|
+
return None, True
|
|
219
|
+
if live_record is None:
|
|
220
|
+
return None, False
|
|
221
|
+
|
|
222
|
+
provider = self._provider(provider_id)
|
|
223
|
+
identity = provider.identity(live_record)
|
|
224
|
+
owner = self._find_by_identity(provider_id, identity)
|
|
225
|
+
if owner == name:
|
|
226
|
+
return live_record, False
|
|
227
|
+
|
|
228
|
+
if self.registry.get_active(provider_id) == name:
|
|
229
|
+
meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
230
|
+
if meta is not None and meta.type == live_record.type:
|
|
231
|
+
stored_record = self._load_record(provider_id, name)
|
|
232
|
+
if stored_record is not None and not provider.identity_is_stable(stored_record):
|
|
233
|
+
return None, True
|
|
234
|
+
|
|
235
|
+
return None, False
|
|
236
|
+
|
|
237
|
+
def _ensure_refreshed(self, provider_id: str, name: str, *, allow_refresh: bool) -> tuple[AuthRecord, RefreshOutcome] | None:
|
|
238
|
+
"""The most current OAuth record attributable to saved account
|
|
239
|
+
`name`, as `(record, outcome)`. None if there's no live-owned or
|
|
240
|
+
stored record to resolve at all.
|
|
241
|
+
|
|
242
|
+
Live-attribution, sync-back, and any standalone refresh all happen
|
|
243
|
+
inside a single `self.lock` acquisition, and everything they act on
|
|
244
|
+
(live auth.json, the stored secret) is re-read fresh *after* the
|
|
245
|
+
lock is acquired rather than trusted from a value a caller computed
|
|
246
|
+
before waiting for the lock. Both properties matter for the same
|
|
247
|
+
reason: `use_account` (which also takes this lock) can splice a
|
|
248
|
+
rotated live credential into the secret store as part of its own
|
|
249
|
+
sync-back at any moment. Without re-checking under the lock, a
|
|
250
|
+
delayed caller here could either (a) attribute a *stale* cached live
|
|
251
|
+
snapshot to `name` and overwrite a newer sync-back `use_account` just
|
|
252
|
+
performed, permanently losing the freshest token, or (b) spend a
|
|
253
|
+
standalone refresh on `name`'s stored refresh token after `name`
|
|
254
|
+
became the live-active account, invalidating the very token
|
|
255
|
+
OpenCode itself is about to try to refresh with on its next
|
|
256
|
+
request.
|
|
257
|
+
|
|
258
|
+
Never triggers a standalone refresh for the account currently live
|
|
259
|
+
in OpenCode, regardless of `allow_refresh`: OpenCode owns that
|
|
260
|
+
refresh on its own next request (docs/opencode-auth.md#loading-and-refresh) --
|
|
261
|
+
reported as `RefreshOutcome.LIVE`. Also never refreshes when
|
|
262
|
+
`_live_attribution` reports the live state as ambiguous (auth.json
|
|
263
|
+
exists but couldn't be read, or `name` is the registry-active
|
|
264
|
+
account mid an unstable-to-stable identity transition) -- reported
|
|
265
|
+
as `RefreshOutcome.AMBIGUOUS`, distinct from `RefreshOutcome.NO_SUPPORT`
|
|
266
|
+
(this provider/record type has no standalone refresh at all): in
|
|
267
|
+
both ambiguous and live cases `name`'s stored refresh token might
|
|
268
|
+
already have been invalidated by whatever is currently live, so a
|
|
269
|
+
standalone refresh attempt here could either spend a token that's
|
|
270
|
+
about to be superseded anyway, or fail and misreport a perfectly
|
|
271
|
+
healthy, refreshable account as needing re-login. The stored record
|
|
272
|
+
(however stale it looks) is returned as-is instead. Callers must
|
|
273
|
+
not assume EXPIRED plus a lack of refresh means "unsupported" --
|
|
274
|
+
check `outcome`.
|
|
275
|
+
|
|
276
|
+
When `allow_refresh` is True, attribution isn't ambiguous, and the
|
|
277
|
+
resolved record is a stored (not live) copy that's expired,
|
|
278
|
+
refreshes it and persists the rotated token before returning
|
|
279
|
+
(`RefreshOutcome.RESOLVED`) -- serialized under the same lock so
|
|
280
|
+
two concurrent callers can never spend the same single-use refresh
|
|
281
|
+
token (OpenAI issues a new one on every grant and invalidates the
|
|
282
|
+
old one; see oauth_refresh.py): the stored record is re-read once
|
|
283
|
+
more immediately before refreshing, so a refresh a concurrent caller
|
|
284
|
+
just completed is picked up instead of spending a second,
|
|
285
|
+
already-superseded token. Raises RefreshError if a refresh was
|
|
286
|
+
attempted and the grant was rejected or the request failed.
|
|
287
|
+
"""
|
|
288
|
+
provider = self._provider(provider_id)
|
|
289
|
+
with self.lock:
|
|
290
|
+
live_record, ambiguous = self._live_attribution(provider_id, name)
|
|
291
|
+
if live_record is not None:
|
|
292
|
+
key = _secret_key(provider_id, name)
|
|
293
|
+
stored = self.secrets.get(key)
|
|
294
|
+
if stored is None or json.loads(stored) != live_record.raw:
|
|
295
|
+
self.secrets.put(key, json.dumps(live_record.raw))
|
|
296
|
+
return live_record, RefreshOutcome.LIVE
|
|
297
|
+
|
|
298
|
+
record = self._load_record(provider_id, name)
|
|
299
|
+
if record is None:
|
|
300
|
+
return None
|
|
301
|
+
if ambiguous:
|
|
302
|
+
return record, RefreshOutcome.AMBIGUOUS
|
|
303
|
+
if not allow_refresh or provider.validate(record) == Validity.OK:
|
|
304
|
+
return record, RefreshOutcome.RESOLVED
|
|
305
|
+
refreshed = provider.refresh(record)
|
|
306
|
+
if refreshed is None:
|
|
307
|
+
return record, RefreshOutcome.NO_SUPPORT
|
|
308
|
+
self.secrets.put(_secret_key(provider_id, name), json.dumps(refreshed.raw))
|
|
309
|
+
meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
310
|
+
account_id = refreshed.raw.get("accountId")
|
|
311
|
+
if meta is not None and isinstance(account_id, str) and account_id and meta.account_id != account_id:
|
|
312
|
+
self.registry.upsert_account(replace(meta, account_id=account_id))
|
|
313
|
+
return refreshed, RefreshOutcome.RESOLVED
|
|
314
|
+
|
|
315
|
+
def _read_live_record(self, provider_id: str) -> AuthRecord | None:
|
|
316
|
+
"""Read + extract the live provider record.
|
|
317
|
+
|
|
318
|
+
Raises AuthFileError if auth.json is missing/unreadable/malformed,
|
|
319
|
+
SchemaError if the provider's entry doesn't match a known shape.
|
|
320
|
+
Deliberately unwrapped: add_account/use_account want a friendly
|
|
321
|
+
"run opencode auth login" message for AuthFileError but must let
|
|
322
|
+
SchemaError propagate untouched (fail-safe — never silently
|
|
323
|
+
guessed at); current() treats AuthFileError as "nothing active"
|
|
324
|
+
but must *also* let SchemaError propagate, so an incompatible
|
|
325
|
+
schema is reported rather than masqueraded as "no active account".
|
|
326
|
+
"""
|
|
327
|
+
auth = opencode_auth.read_auth(self.opencode_auth_path)
|
|
328
|
+
return self._provider(provider_id).extract(auth)
|
|
329
|
+
|
|
330
|
+
def add_account(self, name: str, provider_id: str = "openai") -> AccountMeta:
|
|
331
|
+
provider_id = normalize_provider_id(provider_id)
|
|
332
|
+
name = normalize_account_name(name)
|
|
333
|
+
provider = self._provider(provider_id)
|
|
334
|
+
|
|
335
|
+
with self.lock:
|
|
336
|
+
self._sweep_secret_upgrades()
|
|
337
|
+
try:
|
|
338
|
+
record = self._read_live_record(provider_id)
|
|
339
|
+
except AuthFileError as exc:
|
|
340
|
+
raise OpenCodeSwapError(f"could not read OpenCode's auth file ({exc}); run `opencode auth login` first") from exc
|
|
341
|
+
if record is None:
|
|
342
|
+
raise OpenCodeSwapError(f"no active {provider_id} account in OpenCode; run `opencode auth login` first")
|
|
343
|
+
|
|
344
|
+
identity = provider.identity(record)
|
|
345
|
+
existing_owner = self._find_by_identity(provider_id, identity, confirmed=True)
|
|
346
|
+
|
|
347
|
+
if existing_owner is not None and existing_owner != name:
|
|
348
|
+
raise AccountExistsError(
|
|
349
|
+
f"this account is already saved as '{existing_owner}' "
|
|
350
|
+
f"(use `opencode-swap use {provider_id} {existing_owner}` or "
|
|
351
|
+
f"`opencode-swap rename {provider_id} {existing_owner} {name}`)"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
existing_meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
355
|
+
if existing_meta is not None and existing_owner is None:
|
|
356
|
+
raise AccountExistsError(f"account name '{name}' is already used by a different account; remove it first or choose another name")
|
|
357
|
+
if existing_meta is None:
|
|
358
|
+
destination_secret = self.secrets.get_confirmed(_secret_key(provider_id, name))
|
|
359
|
+
destination_record = self._parse_stored_record(provider_id, name, destination_secret) if destination_secret is not None else None
|
|
360
|
+
if destination_record is not None and not _same_orphan_record(provider, destination_record, record):
|
|
361
|
+
raise AccountExistsError(
|
|
362
|
+
f"account name '{name}' has unregistered stored credentials; recover it with the matching account or remove it first"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
desc = provider.describe(record)
|
|
366
|
+
added = existing_meta.added if existing_meta is not None else _now_iso()
|
|
367
|
+
meta = AccountMeta(
|
|
368
|
+
name=name,
|
|
369
|
+
provider=provider_id,
|
|
370
|
+
type=record.type,
|
|
371
|
+
account_id=desc.account_id,
|
|
372
|
+
email=desc.email,
|
|
373
|
+
added=added,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
self.secrets.put(_secret_key(provider_id, name), json.dumps(record.raw))
|
|
377
|
+
self.registry.upsert_account(meta)
|
|
378
|
+
self.registry.set_active(name, provider_id)
|
|
379
|
+
|
|
380
|
+
return meta
|
|
381
|
+
|
|
382
|
+
def _sync_live_for_export(self, accounts: dict[tuple[str, str], AccountMeta]) -> None:
|
|
383
|
+
if not self.opencode_auth_path.exists():
|
|
384
|
+
return
|
|
385
|
+
auth = opencode_auth.read_auth(self.opencode_auth_path)
|
|
386
|
+
for provider_id in {meta.provider for meta in accounts.values()}:
|
|
387
|
+
provider = self._provider(provider_id)
|
|
388
|
+
live_record = provider.extract(auth)
|
|
389
|
+
if live_record is None:
|
|
390
|
+
continue
|
|
391
|
+
live_identity = provider.identity(live_record)
|
|
392
|
+
owner_name = self._find_by_identity(provider_id, live_identity, confirmed=True)
|
|
393
|
+
if owner_name is not None:
|
|
394
|
+
stored = self.secrets.get_confirmed(_secret_key(provider_id, owner_name))
|
|
395
|
+
if stored is None or json.loads(stored) != live_record.raw:
|
|
396
|
+
self.secrets.put(_secret_key(provider_id, owner_name), json.dumps(live_record.raw))
|
|
397
|
+
continue
|
|
398
|
+
active_name = self.registry.get_active(provider_id)
|
|
399
|
+
active_meta = accounts.get((provider_id, active_name)) if active_name is not None else None
|
|
400
|
+
if active_meta is None or active_meta.type != live_record.type:
|
|
401
|
+
continue
|
|
402
|
+
raise OpenCodeSwapError(
|
|
403
|
+
"live credential does not match the registry-active account; cannot safely determine which saved account to refresh before export"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def export_accounts(self, path: Path, password: str) -> int:
|
|
407
|
+
"""Export every managed account into a password-encrypted archive."""
|
|
408
|
+
with self.lock:
|
|
409
|
+
accounts = self.registry.scoped_accounts()
|
|
410
|
+
if not accounts:
|
|
411
|
+
raise OpenCodeSwapError("no saved accounts to export")
|
|
412
|
+
self._sync_live_for_export(accounts)
|
|
413
|
+
|
|
414
|
+
entries: list[transfer.TransferEntry] = []
|
|
415
|
+
for provider_id, name in sorted(accounts):
|
|
416
|
+
meta = accounts[(provider_id, name)]
|
|
417
|
+
record = self._load_record(meta.provider, name, confirmed=True)
|
|
418
|
+
if record is None:
|
|
419
|
+
raise OpenCodeSwapError(f"no stored credentials for '{name}' (secret store may be unavailable)")
|
|
420
|
+
try:
|
|
421
|
+
_transfer_added(meta.added)
|
|
422
|
+
except ValueError:
|
|
423
|
+
meta = replace(meta, added=_now_iso())
|
|
424
|
+
entries.append(transfer.TransferEntry(meta=meta, record=record.raw))
|
|
425
|
+
transfer.write_archive(path, entries, password)
|
|
426
|
+
return len(entries)
|
|
427
|
+
|
|
428
|
+
def import_accounts( # noqa: PLR0912, PLR0915
|
|
429
|
+
self,
|
|
430
|
+
path: Path,
|
|
431
|
+
password: str,
|
|
432
|
+
resolve_conflict: Callable[[str], ImportConflictAction] | None = None,
|
|
433
|
+
) -> int:
|
|
434
|
+
"""Import an archive, optionally resolving existing-name conflicts."""
|
|
435
|
+
entries = transfer.read_archive(path, password)
|
|
436
|
+
if not entries:
|
|
437
|
+
raise OpenCodeSwapError("account archive contains no accounts")
|
|
438
|
+
|
|
439
|
+
with self.lock:
|
|
440
|
+
self._sweep_secret_upgrades()
|
|
441
|
+
validated: list[tuple[transfer.TransferEntry, Provider, AuthRecord]] = []
|
|
442
|
+
for entry in entries:
|
|
443
|
+
try:
|
|
444
|
+
provider = self._provider(entry.meta.provider)
|
|
445
|
+
record = provider.extract({entry.meta.provider: entry.record})
|
|
446
|
+
except (SchemaError, ValueError):
|
|
447
|
+
raise SchemaError("account archive contains invalid credentials") from None
|
|
448
|
+
if record is None:
|
|
449
|
+
raise SchemaError("account archive contains missing credentials")
|
|
450
|
+
if record.type != entry.meta.type:
|
|
451
|
+
raise SchemaError("account archive credential type does not match metadata")
|
|
452
|
+
validated.append((entry, provider, record))
|
|
453
|
+
|
|
454
|
+
incoming: list[tuple[AccountMeta, AuthRecord]] = []
|
|
455
|
+
incoming_identities: set[tuple[str, str]] = set()
|
|
456
|
+
archive_credentials = {value for _entry, provider, record in validated for value in provider.credential_values(record)}
|
|
457
|
+
if any(
|
|
458
|
+
secret in value
|
|
459
|
+
for entry in entries
|
|
460
|
+
for value in (entry.meta.provider, entry.meta.name, entry.meta.type, entry.meta.added)
|
|
461
|
+
for secret in archive_credentials
|
|
462
|
+
):
|
|
463
|
+
raise SchemaError("account archive contains credential data in non-secret metadata")
|
|
464
|
+
for entry, provider, record in validated:
|
|
465
|
+
try:
|
|
466
|
+
added = _transfer_added(entry.meta.added)
|
|
467
|
+
except ValueError as exc:
|
|
468
|
+
raise SchemaError(f"imported metadata for '{entry.meta.name}' has an invalid added timestamp") from exc
|
|
469
|
+
desc = provider.describe(record)
|
|
470
|
+
meta = AccountMeta(
|
|
471
|
+
name=entry.meta.name,
|
|
472
|
+
provider=entry.meta.provider,
|
|
473
|
+
type=record.type,
|
|
474
|
+
account_id=_without_archive_credential(desc.account_id, archive_credentials),
|
|
475
|
+
email=_without_archive_credential(desc.email, archive_credentials),
|
|
476
|
+
added=added,
|
|
477
|
+
)
|
|
478
|
+
identity_key = (entry.meta.provider, provider.identity(record))
|
|
479
|
+
if identity_key in incoming_identities:
|
|
480
|
+
raise AccountExistsError("account archive contains duplicate account identities")
|
|
481
|
+
incoming_identities.add(identity_key)
|
|
482
|
+
incoming.append((meta, record))
|
|
483
|
+
|
|
484
|
+
existing_accounts = self.registry.scoped_accounts()
|
|
485
|
+
selected: list[tuple[AccountMeta, AuthRecord]] = []
|
|
486
|
+
overwritten_keys: set[tuple[str, str]] = set()
|
|
487
|
+
for meta, record in incoming:
|
|
488
|
+
account_key = (meta.provider, meta.name)
|
|
489
|
+
if account_key in existing_accounts:
|
|
490
|
+
if resolve_conflict is None:
|
|
491
|
+
raise AccountExistsError(f"account '{meta.provider}:{meta.name}' already exists; import made no changes")
|
|
492
|
+
action = resolve_conflict(f"{meta.provider}:{meta.name}")
|
|
493
|
+
if action is ImportConflictAction.ABORT:
|
|
494
|
+
raise OpenCodeSwapError("import aborted; no changes made")
|
|
495
|
+
if action is ImportConflictAction.SKIP:
|
|
496
|
+
continue
|
|
497
|
+
if action is not ImportConflictAction.OVERWRITE:
|
|
498
|
+
raise ValueError("invalid import conflict action")
|
|
499
|
+
overwritten_keys.add(account_key)
|
|
500
|
+
selected.append((meta, record))
|
|
501
|
+
|
|
502
|
+
if not selected:
|
|
503
|
+
return 0
|
|
504
|
+
|
|
505
|
+
existing_identities: set[tuple[str, str]] = set()
|
|
506
|
+
original_secrets: dict[str, str | None] = {}
|
|
507
|
+
for (provider_id, name), meta in existing_accounts.items():
|
|
508
|
+
key = _secret_key(meta.provider, name)
|
|
509
|
+
stored = self.secrets.get_confirmed(key)
|
|
510
|
+
if (provider_id, name) in overwritten_keys:
|
|
511
|
+
original_secrets[key] = stored
|
|
512
|
+
continue
|
|
513
|
+
if stored is None:
|
|
514
|
+
raise OpenCodeSwapError(f"no stored credentials for existing account '{name}'; import made no changes")
|
|
515
|
+
record = self._parse_stored_record(meta.provider, name, stored)
|
|
516
|
+
existing_identities.add((meta.provider, self._provider(meta.provider).identity(record)))
|
|
517
|
+
selected_identities = {(meta.provider, self._provider(meta.provider).identity(record)) for meta, record in selected}
|
|
518
|
+
if selected_identities & existing_identities:
|
|
519
|
+
raise AccountExistsError("an imported account identity is already saved under another name; import made no changes")
|
|
520
|
+
|
|
521
|
+
for meta, _record in selected:
|
|
522
|
+
key = _secret_key(meta.provider, meta.name)
|
|
523
|
+
if (meta.provider, meta.name) not in overwritten_keys and self.secrets.get_confirmed(key) is not None:
|
|
524
|
+
raise AccountExistsError(f"account name '{meta.name}' has unregistered stored credentials; import made no changes")
|
|
525
|
+
original_secrets.setdefault(key, None)
|
|
526
|
+
|
|
527
|
+
attempted_keys: list[str] = []
|
|
528
|
+
try:
|
|
529
|
+
for meta, record in selected:
|
|
530
|
+
key = _secret_key(meta.provider, meta.name)
|
|
531
|
+
attempted_keys.append(key)
|
|
532
|
+
self.secrets.put(key, json.dumps(record.raw))
|
|
533
|
+
self.registry.upsert_accounts([meta for meta, _record in selected])
|
|
534
|
+
except BaseException as exc:
|
|
535
|
+
cleanup_failed = False
|
|
536
|
+
for key in reversed(attempted_keys):
|
|
537
|
+
try:
|
|
538
|
+
original = original_secrets[key]
|
|
539
|
+
if original is None:
|
|
540
|
+
self.secrets.delete(key)
|
|
541
|
+
else:
|
|
542
|
+
self.secrets.put(key, original)
|
|
543
|
+
except BaseException: # noqa: BLE001
|
|
544
|
+
# Deliberately swallowed, not narrowed to Exception:
|
|
545
|
+
# this is a best-effort rollback loop restoring every
|
|
546
|
+
# attempted key, so one key's restore failure (of any
|
|
547
|
+
# kind, including KeyboardInterrupt) must not stop the
|
|
548
|
+
# rest from being attempted. The aggregate failure is
|
|
549
|
+
# reported below via cleanup_failed, chained from the
|
|
550
|
+
# original exc, not discarded.
|
|
551
|
+
cleanup_failed = True
|
|
552
|
+
if cleanup_failed:
|
|
553
|
+
raise OpenCodeSwapError("import failed and cleanup could not restore all previous credentials; registry was not changed") from exc
|
|
554
|
+
raise
|
|
555
|
+
return len(selected)
|
|
556
|
+
|
|
557
|
+
def use_account(self, name: str, provider_id: str = "openai") -> AccountMeta: # noqa: PLR0912, PLR0915
|
|
558
|
+
"""Switch OpenCode's active account to the saved account `name`.
|
|
559
|
+
|
|
560
|
+
Algorithm (see plan §8 for the full rationale):
|
|
561
|
+
1. Sync-back: if the currently-live record belongs to a *different*
|
|
562
|
+
managed account than `name`, capture its (possibly rotated)
|
|
563
|
+
tokens before it's overwritten. If it belongs to no managed
|
|
564
|
+
account at all, stash it under backups/ instead of losing it.
|
|
565
|
+
2. Snapshot the live auth.json to backups/auth.json.bak (and, the
|
|
566
|
+
very first time ever, to backups/auth.json.pristine).
|
|
567
|
+
3. Atomically replace the target provider's entry in auth.json.
|
|
568
|
+
4. Record `name` as the active account in the registry.
|
|
569
|
+
|
|
570
|
+
Only step 3 mutates OpenCode's live state, and atomic_write_auth
|
|
571
|
+
guarantees it's all-or-nothing — so a failure anywhere in this
|
|
572
|
+
method either leaves auth.json completely untouched, or (if it fails
|
|
573
|
+
after step 3 succeeded, e.g. in step 4) gets rolled back by
|
|
574
|
+
restoring the pre-switch auth.json content captured in step 2.
|
|
575
|
+
"""
|
|
576
|
+
provider_id = normalize_provider_id(provider_id)
|
|
577
|
+
name = normalize_account_name(name)
|
|
578
|
+
with self.lock:
|
|
579
|
+
self._sweep_secret_upgrades()
|
|
580
|
+
target_meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
581
|
+
if target_meta is None:
|
|
582
|
+
raise OpenCodeSwapError(f"no such account: {name}")
|
|
583
|
+
provider = self._provider(provider_id)
|
|
584
|
+
|
|
585
|
+
target_record = self._load_record(provider_id, name, confirmed=True)
|
|
586
|
+
if target_record is None:
|
|
587
|
+
raise OpenCodeSwapError(
|
|
588
|
+
f"no stored credentials for '{name}' (secret store may be out of sync); "
|
|
589
|
+
"try `opencode-swap add <provider> <name>` again while that account is active in OpenCode"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
auth = opencode_auth.read_auth(self.opencode_auth_path) if self.opencode_auth_path.exists() else {}
|
|
593
|
+
|
|
594
|
+
backup.write_pristine_if_absent(self.data_root, auth)
|
|
595
|
+
transaction = SwitchTransaction(original_auth=auth)
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
live_record = provider.extract(auth)
|
|
599
|
+
if live_record is not None:
|
|
600
|
+
live_identity = provider.identity(live_record)
|
|
601
|
+
owner_name = self._find_by_identity(provider_id, live_identity, confirmed=True)
|
|
602
|
+
if owner_name is None and not provider.identity_is_stable(live_record):
|
|
603
|
+
active_name = self.registry.get_active(provider_id)
|
|
604
|
+
if active_name is not None:
|
|
605
|
+
active_meta = self.registry.scoped_accounts().get((provider_id, active_name))
|
|
606
|
+
if active_meta is not None and active_meta.type == live_record.type:
|
|
607
|
+
active_record = self._load_record(provider_id, active_name, confirmed=True)
|
|
608
|
+
if active_record is not None and not provider.identity_is_stable(active_record):
|
|
609
|
+
backup.write_unclaimed(self.data_root, provider_id, live_record.raw)
|
|
610
|
+
transaction.record_step("unclaimed_stashed")
|
|
611
|
+
raise OpenCodeSwapError(
|
|
612
|
+
f"live {provider_id} credential changed without a stable account identity; "
|
|
613
|
+
"preserved it as an unclaimed backup and refused to overwrite it"
|
|
614
|
+
)
|
|
615
|
+
if owner_name is not None:
|
|
616
|
+
stored = self.secrets.get_confirmed(_secret_key(provider_id, owner_name))
|
|
617
|
+
if stored is None or json.loads(stored) != live_record.raw:
|
|
618
|
+
self.secrets.put(_secret_key(provider_id, owner_name), json.dumps(live_record.raw))
|
|
619
|
+
transaction.record_step("sync_captured")
|
|
620
|
+
if owner_name == name:
|
|
621
|
+
# A self-switch may have just captured rotated tokens;
|
|
622
|
+
# never splice the pre-sync cached record back over them.
|
|
623
|
+
target_record = self._load_record(provider_id, name, confirmed=True)
|
|
624
|
+
if target_record is None:
|
|
625
|
+
raise OpenCodeSwapError(f"stored credentials for '{name}' disappeared during switch")
|
|
626
|
+
else:
|
|
627
|
+
backup.write_unclaimed(self.data_root, provider_id, live_record.raw)
|
|
628
|
+
transaction.record_step("unclaimed_stashed")
|
|
629
|
+
|
|
630
|
+
backup.write_bak(self.data_root, auth)
|
|
631
|
+
transaction.record_step("bak_written")
|
|
632
|
+
|
|
633
|
+
new_auth = provider.splice(auth, target_record)
|
|
634
|
+
opencode_auth.atomic_write_auth(self.opencode_auth_path, new_auth)
|
|
635
|
+
transaction.record_step("auth_written")
|
|
636
|
+
|
|
637
|
+
self.registry.set_active(name, provider_id)
|
|
638
|
+
transaction.record_step("registry_written")
|
|
639
|
+
except BaseException:
|
|
640
|
+
self._rollback(transaction)
|
|
641
|
+
raise
|
|
642
|
+
|
|
643
|
+
return target_meta
|
|
644
|
+
|
|
645
|
+
def _rollback(self, transaction: SwitchTransaction) -> None:
|
|
646
|
+
if "auth_written" not in transaction.completed_steps:
|
|
647
|
+
return # live auth.json was never touched; nothing to undo
|
|
648
|
+
try:
|
|
649
|
+
opencode_auth.atomic_write_auth(self.opencode_auth_path, transaction.original_auth)
|
|
650
|
+
except OSError:
|
|
651
|
+
raise OpenCodeSwapError(
|
|
652
|
+
"switch failed partway and automatic rollback of auth.json also failed; "
|
|
653
|
+
f"restore manually from {self.data_root / 'backups' / 'auth.json.bak'}"
|
|
654
|
+
) from None
|
|
655
|
+
|
|
656
|
+
def remove_account(self, name: str, provider_id: str = "openai") -> None:
|
|
657
|
+
provider_id = normalize_provider_id(provider_id)
|
|
658
|
+
name = normalize_account_name(name)
|
|
659
|
+
with self.lock:
|
|
660
|
+
self._sweep_secret_upgrades()
|
|
661
|
+
meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
662
|
+
if meta is None:
|
|
663
|
+
raise OpenCodeSwapError(f"no such account: {name}")
|
|
664
|
+
original_active = self.registry.get_active(provider_id)
|
|
665
|
+
secret_key = _secret_key(meta.provider, name)
|
|
666
|
+
secret = self.secrets.get(secret_key)
|
|
667
|
+
self.registry.remove_account(name, provider_id)
|
|
668
|
+
try:
|
|
669
|
+
self.secrets.delete(secret_key)
|
|
670
|
+
except BaseException:
|
|
671
|
+
# OS deletion errors are ambiguous: it might have completed
|
|
672
|
+
# before timing out. Preserve captured credentials in the
|
|
673
|
+
# pinned file fallback before re-registering this account.
|
|
674
|
+
if secret is not None:
|
|
675
|
+
self.secrets.put(secret_key, secret)
|
|
676
|
+
self.registry.upsert_account(meta)
|
|
677
|
+
self.registry.set_active(original_active, provider_id)
|
|
678
|
+
raise
|
|
679
|
+
|
|
680
|
+
def rename_account(self, old: str, new: str, provider_id: str = "openai") -> None:
|
|
681
|
+
provider_id = normalize_provider_id(provider_id)
|
|
682
|
+
old = normalize_account_name(old)
|
|
683
|
+
new = normalize_account_name(new)
|
|
684
|
+
with self.lock:
|
|
685
|
+
self._sweep_secret_upgrades()
|
|
686
|
+
meta = self.registry.scoped_accounts().get((provider_id, old))
|
|
687
|
+
if meta is None:
|
|
688
|
+
raise OpenCodeSwapError(f"no such account: {old}")
|
|
689
|
+
if self.registry.scoped_accounts().get((provider_id, new)) is not None:
|
|
690
|
+
raise AccountExistsError(f"account already exists: {new}")
|
|
691
|
+
|
|
692
|
+
secret = self.secrets.get(_secret_key(meta.provider, old))
|
|
693
|
+
if secret is None:
|
|
694
|
+
raise OpenCodeSwapError(f"no stored credentials for '{old}' (secret store may be unavailable)")
|
|
695
|
+
provider = self._provider(meta.provider)
|
|
696
|
+
source_record = self._load_record(meta.provider, old)
|
|
697
|
+
if source_record is None:
|
|
698
|
+
raise OpenCodeSwapError(f"no stored credentials for '{old}' (secret store may be unavailable)")
|
|
699
|
+
old_key = _secret_key(meta.provider, old)
|
|
700
|
+
new_key = _secret_key(meta.provider, new)
|
|
701
|
+
previous_new_secret = self.secrets.get_confirmed(new_key)
|
|
702
|
+
if previous_new_secret is not None:
|
|
703
|
+
destination_record = self._parse_stored_record(meta.provider, new, previous_new_secret)
|
|
704
|
+
if destination_record is not None and not _same_orphan_record(provider, destination_record, source_record):
|
|
705
|
+
raise AccountExistsError(
|
|
706
|
+
f"account name '{new}' has unregistered stored credentials; recover it with the matching account or remove it first"
|
|
707
|
+
)
|
|
708
|
+
self.secrets.put(new_key, secret)
|
|
709
|
+
registry_renamed = False
|
|
710
|
+
try:
|
|
711
|
+
self.registry.rename_account(old, new, provider_id)
|
|
712
|
+
registry_renamed = True
|
|
713
|
+
self.secrets.delete(old_key)
|
|
714
|
+
except BaseException:
|
|
715
|
+
if registry_renamed:
|
|
716
|
+
# Keep the new registry mapping if recovery fails: it
|
|
717
|
+
# still names the pre-copied usable credential.
|
|
718
|
+
self.secrets.put(old_key, secret)
|
|
719
|
+
self.registry.rename_account(new, old, provider_id)
|
|
720
|
+
if previous_new_secret is None:
|
|
721
|
+
with suppress(OpenCodeSwapError, OSError):
|
|
722
|
+
self.secrets.delete(new_key)
|
|
723
|
+
else:
|
|
724
|
+
self.secrets.put(new_key, previous_new_secret)
|
|
725
|
+
raise
|
|
726
|
+
|
|
727
|
+
def _finish_restore(self, data: JsonObject) -> list[AccountMeta]:
|
|
728
|
+
"""Best-effort bookkeeping after a restore has committed live state."""
|
|
729
|
+
active: list[AccountMeta] = []
|
|
730
|
+
provider_ids = {meta.provider for meta in self.registry.scoped_accounts().values()}
|
|
731
|
+
for provider_id in provider_ids:
|
|
732
|
+
try:
|
|
733
|
+
provider = self._provider(provider_id)
|
|
734
|
+
record = provider.extract(data)
|
|
735
|
+
owner_name = self._find_by_identity(provider_id, provider.identity(record)) if record else None
|
|
736
|
+
except OpenCodeSwapError:
|
|
737
|
+
owner_name = None
|
|
738
|
+
with suppress(OpenCodeSwapError):
|
|
739
|
+
self.registry.set_active(owner_name, provider_id)
|
|
740
|
+
meta = self.registry.scoped_accounts().get((provider_id, owner_name)) if owner_name else None
|
|
741
|
+
if meta is not None:
|
|
742
|
+
active.append(meta)
|
|
743
|
+
return active
|
|
744
|
+
|
|
745
|
+
def restore(self, source: str = "bak", *, discard_pending: bool = False) -> list[AccountMeta]:
|
|
746
|
+
"""Restore OpenCode's auth.json from a backup snapshot.
|
|
747
|
+
|
|
748
|
+
`source` is "bak" (most recent pre-switch state) or "pristine" (the
|
|
749
|
+
very first live state opencode-swap ever saw). The current live
|
|
750
|
+
content is itself chained into .bak first (if readable) so a
|
|
751
|
+
restore is always undoable by restoring again.
|
|
752
|
+
|
|
753
|
+
Returns the managed account that now matches the restored live
|
|
754
|
+
state, or None if it doesn't match any saved account (including
|
|
755
|
+
when the restored data's provider entry can't even be interpreted —
|
|
756
|
+
the restore itself still succeeds; only identification is skipped).
|
|
757
|
+
"""
|
|
758
|
+
with self.lock:
|
|
759
|
+
recovery = backup.read_restore_snapshot(self.data_root)
|
|
760
|
+
if recovery is not None:
|
|
761
|
+
try:
|
|
762
|
+
live = opencode_auth.read_auth(self.opencode_auth_path)
|
|
763
|
+
except AuthFileError:
|
|
764
|
+
live = None
|
|
765
|
+
if live == recovery:
|
|
766
|
+
backup.remove_restore_snapshot(self.data_root)
|
|
767
|
+
return self._finish_restore(recovery)
|
|
768
|
+
if discard_pending:
|
|
769
|
+
# The pending snapshot may be the only surviving copy of
|
|
770
|
+
# whatever .bak held before this restore attempt started
|
|
771
|
+
# (chaining below overwrites .bak with current live state
|
|
772
|
+
# every time) -- archive it before dropping the marker.
|
|
773
|
+
backup.write_discarded_restore(self.data_root, recovery)
|
|
774
|
+
backup.remove_restore_snapshot(self.data_root)
|
|
775
|
+
else:
|
|
776
|
+
raise OpenCodeSwapError(
|
|
777
|
+
"a previous restore failed and its recovery source is retained at "
|
|
778
|
+
f"{self.data_root / 'backups' / backup.RESTORE_SNAPSHOT_FILENAME}; "
|
|
779
|
+
"rerun with --discard-pending to drop it, or delete that file manually before restoring again"
|
|
780
|
+
)
|
|
781
|
+
if source == "bak":
|
|
782
|
+
data = backup.read_bak(self.data_root)
|
|
783
|
+
if data is None:
|
|
784
|
+
raise OpenCodeSwapError("no .bak snapshot found; nothing to restore")
|
|
785
|
+
elif source == "pristine":
|
|
786
|
+
data = backup.read_pristine(self.data_root)
|
|
787
|
+
if data is None:
|
|
788
|
+
raise OpenCodeSwapError("no pristine snapshot found; nothing to restore")
|
|
789
|
+
else:
|
|
790
|
+
raise ValueError(f"unknown restore source: {source!r}")
|
|
791
|
+
|
|
792
|
+
# A restore from .bak must not erase its only source before the
|
|
793
|
+
# new live auth.json has landed. Keep this durable until chaining
|
|
794
|
+
# current live state into .bak and the live replacement both pass.
|
|
795
|
+
backup.write_restore_snapshot(self.data_root, data)
|
|
796
|
+
if self.opencode_auth_path.exists():
|
|
797
|
+
try:
|
|
798
|
+
current_live = opencode_auth.read_auth(self.opencode_auth_path)
|
|
799
|
+
backup.write_bak(self.data_root, current_live)
|
|
800
|
+
except AuthFileError:
|
|
801
|
+
pass # current file is unreadable -- exactly the case restore exists for
|
|
802
|
+
|
|
803
|
+
opencode_auth.atomic_write_auth(self.opencode_auth_path, data)
|
|
804
|
+
backup.remove_restore_snapshot(self.data_root)
|
|
805
|
+
return self._finish_restore(data)
|
|
806
|
+
|
|
807
|
+
def fetch_usage(self, name: str, provider_id: str = "openai") -> usage.UsageSnapshot | None:
|
|
808
|
+
"""Live usage lookup for a saved account, for providers that have a
|
|
809
|
+
usage source (OpenAI OAuth, Z.AI GLM Coding Plan). None when the
|
|
810
|
+
provider has no usage source, the saved record's type isn't one it
|
|
811
|
+
can look up, or the secret store is out of sync -- a distinct "not
|
|
812
|
+
applicable" case from usage.UsageSnapshot's own available=False
|
|
813
|
+
("looked it up, the request failed").
|
|
814
|
+
|
|
815
|
+
Prefers OpenCode's own live auth.json over opencode-swap's stored
|
|
816
|
+
snapshot when the live record can be positively attributed to
|
|
817
|
+
`name` (see `_ensure_refreshed`) -- for whichever account is
|
|
818
|
+
currently active in OpenCode, the live copy is authoritative and
|
|
819
|
+
the stored copy is stale by construction. A drifted live record is
|
|
820
|
+
captured back into the secret store as a side effect. That account
|
|
821
|
+
is never refreshed standalone, live or expired: OpenCode owns that.
|
|
822
|
+
|
|
823
|
+
For any other saved account, refreshes its stored OAuth token on
|
|
824
|
+
demand when it's expired -- this is the one thing besides the
|
|
825
|
+
explicit `refresh` command that spends network quota and a
|
|
826
|
+
single-use refresh token, and only because the caller already
|
|
827
|
+
opted in with `--usage`. (Static API-key providers never refresh;
|
|
828
|
+
the whole block below is a no-op for them.)
|
|
829
|
+
"""
|
|
830
|
+
provider = self._provider(provider_id)
|
|
831
|
+
meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
832
|
+
if meta is None or meta.type not in provider.usage_record_types:
|
|
833
|
+
return None
|
|
834
|
+
|
|
835
|
+
try:
|
|
836
|
+
result = self._ensure_refreshed(provider_id, name, allow_refresh=True)
|
|
837
|
+
except RefreshError as exc:
|
|
838
|
+
return usage.UsageSnapshot(available=False, message=str(exc))
|
|
839
|
+
if result is None:
|
|
840
|
+
return None
|
|
841
|
+
record, outcome = result
|
|
842
|
+
|
|
843
|
+
if provider.validate(record) != Validity.OK:
|
|
844
|
+
if outcome is RefreshOutcome.LIVE:
|
|
845
|
+
message = "expired; OpenCode refreshes on next request"
|
|
846
|
+
elif outcome is RefreshOutcome.AMBIGUOUS:
|
|
847
|
+
message = "expired; live account state could not be confirmed, refresh skipped"
|
|
848
|
+
else:
|
|
849
|
+
message = "expired and no standalone refresh available for this account type"
|
|
850
|
+
return usage.UsageSnapshot(available=False, message=message)
|
|
851
|
+
|
|
852
|
+
return provider.fetch_usage(record)
|
|
853
|
+
|
|
854
|
+
def account_validity(self, name: str, provider_id: str = "openai") -> Validity:
|
|
855
|
+
"""Validity of a saved account's most current record. See
|
|
856
|
+
`account_status` for the full semantics."""
|
|
857
|
+
return self.account_status(name, provider_id)[0]
|
|
858
|
+
|
|
859
|
+
def account_status(self, name: str, provider_id: str = "openai") -> tuple[Validity, AccountDesc | None]:
|
|
860
|
+
"""`(validity, description)` of a saved account's most current record.
|
|
861
|
+
|
|
862
|
+
`validity` is OK/EXPIRED/INVALID; INVALID means the secret is missing
|
|
863
|
+
or unreadable (registry entry orphaned), in which case `description`
|
|
864
|
+
is None.
|
|
865
|
+
|
|
866
|
+
Prefers the live auth.json record when it can be positively
|
|
867
|
+
attributed to `name` (see `_ensure_refreshed`) -- OpenCode rotates
|
|
868
|
+
tokens in `auth.json` in place, so the account currently active in
|
|
869
|
+
OpenCode may look expired in opencode-swap's stored snapshot while
|
|
870
|
+
the live copy is still perfectly valid. Never triggers a network
|
|
871
|
+
refresh: this is called unconditionally by `list`, with no separate
|
|
872
|
+
opt-in for a network call.
|
|
873
|
+
"""
|
|
874
|
+
meta = self.registry.scoped_accounts().get((provider_id, name))
|
|
875
|
+
if meta is None:
|
|
876
|
+
raise OpenCodeSwapError(f"no such account: {name}")
|
|
877
|
+
result = self._ensure_refreshed(provider_id, name, allow_refresh=False)
|
|
878
|
+
if result is None:
|
|
879
|
+
return Validity.INVALID, None
|
|
880
|
+
record, _outcome = result
|
|
881
|
+
provider = self._provider(meta.provider)
|
|
882
|
+
return provider.validate(record), provider.describe(record)
|
|
883
|
+
|
|
884
|
+
def refresh_account(self, name: str, provider_id: str = "openai") -> AccountRefreshResult:
|
|
885
|
+
"""Ensure a saved account's OAuth token is valid, refreshing it over
|
|
886
|
+
the network and persisting the rotated token if it's expired.
|
|
887
|
+
|
|
888
|
+
A no-op beyond the existence check when the token is already valid
|
|
889
|
+
-- refreshing a still-valid token would needlessly spend its
|
|
890
|
+
single-use refresh token for no benefit, which could break another
|
|
891
|
+
holder of the same account (e.g. a second OpenCode install). Never
|
|
892
|
+
refreshes the account currently active in OpenCode: that account's
|
|
893
|
+
validity is reported from its live `auth.json` record instead (see
|
|
894
|
+
`_ensure_refreshed`), since OpenCode owns its refresh on its own
|
|
895
|
+
next request.
|
|
896
|
+
|
|
897
|
+
Raises OpenCodeSwapError if the account doesn't exist or has no
|
|
898
|
+
stored credentials. Raises RefreshError if a refresh was attempted
|
|
899
|
+
and the grant was rejected or the request failed. Returns an
|
|
900
|
+
`AccountRefreshResult` — the resulting Validity (OK if the token is
|
|
901
|
+
now valid) alongside the `RefreshOutcome` explaining why, if it
|
|
902
|
+
isn't: `NO_SUPPORT` (this provider/record type has no standalone
|
|
903
|
+
refresh) is a different, unactionable-here situation from `LIVE`
|
|
904
|
+
or `AMBIGUOUS` (this account genuinely supports refresh, but it was
|
|
905
|
+
deliberately skipped this time) — callers must not collapse those
|
|
906
|
+
into the same "no refresh available" message.
|
|
907
|
+
"""
|
|
908
|
+
provider_id = normalize_provider_id(provider_id)
|
|
909
|
+
name = normalize_account_name(name)
|
|
910
|
+
with self.lock:
|
|
911
|
+
self._sweep_secret_upgrades()
|
|
912
|
+
if self.registry.scoped_accounts().get((provider_id, name)) is None:
|
|
913
|
+
raise OpenCodeSwapError(f"no such account: {name}")
|
|
914
|
+
result = self._ensure_refreshed(provider_id, name, allow_refresh=True)
|
|
915
|
+
if result is None:
|
|
916
|
+
raise OpenCodeSwapError(f"no stored credentials for '{name}' (secret store may be out of sync)")
|
|
917
|
+
record, outcome = result
|
|
918
|
+
return AccountRefreshResult(validity=self._provider(provider_id).validate(record), outcome=outcome)
|
|
919
|
+
|
|
920
|
+
def next_account(self, provider_id: str = "openai") -> AccountMeta:
|
|
921
|
+
"""Return next saved account after the live managed account, wrapping around."""
|
|
922
|
+
current, _ = self.current(provider_id)
|
|
923
|
+
if current is None:
|
|
924
|
+
raise OpenCodeSwapError("current OpenCode account is not managed; use `opencode-swap use <provider> <name>` first")
|
|
925
|
+
accounts = self.registry.scoped_accounts()
|
|
926
|
+
names = sorted(name for (stored_provider, name) in accounts if stored_provider == provider_id)
|
|
927
|
+
return accounts[(provider_id, names[(names.index(current.name) + 1) % len(names)])]
|
|
928
|
+
|
|
929
|
+
def current(self, provider_id: str = "openai") -> tuple[AccountMeta | None, AccountDesc | None]:
|
|
930
|
+
"""Return (managed account meta if recognized, live account description).
|
|
931
|
+
|
|
932
|
+
(None, None) if OpenCode has no active account for this provider.
|
|
933
|
+
A non-None description with a None meta means OpenCode is logged
|
|
934
|
+
into an account opencode-swap hasn't been told to manage.
|
|
935
|
+
"""
|
|
936
|
+
try:
|
|
937
|
+
auth = opencode_auth.read_auth(self.opencode_auth_path)
|
|
938
|
+
except AuthFileError:
|
|
939
|
+
return None, None
|
|
940
|
+
return self.current_from_auth(auth, provider_id)
|
|
941
|
+
|
|
942
|
+
def current_from_auth(self, auth: JsonObject, provider_id: str = "openai") -> tuple[AccountMeta | None, AccountDesc | None]:
|
|
943
|
+
"""Identify current provider account from already-validated auth data."""
|
|
944
|
+
record = self._provider(provider_id).extract(auth)
|
|
945
|
+
if record is None:
|
|
946
|
+
return None, None
|
|
947
|
+
|
|
948
|
+
provider = self._provider(provider_id)
|
|
949
|
+
identity = provider.identity(record)
|
|
950
|
+
owner = self._find_by_identity(provider_id, identity)
|
|
951
|
+
meta = self.registry.scoped_accounts().get((provider_id, owner)) if owner else None
|
|
952
|
+
return meta, provider.describe(record)
|